From 2dcaccd4ec4d932b4f9243a3cf984d040f70e7a5 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 21 Sep 2023 15:00:57 +0530 Subject: [PATCH] fix: merge conflicts (#2231) * chore: dynamic position dropdown (#2138) * chore: dynamic position state dropdown for issue view * style: state select dropdown styling * fix: state icon attribute names * chore: state select dynamic dropdown * chore: member select dynamic dropdown * chore: label select dynamic dropdown * chore: priority select dynamic dropdown * chore: label select dropdown improvement * refactor: state dropdown location * chore: dropdown improvement and code refactor * chore: dynamic dropdown hook type added --------- Co-authored-by: Aaryan Khandelwal * fix: fields not getting selected in the create issue form (#2212) * fix: hydration error and draft issue workflow * fix: build error * fix: properties getting de-selected after create, module & cycle not getting auto-select on the form * fix: display layout, props being updated directly * chore: sub issues count in individual issue (#2221) * fix: service imports * chore: rename csv service file --------- Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> --- apiserver/plane/api/views/issue.py | 7 +- .../core/views/board-view/board-header.tsx | 2 - .../core/views/board-view/single-issue.tsx | 150 ++++++++--- .../core/views/calendar-view/single-issue.tsx | 142 ++++++++--- web/components/core/views/issues-view.tsx | 16 +- .../core/views/list-view/single-issue.tsx | 142 ++++++++--- .../views/spreadsheet-view/single-issue.tsx | 149 ++++++++--- .../cycles/active-cycle-details.tsx | 2 +- web/components/cycles/cycles-view.tsx | 2 +- web/components/exporter/export-modal.tsx | 2 +- .../issues/confirm-issue-discard.tsx | 2 +- web/components/issues/draft-issue-form.tsx | 40 ++- web/components/issues/draft-issue-modal.tsx | 172 +++++++++++-- web/components/issues/form.tsx | 2 + web/components/issues/modal.tsx | 62 ++++- web/components/issues/view-select/index.ts | 3 +- web/components/issues/view-select/state.tsx | 130 ---------- web/components/project/index.ts | 3 + web/components/project/label-select.tsx | 239 ++++++++++++++++++ web/components/project/members-select.tsx | 191 ++++++++++++++ web/components/project/priority-select.tsx | 173 +++++++++++++ web/components/states/index.ts | 1 + web/components/states/state-select.tsx | 169 +++++++++++++ .../workspace/sidebar-quick-action.tsx | 91 +++---- web/hooks/use-dynamic-dropdown.tsx | 64 +++++ .../{csv.services.ts => csv.service.ts} | 0 26 files changed, 1578 insertions(+), 378 deletions(-) delete mode 100644 web/components/issues/view-select/state.tsx create mode 100644 web/components/project/label-select.tsx create mode 100644 web/components/project/members-select.tsx create mode 100644 web/components/project/priority-select.tsx create mode 100644 web/components/states/state-select.tsx create mode 100644 web/hooks/use-dynamic-dropdown.tsx rename web/services/{csv.services.ts => csv.service.ts} (100%) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 8d2ed9b96c0..e653f3d447d 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -330,7 +330,12 @@ def create(self, request, slug, project_id): def retrieve(self, request, slug, project_id, pk=None): try: - issue = Issue.issue_objects.get( + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get( workspace__slug=slug, project_id=project_id, pk=pk ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) diff --git a/web/components/core/views/board-view/board-header.tsx b/web/components/core/views/board-view/board-header.tsx index 2bf811d45dd..93aee9ce7b2 100644 --- a/web/components/core/views/board-view/board-header.tsx +++ b/web/components/core/views/board-view/board-header.tsx @@ -50,8 +50,6 @@ export const BoardHeader: React.FC = ({ const { displayFilters, groupedIssues } = viewProps; - console.log("dF", displayFilters); - const { data: issueLabels } = useSWR( workspaceSlug && projectId && displayFilters?.group_by === "labels" ? PROJECT_ISSUE_LABELS(projectId.toString()) diff --git a/web/components/core/views/board-view/single-issue.tsx b/web/components/core/views/board-view/single-issue.tsx index 1e818ae7e90..29cfe2c3598 100644 --- a/web/components/core/views/board-view/single-issue.tsx +++ b/web/components/core/views/board-view/single-issue.tsx @@ -8,19 +8,14 @@ import { mutate } from "swr"; import { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from "react-beautiful-dnd"; // services import issuesService from "services/issue.service"; +import trackEventServices from "services/track_event.service"; // hooks import useToast from "hooks/use-toast"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewIssueLabel, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { MembersSelect, LabelSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; // ui import { ContextMenu, CustomMenu, Tooltip } from "components/ui"; // icons @@ -39,7 +34,15 @@ import { LayerDiagonalIcon } from "components/icons"; import { handleIssuesMutation } from "helpers/issue.helper"; import { copyTextToClipboard } from "helpers/string.helper"; // types -import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types"; +import { + ICurrentUserResponse, + IIssue, + IIssueViewProps, + IState, + ISubIssueResponse, + TIssuePriorities, + UserAuth, +} from "types"; // fetch-keys import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; @@ -175,6 +178,86 @@ export const SingleBoardIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + useEffect(() => { if (snapshot.isDragging) handleTrashBox(snapshot.isDragging); }, [snapshot, handleTrashBox]); @@ -326,33 +409,30 @@ export const SingleBoardIssue: React.FC = ({ )}
{properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -376,16 +456,22 @@ export const SingleBoardIssue: React.FC = ({ /> )} {properties.labels && issue.labels.length > 0 && ( - + )} {properties.assignee && ( - )} {properties.estimate && issue.estimate_point !== null && ( diff --git a/web/components/core/views/calendar-view/single-issue.tsx b/web/components/core/views/calendar-view/single-issue.tsx index 92e2691c6a9..161c68a1a92 100644 --- a/web/components/core/views/calendar-view/single-issue.tsx +++ b/web/components/core/views/calendar-view/single-issue.tsx @@ -8,28 +8,23 @@ import { mutate } from "swr"; import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd"; // services import issuesService from "services/issue.service"; +import trackEventServices from "services/track_event.service"; // hooks import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useIssuesProperties from "hooks/use-issue-properties"; import useToast from "hooks/use-toast"; // components import { CustomMenu, Tooltip } from "components/ui"; -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewLabelSelect, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { LabelSelect, MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; // icons import { LinkIcon, PaperClipIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // helper import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // type -import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types"; +import { ICurrentUserResponse, IIssue, IState, ISubIssueResponse, TIssuePriorities } from "types"; // fetch-keys import { CYCLE_ISSUES_WITH_PARAMS, @@ -144,6 +139,86 @@ export const SingleCalendarIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + const displayProperties = properties ? Object.values(properties).some((value) => value === true) : false; const openPeekOverview = () => { @@ -214,22 +289,19 @@ export const SingleCalendarIssue: React.FC = ({ {displayProperties && (
{properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -249,21 +321,23 @@ export const SingleCalendarIssue: React.FC = ({ /> )} {properties.labels && issue.labels.length > 0 && ( - )} {properties.assignee && ( - )} {properties.estimate && issue.estimate_point !== null && ( diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index 6b6e1c909d4..521318e40b7 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; @@ -82,7 +82,8 @@ export const IssuesView: React.FC = ({ openIssuesListModal, disableUserAc const { setToastAlert } = useToast(); - const { groupedByIssues, mutateIssues, displayFilters, filters, isEmpty, setFilters, params } = useIssuesView(); + const { groupedByIssues, mutateIssues, displayFilters, filters, isEmpty, setFilters, params, setDisplayFilters } = + useIssuesView(); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const { data: stateGroups } = useSWR( @@ -100,6 +101,17 @@ export const IssuesView: React.FC = ({ openIssuesListModal, disableUserAc const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString()); + useEffect(() => { + if (!isDraftIssues) return; + + if ( + displayFilters.layout === "calendar" || + displayFilters.layout === "gantt_chart" || + displayFilters.layout === "spreadsheet" + ) + setDisplayFilters({ layout: "list" }); + }, [isDraftIssues, displayFilters, setDisplayFilters]); + const handleDeleteIssue = useCallback( (issue: IIssue) => { setDeleteIssueModal(true); diff --git a/web/components/core/views/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx index 34f7272222b..54ef1a15baf 100644 --- a/web/components/core/views/list-view/single-issue.tsx +++ b/web/components/core/views/list-view/single-issue.tsx @@ -6,19 +6,13 @@ import { mutate } from "swr"; // services import issuesService from "services/issue.service"; +import trackEventServices from "services/track_event.service"; // hooks import useToast from "hooks/use-toast"; // components -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewIssueLabel, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, - CreateUpdateDraftIssueModal, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { LabelSelect, MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; // ui import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; // icons @@ -40,8 +34,10 @@ import { ICurrentUserResponse, IIssue, IIssueViewProps, + IState, ISubIssueResponse, IUserProfileProjectSegregation, + TIssuePriorities, UserAuth, } from "types"; // fetch-keys @@ -161,6 +157,86 @@ export const SingleListIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + const issuePath = isArchivedIssues ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` : isDraftIssues @@ -265,21 +341,19 @@ export const SingleListIssue: React.FC = ({
{properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -298,14 +372,24 @@ export const SingleListIssue: React.FC = ({ isNotAllowed={isNotAllowed} /> )} - {properties.labels && } - {properties.assignee && ( - + )} + {properties.assignee && ( + )} {properties.estimate && issue.estimate_point !== null && ( diff --git a/web/components/core/views/spreadsheet-view/single-issue.tsx b/web/components/core/views/spreadsheet-view/single-issue.tsx index c4519d2dcd7..f9c9f1ceebf 100644 --- a/web/components/core/views/spreadsheet-view/single-issue.tsx +++ b/web/components/core/views/spreadsheet-view/single-issue.tsx @@ -5,15 +5,9 @@ import { useRouter } from "next/router"; import { mutate } from "swr"; // components -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewIssueLabel, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { LabelSelect, MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; import { Popover2 } from "@blueprintjs/popover2"; // icons import { Icon } from "components/ui"; @@ -23,6 +17,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useToast from "hooks/use-toast"; // services import issuesService from "services/issue.service"; +import trackEventServices from "services/track_event.service"; // constant import { CYCLE_DETAILS, @@ -34,7 +29,7 @@ import { VIEW_ISSUES, } from "constants/fetch-keys"; // types -import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types"; +import { ICurrentUserResponse, IIssue, IState, ISubIssueResponse, Properties, TIssuePriorities, UserAuth } from "types"; // helper import { copyTextToClipboard } from "helpers/string.helper"; import { renderLongDetailDateFormat } from "helpers/date-time.helper"; @@ -166,6 +161,86 @@ export const SingleSpreadsheetIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + const paddingLeft = `${nestingLevel * 68}px`; const tooltipPosition = index === 0 ? "bottom" : "top"; @@ -269,47 +344,49 @@ export const SingleSpreadsheetIssue: React.FC = ({
{properties.state && (
-
)} {properties.priority && (
-
)} {properties.assignee && (
-
)} {properties.labels && (
- +
)} diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 062dd57e74c..7816f0edba1 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -127,7 +127,7 @@ export const ActiveCycleDetails: React.FC = () => { cy="34.375" r="22" stroke="rgb(var(--color-text-400))" - stroke-linecap="round" + strokeLinecap="round" /> = ({ cycles, mutateCycles, viewType }) cy="34.375" r="22" stroke="rgb(var(--color-text-400))" - stroke-linecap="round" + strokeLinecap="round" /> = (props) => {
-
+
= { }; interface IssueFormProps { - handleFormSubmit: (formData: Partial) => Promise; + handleFormSubmit: ( + formData: Partial, + action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" + ) => Promise; data?: Partial | null; prePopulatedData?: Partial | null; projectId: string; @@ -134,12 +137,16 @@ export const DraftIssueForm: FC = (props) => { const handleCreateUpdateIssue = async ( formData: Partial, - action: "saveDraft" | "createToNewIssue" = "saveDraft" + action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" ) => { - await handleFormSubmit({ - ...formData, - is_draft: action === "saveDraft", - }); + await handleFormSubmit( + { + ...(data ?? {}), + ...formData, + is_draft: action === "createDraft" || action === "updateDraft", + }, + action + ); setGptAssistantModal(false); @@ -263,7 +270,9 @@ export const DraftIssueForm: FC = (props) => { )}
handleCreateUpdateIssue(formData, "createToNewIssue"))} + onSubmit={handleSubmit((formData) => + handleCreateUpdateIssue(formData, "convertToNewIssue") + )} >
@@ -563,15 +572,20 @@ export const DraftIssueForm: FC = (props) => { Discard handleCreateUpdateIssue(formData, "saveDraft"))} + onClick={handleSubmit((formData) => + handleCreateUpdateIssue(formData, data?.id ? "updateDraft" : "createDraft") + )} > {isSubmitting ? "Saving..." : "Save Draft"} - {data && ( - - {isSubmitting ? "Saving..." : "Add Issue"} - - )} + + handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createNewIssue") + )} + > + {isSubmitting ? "Saving..." : "Add Issue"} +
diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index eafc2a4dfe9..97d1c18a62a 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -31,7 +31,10 @@ import { MODULE_ISSUES_WITH_PARAMS, VIEW_ISSUES, PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, + CYCLE_DETAILS, + MODULE_DETAILS, } from "constants/fetch-keys"; +import modulesService from "services/modules.service"; interface IssuesModalProps { data?: IIssue | null; @@ -56,18 +59,21 @@ interface IssuesModalProps { onSubmit?: (data: Partial) => Promise | void; } -export const CreateUpdateDraftIssueModal: React.FC = ({ - data, - handleClose, - isOpen, - isUpdatingSingleIssue = false, - prePopulateData, - fieldsToShow = ["all"], - onSubmit, -}) => { +export const CreateUpdateDraftIssueModal: React.FC = (props) => { + const { + data, + handleClose, + isOpen, + isUpdatingSingleIssue = false, + prePopulateData: prePopulateDataProps, + fieldsToShow = ["all"], + onSubmit, + } = props; + // states const [createMore, setCreateMore] = useState(false); const [activeProject, setActiveProject] = useState(null); + const [prePopulateData, setPreloadedData] = useState | undefined>(undefined); const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; @@ -86,19 +92,40 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ const { setToastAlert } = useToast(); - if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; - if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; - if (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) - prePopulateData = { - ...prePopulateData, - assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], - }; - const onClose = () => { handleClose(); setActiveProject(null); }; + useEffect(() => { + setPreloadedData(prePopulateDataProps ?? {}); + + if (cycleId && !prePopulateDataProps?.cycle) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + cycle: cycleId.toString(), + })); + } + if (moduleId && !prePopulateDataProps?.module) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + module: moduleId.toString(), + })); + } + if ( + (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && + !prePopulateDataProps?.assignees + ) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + })); + } + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + useEffect(() => { // if modal is closed, reset active project to null // and return to avoid activeProject being set to some other project @@ -109,10 +136,10 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ // if data is present, set active project to the project of the // issue. This has more priority than the project in the url. - if (data && data.project) { - setActiveProject(data.project); - return; - } + if (data && data.project) return setActiveProject(data.project); + + if (prePopulateData && prePopulateData.project && !activeProject) + return setActiveProject(prePopulateData.project); if (prePopulateData && prePopulateData.project) return setActiveProject(prePopulateData.project); @@ -146,7 +173,7 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ ? VIEW_ISSUES(viewId.toString(), viewGanttParams) : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? ""); - const createIssue = async (payload: Partial) => { + const createDraftIssue = async (payload: Partial) => { if (!workspaceSlug || !activeProject || !user) return; await issuesService @@ -186,7 +213,7 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ if (!createMore) onClose(); }; - const updateIssue = async (payload: Partial) => { + const updateDraftIssue = async (payload: Partial) => { if (!user) return; await issuesService @@ -202,6 +229,11 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); } + if (!payload.is_draft) { + if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); + if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); + } + if (!createMore) onClose(); setToastAlert({ @@ -219,7 +251,93 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ }); }; - const handleFormSubmit = async (formData: Partial) => { + const addIssueToCycle = async (issueId: string, cycleId: string) => { + if (!workspaceSlug || !activeProject) return; + + await issuesService + .addIssueToCycle( + workspaceSlug as string, + activeProject ?? "", + cycleId, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params)); + mutate(CYCLE_DETAILS(cycleId as string)); + } + }); + }; + + const addIssueToModule = async (issueId: string, moduleId: string) => { + if (!workspaceSlug || !activeProject) return; + + await modulesService + .addIssuesToModule( + workspaceSlug as string, + activeProject ?? "", + moduleId as string, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); + mutate(MODULE_DETAILS(moduleId as string)); + } + }); + }; + + const createIssue = async (payload: Partial) => { + if (!workspaceSlug || !activeProject) return; + + await issuesService + .createIssues(workspaceSlug as string, activeProject ?? "", payload, user) + .then(async (res) => { + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle); + if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module); + + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "gantt_chart") + mutate(ganttFetchKey, { + start_target_date: true, + order_by: "sort_order", + }); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); + if (groupedIssues) mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (!createMore) onClose(); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + }; + + const handleFormSubmit = async ( + formData: Partial, + action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" + ) => { if (!workspaceSlug || !activeProject) return; const payload: Partial = { @@ -230,8 +348,10 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ description_html: formData.description_html ?? "

", }; - if (!data) await createIssue(payload); - else await updateIssue(payload); + if (action === "createDraft") await createDraftIssue(payload); + else if (action === "updateDraft" || action === "convertToNewIssue") + await updateDraftIssue(payload); + else if (action === "createNewIssue") await createIssue(payload); clearDraftIssueLocalStorage(); diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index 2e53f38666e..c92c3d332ed 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -139,6 +139,8 @@ export const IssueForm: FC = (props) => { target_date: getValues("target_date"), project: getValues("project"), parent: getValues("parent"), + cycle: getValues("cycle"), + module: getValues("module"), }; useEffect(() => { diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index 88dc38f7ac1..9ea5f18712d 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -69,7 +69,7 @@ export const CreateUpdateIssueModal: React.FC = ({ handleClose, isOpen, isUpdatingSingleIssue = false, - prePopulateData, + prePopulateData: prePopulateDataProps, fieldsToShow = ["all"], onSubmit, }) => { @@ -78,6 +78,7 @@ export const CreateUpdateIssueModal: React.FC = ({ const [formDirtyState, setFormDirtyState] = useState(null); const [showConfirmDiscard, setShowConfirmDiscard] = useState(false); const [activeProject, setActiveProject] = useState(null); + const [prePopulateData, setPreloadedData] = useState>({}); const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query; @@ -100,11 +101,40 @@ export const CreateUpdateIssueModal: React.FC = ({ const { setToastAlert } = useToast(); - if (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) - prePopulateData = { - ...prePopulateData, - assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], - }; + useEffect(() => { + setPreloadedData(prePopulateDataProps ?? {}); + + if (cycleId && !prePopulateDataProps?.cycle) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + cycle: cycleId.toString(), + })); + } + if (moduleId && !prePopulateDataProps?.module) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + module: moduleId.toString(), + })); + } + if ( + (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && + !prePopulateDataProps?.assignees + ) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + })); + } + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + + /** + * + * @description This function is used to close the modals. This function will show a confirm discard modal if the form is dirty. + * @returns void + */ const onClose = () => { if (!showConfirmDiscard) handleClose(); @@ -113,6 +143,22 @@ export const CreateUpdateIssueModal: React.FC = ({ setValueInLocalStorage(data); }; + /** + * @description This function is used to close the modals. This function is to be used when the form is submitted, + * meaning we don't need to show the confirm discard modal or store the form data in local storage. + */ + + const onFormSubmitClose = () => { + setFormDirtyState(null); + handleClose(); + }; + + /** + * @description This function is used to close the modals. This function is to be used when we click outside the modal, + * meaning we don't need to show the confirm discard modal but will store the form data in local storage. + * Use this function when you want to store the form data in local storage. + */ + const onDiscardClose = () => { if (formDirtyState !== null) { setShowConfirmDiscard(true); @@ -290,7 +336,7 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }); - if (!createMore) onDiscardClose(); + if (!createMore) onFormSubmitClose(); }; const createDraftIssue = async () => { @@ -349,7 +395,7 @@ export const CreateUpdateIssueModal: React.FC = ({ if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); - if (!createMore) onDiscardClose(); + if (!createMore) onFormSubmitClose(); setToastAlert({ type: "success", diff --git a/web/components/issues/view-select/index.ts b/web/components/issues/view-select/index.ts index d78a82ed35e..99191eb3d21 100644 --- a/web/components/issues/view-select/index.ts +++ b/web/components/issues/view-select/index.ts @@ -3,5 +3,4 @@ export * from "./due-date"; export * from "./estimate"; export * from "./label"; export * from "./priority"; -export * from "./start-date"; -export * from "./state"; +export * from "./start-date"; \ No newline at end of file diff --git a/web/components/issues/view-select/state.tsx b/web/components/issues/view-select/state.tsx deleted file mode 100644 index 2b2f16467c9..00000000000 --- a/web/components/issues/view-select/state.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useState } from "react"; - -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import stateService from "services/project_state.service"; -import trackEventServices from "services/track_event.service"; -// ui -import { CustomSearchSelect, Tooltip } from "components/ui"; -// icons -import { StateGroupIcon } from "components/icons"; -// helpers -import { getStatesList } from "helpers/state.helper"; -// types -import { ICurrentUserResponse, IIssue } from "types"; -// fetch-keys -import { STATES_LIST } from "constants/fetch-keys"; - -type Props = { - issue: IIssue; - partialUpdateIssue: (formData: Partial, issue: IIssue) => void; - position?: "left" | "right"; - tooltipPosition?: "top" | "bottom"; - className?: string; - selfPositioned?: boolean; - customButton?: boolean; - user: ICurrentUserResponse | undefined; - isNotAllowed: boolean; -}; - -export const ViewStateSelect: React.FC = ({ - issue, - partialUpdateIssue, - position = "left", - tooltipPosition = "top", - className = "", - selfPositioned = false, - customButton = false, - user, - isNotAllowed, -}) => { - const [fetchStates, setFetchStates] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { data: stateGroups } = useSWR( - workspaceSlug && issue && fetchStates ? STATES_LIST(issue.project) : null, - workspaceSlug && issue && fetchStates ? () => stateService.getStates(workspaceSlug as string, issue.project) : null - ); - const states = getStatesList(stateGroups); - - const options = states?.map((state) => ({ - value: state.id, - query: state.name, - content: ( -
- - {state.name} -
- ), - })); - - const selectedOption = issue.state_detail; - - const stateLabel = ( - -
- - {selectedOption && } - - {selectedOption?.name ?? "State"} -
-
- ); - - return ( - { - const oldState = states?.find((s) => s.id === issue.state); - const newState = states?.find((s) => s.id === data); - - partialUpdateIssue( - { - state: data, - state_detail: newState, - }, - issue - ); - trackEventServices.trackIssuePartialPropertyUpdateEvent( - { - workspaceSlug, - workspaceId: issue.workspace, - projectId: issue.project_detail.id, - projectIdentifier: issue.project_detail.identifier, - projectName: issue.project_detail.name, - issueId: issue.id, - }, - "ISSUE_PROPERTY_UPDATE_STATE", - user - ); - - if (oldState?.group !== "completed" && newState?.group !== "completed") { - trackEventServices.trackIssueMarkedAsDoneEvent( - { - workspaceSlug: issue.workspace_detail.slug, - workspaceId: issue.workspace_detail.id, - projectId: issue.project_detail.id, - projectIdentifier: issue.project_detail.identifier, - projectName: issue.project_detail.name, - issueId: issue.id, - }, - user - ); - } - }} - options={options} - {...(customButton ? { customButton: stateLabel } : { label: stateLabel })} - position={position} - disabled={isNotAllowed} - onOpen={() => setFetchStates(true)} - noChevron - selfPositioned={selfPositioned} - /> - ); -}; diff --git a/web/components/project/index.ts b/web/components/project/index.ts index 23ad6ddbad1..329e826a82b 100644 --- a/web/components/project/index.ts +++ b/web/components/project/index.ts @@ -7,3 +7,6 @@ export * from "./single-project-card"; export * from "./single-sidebar-project"; export * from "./confirm-project-leave-modal"; export * from "./member-select"; +export * from "./members-select"; +export * from "./label-select"; +export * from "./priority-select"; diff --git a/web/components/project/label-select.tsx b/web/components/project/label-select.tsx new file mode 100644 index 00000000000..56c1aef5e7a --- /dev/null +++ b/web/components/project/label-select.tsx @@ -0,0 +1,239 @@ +import React, { useRef, useState } from "react"; + +import useSWR from "swr"; + +import { useRouter } from "next/router"; + +// services +import issuesService from "services/issue.service"; +// hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; +// headless ui +import { Combobox } from "@headlessui/react"; +// component +import { CreateLabelModal } from "components/labels"; +// icons +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { PlusIcon } from "lucide-react"; +// types +import { Tooltip } from "components/ui"; +import { ICurrentUserResponse, IIssueLabels } from "types"; +// constants +import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; + +type Props = { + value: string[]; + onChange: (data: any) => void; + labelsDetails: any[]; + className?: string; + buttonClassName?: string; + optionsClassName?: string; + maxRender?: number; + hideDropdownArrow?: boolean; + disabled?: boolean; + user: ICurrentUserResponse | undefined; +}; + +export const LabelSelect: React.FC = ({ + value, + onChange, + labelsDetails, + className = "", + buttonClassName = "", + optionsClassName = "", + maxRender = 2, + hideDropdownArrow = false, + disabled = false, + user, +}) => { + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [fetchStates, setFetchStates] = useState(false); + + const [labelModal, setLabelModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const dropdownBtn = useRef(null); + const dropdownOptions = useRef(null); + + const { data: issueLabels } = useSWR( + projectId && fetchStates ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, + workspaceSlug && projectId && fetchStates + ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) + : null + ); + + const options = issueLabels?.map((label) => ({ + value: label.id, + query: label.name, + content: ( +
+ + {label.name} +
+ ), + })); + + const filteredOptions = + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const label = ( +
+ {labelsDetails.length > 0 ? ( + labelsDetails.length <= maxRender ? ( + <> + {labelsDetails.map((label) => ( +
+
+ + {label.name} +
+
+ ))} + + ) : ( +
+ l.name).join(", ")} + > +
+ + {`${value.length} Labels`} +
+
+
+ ) + ) : ( + "" + )} +
+ ); + + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); + + const footerOption = ( + + ); + + return ( + <> + {projectId && ( + setLabelModal(false)} + projectId={projectId.toString()} + user={user} + /> + )} + + {({ open }: { open: boolean }) => { + if (open) { + if (!isOpen) setIsOpen(true); + setFetchStates(true); + } else if (isOpen) setIsOpen(false); + + return ( + <> + + {label} + {!hideDropdownArrow && !disabled && +
+ +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active && !selected ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( + +

No matching results

+
+ ) + ) : ( +

Loading...

+ )} +
+ {footerOption} +
+
+ + ); + }} +
+ + ); +}; diff --git a/web/components/project/members-select.tsx b/web/components/project/members-select.tsx new file mode 100644 index 00000000000..f99d8517488 --- /dev/null +++ b/web/components/project/members-select.tsx @@ -0,0 +1,191 @@ +import React, { useRef, useState } from "react"; + +import { useRouter } from "next/router"; + +// hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; +import useProjectMembers from "hooks/use-project-members"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +// headless ui +import { Combobox } from "@headlessui/react"; +// components +import { AssigneesList, Avatar, Icon, Tooltip } from "components/ui"; +// icons +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +// types +import { IUser } from "types"; + +type Props = { + value: string | string[]; + onChange: (data: any) => void; + membersDetails: IUser[]; + renderWorkspaceMembers?: boolean; + className?: string; + buttonClassName?: string; + optionsClassName?: string; + hideDropdownArrow?: boolean; + disabled?: boolean; +}; + +export const MembersSelect: React.FC = ({ + value, + onChange, + membersDetails, + renderWorkspaceMembers = false, + className = "", + buttonClassName = "", + optionsClassName = "", + hideDropdownArrow = false, + disabled = false, +}) => { + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [fetchStates, setFetchStates] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const dropdownBtn = useRef(null); + const dropdownOptions = useRef(null); + + const { members } = useProjectMembers( + workspaceSlug?.toString(), + projectId?.toString(), + fetchStates && !renderWorkspaceMembers + ); + + const { workspaceMembers } = useWorkspaceMembers( + workspaceSlug?.toString() ?? "", + fetchStates && renderWorkspaceMembers + ); + + const membersOptions = renderWorkspaceMembers ? workspaceMembers : members; + + const options = membersOptions?.map((member) => ({ + value: member.member.id, + query: member.member.display_name, + content: ( +
+ + {member.member.display_name} +
+ ), + })); + + const filteredOptions = + query === "" + ? options + : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const label = ( + 0 + ? membersDetails.map((assignee) => assignee?.display_name).join(", ") + : "No Assignee" + } + position="top" + > +
+ {value && value.length > 0 && Array.isArray(value) ? ( + + ) : ( + + + + )} +
+
+ ); + + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); + + return ( + + {({ open }: { open: boolean }) => { + if (open) { + if (!isOpen) setIsOpen(true); + setFetchStates(true); + } else if (isOpen) setIsOpen(false); + + return ( + <> + + {label} + {!hideDropdownArrow && !disabled && ( + +
+ +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active && !selected ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( + +

No matching results

+
+ ) + ) : ( +

Loading...

+ )} +
+
+
+ + ); + }} +
+ ); +}; diff --git a/web/components/project/priority-select.tsx b/web/components/project/priority-select.tsx new file mode 100644 index 00000000000..4db844b5d44 --- /dev/null +++ b/web/components/project/priority-select.tsx @@ -0,0 +1,173 @@ +import React, { useRef, useState } from "react"; + +// hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { PriorityIcon } from "components/icons"; +// components +import { Tooltip } from "components/ui"; +// types +import { TIssuePriorities } from "types"; +// constants +import { PRIORITIES } from "constants/project"; + +type Props = { + value: TIssuePriorities; + onChange: (data: any) => void; + className?: string; + buttonClassName?: string; + optionsClassName?: string; + hideDropdownArrow?: boolean; + disabled?: boolean; +}; + +export const PrioritySelect: React.FC = ({ + value, + onChange, + className = "", + buttonClassName = "", + optionsClassName = "", + hideDropdownArrow = false, + disabled = false, +}) => { + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const dropdownBtn = useRef(null); + const dropdownOptions = useRef(null); + + const options = PRIORITIES?.map((priority) => ({ + value: priority, + query: priority, + content: ( +
+ + {priority ?? "None"} +
+ ), + })); + + const filteredOptions = + query === "" + ? options + : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const selectedOption = value ?? "None"; + + const label = ( + +
+ + + +
+
+ ); + + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); + + return ( + + {({ open }: { open: boolean }) => { + if (open) { + if (!isOpen) setIsOpen(true); + } else if (isOpen) setIsOpen(false); + + return ( + <> + + {label} + {!hideDropdownArrow && !disabled && ( + +
+ +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active && !selected ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( + +

No matching results

+
+ ) + ) : ( +

Loading...

+ )} +
+
+
+ + ); + }} +
+ ); +}; diff --git a/web/components/states/index.ts b/web/components/states/index.ts index 39285a77f5d..96c26eee34e 100644 --- a/web/components/states/index.ts +++ b/web/components/states/index.ts @@ -2,3 +2,4 @@ export * from "./create-update-state-inline"; export * from "./create-state-modal"; export * from "./delete-state-modal"; export * from "./single-state"; +export * from "./state-select"; diff --git a/web/components/states/state-select.tsx b/web/components/states/state-select.tsx new file mode 100644 index 00000000000..22e4ef11511 --- /dev/null +++ b/web/components/states/state-select.tsx @@ -0,0 +1,169 @@ +import React, { useRef, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; +// services +import projectStateService from "services/project_state.service"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { StateGroupIcon } from "components/icons"; +// types +import { Tooltip } from "components/ui"; +// constants +import { IState } from "types"; +import { STATES_LIST } from "constants/fetch-keys"; +// helper +import { getStatesList } from "helpers/state.helper"; + +type Props = { + value: IState; + onChange: (data: any, states: IState[] | undefined) => void; + className?: string; + buttonClassName?: string; + optionsClassName?: string; + hideDropdownArrow?: boolean; + disabled?: boolean; +}; + +export const StateSelect: React.FC = ({ + value, + onChange, + className = "", + buttonClassName = "", + optionsClassName = "", + hideDropdownArrow = false, + disabled = false, +}) => { + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const dropdownBtn = useRef(null); + const dropdownOptions = useRef(null); + + const [fetchStates, setFetchStates] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: stateGroups } = useSWR( + workspaceSlug && projectId && fetchStates ? STATES_LIST(projectId as string) : null, + workspaceSlug && projectId && fetchStates + ? () => projectStateService.getStates(workspaceSlug as string, projectId as string) + : null + ); + + const states = getStatesList(stateGroups); + + const options = states?.map((state) => ({ + value: state.id, + query: state.name, + content: ( +
+ + {state.name} +
+ ), + })); + + const filteredOptions = + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const label = ( + +
+ {value && } + {value?.name ?? "State"} +
+
+ ); + + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); + + return ( + { + onChange(data, states); + }} + disabled={disabled} + > + {({ open }: { open: boolean }) => { + if (open) { + if (!isOpen) setIsOpen(true); + setFetchStates(true); + } else if (isOpen) setIsOpen(false); + + return ( + <> + + {label} + {!hideDropdownArrow && !disabled && +
+ +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active && !selected ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( + +

No matching results

+
+ ) + ) : ( +

Loading...

+ )} +
+
+
+ + ); + }} +
+ ); +}; diff --git a/web/components/workspace/sidebar-quick-action.tsx b/web/components/workspace/sidebar-quick-action.tsx index 4c7dda3b99b..8923abc141e 100644 --- a/web/components/workspace/sidebar-quick-action.tsx +++ b/web/components/workspace/sidebar-quick-action.tsx @@ -3,8 +3,6 @@ import React, { useState } from "react"; // ui import { Icon } from "components/ui"; import { ChevronDown, PenSquare } from "lucide-react"; -// headless ui -import { Menu, Transition } from "@headlessui/react"; // hooks import useLocalStorage from "hooks/use-local-storage"; // components @@ -17,10 +15,7 @@ export const WorkspaceSidebarQuickAction = () => { const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); - const { storedValue, clearValue } = useLocalStorage( - "draftedIssue", - JSON.stringify(undefined) - ); + const { storedValue, clearValue } = useLocalStorage("draftedIssue", JSON.stringify({})); return ( <> @@ -31,18 +26,17 @@ export const WorkspaceSidebarQuickAction = () => { onSubmit={() => { localStorage.removeItem("draftedIssue"); clearValue(); - setIsDraftIssueModalOpen(false); }} fieldsToShow={["all"]} />
{ > - {storedValue &&
} + {storedValue && Object.keys(JSON.parse(storedValue)).length > 0 && ( + <> +
+ + - {storedValue && ( -
- - {({ open }) => ( - <> -
- - - -
- - -
- - - -
-
-
- - )} -
-
+
+
+ +
+
+ )}
diff --git a/web/hooks/use-dynamic-dropdown.tsx b/web/hooks/use-dynamic-dropdown.tsx new file mode 100644 index 00000000000..7bee1bd0cef --- /dev/null +++ b/web/hooks/use-dynamic-dropdown.tsx @@ -0,0 +1,64 @@ +import React, { useCallback, useEffect } from "react"; + +// hook +import useOutsideClickDetector from "./use-outside-click-detector"; + +/** + * Custom hook for dynamic dropdown position calculation. + * @param isOpen - Indicates whether the dropdown is open. + * @param handleClose - Callback to handle closing the dropdown. + * @param buttonRef - Ref object for the button triggering the dropdown. + * @param dropdownRef - Ref object for the dropdown element. + */ + +const useDynamicDropdownPosition = ( + isOpen: boolean, + handleClose: () => void, + buttonRef: React.RefObject, + dropdownRef: React.RefObject +) => { + const handlePosition = useCallback(() => { + const button = buttonRef.current; + const dropdown = dropdownRef.current; + + if (!dropdown || !button) return; + + const buttonRect = button.getBoundingClientRect(); + const dropdownRect = dropdown.getBoundingClientRect(); + + const { innerHeight, innerWidth, scrollX, scrollY } = window; + + let top: number = buttonRect.bottom + scrollY; + if (top + dropdownRect.height > innerHeight) top = innerHeight - dropdownRect.height; + + let left: number = buttonRect.left + scrollX + (buttonRect.width - dropdownRect.width) / 2; + if (left + dropdownRect.width > innerWidth) left = innerWidth - dropdownRect.width; + + dropdown.style.top = `${Math.max(top, 5)}px`; + dropdown.style.left = `${Math.max(left, 5)}px`; + }, [buttonRef, dropdownRef]); + + useEffect(() => { + if (isOpen) handlePosition(); + }, [handlePosition, isOpen]); + + useOutsideClickDetector(dropdownRef, () => { + if (isOpen) handleClose(); + }); + + const handleResize = useCallback(() => { + if (isOpen) { + handlePosition(); + } + }, [handlePosition, isOpen]); + + useEffect(() => { + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [isOpen, handleResize]); +}; + +export default useDynamicDropdownPosition; diff --git a/web/services/csv.services.ts b/web/services/csv.service.ts similarity index 100% rename from web/services/csv.services.ts rename to web/services/csv.service.ts