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] 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 1cbfdc81a..9913ce733 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 2c15f0a48..0e27f895b 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 3db571c99..81d6f631f 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 0bcd98d09..5c2eadf0a 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 731d7f921..7a309f728 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 062dd57e7..7816f0edb 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 1c3c4e3d2..4b06d80ba 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 61a782b1f..aa0d44935 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 d78a82ed3..99191eb3d 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 08ca77d80..000000000 --- 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 23ad6ddba..329e826a8 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 000000000..b4cc6da06 --- /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 000000000..f99d85174 --- /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 000000000..4db844b5d --- /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 39285a77f..96c26eee3 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 000000000..ed37e97b5 --- /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 000000000..7bee1bd0c --- /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;