From e01a0d20fead3f71dbfd872cd42fb97fa6874394 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 20 Sep 2023 12:24:52 +0530 Subject: [PATCH 01/10] 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 --- .../core/views/board-view/board-header.tsx | 2 - .../core/views/board-view/single-issue.tsx | 150 ++++++++--- .../core/views/calendar-view/single-issue.tsx | 142 +++++++--- .../core/views/list-view/single-issue.tsx | 142 +++++++--- .../views/spreadsheet-view/single-issue.tsx | 157 ++++++++--- .../cycles/active-cycle-details.tsx | 2 +- web/components/cycles/cycles-view.tsx | 2 +- web/components/icons/module/cancelled.tsx | 2 +- web/components/icons/module/paused.tsx | 2 +- web/components/icons/state/backlog.tsx | 2 +- web/components/icons/state/cancelled.tsx | 2 +- web/components/icons/state/started.tsx | 4 +- web/components/icons/state/unstarted.tsx | 2 +- web/components/issues/view-select/index.ts | 3 +- web/components/issues/view-select/state.tsx | 138 ---------- web/components/project/index.ts | 3 + web/components/project/label-select.tsx | 243 ++++++++++++++++++ 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 | 177 +++++++++++++ web/hooks/use-dynamic-dropdown.tsx | 64 +++++ 22 files changed, 1322 insertions(+), 282 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 diff --git a/web/components/core/views/board-view/board-header.tsx b/web/components/core/views/board-view/board-header.tsx index 1cbfdc81a98..9913ce73339 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 2c15f0a480d..0e27f895b1a 100644 --- a/web/components/core/views/board-view/single-issue.tsx +++ b/web/components/core/views/board-view/single-issue.tsx @@ -13,19 +13,14 @@ import { } from "react-beautiful-dnd"; // services import issuesService from "services/issues.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 @@ -44,7 +39,15 @@ import { LayerDiagonalIcon } from "components/icons"; import { handleIssuesMutation } from "constants/issue"; 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"; @@ -188,6 +191,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]); @@ -343,13 +426,12 @@ export const SingleBoardIssue: React.FC = ({ )} @@ -359,21 +441,19 @@ export const SingleBoardIssue: React.FC = ({ }`} > {properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -397,16 +477,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 3db571c9933..81d6f631f17 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/issues.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, @@ -153,6 +148,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; @@ -225,22 +300,19 @@ export const SingleCalendarIssue: React.FC = ({ {displayProperties && (
{properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -260,21 +332,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/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx index 0bcd98d0956..5c2eadf0a68 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/issues.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 @@ -181,6 +177,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 @@ -290,21 +366,19 @@ export const SingleListIssue: React.FC = ({ }`} > {properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -323,14 +397,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 731d7f92175..7a309f7288a 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"; @@ -28,6 +22,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useToast from "hooks/use-toast"; // services import issuesService from "services/issues.service"; +import trackEventServices from "services/track-event.service"; // constant import { CYCLE_DETAILS, @@ -39,7 +34,15 @@ 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"; @@ -180,6 +183,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"; @@ -283,47 +366,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" /> = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ width = "20", height = "20", fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + ); diff --git a/web/components/icons/state/cancelled.tsx b/web/components/icons/state/cancelled.tsx index 1c3c4e3d244..4b06d80ba88 100644 --- a/web/components/icons/state/cancelled.tsx +++ b/web/components/icons/state/cancelled.tsx @@ -19,7 +19,7 @@ export const StateGroupCancelledIcon: React.FC = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ viewBox="0 0 12 12" fill="none" > - - + + ); diff --git a/web/components/icons/state/unstarted.tsx b/web/components/icons/state/unstarted.tsx index 61a782b1f75..aa0d44935e5 100644 --- a/web/components/icons/state/unstarted.tsx +++ b/web/components/icons/state/unstarted.tsx @@ -19,6 +19,6 @@ export const StateGroupUnstartedIcon: React.FC = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + ); 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 08ca77d8086..00000000000 --- a/web/components/issues/view-select/state.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState } from "react"; - -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import stateService from "services/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..b4cc6da06ef --- /dev/null +++ b/web/components/project/label-select.tsx @@ -0,0 +1,243 @@ +import React, { useRef, useState } from "react"; + +import useSWR from "swr"; + +import { useRouter } from "next/router"; + +// services +import issuesService from "services/issues.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..ed37e97b599 --- /dev/null +++ b/web/components/states/state-select.tsx @@ -0,0 +1,177 @@ +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 stateService from "services/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 + ? () => stateService.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/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; From cdfff12f4f046e651825198fec6645cb9d2091f0 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Date: Wed, 20 Sep 2023 13:06:51 +0530 Subject: [PATCH 02/10] 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 --- web/components/core/views/issues-view.tsx | 25 ++- .../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 ++++++- .../workspace/sidebar-quick-action.tsx | 91 ++++----- 7 files changed, 284 insertions(+), 110 deletions(-) diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index 55eefc39696..9a2d482faa2 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"; @@ -87,8 +87,16 @@ export const IssuesView: React.FC = ({ 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( @@ -108,6 +116,17 @@ export const IssuesView: React.FC = ({ 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/issues/confirm-issue-discard.tsx b/web/components/issues/confirm-issue-discard.tsx index 1294913cc4f..f8feab73d65 100644 --- a/web/components/issues/confirm-issue-discard.tsx +++ b/web/components/issues/confirm-issue-discard.tsx @@ -57,7 +57,7 @@ export const ConfirmIssueDiscard: React.FC = (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 3b0664cb804..c060c72f6c3 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); @@ -147,7 +174,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 @@ -187,7 +214,7 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ if (!createMore) onClose(); }; - const updateIssue = async (payload: Partial) => { + const updateDraftIssue = async (payload: Partial) => { if (!user) return; await issuesService @@ -203,6 +230,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({ @@ -220,7 +252,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 = { @@ -231,8 +349,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 65580c94ae1..608cf4fd1eb 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; @@ -98,11 +99,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(); @@ -111,6 +141,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); @@ -295,7 +341,7 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }); - if (!createMore) onDiscardClose(); + if (!createMore) onFormSubmitClose(); }; const createDraftIssue = async () => { @@ -354,7 +400,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/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 }) => ( - <> -
- - - -
- - -
- - - -
-
-
- - )} -
-
+
+
+ +
+
+ )}
From 60ae940d40bb3ed3bcb0ca2967c9d9fec8aa8f45 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 20 Sep 2023 17:00:03 +0530 Subject: [PATCH 03/10] chore: sub issues count in individual issue (#2221) --- apiserver/plane/api/views/issue.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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) From bd077e6500edefdfc2f0742ff3eeb02504768acf Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Thu, 21 Sep 2023 15:39:45 +0530 Subject: [PATCH 04/10] Implemented nested issues in the sub issues section in issue detail page (#2233) * feat: subissues infinte level * feat: updated UI for sub issues * feat: subissues new ui and nested sub issues in issue detail * chore: removed repeated code --- web/components/issues/delete-issue-modal.tsx | 4 +- web/components/issues/index.ts | 1 - web/components/issues/main-content.tsx | 4 +- web/components/issues/sub-issues-list.tsx | 251 ---------------- web/components/issues/sub-issues/index.ts | 1 + web/components/issues/sub-issues/issue.tsx | 171 +++++++++++ .../issues/sub-issues/issues-list.tsx | 84 ++++++ .../issues/sub-issues/progressbar.tsx | 25 ++ .../issues/sub-issues/properties.tsx | 204 +++++++++++++ web/components/issues/sub-issues/root.tsx | 283 ++++++++++++++++++ web/components/project/members-select.tsx | 2 +- 11 files changed, 774 insertions(+), 256 deletions(-) delete mode 100644 web/components/issues/sub-issues-list.tsx create mode 100644 web/components/issues/sub-issues/index.ts create mode 100644 web/components/issues/sub-issues/issue.tsx create mode 100644 web/components/issues/sub-issues/issues-list.tsx create mode 100644 web/components/issues/sub-issues/progressbar.tsx create mode 100644 web/components/issues/sub-issues/properties.tsx create mode 100644 web/components/issues/sub-issues/root.tsx diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index d76e5ddd2ed..62fc0472348 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -35,6 +35,7 @@ type Props = { data: IIssue | null; user: ICurrentUserResponse | undefined; onSubmit?: () => Promise; + redirection?: boolean; }; export const DeleteIssueModal: React.FC = ({ @@ -43,6 +44,7 @@ export const DeleteIssueModal: React.FC = ({ data, user, onSubmit, + redirection = true, }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); @@ -132,7 +134,7 @@ export const DeleteIssueModal: React.FC = ({ message: "Issue deleted successfully", }); - if (issueId) router.back(); + if (issueId && redirection) router.back(); }) .catch((error) => { console.log(error); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 1c51031f36c..6b83e7ef438 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -12,7 +12,6 @@ export * from "./main-content"; export * from "./modal"; export * from "./parent-issues-list-modal"; export * from "./sidebar"; -export * from "./sub-issues-list"; export * from "./label"; export * from "./issue-reaction"; export * from "./peek-overview"; diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx index b7b154ce2c6..5e14ba4326e 100644 --- a/web/components/issues/main-content.tsx +++ b/web/components/issues/main-content.tsx @@ -18,9 +18,9 @@ import { IssueAttachmentUpload, IssueAttachments, IssueDescriptionForm, - SubIssuesList, IssueReaction, } from "components/issues"; +import { SubIssuesRoot } from "./sub-issues"; // ui import { CustomMenu } from "components/ui"; // icons @@ -206,7 +206,7 @@ export const IssueMainContent: React.FC = ({
- +
diff --git a/web/components/issues/sub-issues-list.tsx b/web/components/issues/sub-issues-list.tsx deleted file mode 100644 index 9ba920ff53f..00000000000 --- a/web/components/issues/sub-issues-list.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { FC, useState } from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -import useSWR, { mutate } from "swr"; - -// headless ui -import { Disclosure, Transition } from "@headlessui/react"; -// services -import issuesService from "services/issues.service"; -// contexts -import { useProjectMyMembership } from "contexts/project-member.context"; -// components -import { ExistingIssuesListModal } from "components/core"; -import { CreateUpdateIssueModal } from "components/issues"; -// ui -import { CustomMenu } from "components/ui"; -// icons -import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; -// types -import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types"; -// fetch-keys -import { SUB_ISSUES } from "constants/fetch-keys"; - -type Props = { - parentIssue: IIssue; - user: ICurrentUserResponse | undefined; - disabled?: boolean; -}; - -export const SubIssuesList: FC = ({ parentIssue, user, disabled = false }) => { - // states - const [createIssueModal, setCreateIssueModal] = useState(false); - const [subIssuesListModal, setSubIssuesListModal] = useState(false); - const [preloadedData, setPreloadedData] = useState | null>(null); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { memberRole } = useProjectMyMembership(); - - const { data: subIssuesResponse } = useSWR( - workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null, - workspaceSlug && parentIssue - ? () => issuesService.subIssues(workspaceSlug as string, parentIssue.project, parentIssue.id) - : null - ); - - const addAsSubIssue = async (data: ISearchIssueResponse[]) => { - if (!workspaceSlug || !parentIssue) return; - - const payload = { - sub_issue_ids: data.map((i) => i.id), - }; - - await issuesService - .addSubIssues(workspaceSlug as string, parentIssue.project, parentIssue.id, payload) - .finally(() => mutate(SUB_ISSUES(parentIssue.id))); - }; - - const handleSubIssueRemove = (issue: IIssue) => { - if (!workspaceSlug || !parentIssue) return; - - mutate( - SUB_ISSUES(parentIssue.id), - (prevData) => { - if (!prevData) return prevData; - - const stateDistribution = { ...prevData.state_distribution }; - - const issueGroup = issue.state_detail.group; - stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1; - - return { - state_distribution: stateDistribution, - sub_issues: prevData.sub_issues.filter((i) => i.id !== issue.id), - }; - }, - false - ); - - issuesService - .patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user) - .finally(() => mutate(SUB_ISSUES(parentIssue.id))); - }; - - const handleCreateIssueModal = () => { - setCreateIssueModal(true); - - setPreloadedData({ - parent: parentIssue.id, - }); - }; - - const completedSubIssue = subIssuesResponse?.state_distribution.completed ?? 0; - const cancelledSubIssue = subIssuesResponse?.state_distribution.cancelled ?? 0; - - const totalCompletedSubIssues = completedSubIssue + cancelledSubIssue; - - const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0; - - const completionPercentage = (totalCompletedSubIssues / totalSubIssues) * 100; - - const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled; - - return ( - <> - setCreateIssueModal(false)} - /> - setSubIssuesListModal(false)} - searchParams={{ sub_issue: true, issue_id: parentIssue?.id }} - handleOnSubmit={addAsSubIssue} - workspaceLevelToggle - /> - {subIssuesResponse && subIssuesResponse.sub_issues.length > 0 ? ( - - {({ open }) => ( - <> -
-
- - - Sub-issues{" "} - - {subIssuesResponse.sub_issues.length} - - -
-
-
100 - ? 100 - : completionPercentage.toFixed(0) - }%`, - }} - /> -
- - {isNaN(completionPercentage) - ? 0 - : completionPercentage > 100 - ? 100 - : completionPercentage.toFixed(0)} - % Done - -
-
- - {open && !isNotAllowed ? ( -
- - - - setSubIssuesListModal(true)}> - Add an existing issue - - -
- ) : null} -
- - - {subIssuesResponse.sub_issues.map((issue) => ( - - -
- - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} -
- - {!isNotAllowed && ( - - )} -
- - ))} -
-
- - )} - - ) : ( - !isNotAllowed && ( - - - Add sub-issue - - } - buttonClassName="whitespace-nowrap" - position="left" - noBorder - noChevron - > - Create new - setSubIssuesListModal(true)}> - Add an existing issue - - - ) - )} - - ); -}; diff --git a/web/components/issues/sub-issues/index.ts b/web/components/issues/sub-issues/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/components/issues/sub-issues/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx new file mode 100644 index 00000000000..2e3d8acdbb3 --- /dev/null +++ b/web/components/issues/sub-issues/issue.tsx @@ -0,0 +1,171 @@ +import React from "react"; +// next imports +import Link from "next/link"; +// lucide icons +import { + ChevronDown, + ChevronRight, + X, + Pencil, + Trash, + Link as LinkIcon, + Loader, +} from "lucide-react"; +// components +import { SubIssuesRootList } from "./issues-list"; +import { IssueProperty } from "./properties"; +// ui +import { Tooltip, CustomMenu } from "components/ui"; + +// types +import { ICurrentUserResponse, IIssue } from "types"; + +export interface ISubIssues { + workspaceSlug: string; + projectId: string; + parentIssue: IIssue; + issue: any; + spacingLeft?: number; + user: ICurrentUserResponse | undefined; + editable: boolean; + removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void; + issuesVisibility: string[]; + handleIssuesVisibility: (issueId: string) => void; + copyText: (text: string) => void; + handleIssueCrudOperation: ( + key: "create" | "existing" | "edit" | "delete", + issueId: string, + issue?: IIssue | null + ) => void; +} + +export const SubIssues: React.FC = ({ + workspaceSlug, + projectId, + parentIssue, + issue, + spacingLeft = 0, + user, + editable, + removeIssueFromSubIssues, + issuesVisibility, + handleIssuesVisibility, + copyText, + handleIssueCrudOperation, +}) => ( +
+ {issue && ( +
+
+ {issue?.sub_issues_count > 0 && ( + <> + {true ? ( +
handleIssuesVisibility(issue?.id)} + > + {issuesVisibility && issuesVisibility.includes(issue?.id) ? ( + + ) : ( + + )} +
+ ) : ( + + )} + + )} +
+ + + +
+
+ {issue.project_detail.identifier}-{issue?.sequence_id} +
+ +
{issue?.name}
+
+
+ + +
+ +
+ +
+ + {editable && ( + handleIssueCrudOperation("edit", parentIssue?.id, issue)} + > +
+ + Edit issue +
+
+ )} + + {editable && ( + handleIssueCrudOperation("delete", parentIssue?.id, issue)} + > +
+ + Delete issue +
+
+ )} + + +
+ + Copy issue link +
+
+
+
+ + {editable && ( +
removeIssueFromSubIssues(parentIssue?.id, issue)} + > + +
+ )} +
+ )} + + {issuesVisibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( + + )} +
+); diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx new file mode 100644 index 00000000000..45c0b18828f --- /dev/null +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -0,0 +1,84 @@ +import React from "react"; +// swr +import useSWR from "swr"; +// components +import { SubIssues } from "./issue"; +// types +import { ICurrentUserResponse, IIssue } from "types"; +// services +import issuesService from "services/issues.service"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +export interface ISubIssuesRootList { + workspaceSlug: string; + projectId: string; + parentIssue: IIssue; + spacingLeft?: number; + user: ICurrentUserResponse | undefined; + editable: boolean; + removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void; + issuesVisibility: string[]; + handleIssuesVisibility: (issueId: string) => void; + copyText: (text: string) => void; + handleIssueCrudOperation: ( + key: "create" | "existing" | "edit" | "delete", + issueId: string, + issue?: IIssue | null + ) => void; +} + +export const SubIssuesRootList: React.FC = ({ + workspaceSlug, + projectId, + parentIssue, + spacingLeft = 10, + user, + editable, + removeIssueFromSubIssues, + issuesVisibility, + handleIssuesVisibility, + copyText, + handleIssueCrudOperation, +}) => { + const { data: issues, isLoading } = useSWR( + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? SUB_ISSUES(parentIssue?.id) + : null, + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id) + : null + ); + + return ( +
+ {issues && + issues.sub_issues && + issues.sub_issues.length > 0 && + issues.sub_issues.map((issue: IIssue) => ( + + ))} + +
10 ? `border-l border-custom-border-100` : `` + }`} + style={{ left: `${spacingLeft - 12}px` }} + /> +
+ ); +}; diff --git a/web/components/issues/sub-issues/progressbar.tsx b/web/components/issues/sub-issues/progressbar.tsx new file mode 100644 index 00000000000..368078a3d09 --- /dev/null +++ b/web/components/issues/sub-issues/progressbar.tsx @@ -0,0 +1,25 @@ +export interface IProgressBar { + total: number; + done: number; +} + +export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => { + const calPercentage = (doneValue: number, totalValue: number): string => { + if (doneValue === 0 || totalValue === 0) return (0).toFixed(0); + return ((100 * doneValue) / totalValue).toFixed(0); + }; + + return ( +
+
+
+
+
+
+
{calPercentage(done, total)}% Done
+
+ ); +}; diff --git a/web/components/issues/sub-issues/properties.tsx b/web/components/issues/sub-issues/properties.tsx new file mode 100644 index 00000000000..a899efdcfb6 --- /dev/null +++ b/web/components/issues/sub-issues/properties.tsx @@ -0,0 +1,204 @@ +import React from "react"; +// swr +import { mutate } from "swr"; +// components +import { ViewDueDateSelect, ViewStartDateSelect } from "components/issues"; +import { MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; +// hooks +import useIssuesProperties from "hooks/use-issue-properties"; +// types +import { ICurrentUserResponse, IIssue, IState } from "types"; +// fetch-keys +import { SUB_ISSUES } from "constants/fetch-keys"; +// services +import issuesService from "services/issues.service"; +import trackEventServices from "services/track-event.service"; + +export interface IIssueProperty { + workspaceSlug: string; + projectId: string; + parentIssue: IIssue; + issue: IIssue; + user: ICurrentUserResponse | undefined; + editable: boolean; +} + +export const IssueProperty: React.FC = ({ + workspaceSlug, + projectId, + parentIssue, + issue, + user, + editable, +}) => { + const [properties] = useIssuesProperties(workspaceSlug, projectId); + + const handlePriorityChange = (data: any) => { + partialUpdateIssue({ priority: data }); + 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 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, + }); + 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 }); + + 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 partialUpdateIssue = async (data: Partial) => { + mutate( + workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null, + (elements: any) => { + const _elements = { ...elements }; + const _issues = _elements.sub_issues.map((element: IIssue) => + element.id === issue.id ? { ...element, ...data } : element + ); + _elements["sub_issues"] = [..._issues]; + return _elements; + }, + false + ); + + const issueResponse = await issuesService.patchIssue( + workspaceSlug as string, + issue.project, + issue.id, + data, + user + ); + + mutate( + SUB_ISSUES(parentIssue.id), + (elements: any) => { + const _elements = elements.sub_issues.map((element: IIssue) => + element.id === issue.id ? issueResponse : element + ); + elements["sub_issues"] = _elements; + return elements; + }, + true + ); + }; + + return ( +
+ {properties.priority && ( +
+ +
+ )} + + {properties.state && ( +
+ +
+ )} + + {properties.start_date && issue.start_date && ( +
+ +
+ )} + + {properties.due_date && issue.target_date && ( +
+ +
+ )} + + {properties.assignee && ( +
+ +
+ )} +
+ ); +}; diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx new file mode 100644 index 00000000000..75161e63964 --- /dev/null +++ b/web/components/issues/sub-issues/root.tsx @@ -0,0 +1,283 @@ +import React from "react"; +// next imports +import { useRouter } from "next/router"; +// swr +import useSWR, { mutate } from "swr"; +// lucide icons +import { Plus, ChevronRight, ChevronDown } from "lucide-react"; +// components +import { ExistingIssuesListModal } from "components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { SubIssuesRootList } from "./issues-list"; +import { ProgressBar } from "./progressbar"; +// ui +import { CustomMenu } from "components/ui"; +// hooks +import { useProjectMyMembership } from "contexts/project-member.context"; +// helpers +import { copyTextToClipboard } from "helpers/string.helper"; +// types +import { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; +// services +import issuesService from "services/issues.service"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +export interface ISubIssuesRoot { + parentIssue: IIssue; + + user: ICurrentUserResponse | undefined; + editable: boolean; +} + +export const SubIssuesRoot: React.FC = ({ parentIssue, user, editable }) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + + const { memberRole } = useProjectMyMembership(); + + const { data: issues } = useSWR( + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? SUB_ISSUES(parentIssue?.id) + : null, + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id) + : null + ); + + const [issuesVisibility, setIssuesVisibility] = React.useState([parentIssue?.id]); + const handleIssuesVisibility = (issueId: string) => { + if (issuesVisibility.includes(issueId)) { + setIssuesVisibility(issuesVisibility.filter((i: string) => i !== issueId)); + } else { + setIssuesVisibility([...issuesVisibility, issueId]); + } + }; + + const [issueCrudOperation, setIssueCrudOperation] = React.useState<{ + create: { toggle: boolean; issueId: string | null }; + existing: { toggle: boolean; issueId: string | null }; + edit: { toggle: boolean; issueId: string | null; issue: IIssue | null }; + delete: { toggle: boolean; issueId: string | null; issue: IIssue | null }; + }>({ + create: { + toggle: false, + issueId: null, + }, + existing: { + toggle: false, + issueId: null, + }, + edit: { + toggle: false, + issueId: null, // parent issue id for mutation + issue: null, + }, + delete: { + toggle: false, + issueId: null, // parent issue id for mutation + issue: null, + }, + }); + const handleIssueCrudOperation = ( + key: "create" | "existing" | "edit" | "delete", + issueId: string | null, + issue: IIssue | null = null + ) => { + setIssueCrudOperation({ + ...issueCrudOperation, + [key]: { + toggle: !issueCrudOperation[key].toggle, + issueId: issueId, + issue: issue, + }, + }); + }; + + const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !parentIssue || issueCrudOperation?.existing?.issueId === null) return; + const issueId = issueCrudOperation?.existing?.issueId; + const payload = { + sub_issue_ids: data.map((i) => i.id), + }; + + await issuesService.addSubIssues(workspaceSlug, projectId, issueId, payload).finally(() => { + if (issueId) mutate(SUB_ISSUES(issueId)); + }); + }; + + const removeIssueFromSubIssues = async (parentIssueId: string, issue: IIssue) => { + if (!workspaceSlug || !parentIssue || !issue?.id) return; + issuesService + .patchIssue(workspaceSlug, projectId, issue.id, { parent: null }, user) + .finally(() => { + if (parentIssueId) mutate(SUB_ISSUES(parentIssueId)); + }); + }; + + const copyText = (text: string) => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard(`${originURL}/${text}`).then(() => { + // setToastAlert({ + // type: "success", + // title: "Link Copied!", + // message: "Issue link copied to clipboard.", + // }); + }); + }; + + const isEditable = memberRole?.isGuest || memberRole?.isViewer ? false : true; + + const mutateSubIssues = (parentIssueId: string | null) => { + if (parentIssueId) mutate(SUB_ISSUES(parentIssueId)); + }; + + return ( +
+ {parentIssue && parentIssue?.sub_issues_count > 0 ? ( + <> + {/* header */} +
+
handleIssuesVisibility(parentIssue?.id)} + > +
+ {issuesVisibility.includes(parentIssue?.id) ? ( + + ) : ( + + )} +
+
Sub-issues
+
({parentIssue?.sub_issues_count})
+
+ +
+ +
+ + {isEditable && issuesVisibility.includes(parentIssue?.id) && ( +
+
handleIssueCrudOperation("create", parentIssue?.id)} + > + Add sub-issue +
+
handleIssueCrudOperation("existing", parentIssue?.id)} + > + Add an existing issue +
+
+ )} +
+ + {/* issues */} + {issuesVisibility.includes(parentIssue?.id) && ( +
+ +
+ )} + + ) : ( + isEditable && ( +
+
No sub issues are available
+ <> + + + Add sub-issue + + } + buttonClassName="whitespace-nowrap" + position="left" + noBorder + noChevron + > + handleIssueCrudOperation("create", parentIssue?.id)} + > + Create new + + handleIssueCrudOperation("existing", parentIssue?.id)} + > + Add an existing issue + + + +
+ ) + )} + + {isEditable && issueCrudOperation?.create?.toggle && ( + handleIssueCrudOperation("create", null)} + /> + )} + + {isEditable && + issueCrudOperation?.existing?.toggle && + issueCrudOperation?.existing?.issueId && ( + handleIssueCrudOperation("existing", null)} + searchParams={{ sub_issue: true, issue_id: issueCrudOperation?.existing?.issueId }} + handleOnSubmit={addAsSubIssueFromExistingIssues} + workspaceLevelToggle + /> + )} + + {isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( + { + mutateSubIssues(issueCrudOperation?.edit?.issueId); + handleIssueCrudOperation("edit", null, null); + }} + data={issueCrudOperation?.edit?.issue} + /> + )} + + {isEditable && issueCrudOperation?.delete?.toggle && issueCrudOperation?.delete?.issueId && ( + { + mutateSubIssues(issueCrudOperation?.delete?.issueId); + handleIssueCrudOperation("delete", null, null); + }} + data={issueCrudOperation?.delete?.issue} + user={user} + redirection={false} + /> + )} +
+ ); +}; diff --git a/web/components/project/members-select.tsx b/web/components/project/members-select.tsx index f99d8517488..4ffad72b9fe 100644 --- a/web/components/project/members-select.tsx +++ b/web/components/project/members-select.tsx @@ -82,7 +82,7 @@ export const MembersSelect: React.FC = ({ 0 + membersDetails && membersDetails.length > 0 ? membersDetails.map((assignee) => assignee?.display_name).join(", ") : "No Assignee" } From 1621125f6d83058fc7276eb7d3f692db768b4f50 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 21 Sep 2023 16:03:06 +0530 Subject: [PATCH 05/10] refactor: product updates modal layout (#2225) --- web/components/ui/product-updates-modal.tsx | 108 +++++++++++--------- 1 file changed, 61 insertions(+), 47 deletions(-) diff --git a/web/components/ui/product-updates-modal.tsx b/web/components/ui/product-updates-modal.tsx index b142f8325f0..4f5bad7b3f1 100644 --- a/web/components/ui/product-updates-modal.tsx +++ b/web/components/ui/product-updates-modal.tsx @@ -1,15 +1,16 @@ import React from "react"; + import useSWR from "swr"; // headless ui import { Dialog, Transition } from "@headlessui/react"; -// component -import { MarkdownRenderer, Spinner } from "components/ui"; -// icons -import { XMarkIcon } from "@heroicons/react/20/solid"; // services import workspaceService from "services/workspace.service"; -// helper +// components +import { Loader, MarkdownRenderer } from "components/ui"; +// icons +import { XMarkIcon } from "@heroicons/react/20/solid"; +// helpers import { renderLongDateFormat } from "helpers/date-time.helper"; type Props = { @@ -34,8 +35,8 @@ export const ProductUpdatesModal: React.FC = ({ isOpen, setIsOpen }) => {
-
-
+
+
= ({ isOpen, setIsOpen }) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - -
-
-
- - Product Updates - - - - - {updates && updates.length > 0 ? ( - updates.map((item, index) => ( - -
- - {item.tag_name} + +
+ + Product Updates + + + + + {updates && updates.length > 0 ? ( +
+ {updates.map((item, index) => ( + +
+ + {item.tag_name} + + {renderLongDateFormat(item.published_at)} + {index === 0 && ( + + New - {renderLongDateFormat(item.published_at)} - {index === 0 && ( - - New - - )} -
- -
- )) - ) : ( -
- - Loading... + )} +
+ + + ))} +
+ ) : ( +
+ +
+ + + +
+
+ + + +
+
+ + +
- )} +
-
+ )}
From e3793f446432189118a7b94ae4aab0c986b7a17b Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 21 Sep 2023 16:03:33 +0530 Subject: [PATCH 06/10] fix: handle no issues in custom analytics (#2226) --- web/components/analytics/custom-analytics/graph/index.tsx | 1 - web/helpers/array.helper.ts | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/components/analytics/custom-analytics/graph/index.tsx b/web/components/analytics/custom-analytics/graph/index.tsx index 349f9884db0..733d1743729 100644 --- a/web/components/analytics/custom-analytics/graph/index.tsx +++ b/web/components/analytics/custom-analytics/graph/index.tsx @@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper"; import { generateBarColor } from "helpers/analytics.helper"; // types import { IAnalyticsParams, IAnalyticsResponse } from "types"; -// constants type Props = { analytics: IAnalyticsResponse; diff --git a/web/helpers/array.helper.ts b/web/helpers/array.helper.ts index bcadbe3c8cc..a682b0a1c12 100644 --- a/web/helpers/array.helper.ts +++ b/web/helpers/array.helper.ts @@ -38,10 +38,13 @@ export const orderArrayBy = ( export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length; -export const findStringWithMostCharacters = (strings: string[]) => - strings.reduce((longestString, currentString) => +export const findStringWithMostCharacters = (strings: string[]): string => { + if (!strings || strings.length === 0) return ""; + + return strings.reduce((longestString, currentString) => currentString.length > longestString.length ? currentString : longestString ); +}; export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => { if (!arr1 || !arr2) return false; From de9f34cac3cfd2cd588bc4eb8efdaa34f06d52cd Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 21 Sep 2023 16:04:05 +0530 Subject: [PATCH 07/10] fix: activity label color (#2227) --- web/components/core/activity.tsx | 44 +++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 7c2798e7a92..c76f1aece86 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -1,5 +1,9 @@ import { useRouter } from "next/router"; +import useSWR from "swr"; + +// services +import issuesService from "services/issues.service"; // icons import { Icon, Tooltip } from "components/ui"; import { CopyPlus } from "lucide-react"; @@ -10,6 +14,8 @@ import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // types import { IIssueActivity } from "types"; +// fetch-keys +import { WORKSPACE_LABELS } from "constants/fetch-keys"; const IssueLink = ({ activity }: { activity: IIssueActivity }) => { const router = useRouter(); @@ -52,6 +58,26 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => { ); }; +const LabelPill = ({ labelId }: { labelId: string }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { data: labels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + return ( + l.id === labelId)?.color ?? "#000000", + }} + aria-hidden="true" + /> + ); +}; + const activityDetails: { [key: string]: { message: ( @@ -325,14 +351,8 @@ const activityDetails: { return ( <> added a new label{" "} - -