From 3cfb0ac5187954409eb5d28305cecb2b40cfb992 Mon Sep 17 00:00:00 2001 From: Vihar Kurama Date: Fri, 5 Jul 2024 14:30:37 +0530 Subject: [PATCH 001/555] update assignes for github issue templates (#5053) --- .github/ISSUE_TEMPLATE/--bug-report.yaml | 2 +- .github/ISSUE_TEMPLATE/--feature-request.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index d1d7fa009f3..ec03769295d 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -2,7 +2,7 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " labels: [🐛bug] -assignees: [srinivaspendem, pushya22] +assignees: [vihar, pushya22] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index ff9cdd23839..390c95aaac6 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -2,7 +2,7 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " labels: [✨feature] -assignees: [srinivaspendem, pushya22] +assignees: [vihar, pushya22] body: - type: markdown attributes: From 509c258b0707b6871ce7396105ff9112661ff2e2 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 5 Jul 2024 14:55:48 +0530 Subject: [PATCH 002/555] chore: custom analytics charts (#5052) --- apiserver/plane/utils/analytics_plot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 0d2564a041c..eda3b30ac9c 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -12,7 +12,7 @@ Sum, Value, When, - IntegerField, + FloatField, ) from django.db.models.functions import ( Coalesce, @@ -98,7 +98,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): # Estimate else: queryset = queryset.annotate( - estimate=Sum(Cast("estimate_point__value", IntegerField())) + estimate=Sum(Cast("estimate_point__value", FloatField())) ).order_by(x_axis) queryset = ( queryset.annotate(segment=F(segment)) if segment else queryset From 977b47d35f59f5047ae07273c22313385416fb8f Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 5 Jul 2024 14:58:42 +0530 Subject: [PATCH 003/555] [WEB-1843] chore: minor file restructuring. (#5044) --- packages/editor/src/core/types/index.ts | 1 + packages/types/src/pages.d.ts | 2 ++ web/ce/components/pages/editor/embed/index.ts | 2 +- ...embed.tsx => issue-embed-upgrade-card.tsx} | 7 +++--- web/ce/constants/issue.ts | 1 - web/ce/hooks/use-bulk-operation-status.ts | 1 + web/ce/hooks/use-issue-embed.tsx | 24 +++++++++++++++++++ .../gantt-chart/chart/main-content.tsx | 8 ++++--- .../issue-layouts/gantt/base-gantt-root.tsx | 8 ++++--- .../issues/issue-layouts/list/default.tsx | 8 ++++--- .../spreadsheet/spreadsheet-view.tsx | 10 ++++---- .../components/pages/editor/editor-body.tsx | 21 +++++++++------- web/ee/constants/issue.ts | 1 - web/ee/hooks/use-issue-embed.tsx | 1 + 14 files changed, 66 insertions(+), 29 deletions(-) rename web/ce/components/pages/editor/embed/{issue-embed.tsx => issue-embed-upgrade-card.tsx} (78%) delete mode 100644 web/ce/constants/issue.ts create mode 100644 web/ce/hooks/use-bulk-operation-status.ts create mode 100644 web/ce/hooks/use-issue-embed.tsx delete mode 100644 web/ee/constants/issue.ts create mode 100644 web/ee/hooks/use-issue-embed.tsx diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index f4dd894128c..9e5980531a2 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -3,3 +3,4 @@ export * from "./embed"; export * from "./image"; export * from "./mention-suggestion"; export * from "./slash-commands-suggestion"; +export * from "@/plane-editor/types"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 9b7249bdc9e..ea9b8b8ea5b 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -46,3 +46,5 @@ export type TPageFilters = { sortBy: TPageFiltersSortBy; filters?: TPageFilterProps; }; + +export type TPageEmbedType = "mention" | "issue"; diff --git a/web/ce/components/pages/editor/embed/index.ts b/web/ce/components/pages/editor/embed/index.ts index f30596cb00f..e16822834a5 100644 --- a/web/ce/components/pages/editor/embed/index.ts +++ b/web/ce/components/pages/editor/embed/index.ts @@ -1 +1 @@ -export * from "./issue-embed"; +export * from "./issue-embed-upgrade-card"; diff --git a/web/ce/components/pages/editor/embed/issue-embed.tsx b/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx similarity index 78% rename from web/ce/components/pages/editor/embed/issue-embed.tsx rename to web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx index dc06f4f9d85..78aac54801d 100644 --- a/web/ce/components/pages/editor/embed/issue-embed.tsx +++ b/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx @@ -2,11 +2,10 @@ import { Crown } from "lucide-react"; // ui import { Button } from "@plane/ui"; -export const IssueEmbedCard: React.FC = (props) => ( +export const IssueEmbedUpgradeCard: React.FC = (props) => (
{props.node?.attrs?.project_identifier}-{props?.node?.attrs?.sequence_id} diff --git a/web/ce/constants/issue.ts b/web/ce/constants/issue.ts deleted file mode 100644 index 68622c8feda..00000000000 --- a/web/ce/constants/issue.ts +++ /dev/null @@ -1 +0,0 @@ -export const ENABLE_BULK_OPERATIONS = false; diff --git a/web/ce/hooks/use-bulk-operation-status.ts b/web/ce/hooks/use-bulk-operation-status.ts new file mode 100644 index 00000000000..0bb6768101e --- /dev/null +++ b/web/ce/hooks/use-bulk-operation-status.ts @@ -0,0 +1 @@ +export const useBulkOperationStatus = () => false; diff --git a/web/ce/hooks/use-issue-embed.tsx b/web/ce/hooks/use-issue-embed.tsx new file mode 100644 index 00000000000..5ca4d4b02b4 --- /dev/null +++ b/web/ce/hooks/use-issue-embed.tsx @@ -0,0 +1,24 @@ +// editor +import { TEmbedConfig, TReadOnlyEmbedConfig } from "@plane/editor"; +// types +import { TPageEmbedType } from "@plane/types"; +// plane web components +import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => { + const widgetCallback = () => ; + + const issueEmbedProps: TEmbedConfig["issue"] = { + widgetCallback, + }; + + const issueEmbedReadOnlyProps: TReadOnlyEmbedConfig["issue"] = { + widgetCallback, + }; + + return { + issueEmbedProps, + issueEmbedReadOnlyProps, + }; +}; diff --git a/web/core/components/gantt-chart/chart/main-content.tsx b/web/core/components/gantt-chart/chart/main-content.tsx index 8fd19cd2e44..e49d1977156 100644 --- a/web/core/components/gantt-chart/chart/main-content.tsx +++ b/web/core/components/gantt-chart/chart/main-content.tsx @@ -22,8 +22,8 @@ import { import { cn } from "@/helpers/common.helper"; // plane web components import { IssueBulkOperationsRoot } from "@/plane-web/components/issues"; -// plane web constants -import { ENABLE_BULK_OPERATIONS } from "@/plane-web/constants/issue"; +// plane web hooks +import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status"; // helpers // constants import { GANTT_SELECT_GROUP } from "../constants"; @@ -78,6 +78,8 @@ export const GanttChartMainContent: React.FC = observer((props) => { const ganttContainerRef = useRef(null); // chart hook const { currentView, currentViewData } = useGanttChart(); + // plane web hooks + const isBulkOperationsEnabled = useBulkOperationStatus(); // Enable Auto Scroll for Ganttlist useEffect(() => { @@ -126,7 +128,7 @@ export const GanttChartMainContent: React.FC = observer((props) => { entities={{ [GANTT_SELECT_GROUP]: blockIds ?? [], }} - disabled={!ENABLE_BULK_OPERATIONS} + disabled={!isBulkOperationsEnabled} > {(helpers) => ( <> diff --git a/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 668984496f5..2cc5fad1466 100644 --- a/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -14,8 +14,8 @@ import { getIssueBlocksStructure } from "@/helpers/issue.helper"; import { useIssues, useUser } from "@/hooks/store"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; -// plane web constants -import { ENABLE_BULK_OPERATIONS } from "@/plane-web/constants/issue"; +// plane web hooks +import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status"; import { IssueLayoutHOC } from "../issue-layout-HOC"; @@ -42,6 +42,8 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan membership: { currentProjectRole }, } = useUser(); const appliedDisplayFilters = issuesFilter.issueFilters?.displayFilters; + // plane web hooks + const isBulkOperationsEnabled = useBulkOperationStatus(); useEffect(() => { fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }, viewId); @@ -99,7 +101,7 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan enableBlockMove={isAllowed} enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed} enableAddBlock={isAllowed} - enableSelection={ENABLE_BULK_OPERATIONS && isAllowed} + enableSelection={isBulkOperationsEnabled && isAllowed} quickAdd={ enableIssueCreation && isAllowed ? : undefined } diff --git a/web/core/components/issues/issue-layouts/list/default.tsx b/web/core/components/issues/issue-layouts/list/default.tsx index dafb00ed233..ad66a56c90a 100644 --- a/web/core/components/issues/issue-layouts/list/default.tsx +++ b/web/core/components/issues/issue-layouts/list/default.tsx @@ -22,8 +22,8 @@ import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; // plane web components import { IssueBulkOperationsRoot } from "@/plane-web/components/issues"; -// plane web constants -import { ENABLE_BULK_OPERATIONS } from "@/plane-web/constants/issue"; +// plane web hooks +import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status"; // utils import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation, isSubGrouped } from "../utils"; import { ListGroup } from "./list-group"; @@ -76,6 +76,8 @@ export const List: React.FC = observer((props) => { const projectState = useProjectState(); const cycle = useCycle(); const projectModule = useModule(); + // plane web hooks + const isBulkOperationsEnabled = useBulkOperationStatus(); const containerRef = useRef(null); @@ -129,7 +131,7 @@ export const List: React.FC = observer((props) => { return (
{groups && ( - + {(helpers) => ( <>
= observer((props) => { // refs const containerRef = useRef(null); const portalRef = useRef(null); - + // store hooks const { currentProjectDetails } = useProject(); + // plane web hooks + const isBulkOperationsEnabled = useBulkOperationStatus(); const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; @@ -82,7 +84,7 @@ export const SpreadsheetView: React.FC = observer((props) => { entities={{ [SPREADSHEET_SELECT_GROUP]: issueIds, }} - disabled={!ENABLE_BULK_OPERATIONS} + disabled={!isBulkOperationsEnabled} > {(helpers) => ( <> diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index 2c456000440..6bc1b2e7bcd 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -18,8 +18,8 @@ import { cn } from "@/helpers/common.helper"; // hooks import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store"; import { usePageFilters } from "@/hooks/use-page-filters"; -// plane web components -import { IssueEmbedCard } from "@/plane-web/components/pages"; +// plane web hooks +import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; // services import { FileService } from "@/services/file.service"; // store @@ -80,14 +80,21 @@ export const PageEditorBody: React.FC = observer((props) => { members: projectMemberDetails, user: currentUser ?? undefined, }); + // page filters const { isFullWidth } = usePageFilters(); + // issue-embed + const { issueEmbedProps, issueEmbedReadOnlyProps } = useIssueEmbed( + workspaceSlug?.toString() ?? "", + projectId?.toString() ?? "" + ); useEffect(() => { updateMarkings(pageDescription ?? "

"); }, [pageDescription, updateMarkings]); - if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return ; + if (pageId === undefined || pageDescription === undefined || !pageDescriptionYJS || !isDescriptionReady) + return ; return (
@@ -140,9 +147,7 @@ export const PageEditorBody: React.FC = observer((props) => { suggestions: mentionSuggestions, }} embedHandler={{ - issue: { - widgetCallback: () => , - }, + issue: issueEmbedProps, }} /> ) : ( @@ -156,9 +161,7 @@ export const PageEditorBody: React.FC = observer((props) => { highlights: mentionHighlights, }} embedHandler={{ - issue: { - widgetCallback: () => , - }, + issue: issueEmbedReadOnlyProps, }} /> )} diff --git a/web/ee/constants/issue.ts b/web/ee/constants/issue.ts deleted file mode 100644 index 17d60005ae6..00000000000 --- a/web/ee/constants/issue.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/constants/issue"; \ No newline at end of file diff --git a/web/ee/hooks/use-issue-embed.tsx b/web/ee/hooks/use-issue-embed.tsx new file mode 100644 index 00000000000..96e436e70d7 --- /dev/null +++ b/web/ee/hooks/use-issue-embed.tsx @@ -0,0 +1 @@ +export * from "ce/hooks/use-issue-embed"; From 61ce055cb3bb73409f416198f41ee71a8bf7e5af Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:00:15 +0530 Subject: [PATCH 004/555] [WEB - 1740] chore: add issue id in pages detail endpoint (#4942) * chore: add issue id in pages detail endpoint * fix: response structure changed --------- Co-authored-by: sriram veeraghanta --- apiserver/plane/app/views/page/base.py | 8 +++++++- apiserver/plane/db/models/page.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 60fb81eebc8..2ce7ce11c61 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -215,8 +215,14 @@ def retrieve(self, request, slug, project_id, pk=None): status=status.HTTP_404_NOT_FOUND, ) else: + issue_ids = PageLog.objects.filter( + page_id=pk, entity_name="issue" + ).values_list("entity_identifier", flat=True) + data = PageDetailSerializer(page).data + data["issue_ids"] = issue_ids return Response( - PageDetailSerializer(page).data, status=status.HTTP_200_OK + data, + status=status.HTTP_200_OK, ) def lock(self, request, slug, project_id, pk): diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 9a8b3078d6b..721cf005e31 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -93,7 +93,9 @@ class PageLog(BaseModel): verbose_name="Transaction Type", ) workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_page_log", ) class Meta: From c75091ca3a13f6d14ea44ce938a7b9ae58b986d5 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Fri, 5 Jul 2024 16:09:16 +0530 Subject: [PATCH 005/555] [WEB-1803] chore: workspace notification sorting when we refresh the notifications on workspace notifications (#5049) * chore: workspace notification sorting when we refresh the notifications on workspace notifications * chore: replaced sorting with ISO to EPOCH * chore: converted obj to array --- .../notifications/workspace-notifications.store.ts | 10 +++++++++- web/helpers/date-time.helper.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/web/core/store/notifications/workspace-notifications.store.ts b/web/core/store/notifications/workspace-notifications.store.ts index 2cfe37990e4..59e9b07a23e 100644 --- a/web/core/store/notifications/workspace-notifications.store.ts +++ b/web/core/store/notifications/workspace-notifications.store.ts @@ -1,4 +1,5 @@ import isEmpty from "lodash/isEmpty"; +import orderBy from "lodash/orderBy"; import set from "lodash/set"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; @@ -18,6 +19,8 @@ import { ENotificationTab, TNotificationTab, } from "@/constants/notification"; +// helpers +import { convertToEpoch } from "@/helpers/date-time.helper"; // services import workspaceNotificationService from "@/services/workspace-notification.service"; // store @@ -119,7 +122,12 @@ export class WorkspaceNotificationStore implements IWorkspaceNotificationStore { */ notificationIdsByWorkspaceId = computedFn((workspaceId: string) => { if (!workspaceId || isEmpty(this.notifications)) return undefined; - const workspaceNotificationIds = Object.values(this.notifications || {}) + const workspaceNotifications = orderBy( + Object.values(this.notifications || []), + (n) => convertToEpoch(n.created_at), + ["desc"] + ); + const workspaceNotificationIds = workspaceNotifications .filter((n) => n.workspace === workspaceId) .filter((n) => { if (!this.filters.archived && !this.filters.snoozed) { diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index 1eb7fdd7048..b6ec1165599 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -279,6 +279,18 @@ export const convertToISODateString = (dateString: string | undefined) => { return date.toISOString(); }; +/** + * returns the date string in Epoch regardless of the timezone in input date string + * @param dateString + * @returns + */ +export const convertToEpoch = (dateString: string | undefined) => { + if (!dateString) return dateString; + + const date = new Date(dateString); + return date.getTime(); +}; + /** * get current Date time in UTC ISO format * @returns From 38f8aa90c1f77c6c480af38d7b4f3b2aae0a61a0 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Fri, 5 Jul 2024 16:09:33 +0530 Subject: [PATCH 006/555] [WEB-1519] chore: update component structure in project state settings and implement DND (#5043) * chore: updated project settings state * chore: updated sorting on project state * chore: updated grab handler in state item * chore: Updated UI and added garb handler icon * chore: handled top and bottom sequence in middle element swap * chore: handled input state element char limit to 100 * chore: typos and code cleanup in create state * chore: handled typos and comments wherever is required * chore: handled sorting logic --- .../[projectId]/settings/states/page.tsx | 9 +- .../project-states/create-update/create.tsx | 93 ++++++ .../project-states/create-update/form.tsx | 112 +++++++ .../project-states/create-update/index.ts | 3 + .../project-states/create-update/update.tsx | 92 ++++++ .../components/project-states/group-item.tsx | 55 ++++ .../components/project-states/group-list.tsx | 36 ++ web/core/components/project-states/index.ts | 12 + web/core/components/project-states/loader.tsx | 12 + .../project-states/options/delete.tsx | 119 +++++++ .../project-states/options/index.ts | 2 + .../options/mark-as-default.tsx | 44 +++ web/core/components/project-states/root.tsx | 34 ++ .../state-delete-modal.tsx} | 4 +- .../components/project-states/state-item.tsx | 179 ++++++++++ .../components/project-states/state-list.tsx | 35 ++ .../components/states/create-state-modal.tsx | 259 --------------- .../states/create-update-state-inline.tsx | 310 ------------------ web/core/components/states/index.ts | 5 - .../project-setting-state-list-item.tsx | 128 -------- .../states/project-setting-state-list.tsx | 183 ----------- web/core/constants/state.ts | 5 + web/core/store/state.store.ts | 32 +- web/helpers/state.helper.ts | 32 +- 24 files changed, 880 insertions(+), 915 deletions(-) create mode 100644 web/core/components/project-states/create-update/create.tsx create mode 100644 web/core/components/project-states/create-update/form.tsx create mode 100644 web/core/components/project-states/create-update/index.ts create mode 100644 web/core/components/project-states/create-update/update.tsx create mode 100644 web/core/components/project-states/group-item.tsx create mode 100644 web/core/components/project-states/group-list.tsx create mode 100644 web/core/components/project-states/index.ts create mode 100644 web/core/components/project-states/loader.tsx create mode 100644 web/core/components/project-states/options/delete.tsx create mode 100644 web/core/components/project-states/options/index.ts create mode 100644 web/core/components/project-states/options/mark-as-default.tsx create mode 100644 web/core/components/project-states/root.tsx rename web/core/components/{states/delete-state-modal.tsx => project-states/state-delete-modal.tsx} (95%) create mode 100644 web/core/components/project-states/state-item.tsx create mode 100644 web/core/components/project-states/state-list.tsx delete mode 100644 web/core/components/states/create-state-modal.tsx delete mode 100644 web/core/components/states/create-update-state-inline.tsx delete mode 100644 web/core/components/states/index.ts delete mode 100644 web/core/components/states/project-setting-state-list-item.tsx delete mode 100644 web/core/components/states/project-setting-state-list.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx index 6c030a1fdc2..50b0c0abcaf 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx @@ -1,17 +1,20 @@ "use client"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; -import { ProjectSettingStateList } from "@/components/states"; +import { ProjectStateRoot } from "@/components/project-states"; // hook import { useProject } from "@/hooks/store"; const StatesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); // store const { currentProjectDetails } = useProject(); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined; + return ( <> @@ -19,7 +22,9 @@ const StatesSettingsPage = observer(() => {

States

- + {workspaceSlug && projectId && ( + + )}
); diff --git a/web/core/components/project-states/create-update/create.tsx b/web/core/components/project-states/create-update/create.tsx new file mode 100644 index 00000000000..b581eacecc5 --- /dev/null +++ b/web/core/components/project-states/create-update/create.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { IState, TStateGroups } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { StateForm } from "@/components/project-states"; +// constants +import { STATE_CREATED } from "@/constants/event-tracker"; +import { STATE_GROUPS } from "@/constants/state"; +// hooks +import { useEventTracker, useProjectState } from "@/hooks/store"; + +type TStateCreate = { + workspaceSlug: string; + projectId: string; + groupKey: TStateGroups; + handleClose: () => void; +}; + +export const StateCreate: FC = observer((props) => { + const { workspaceSlug, projectId, groupKey, handleClose } = props; + // hooks + const { captureProjectStateEvent, setTrackElement } = useEventTracker(); + const { createState } = useProjectState(); + // states + const [loader, setLoader] = useState(false); + + const onCancel = () => { + setLoader(false); + handleClose(); + }; + + const onSubmit = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !groupKey) return { status: "error" }; + + setTrackElement("PROJECT_SETTINGS_STATE_PAGE"); + try { + const stateResponse = await createState(workspaceSlug, projectId, { ...formData, group: groupKey }); + captureProjectStateEvent({ + eventName: STATE_CREATED, + payload: { + ...stateResponse, + state: "SUCCESS", + element: "Project settings states page", + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "State created successfully.", + }); + handleClose(); + return { status: "success" }; + } catch (error) { + const errorStatus = error as unknown as { status: number; data: { error: string } }; + captureProjectStateEvent({ + eventName: STATE_CREATED, + payload: { + ...formData, + state: "FAILED", + element: "Project settings states page", + }, + }); + if (errorStatus?.status === 400) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "State with that name already exists. Please try again with another name.", + }); + return { status: "already_exists" }; + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: errorStatus.data.error ?? "State could not be created. Please try again.", + }); + return { status: "error" }; + } + } + }; + + return ( + + ); +}); diff --git a/web/core/components/project-states/create-update/form.tsx b/web/core/components/project-states/create-update/form.tsx new file mode 100644 index 00000000000..6a26d7cdd2d --- /dev/null +++ b/web/core/components/project-states/create-update/form.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { FormEvent, FC, useEffect, useState, Fragment } from "react"; +import { TwitterPicker } from "react-color"; +import { Popover, Transition } from "@headlessui/react"; +import { IState } from "@plane/types"; +import { Button, Input } from "@plane/ui"; + +type TStateForm = { + data: Partial; + onSubmit: (formData: Partial) => Promise<{ status: string }>; + onCancel: () => void; + buttonDisabled: boolean; + buttonTitle: string; +}; + +export const StateForm: FC = (props) => { + const { data, onSubmit, onCancel, buttonDisabled, buttonTitle } = props; + // states + const [formData, setFromData] = useState | undefined>(undefined); + const [errors, setErrors] = useState> | undefined>(undefined); + + useEffect(() => { + if (data && !formData) setFromData(data); + }, [data, formData]); + + const handleFormData = (key: T, value: IState[T]) => { + setFromData((prev) => ({ ...prev, [key]: value })); + setErrors((prev) => ({ ...prev, [key]: "" })); + }; + + const formSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const name = formData?.name || undefined; + if (!formData || !name) { + let currentErrors: Partial> = {}; + if (!name) currentErrors = { ...currentErrors, name: "Name is required" }; + setErrors(currentErrors); + return; + } + + try { + await onSubmit(formData); + } catch (error) { + console.log("error", error); + } + }; + + return ( +
+ {/* color */} +
+ + + + + handleFormData("color", value.hex)} /> + + + +
+ + {/* title */} + handleFormData("name", e.target.value)} + hasError={(errors && Boolean(errors.name)) || false} + className="w-full" + maxLength={100} + autoFocus + /> + + {/* description */} + handleFormData("description", e.target.value)} + hasError={(errors && Boolean(errors.description)) || false} + className="w-full" + /> + + + + +
+ ); +}; diff --git a/web/core/components/project-states/create-update/index.ts b/web/core/components/project-states/create-update/index.ts new file mode 100644 index 00000000000..a295e4a83e0 --- /dev/null +++ b/web/core/components/project-states/create-update/index.ts @@ -0,0 +1,3 @@ +export * from "./create"; +export * from "./update"; +export * from "./form"; diff --git a/web/core/components/project-states/create-update/update.tsx b/web/core/components/project-states/create-update/update.tsx new file mode 100644 index 00000000000..669a8651612 --- /dev/null +++ b/web/core/components/project-states/create-update/update.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { IState } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { StateForm } from "@/components/project-states"; +// constants +import { STATE_UPDATED } from "@/constants/event-tracker"; +// hooks +import { useEventTracker, useProjectState } from "@/hooks/store"; + +type TStateUpdate = { + workspaceSlug: string; + projectId: string; + state: IState; + handleClose: () => void; +}; + +export const StateUpdate: FC = observer((props) => { + const { workspaceSlug, projectId, state, handleClose } = props; + // hooks + const { captureProjectStateEvent, setTrackElement } = useEventTracker(); + const { updateState } = useProjectState(); + // states + const [loader, setLoader] = useState(false); + + const onCancel = () => { + setLoader(false); + handleClose(); + }; + + const onSubmit = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !state.id) return { status: "error" }; + + setTrackElement("PROJECT_SETTINGS_STATE_PAGE"); + try { + const stateResponse = await updateState(workspaceSlug, projectId, state.id, formData); + captureProjectStateEvent({ + eventName: STATE_UPDATED, + payload: { + ...stateResponse, + state: "SUCCESS", + element: "Project settings states page", + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "State updated successfully.", + }); + handleClose(); + return { status: "success" }; + } catch (error) { + const errorStatus = error as unknown as { status: number }; + if (errorStatus?.status === 400) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Another state exists with the same name. Please try again with another name.", + }); + return { status: "already_exists" }; + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "State could not be updated. Please try again.", + }); + captureProjectStateEvent({ + eventName: STATE_UPDATED, + payload: { + ...formData, + state: "FAILED", + element: "Project settings states page", + }, + }); + return { status: "error" }; + } + } + }; + + return ( + + ); +}); diff --git a/web/core/components/project-states/group-item.tsx b/web/core/components/project-states/group-item.tsx new file mode 100644 index 00000000000..5316871a1c3 --- /dev/null +++ b/web/core/components/project-states/group-item.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { Plus } from "lucide-react"; +import { IState, TStateGroups } from "@plane/types"; +// components +import { StateList, StateCreate } from "@/components/project-states"; + +type TGroupItem = { + workspaceSlug: string; + projectId: string; + groupKey: TStateGroups; + groupedStates: Record; + states: IState[]; +}; + +export const GroupItem: FC = observer((props) => { + const { workspaceSlug, projectId, groupKey, groupedStates, states } = props; + // state + const [createState, setCreateState] = useState(false); + + return ( +
+
+
{groupKey}
+
!createState && setCreateState(true)} + > + +
+
+ + {createState && ( + setCreateState(false)} + /> + )} + +
+ +
+
+ ); +}); diff --git a/web/core/components/project-states/group-list.tsx b/web/core/components/project-states/group-list.tsx new file mode 100644 index 00000000000..59b5e66be9d --- /dev/null +++ b/web/core/components/project-states/group-list.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { IState, TStateGroups } from "@plane/types"; +// components +import { GroupItem } from "@/components/project-states"; + +type TGroupList = { + workspaceSlug: string; + projectId: string; + groupedStates: Record; +}; + +export const GroupList: FC = observer((props) => { + const { workspaceSlug, projectId, groupedStates } = props; + + return ( +
+ {Object.entries(groupedStates).map(([key, value]) => { + const groupKey = key as TStateGroups; + const groupStates = value; + return ( + + ); + })} +
+ ); +}); diff --git a/web/core/components/project-states/index.ts b/web/core/components/project-states/index.ts new file mode 100644 index 00000000000..8c2901392bd --- /dev/null +++ b/web/core/components/project-states/index.ts @@ -0,0 +1,12 @@ +export * from "./root"; + +export * from "./group-list"; +export * from "./group-item"; + +export * from "./state-list"; +export * from "./state-item"; +export * from "./options"; + +export * from "./loader"; +export * from "./create-update"; +export * from "./state-delete-modal"; diff --git a/web/core/components/project-states/loader.tsx b/web/core/components/project-states/loader.tsx new file mode 100644 index 00000000000..958d0ea9eb0 --- /dev/null +++ b/web/core/components/project-states/loader.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Loader } from "@plane/ui"; + +export const ProjectStateLoader = () => ( + + + + + + +); diff --git a/web/core/components/project-states/options/delete.tsx b/web/core/components/project-states/options/delete.tsx new file mode 100644 index 00000000000..00f88f03490 --- /dev/null +++ b/web/core/components/project-states/options/delete.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { Loader, X } from "lucide-react"; +import { IState } from "@plane/types"; +import { AlertModalCore, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +// constants +import { STATE_DELETED } from "@/constants/event-tracker"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useEventTracker, useProjectState } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +type TStateDelete = { + workspaceSlug: string; + projectId: string; + totalStates: number; + state: IState; +}; + +export const StateDelete: FC = observer((props) => { + const { workspaceSlug, projectId, totalStates, state } = props; + // hooks + const { isMobile } = usePlatformOS(); + const { captureProjectStateEvent, setTrackElement } = useEventTracker(); + const { deleteState } = useProjectState(); + // states + const [isDeleteModal, setIsDeleteModal] = useState(false); + const [isDelete, setIsDelete] = useState(false); + + // derived values + const isDeleteDisabled = state.default ? true : totalStates === 1 ? true : false; + + const handleDeleteState = async () => { + if (!workspaceSlug || !projectId || isDeleteDisabled) return; + + setTrackElement("PROJECT_SETTINGS_STATE_PAGE"); + setIsDelete(true); + + try { + await deleteState(workspaceSlug, projectId, state.id); + captureProjectStateEvent({ + eventName: STATE_DELETED, + payload: { + ...state, + state: "SUCCESS", + }, + }); + setIsDelete(false); + } catch (error) { + const errorStatus = error as unknown as { status: number; data: { error: string } }; + captureProjectStateEvent({ + eventName: STATE_DELETED, + payload: { + ...state, + state: "FAILED", + }, + }); + if (errorStatus.status === 400) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: + "This state contains some issues within it, please move them to some other state to delete this state.", + }); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "State could not be deleted. Please try again.", + }); + } + setIsDelete(false); + } + }; + + return ( + <> + setIsDeleteModal(false)} + handleSubmit={handleDeleteState} + isSubmitting={isDelete} + isOpen={isDeleteModal} + title="Delete State" + content={ + <> + Are you sure you want to delete state-{" "} + {state?.name}? All of the data related to the + state will be permanently removed. This action cannot be undone. + + } + /> + + + + ); +}); diff --git a/web/core/components/project-states/options/index.ts b/web/core/components/project-states/options/index.ts new file mode 100644 index 00000000000..6aad9566cbe --- /dev/null +++ b/web/core/components/project-states/options/index.ts @@ -0,0 +1,2 @@ +export * from "./mark-as-default"; +export * from "./delete"; diff --git a/web/core/components/project-states/options/mark-as-default.tsx b/web/core/components/project-states/options/mark-as-default.tsx new file mode 100644 index 00000000000..667b2063b4f --- /dev/null +++ b/web/core/components/project-states/options/mark-as-default.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useProjectState } from "@/hooks/store"; + +type TStateMarksAsDefault = { workspaceSlug: string; projectId: string; stateId: string; isDefault: boolean }; + +export const StateMarksAsDefault: FC = observer((props) => { + const { workspaceSlug, projectId, stateId, isDefault } = props; + // hooks + const { markStateAsDefault } = useProjectState(); + // states + const [isLoading, setIsLoading] = useState(false); + + const handleMarkAsDefault = async () => { + if (!workspaceSlug || !projectId || !stateId || isDefault) return; + setIsLoading(true); + + try { + setIsLoading(false); + await markStateAsDefault(workspaceSlug, projectId, stateId); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + } + }; + + return ( + + ); +}); diff --git a/web/core/components/project-states/root.tsx b/web/core/components/project-states/root.tsx new file mode 100644 index 00000000000..aca642ef0c8 --- /dev/null +++ b/web/core/components/project-states/root.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { ProjectStateLoader, GroupList } from "@/components/project-states"; +// hooks +import { useProjectState } from "@/hooks/store"; + +type TProjectState = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectStateRoot: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + // hooks + const { groupedProjectStates, fetchProjectStates } = useProjectState(); + + useSWR( + workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null, + workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null + ); + + // Loader + if (!groupedProjectStates) return ; + + return ( +
+ +
+ ); +}); diff --git a/web/core/components/states/delete-state-modal.tsx b/web/core/components/project-states/state-delete-modal.tsx similarity index 95% rename from web/core/components/states/delete-state-modal.tsx rename to web/core/components/project-states/state-delete-modal.tsx index de66c3b4962..f36c4ca12ca 100644 --- a/web/core/components/states/delete-state-modal.tsx +++ b/web/core/components/project-states/state-delete-modal.tsx @@ -12,13 +12,13 @@ import { STATE_DELETED } from "@/constants/event-tracker"; // hooks import { useEventTracker, useProjectState } from "@/hooks/store"; -type Props = { +type TStateDeleteModal = { isOpen: boolean; onClose: () => void; data: IState | null; }; -export const DeleteStateModal: React.FC = observer((props) => { +export const StateDeleteModal: React.FC = observer((props) => { const { isOpen, onClose, data } = props; // states const [isDeleteLoading, setIsDeleteLoading] = useState(false); diff --git a/web/core/components/project-states/state-item.tsx b/web/core/components/project-states/state-item.tsx new file mode 100644 index 00000000000..e15a708d7a5 --- /dev/null +++ b/web/core/components/project-states/state-item.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { FC, Fragment, useCallback, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { observer } from "mobx-react"; +import { GripVertical, Pencil } from "lucide-react"; +import { IState, TStateGroups } from "@plane/types"; +import { DropIndicator, StateGroupIcon } from "@plane/ui"; +// components +import { StateUpdate, StateDelete, StateMarksAsDefault } from "@/components/project-states"; +// helpers +import { TDraggableData } from "@/constants/state"; +import { cn } from "@/helpers/common.helper"; +import { getCurrentStateSequence } from "@/helpers/state.helper"; +// hooks +import { useProjectState } from "@/hooks/store"; + +type TStateItem = { + workspaceSlug: string; + projectId: string; + groupKey: TStateGroups; + groupedStates: Record; + totalStates: number; + state: IState; +}; + +export const StateItem: FC = observer((props) => { + const { workspaceSlug, projectId, groupKey, groupedStates, totalStates, state } = props; + // hooks + const { moveStatePosition } = useProjectState(); + // states + const [updateStateModal, setUpdateStateModal] = useState(false); + + const handleStateSequence = useCallback( + async (payload: Partial) => { + try { + if (!workspaceSlug || !projectId || !payload.id) return; + await moveStatePosition(workspaceSlug, projectId, payload.id, payload); + } catch (error) { + console.error("error", error); + } + }, + [workspaceSlug, projectId, moveStatePosition] + ); + + // derived values + const isDraggable = totalStates === 1 ? false : true; + + // DND starts + // ref + const draggableElementRef = useRef(null); + // states + const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState(false); + const [closestEdge, setClosestEdge] = useState(null); + useEffect(() => { + const elementRef = draggableElementRef.current; + const initialData: TDraggableData = { groupKey: groupKey, id: state.id }; + + if (elementRef && state) { + combine( + draggable({ + element: elementRef, + getInitialData: () => initialData, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + canDrag: () => isDraggable, + }), + dropTargetForElements({ + element: elementRef, + getData: ({ input, element }) => + attachClosestEdge(initialData, { + input, + element, + allowedEdges: ["top", "bottom"], + }), + onDragEnter: (args) => { + setIsDraggedOver(true); + setClosestEdge(extractClosestEdge(args.self.data)); + }, + onDragLeave: () => { + setIsDraggedOver(false); + setClosestEdge(null); + }, + onDrop: (data) => { + setIsDraggedOver(false); + const { self, source } = data; + const sourceData = source.data as TDraggableData; + const destinationData = self.data as TDraggableData; + + if (sourceData && destinationData && sourceData.id) { + const destinationGroupKey = destinationData.groupKey as TStateGroups; + const edge = extractClosestEdge(destinationData) || undefined; + const payload: Partial = { + id: sourceData.id as string, + group: destinationGroupKey, + sequence: getCurrentStateSequence(groupedStates[destinationGroupKey], destinationData, edge), + }; + handleStateSequence(payload); + } + }, + }) + ); + } + }, [draggableElementRef, state, groupKey, isDraggable, groupedStates, handleStateSequence]); + // DND ends + + if (updateStateModal) + return ( + setUpdateStateModal(false)} + /> + ); + + return ( + + {/* draggable drop top indicator */} + + +
+ {/* draggable indicator */} + {totalStates != 1 && ( +
+ +
+ )} + + {/* state icon */} +
+ +
+ + {/* state title and description */} +
+
{state.name}
+

{state.description}

+
+ +
+ {/* state mark as default option */} +
+ +
+ + {/* state edit options */} +
+ + +
+
+
+ + {/* draggable drop bottom indicator */} + +
+ ); +}); diff --git a/web/core/components/project-states/state-list.tsx b/web/core/components/project-states/state-list.tsx new file mode 100644 index 00000000000..42ddb6955c0 --- /dev/null +++ b/web/core/components/project-states/state-list.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { IState, TStateGroups } from "@plane/types"; +// components +import { StateItem } from "@/components/project-states"; + +type TStateList = { + workspaceSlug: string; + projectId: string; + groupKey: TStateGroups; + groupedStates: Record; + states: IState[]; +}; + +export const StateList: FC = observer((props) => { + const { workspaceSlug, projectId, groupKey, groupedStates, states } = props; + + return ( + <> + {states.map((state: IState) => ( + + ))} + + ); +}); diff --git a/web/core/components/states/create-state-modal.tsx b/web/core/components/states/create-state-modal.tsx deleted file mode 100644 index ccbcf8895b0..00000000000 --- a/web/core/components/states/create-state-modal.tsx +++ /dev/null @@ -1,259 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { TwitterPicker } from "react-color"; -import { Controller, useForm } from "react-hook-form"; -import { ChevronDown } from "lucide-react"; -import { Dialog, Popover, Transition } from "@headlessui/react"; -// icons -import type { IState } from "@plane/types"; -// ui -import { Button, CustomSelect, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { GROUP_CHOICES } from "@/constants/project"; -// hooks -import { useProjectState } from "@/hooks/store"; -// types - -// types -type Props = { - isOpen: boolean; - projectId: string; - handleClose: () => void; -}; - -const defaultValues: Partial = { - name: "", - description: "", - color: "rgb(var(--color-text-200))", - group: "backlog", -}; - -export const CreateStateModal: React.FC = observer((props) => { - const { isOpen, projectId, handleClose } = props; - // router - const { workspaceSlug } = useParams(); - // store hooks - const { createState } = useProjectState(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - watch, - control, - reset, - } = useForm({ - defaultValues, - }); - - const onClose = () => { - handleClose(); - reset(defaultValues); - }; - - const onSubmit = async (formData: IState) => { - if (!workspaceSlug) return; - - const payload: IState = { - ...formData, - }; - - await createState(workspaceSlug.toString(), projectId.toString(), payload) - .then(() => { - onClose(); - }) - .catch((err) => { - const error = err.response; - - if (typeof error === "object") { - Object.keys(error).forEach((key) => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: Array.isArray(error[key]) ? error[key].join(", ") : error[key], - }); - }); - } else { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - error ?? err.status === 400 - ? "Another state exists with the same name. Please try again with another name." - : "State could not be created. Please try again.", - }); - } - }); - }; - - return ( - - - -
- - -
-
- - -
-
- - Create State - -
-
- ( - <> - - - - )} - /> -
-
- ( - - {Object.keys(GROUP_CHOICES).map((key) => ( - - {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} - - ))} - - )} - /> -
-
- - {({ open }) => ( - <> - - Color - {watch("color") && watch("color") !== "" && ( - - )} - - - - - ( - onChange(value.hex)} /> - )} - /> - - - - )} - -
-
- - ( -