diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-pallette.tsx index f183de9c6..8e17cbafd 100644 --- a/web/components/command-palette/command-pallette.tsx +++ b/web/components/command-palette/command-pallette.tsx @@ -161,6 +161,7 @@ export const CommandPalette: React.FC = observer(() => { /> setIsCreateViewModalOpen(false)} + viewType="project" isOpen={isCreateViewModalOpen} user={user} /> diff --git a/web/components/core/filters/filters-list.tsx b/web/components/core/filters/filters-list.tsx index 81a12bd86..a2b12b69c 100644 --- a/web/components/core/filters/filters-list.tsx +++ b/web/components/core/filters/filters-list.tsx @@ -10,7 +10,14 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // helpers import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types -import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types"; +import { + IIssueFilterOptions, + IIssueLabels, + IProject, + IState, + IUserLite, + TStateGroups, +} from "types"; // constants import { STATE_GROUP_COLORS } from "constants/state"; @@ -20,7 +27,9 @@ type Props = { clearAllFilters: (...args: any) => void; labels: IIssueLabels[] | undefined; members: IUserLite[] | undefined; - states: IState[] | undefined; + states?: IState[] | undefined; + stateGroup?: string[] | undefined; + project?: IProject[] | undefined; }; export const FiltersList: React.FC = ({ @@ -30,6 +39,7 @@ export const FiltersList: React.FC = ({ labels, members, states, + project, }) => { if (!filters) return <>; @@ -155,6 +165,29 @@ export const FiltersList: React.FC = ({ : key === "assignees" ? filters.assignees?.map((memberId: string) => { const member = members?.find((m) => m.id === memberId); + return ( +
+ + {member?.display_name} + + setFilters({ + assignees: filters.assignees?.filter((p: any) => p !== memberId), + }) + } + > + + +
+ ); + }) + : key === "subscriber" + ? filters.subscriber?.map((memberId: string) => { + const member = members?.find((m) => m.id === memberId); return (
= ({
); }) + : key === "project" + ? filters.project?.map((projectId) => { + const currentProject = project?.find((p) => p.id === projectId); + console.log("currentProject", currentProject); + console.log("currentProject", projectId); + return ( +

+ {currentProject?.name} + + setFilters({ + project: filters.project?.filter((p) => p !== projectId), + }) + } + > + + +

+ ); + }) : (filters[key] as any)?.join(", ")} + + + + + + } + placement="bottom-start" + > + + + + )} + + + {issue.sub_issues_count > 0 && ( +
+ +
+ )} + + + + + + ); +}; diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx b/web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx similarity index 72% rename from web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx rename to web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx index 0b16b617b..966852a5b 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx +++ b/web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx @@ -1,36 +1,34 @@ -import React, { useState } from "react"; +import React from "react"; // components -import { SingleSpreadsheetIssue } from "components/core"; +import { IssueColumn } from "components/core"; // hooks import useSubIssue from "hooks/use-sub-issue"; // types -import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; +import { IIssue, Properties, UserAuth } from "types"; type Props = { issue: IIssue; - index: number; + projectId: string; expandedIssues: string[]; setExpandedIssues: React.Dispatch>; properties: Properties; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; - gridTemplateColumns: string; + setCurrentProjectId: React.Dispatch>; disableUserActions: boolean; - user: ICurrentUserResponse | undefined; userAuth: UserAuth; nestingLevel?: number; }; -export const SpreadsheetIssues: React.FC = ({ - index, +export const SpreadsheetIssuesColumn: React.FC = ({ issue, + projectId, expandedIssues, setExpandedIssues, - gridTemplateColumns, properties, handleIssueAction, + setCurrentProjectId, disableUserActions, - user, userAuth, nestingLevel = 0, }) => { @@ -49,22 +47,20 @@ export const SpreadsheetIssues: React.FC = ({ const isExpanded = expandedIssues.indexOf(issue.id) > -1; - const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded); + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); return (
- handleIssueAction(issue, "edit")} handleDeleteIssue={() => handleIssueAction(issue, "delete")} + setCurrentProjectId={setCurrentProjectId} disableUserActions={disableUserActions} - user={user} userAuth={userAuth} nestingLevel={nestingLevel} /> @@ -74,17 +70,16 @@ export const SpreadsheetIssues: React.FC = ({ subIssues && subIssues.length > 0 && subIssues.map((subIssue: IIssue) => ( - diff --git a/web/components/core/views/spreadsheet-view/label-column/index.ts b/web/components/core/views/spreadsheet-view/label-column/index.ts new file mode 100644 index 000000000..a1b69c1a9 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-label-column"; +export * from "./label-column"; diff --git a/web/components/core/views/spreadsheet-view/label-column/label-column.tsx b/web/components/core/views/spreadsheet-view/label-column/label-column.tsx new file mode 100644 index 000000000..cad1e7666 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/label-column.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +// components +import { LabelSelect } from "components/project"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const LabelColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + return ( +
+ + {properties.labels && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx b/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx new file mode 100644 index 000000000..5ab77e909 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { LabelColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetLabelColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/priority-column/index.ts b/web/components/core/views/spreadsheet-view/priority-column/index.ts new file mode 100644 index 000000000..fc542331e --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-priority-column"; +export * from "./priority-column"; diff --git a/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx b/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx new file mode 100644 index 000000000..feb8acdf5 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { PrioritySelect } from "components/project"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, Properties, TIssuePriorities } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const PriorityColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + 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 + ); + }; + + return ( +
+ + {properties.priority && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx b/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx new file mode 100644 index 000000000..f0b84fb59 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { PriorityColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetPriorityColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx b/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx index d11fe85d6..797aa7785 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx +++ b/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx @@ -1,23 +1,52 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; // next import { useRouter } from "next/router"; +import { KeyedMutator, mutate } from "swr"; + // components -import { SpreadsheetColumns, SpreadsheetIssues, ListInlineCreateIssueForm } from "components/core"; -import { CustomMenu, Spinner } from "components/ui"; +import { + SpreadsheetAssigneeColumn, + SpreadsheetCreatedOnColumn, + SpreadsheetDueDateColumn, + SpreadsheetEstimateColumn, + SpreadsheetIssuesColumn, + SpreadsheetLabelColumn, + SpreadsheetPriorityColumn, + SpreadsheetStartDateColumn, + SpreadsheetStateColumn, + SpreadsheetUpdatedOnColumn, +} from "components/core"; +import { Spinner } from "components/ui"; import { IssuePeekOverview } from "components/issues"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; -import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; // types -import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; -// constants -import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; +import { ICurrentUserResponse, IIssue, ISubIssueResponse, UserAuth } from "types"; +import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter"; +import { + CYCLE_DETAILS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_DETAILS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, + VIEW_ISSUES, + WORKSPACE_VIEW_ISSUES, +} from "constants/fetch-keys"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import projectIssuesServices from "services/issues.service"; // icon -import { PlusIcon } from "@heroicons/react/24/outline"; type Props = { + spreadsheetIssues: IIssue[]; + mutateIssues: KeyedMutator< + | IIssue[] + | { + [key: string]: IIssue[]; + } + >; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; disableUserActions: boolean; @@ -26,6 +55,8 @@ type Props = { }; export const SpreadsheetView: React.FC = ({ + spreadsheetIssues, + mutateIssues, handleIssueAction, openIssuesListModal, disableUserActions, @@ -33,126 +64,220 @@ export const SpreadsheetView: React.FC = ({ userAuth, }) => { const [expandedIssues, setExpandedIssues] = useState([]); - const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + const [currentProjectId, setCurrentProjectId] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - - const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; - - const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId, workspaceViewId } = router.query; const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); - const columnData = SPREADSHEET_COLUMN.map((column) => ({ - ...column, - isActive: properties - ? column.propertyName === "labels" - ? properties[column.propertyName as keyof Properties] - : column.propertyName === "title" - ? true - : properties[column.propertyName as keyof Properties] - : false, - })); + const workspaceIssuesPath = [ + { + params: { + sub_issue: false, + }, + path: "workspace-views/all-issues", + }, + { + params: { + assignees: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/assigned", + }, + { + params: { + created_by: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/created", + }, + { + params: { + subscriber: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/subscribed", + }, + ]; - const gridTemplateColumns = columnData - .filter((column) => column.isActive) - .map((column) => column.colSize) - .join(" "); + const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) => + router.pathname.includes(path.path) + ); + + const { params: workspaceViewParams } = useWorkspaceIssuesFilters( + workspaceSlug?.toString(), + workspaceViewId?.toString() + ); + + const { params } = useSpreadsheetIssuesView(); + + const partialUpdateIssue = useCallback( + (formData: Partial, issue: IIssue) => { + if (!workspaceSlug || !issue) return; + + const fetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : viewId + ? VIEW_ISSUES(viewId.toString(), params) + : workspaceViewId + ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), workspaceViewParams) + : currentWorkspaceIssuePath + ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params) + : PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params); + + if (issue.parent) + mutate( + SUB_ISSUES(issue.parent.toString()), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + sub_issues: (prevData.sub_issues ?? []).map((i) => { + if (i.id === issue.id) { + return { + ...i, + ...formData, + }; + } + return i; + }), + }; + }, + false + ); + else + mutate( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issue.id) { + return { + ...p, + ...formData, + }; + } + return p; + }), + false + ); + + projectIssuesServices + .patchIssue( + workspaceSlug as string, + issue.project_detail.id, + issue.id as string, + formData, + user + ) + .then(() => { + if (issue.parent) { + mutate(SUB_ISSUES(issue.parent as string)); + } else { + mutate(fetchKey); + + if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); + if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); + } + }) + .catch((error) => { + console.log(error); + }); + }, + [ + workspaceSlug, + cycleId, + moduleId, + viewId, + workspaceViewId, + currentWorkspaceIssuePath, + workspaceViewParams, + params, + user, + ] + ); + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + const renderColumn = (header: string, Component: React.ComponentType) => ( +
+
+ {header} +
+
+ {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+
+ ); return ( <> mutateIssues()} - projectId={projectId?.toString() ?? ""} + projectId={currentProjectId ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""} readOnly={disableUserActions} /> -
-
- -
+
{spreadsheetIssues ? ( -
- {spreadsheetIssues.map((issue: IIssue, index) => ( - - ))} -
+ <> +
+
+
+ + ID + + + Issue + +
+ + {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+
+ {renderColumn("State", SpreadsheetStateColumn)} + {renderColumn("Priority", SpreadsheetPriorityColumn)} + {renderColumn("Assignees", SpreadsheetAssigneeColumn)} + {renderColumn("Label", SpreadsheetLabelColumn)} + {renderColumn("Start Date", SpreadsheetStartDateColumn)} + {renderColumn("Due Date", SpreadsheetDueDateColumn)} + {renderColumn("Estimate", SpreadsheetEstimateColumn)} + {renderColumn("Created On", SpreadsheetCreatedOnColumn)} + {renderColumn("Updated On", SpreadsheetUpdatedOnColumn)} + ) : ( - +
+ +
)}
- -
- setIsInlineCreateIssueFormOpen(false)} - prePopulatedData={{ - ...(cycleId && { cycle: cycleId.toString() }), - ...(moduleId && { module: moduleId.toString() }), - }} - /> - - {type === "issue" - ? !disableUserActions && - !isInlineCreateIssueFormOpen && ( - - ) - : !disableUserActions && - !isInlineCreateIssueFormOpen && ( - - - Add Issue - - } - position="left" - verticalPosition="top" - optionsClassName="left-5 !w-36" - noBorder - > - setIsInlineCreateIssueFormOpen(true)}> - Create new - - {openIssuesListModal && ( - - Add an existing issue - - )} - - )} -
); }; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/index.ts b/web/components/core/views/spreadsheet-view/start-date-column/index.ts new file mode 100644 index 000000000..94f229498 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-start-date-column"; +export * from "./start-date-column"; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx b/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx new file mode 100644 index 000000000..064506ca2 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { StartDateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetStartDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx b/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx new file mode 100644 index 000000000..3b4b9a0f7 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewStartDateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const StartDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.due_date && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/state-column/index.ts b/web/components/core/views/spreadsheet-view/state-column/index.ts new file mode 100644 index 000000000..f3cbef871 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-state-column"; +export * from "./state-column"; diff --git a/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx b/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx new file mode 100644 index 000000000..606f3e28a --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { StateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetStateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/state-column/state-column.tsx b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx new file mode 100644 index 000000000..6b3d3c696 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { StateSelect } from "components/states"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, IState, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const StateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + 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 + ); + } + }; + + return ( +
+ + {properties.state && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/index.ts b/web/components/core/views/spreadsheet-view/updated-on-column/index.ts new file mode 100644 index 000000000..af1337a7f --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-updated-on-column"; +export * from "./updated-on-column"; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx b/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx new file mode 100644 index 000000000..bb29e460d --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { UpdatedOnColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetUpdatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx b/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx new file mode 100644 index 000000000..b63519095 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; +// helper +import { renderLongDetailDateFormat } from "helpers/date-time.helper"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const UpdatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.updated_on && ( +
+ {renderLongDetailDateFormat(issue.updated_at)} +
+ )} +
+
+); diff --git a/web/components/issues/my-issues/my-issues-select-filters.tsx b/web/components/issues/my-issues/my-issues-select-filters.tsx index ce8e03797..8085b5e78 100644 --- a/web/components/issues/my-issues/my-issues-select-filters.tsx +++ b/web/components/issues/my-issues/my-issues-select-filters.tsx @@ -4,18 +4,21 @@ import { useRouter } from "next/router"; import useSWR from "swr"; +// hook +import useProjects from "hooks/use-projects"; +import useWorkspaceMembers from "hooks/use-workspace-members"; // services import issuesService from "services/issues.service"; // components import { DateFilterModal } from "components/core"; // ui -import { MultiLevelDropdown } from "components/ui"; +import { Avatar, MultiLevelDropdown } from "components/ui"; // icons import { PriorityIcon, StateGroupIcon } from "components/icons"; // helpers import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { IIssueFilterOptions, IQuery, TStateGroups } from "types"; +import { IIssueFilterOptions, TStateGroups } from "types"; // fetch-keys import { WORKSPACE_LABELS } from "constants/fetch-keys"; // constants @@ -23,7 +26,7 @@ import { GROUP_CHOICES, PRIORITIES } from "constants/project"; import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { - filters: Partial | IQuery; + filters: Partial; onSelect: (option: any) => void; direction?: "left" | "right"; height?: "sm" | "md" | "rg" | "lg"; @@ -55,6 +58,11 @@ export const MyIssuesSelectFilters: React.FC = ({ : null ); + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + return ( <> {isDateFilterModalOpen && ( @@ -74,25 +82,19 @@ export const MyIssuesSelectFilters: React.FC = ({ height={height} options={[ { - id: "priority", - label: "Priority", - value: PRIORITIES, + id: "project", + label: "Project", + value: joinedProjects, hasChildren: true, - children: [ - ...PRIORITIES.map((priority) => ({ - id: priority === null ? "null" : priority, - label: ( -
- {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - ], + children: joinedProjects?.map((project) => ({ + id: project.id, + label:
{project.name}
, + value: { + key: "project", + value: project.id, + }, + selected: filters?.project?.includes(project.id), + })), }, { id: "state_group", @@ -142,6 +144,87 @@ export const MyIssuesSelectFilters: React.FC = ({ selected: filters?.labels?.includes(label.id), })), }, + { + id: "priority", + label: "Priority", + value: PRIORITIES, + hasChildren: true, + children: [ + ...PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
+ {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + ], + }, + { + id: "created_by", + label: "Created by", + value: workspaceMembers, + hasChildren: true, + children: workspaceMembers?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "created_by", + value: member.member.id, + }, + selected: filters?.created_by?.includes(member.member.id), + })), + }, + { + id: "assignees", + label: "Assignees", + value: workspaceMembers, + hasChildren: true, + children: workspaceMembers?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "assignees", + value: member.member.id, + }, + selected: filters?.assignees?.includes(member.member.id), + })), + }, + { + id: "subscriber", + label: "Subscriber", + value: workspaceMembers, + hasChildren: true, + children: workspaceMembers?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "subscriber", + value: member.member.id, + }, + selected: filters?.subscriber?.includes(member.member.id), + })), + }, { id: "start_date", label: "Start date", diff --git a/web/components/issues/my-issues/my-issues-view-options.tsx b/web/components/issues/my-issues/my-issues-view-options.tsx index 8c8bd9dc7..1cbc467a8 100644 --- a/web/components/issues/my-issues/my-issues-view-options.tsx +++ b/web/components/issues/my-issues/my-issues-view-options.tsx @@ -2,25 +2,20 @@ import React from "react"; import { useRouter } from "next/router"; -// headless ui -import { Popover, Transition } from "@headlessui/react"; // hooks import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; -import useEstimateOption from "hooks/use-estimate-option"; // components import { MyIssuesSelectFilters } from "components/issues"; // ui -import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui"; +import { Tooltip } from "components/ui"; // icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; -import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material"; +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { CreditCard } from "lucide-react"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { Properties, TIssueViewOptions } from "types"; -// constants -import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; +import { TIssueViewOptions } from "types"; const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ { @@ -28,19 +23,26 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ Icon: FormatListBulletedOutlined, }, { - type: "kanban", - Icon: GridViewOutlined, + type: "spreadsheet", + Icon: CreditCard, }, ]; export const MyIssuesViewOptions: React.FC = () => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, workspaceViewId } = router.query; - const { displayFilters, setDisplayFilters, properties, setProperty, filters, setFilters } = - useMyIssuesFilters(workspaceSlug?.toString()); + const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters( + workspaceSlug?.toString() + ); - const { isEstimateActive } = useEstimateOption(); + const workspaceViewPathName = ["workspace-views/all-issues"]; + + const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => + router.pathname.includes(pathname) + ); + + const showFilters = isWorkspaceViewPath || workspaceViewId; return (
@@ -49,250 +51,65 @@ export const MyIssuesViewOptions: React.FC = () => { {replaceUnderscoreIfSnakeCase(option.type)} Layout + {replaceUnderscoreIfSnakeCase(option.type)} View } position="bottom" > ))}
- { - const key = option.key as keyof typeof filters; + {showFilters && ( + { + const key = option.key as keyof typeof filters; - if (key === "start_date" || key === "target_date") { - const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); + if (key === "start_date" || key === "target_date") { + const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); - setFilters({ - [key]: valueExists ? null : option.value, - }); - } else { - const valueExists = filters[key]?.includes(option.value); - - if (valueExists) setFilters({ - [option.key]: ((filters[key] ?? []) as any[])?.filter( - (val) => val !== option.value - ), + [key]: valueExists ? null : option.value, }); - else - setFilters({ - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }); - } - }} - direction="left" - height="rg" - /> - - {({ open }) => ( - <> - - Display - + } else { + const valueExists = filters[key]?.includes(option.value); - - -
-
- {displayFilters?.layout !== "calendar" && - displayFilters?.layout !== "spreadsheet" && ( - <> -
-

Group by

-
- option.key === displayFilters?.group_by - )?.name ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {GROUP_BY_OPTIONS.map((option) => { - if (displayFilters?.layout === "kanban" && option.key === null) - return null; - if ( - option.key === "state" || - option.key === "created_by" || - option.key === "assignees" - ) - return null; - - return ( - setDisplayFilters({ group_by: option.key })} - > - {option.name} - - ); - })} - -
-
-
-

Order by

-
- option.key === displayFilters?.order_by - )?.name ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ORDER_BY_OPTIONS.map((option) => { - if ( - displayFilters?.group_by === "priority" && - option.key === "priority" - ) - return null; - if (option.key === "sort_order") return null; - - return ( - { - setDisplayFilters({ order_by: option.key }); - }} - > - {option.name} - - ); - })} - -
-
- - )} -
-

Issue type

-
- option.key === displayFilters?.type - )?.name ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {FILTER_ISSUE_OPTIONS.map((option) => ( - - setDisplayFilters({ - type: option.key, - }) - } - > - {option.name} - - ))} - -
-
- - {displayFilters?.layout !== "calendar" && - displayFilters?.layout !== "spreadsheet" && ( - <> -
-

Show empty groups

-
- - setDisplayFilters({ - show_empty_groups: !displayFilters?.show_empty_groups, - }) - } - /> -
-
- - )} -
- -
-

Display Properties

-
- {Object.keys(properties).map((key) => { - if (key === "estimate" && !isEstimateActive) return null; - - if ( - displayFilters?.layout === "spreadsheet" && - (key === "attachment_count" || - key === "link" || - key === "sub_issue_count") - ) - return null; - - if ( - displayFilters?.layout !== "spreadsheet" && - (key === "created_on" || key === "updated_on") - ) - return null; - - return ( - - ); - })} -
-
-
-
-
- - )} -
+ if (valueExists) + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter( + (val) => val !== option.value + ), + }); + else + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="left" + height="rg" + /> + )}
); }; diff --git a/web/components/issues/workspace-views/workspace-issue-view-option.tsx b/web/components/issues/workspace-views/workspace-issue-view-option.tsx new file mode 100644 index 000000000..4e98cce92 --- /dev/null +++ b/web/components/issues/workspace-views/workspace-issue-view-option.tsx @@ -0,0 +1,121 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// hooks +import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; +import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter"; +// components +import { MyIssuesSelectFilters } from "components/issues"; +// ui +import { Tooltip } from "components/ui"; +// icons +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { CreditCard } from "lucide-react"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { TIssueViewOptions } from "types"; + +const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ + { + type: "list", + Icon: FormatListBulletedOutlined, + }, + { + type: "spreadsheet", + Icon: CreditCard, + }, +]; + +export const WorkspaceIssuesViewOptions: React.FC = () => { + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { displayFilters, setDisplayFilters } = useMyIssuesFilters(workspaceSlug?.toString()); + + const { filters, setFilters } = useWorkspaceIssuesFilters( + workspaceSlug?.toString(), + workspaceViewId?.toString() + ); + + const isWorkspaceViewPath = router.pathname.includes("workspace-views/all-issues"); + + const showFilters = isWorkspaceViewPath || workspaceViewId; + + return ( +
+
+ {issueViewOptions.map((option) => ( + {replaceUnderscoreIfSnakeCase(option.type)} View + } + position="bottom" + > + + + ))} +
+ + {showFilters && ( + <> + { + const key = option.key as keyof typeof filters; + + if (key === "start_date" || key === "target_date") { + const valueExists = checkIfArraysHaveSameElements( + filters?.[key] ?? [], + option.value + ); + + setFilters({ + [key]: valueExists ? null : option.value, + }); + } else { + const valueExists = filters[key]?.includes(option.value); + + if (valueExists) + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter( + (val) => val !== option.value + ), + }); + else + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="left" + height="rg" + /> + + )} +
+ ); +}; diff --git a/web/components/views/delete-view-modal.tsx b/web/components/views/delete-view-modal.tsx index c65f7ba29..0d49c62cc 100644 --- a/web/components/views/delete-view-modal.tsx +++ b/web/components/views/delete-view-modal.tsx @@ -8,6 +8,7 @@ import { mutate } from "swr"; import { Dialog, Transition } from "@headlessui/react"; // services import viewsService from "services/views.service"; +import workspaceService from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; // ui @@ -17,16 +18,17 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // types import type { ICurrentUserResponse, IView } from "types"; // fetch-keys -import { VIEWS_LIST } from "constants/fetch-keys"; +import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; type Props = { isOpen: boolean; + viewType: "project" | "workspace"; setIsOpen: React.Dispatch>; data: IView | null; user: ICurrentUserResponse | undefined; }; -export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, user }) => { +export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, viewType, user }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const router = useRouter(); @@ -41,34 +43,64 @@ export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, user const handleDeletion = async () => { setIsDeleteLoading(true); - if (!workspaceSlug || !data || !projectId) return; - await viewsService - .deleteView(workspaceSlug as string, projectId as string, data.id, user) - .then(() => { - mutate( - VIEWS_LIST(projectId as string), - (views) => views?.filter((view) => view.id !== data.id) - ); + if (viewType === "project") { + if (!workspaceSlug || !data || !projectId) return; - handleClose(); + await viewsService + .deleteView(workspaceSlug as string, projectId as string, data.id, user) + .then(() => { + mutate(VIEWS_LIST(projectId as string), (views) => + views?.filter((view) => view.id !== data.id) + ); - setToastAlert({ - type: "success", - title: "Success!", - message: "View deleted successfully.", + handleClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View deleted successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be deleted. Please try again.", + }); + }) + .finally(() => { + setIsDeleteLoading(false); }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be deleted. Please try again.", + } else { + if (!workspaceSlug || !data) return; + + await workspaceService + .deleteView(workspaceSlug as string, data.id) + .then(() => { + mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string), (views) => + views?.filter((view) => view.id !== data.id) + ); + + handleClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View deleted successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be deleted. Please try again.", + }); + }) + .finally(() => { + setIsDeleteLoading(false); }); - }) - .finally(() => { - setIsDeleteLoading(false); - }); + } }; return ( diff --git a/web/components/views/form.tsx b/web/components/views/form.tsx index 0c57a9542..29d16ca9c 100644 --- a/web/components/views/form.tsx +++ b/web/components/views/form.tsx @@ -10,6 +10,8 @@ import { useForm } from "react-hook-form"; import stateService from "services/state.service"; // hooks import useProjectMembers from "hooks/use-project-members"; +import useProjects from "hooks/use-projects"; +import useWorkspaceMembers from "hooks/use-workspace-members"; // components import { FiltersList } from "components/core"; import { SelectFilters } from "components/views"; @@ -22,13 +24,14 @@ import { getStatesList } from "helpers/state.helper"; import { IQuery, IView } from "types"; import issuesService from "services/issues.service"; // fetch-keys -import { PROJECT_ISSUE_LABELS, STATES_LIST } from "constants/fetch-keys"; +import { PROJECT_ISSUE_LABELS, STATES_LIST, WORKSPACE_LABELS } from "constants/fetch-keys"; type Props = { handleFormSubmit: (values: IView) => Promise; handleClose: () => void; status: boolean; data?: IView | null; + viewType?: "workspace" | "project"; preLoadedData?: Partial | null; }; @@ -42,6 +45,7 @@ export const ViewForm: React.FC = ({ handleClose, status, data, + viewType, preLoadedData, }) => { const router = useRouter(); @@ -77,8 +81,26 @@ export const ViewForm: React.FC = ({ ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) : null ); + + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const labelOptions = viewType === "workspace" ? workspaceLabels : labels; + const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString()); + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const memberOptions = + viewType === "workspace" + ? workspaceMembers?.map((m) => m.member) + : members?.map((m) => m.member); + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + const handleCreateUpdateView = async (formData: IView) => { await handleFormSubmit(formData); @@ -91,12 +113,14 @@ export const ViewForm: React.FC = ({ setValue("query", { assignees: null, created_by: null, + subscriber: null, labels: null, priority: null, state: null, + state_group: null, start_date: null, target_date: null, - type: null, + project: null, }); }; @@ -185,9 +209,10 @@ export const ViewForm: React.FC = ({
m.member)} + labels={labelOptions} + members={memberOptions} states={states} + project={joinedProjects} clearAllFilters={clearAllFilters} setFilters={(query: any) => { setValue("query", { diff --git a/web/components/views/modal.tsx b/web/components/views/modal.tsx index c1ff54231..03f4f0b60 100644 --- a/web/components/views/modal.tsx +++ b/web/components/views/modal.tsx @@ -8,6 +8,7 @@ import { mutate } from "swr"; import { Dialog, Transition } from "@headlessui/react"; // services import viewsService from "services/views.service"; +import workspaceService from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; // components @@ -15,10 +16,11 @@ import { ViewForm } from "components/views"; // types import { ICurrentUserResponse, IView } from "types"; // fetch-keys -import { VIEWS_LIST } from "constants/fetch-keys"; +import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; type Props = { isOpen: boolean; + viewType: "project" | "workspace"; handleClose: () => void; data?: IView | null; preLoadedData?: Partial | null; @@ -27,6 +29,7 @@ type Props = { export const CreateUpdateViewModal: React.FC = ({ isOpen, + viewType, handleClose, data, preLoadedData, @@ -46,25 +49,48 @@ export const CreateUpdateViewModal: React.FC = ({ ...payload, query_data: payload.query, }; - await viewsService - .createView(workspaceSlug as string, projectId as string, payload, user) - .then(() => { - mutate(VIEWS_LIST(projectId as string)); - handleClose(); - setToastAlert({ - type: "success", - title: "Success!", - message: "View created successfully.", + if (viewType === "project") { + await viewsService + .createView(workspaceSlug as string, projectId as string, payload, user) + .then(() => { + mutate(VIEWS_LIST(projectId as string)); + handleClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View created successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be created. Please try again.", + }); }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be created. Please try again.", + } else { + await workspaceService + .createView(workspaceSlug as string, payload) + .then(() => { + mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string)); + handleClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View created successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be created. Please try again.", + }); }); - }); + } }; const updateView = async (payload: IView) => { @@ -72,41 +98,79 @@ export const CreateUpdateViewModal: React.FC = ({ ...payload, query_data: payload.query, }; - await viewsService - .updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user) - .then((res) => { - mutate( - VIEWS_LIST(projectId as string), - (prevData) => - prevData?.map((p) => { - if (p.id === res.id) return { ...p, ...payloadData }; + if (viewType === "project") { + await viewsService + .updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user) + .then((res) => { + mutate( + VIEWS_LIST(projectId as string), + (prevData) => + prevData?.map((p) => { + if (p.id === res.id) return { ...p, ...payloadData }; - return p; - }), - false - ); - onClose(); + return p; + }), + false + ); + onClose(); - setToastAlert({ - type: "success", - title: "Success!", - message: "View updated successfully.", + setToastAlert({ + type: "success", + title: "Success!", + message: "View updated successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be updated. Please try again.", + }); }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be updated. Please try again.", + } else { + await workspaceService + .updateView(workspaceSlug as string, data?.id ?? "", payloadData) + .then((res) => { + mutate( + WORKSPACE_VIEWS_LIST(workspaceSlug as string), + (prevData) => + prevData?.map((p) => { + if (p.id === res.id) return { ...p, ...payloadData }; + + return p; + }), + false + ); + onClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View updated successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be updated. Please try again.", + }); }); - }); + } }; const handleFormSubmit = async (formData: IView) => { - if (!workspaceSlug || !projectId) return; + if (viewType === "project") { + if (!workspaceSlug || !projectId) return; - if (!data) await createView(formData); - else await updateView(formData); + if (!data) await createView(formData); + else await updateView(formData); + } else { + if (!workspaceSlug) return; + + if (!data) await createView(formData); + else await updateView(formData); + } }; return ( @@ -141,6 +205,7 @@ export const CreateUpdateViewModal: React.FC = ({ handleClose={handleClose} status={data ? true : false} data={data} + viewType={viewType} preLoadedData={preLoadedData} /> diff --git a/web/components/views/select-filters.tsx b/web/components/views/select-filters.tsx index 52671f41f..7b1324f5f 100644 --- a/web/components/views/select-filters.tsx +++ b/web/components/views/select-filters.tsx @@ -4,6 +4,9 @@ import { useRouter } from "next/router"; import useSWR from "swr"; +// hook +import useProjects from "hooks/use-projects"; +import useWorkspaceMembers from "hooks/use-workspace-members"; // services import stateService from "services/state.service"; import projectService from "services/project.service"; @@ -18,15 +21,20 @@ import { PriorityIcon, StateGroupIcon } from "components/icons"; import { getStatesList } from "helpers/state.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { IIssueFilterOptions, IQuery } from "types"; +import { IIssueFilterOptions, TStateGroups } from "types"; // fetch-keys -import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys"; +import { + PROJECT_ISSUE_LABELS, + PROJECT_MEMBERS, + STATES_LIST, + WORKSPACE_LABELS, +} from "constants/fetch-keys"; // constants -import { PRIORITIES } from "constants/project"; +import { GROUP_CHOICES, PRIORITIES } from "constants/project"; import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { - filters: Partial | IQuery; + filters: Partial; onSelect: (option: any) => void; direction?: "left" | "right"; height?: "sm" | "md" | "rg" | "lg"; @@ -48,7 +56,7 @@ export const SelectFilters: React.FC = ({ }); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, workspaceViewId } = router.query; const { data: states } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, @@ -58,6 +66,20 @@ export const SelectFilters: React.FC = ({ ); const statesList = getStatesList(states); + const workspaceViewPathName = [ + "workspace-views", + "workspace-views/all-issues", + "workspace-views/assigned", + "workspace-views/created", + "workspace-views/subscribed", + ]; + + const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => + router.pathname.includes(pathname) + ); + + const isWorkspaceView = isWorkspaceViewPath || workspaceViewId; + const { data: members } = useSWR( projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId @@ -65,6 +87,8 @@ export const SelectFilters: React.FC = ({ : null ); + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + const { data: issueLabels } = useSWR( projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, workspaceSlug && projectId @@ -72,6 +96,413 @@ export const SelectFilters: React.FC = ({ : null ); + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const projectFilterOption = [ + { + id: "priority", + label: "Priority", + value: PRIORITIES, + hasChildren: true, + children: PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
+ + {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + }, + { + id: "state", + label: "State", + value: statesList, + hasChildren: true, + children: statesList?.map((state) => ({ + id: state.id, + label: ( +
+ + {state.name} +
+ ), + value: { + key: "state", + value: state.id, + }, + selected: filters?.state?.includes(state.id), + })), + }, + { + id: "assignees", + label: "Assignees", + value: members, + hasChildren: true, + children: members?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "assignees", + value: member.member.id, + }, + selected: filters?.assignees?.includes(member.member.id), + })), + }, + { + id: "created_by", + label: "Created by", + value: members, + hasChildren: true, + children: members?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "created_by", + value: member.member.id, + }, + selected: filters?.created_by?.includes(member.member.id), + })), + }, + { + id: "labels", + label: "Labels", + value: issueLabels, + hasChildren: true, + children: issueLabels?.map((label) => ({ + id: label.id, + label: ( +
+
+ {label.name} +
+ ), + value: { + key: "labels", + value: label.id, + }, + selected: filters?.labels?.includes(label.id), + })), + }, + { + id: "start_date", + label: "Start date", + value: DATE_FILTER_OPTIONS, + hasChildren: true, + children: [ + ...DATE_FILTER_OPTIONS.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "start_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value), + })), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + { + id: "target_date", + label: "Due date", + value: DATE_FILTER_OPTIONS, + hasChildren: true, + children: [ + ...DATE_FILTER_OPTIONS.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "target_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), + })), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + ]; + + const workspaceFilterOption = [ + { + id: "project", + label: "Project", + value: joinedProjects, + hasChildren: true, + children: joinedProjects?.map((project) => ({ + id: project.id, + label:
{project.name}
, + value: { + key: "project", + value: project.id, + }, + selected: filters?.project?.includes(project.id), + })), + }, + { + id: "state_group", + label: "State groups", + value: GROUP_CHOICES, + hasChildren: true, + children: [ + ...Object.keys(GROUP_CHOICES).map((key) => ({ + id: key, + label: ( +
+ + {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} +
+ ), + value: { + key: "state_group", + value: key, + }, + selected: filters?.state?.includes(key), + })), + ], + }, + { + id: "labels", + label: "Labels", + value: workspaceLabels, + hasChildren: true, + children: workspaceLabels?.map((label) => ({ + id: label.id, + label: ( +
+
+ {label.name} +
+ ), + value: { + key: "labels", + value: label.id, + }, + selected: filters?.labels?.includes(label.id), + })), + }, + { + id: "priority", + label: "Priority", + value: PRIORITIES, + hasChildren: true, + children: PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
+ + {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + }, + { + id: "created_by", + label: "Created by", + value: workspaceMembers, + hasChildren: true, + children: workspaceMembers?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "created_by", + value: member.member.id, + }, + selected: filters?.created_by?.includes(member.member.id), + })), + }, + { + id: "assignees", + label: "Assignees", + value: workspaceMembers, + hasChildren: true, + children: workspaceMembers?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "assignees", + value: member.member.id, + }, + selected: filters?.assignees?.includes(member.member.id), + })), + }, + { + id: "subscriber", + label: "Subscriber", + value: workspaceMembers, + hasChildren: true, + children: workspaceMembers?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "subscriber", + value: member.member.id, + }, + selected: filters?.subscriber?.includes(member.member.id), + })), + }, + { + id: "start_date", + label: "Start date", + value: DATE_FILTER_OPTIONS, + hasChildren: true, + children: [ + ...DATE_FILTER_OPTIONS.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "start_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value), + })), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + { + id: "target_date", + label: "Due date", + value: DATE_FILTER_OPTIONS, + hasChildren: true, + children: [ + ...DATE_FILTER_OPTIONS.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "target_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), + })), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + ]; + + const filterOption = isWorkspaceView ? workspaceFilterOption : projectFilterOption; + return ( <> {isDateFilterModalOpen && ( @@ -89,185 +520,7 @@ export const SelectFilters: React.FC = ({ onSelect={onSelect} direction={direction} height={height} - options={[ - { - id: "priority", - label: "Priority", - value: PRIORITIES, - hasChildren: true, - children: PRIORITIES.map((priority) => ({ - id: priority === null ? "null" : priority, - label: ( -
- - {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - }, - { - id: "state", - label: "State", - value: statesList, - hasChildren: true, - children: statesList?.map((state) => ({ - id: state.id, - label: ( -
- - {state.name} -
- ), - value: { - key: "state", - value: state.id, - }, - selected: filters?.state?.includes(state.id), - })), - }, - { - id: "assignees", - label: "Assignees", - value: members, - hasChildren: true, - children: members?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "assignees", - value: member.member.id, - }, - selected: filters?.assignees?.includes(member.member.id), - })), - }, - { - id: "created_by", - label: "Created by", - value: members, - hasChildren: true, - children: members?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "created_by", - value: member.member.id, - }, - selected: filters?.created_by?.includes(member.member.id), - })), - }, - { - id: "labels", - label: "Labels", - value: issueLabels, - hasChildren: true, - children: issueLabels?.map((label) => ({ - id: label.id, - label: ( -
-
- {label.name} -
- ), - value: { - key: "labels", - value: label.id, - }, - selected: filters?.labels?.includes(label.id), - })), - }, - { - id: "start_date", - label: "Start date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...DATE_FILTER_OPTIONS.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "start_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value), - })), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - { - id: "target_date", - label: "Due date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...DATE_FILTER_OPTIONS.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "target_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), - })), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - ]} + options={filterOption} /> ); diff --git a/web/components/views/single-view-item.tsx b/web/components/views/single-view-item.tsx index a6f81912c..d27eb3cf1 100644 --- a/web/components/views/single-view-item.tsx +++ b/web/components/views/single-view-item.tsx @@ -5,9 +5,9 @@ import { useRouter } from "next/router"; // icons import { TrashIcon, StarIcon, PencilIcon } from "@heroicons/react/24/outline"; -import { StackedLayersIcon } from "components/icons"; +import { PhotoFilterOutlined } from "@mui/icons-material"; //components -import { CustomMenu, Tooltip } from "components/ui"; +import { CustomMenu } from "components/ui"; // services import viewsService from "services/views.service"; // types @@ -18,15 +18,20 @@ import { VIEWS_LIST } from "constants/fetch-keys"; import useToast from "hooks/use-toast"; // helpers import { truncateText } from "helpers/string.helper"; -import { renderShortDateWithYearFormat, render24HourFormatTime } from "helpers/date-time.helper"; type Props = { view: IView; + viewType: "project" | "workspace"; handleEditView: () => void; handleDeleteView: () => void; }; -export const SingleViewItem: React.FC = ({ view, handleEditView, handleDeleteView }) => { +export const SingleViewItem: React.FC = ({ + view, + viewType, + handleEditView, + handleDeleteView, +}) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -82,38 +87,46 @@ export const SingleViewItem: React.FC = ({ view, handleEditView, handleDe }); }; + const viewRedirectionUrl = + viewType === "project" + ? `/${workspaceSlug}/projects/${projectId}/views/${view.id}` + : `/${workspaceSlug}/workspace-views/${view.id}`; + return ( -
- - -
-
-
- -

{truncateText(view.name, 75)}

+
+ + +
+
+
+
-
-
-

- {Object.keys(view.query_data) - .map((key: string) => - view.query_data[key as keyof typeof view.query_data] !== null - ? (view.query_data[key as keyof typeof view.query_data] as any).length - : 0 - ) - .reduce((curr, prev) => curr + prev, 0)}{" "} - filters -

- -

- {render24HourFormatTime(view.updated_at)} -

-
- {view.is_favorite ? ( +
+

+ {truncateText(view.name, 75)} +

+ {view?.description && ( +

{view.description}

+ )} +
+
+
+
+

+ {Object.keys(view.query_data) + .map((key: string) => + view.query_data[key as keyof typeof view.query_data] !== null + ? (view.query_data[key as keyof typeof view.query_data] as any).length + : 0 + ) + .reduce((curr, prev) => curr + prev, 0)}{" "} + filters +

+ + {viewType === "project" ? ( + view.is_favorite ? ( - )} - - { - e.preventDefault(); - e.stopPropagation(); - handleEditView(); - }} - > - - - Edit View - - - { - e.preventDefault(); - e.stopPropagation(); - handleDeleteView(); - }} - > - - - Delete View - - - -
+ ) + ) : null} + + { + e.preventDefault(); + e.stopPropagation(); + handleEditView(); + }} + > + + + Edit View + + + { + e.preventDefault(); + e.stopPropagation(); + handleDeleteView(); + }} + > + + + Delete View + + +
- {view?.description && ( -

- {view.description} -

- )}
diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index 946f4b708..df38ea8af 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -34,8 +34,8 @@ const workspaceLinks = (workspaceSlug: string) => [ }, { Icon: TaskAltOutlined, - name: "My Issues", - href: `/${workspaceSlug}/me/my-issues`, + name: "Issues", + href: `/${workspaceSlug}/workspace-views/all-issues`, }, ]; diff --git a/web/components/workspace/views/workpace-view-navigation.tsx b/web/components/workspace/views/workpace-view-navigation.tsx new file mode 100644 index 000000000..c15943e8a --- /dev/null +++ b/web/components/workspace/views/workpace-view-navigation.tsx @@ -0,0 +1,92 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// icon +import { PlusIcon } from "lucide-react"; +// constant +import { WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; +// service +import workspaceService from "services/workspace.service"; + +type Props = { + handleAddView: () => void; +}; + +export const WorkspaceViewsNavigation: React.FC = ({ handleAddView }) => { + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { data: workspaceViews } = useSWR( + workspaceSlug ? WORKSPACE_VIEWS_LIST(workspaceSlug.toString()) : null, + workspaceSlug ? () => workspaceService.getAllViews(workspaceSlug.toString()) : null + ); + + const isSelected = (pathName: string) => router.pathname.includes(pathName); + + const tabsList = [ + { + key: "all", + label: "All Issues", + selected: isSelected("workspace-views/all-issues"), + onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues`), + }, + { + key: "assigned", + label: "Assigned", + selected: isSelected("workspace-views/assigned"), + onClick: () => router.push(`/${workspaceSlug}/workspace-views/assigned`), + }, + { + key: "created", + label: "Created", + selected: isSelected("workspace-views/created"), + onClick: () => router.push(`/${workspaceSlug}/workspace-views/created`), + }, + { + key: "subscribed", + label: "Subscribed", + selected: isSelected("workspace-views/subscribed"), + onClick: () => router.push(`/${workspaceSlug}/workspace-views/subscribed`), + }, + ]; + + return ( +
+ {tabsList.map((tab) => ( + + ))} + {workspaceViews && + workspaceViews.length > 0 && + workspaceViews?.map((view) => ( + + ))} + + +
+ ); +}; diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index 0f0643c66..75107a0bb 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -4,6 +4,7 @@ import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types"; const paramsToKey = (params: any) => { const { state, + state_group, priority, assignees, created_by, @@ -12,9 +13,12 @@ const paramsToKey = (params: any) => { target_date, sub_issue, start_target_date, + project, } = params; + let projectKey = project ? project.split(",") : []; let stateKey = state ? state.split(",") : []; + let stateGroupKey = state_group ? state_group.split(",") : []; let priorityKey = priority ? priority.split(",") : []; let assigneesKey = assignees ? assignees.split(",") : []; let createdByKey = created_by ? created_by.split(",") : []; @@ -27,13 +31,15 @@ const paramsToKey = (params: any) => { const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL"; // sorting each keys in ascending order + projectKey = projectKey.sort().join("_"); stateKey = stateKey.sort().join("_"); + stateGroupKey = stateGroupKey.sort().join("_"); priorityKey = priorityKey.sort().join("_"); assigneesKey = assigneesKey.sort().join("_"); createdByKey = createdByKey.sort().join("_"); labelsKey = labelsKey.sort().join("_"); - return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${startTargetDate}`; + return `${projectKey}_${stateGroupKey}_${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${startTargetDate}`; }; const inboxParamsToKey = (params: any) => { @@ -149,6 +155,18 @@ export const PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params? return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}_${paramsKey}`; }; +export const WORKSPACE_VIEWS_LIST = (workspaceSlug: string) => + `WORKSPACE_VIEWS_LIST_${workspaceSlug.toUpperCase()}`; +export const WORKSPACE_VIEW_DETAILS = (workspaceViewId: string) => + `WORKSPACE_VIEW_DETAILS_${workspaceViewId.toUpperCase()}`; +export const WORKSPACE_VIEW_ISSUES = (workspaceViewId: string, params?: any) => { + if (!params) return `WORKSPACE_VIEW_ISSUES_${workspaceViewId.toUpperCase()}`; + + const paramsKey = paramsToKey(params); + + return `WORKSPACE_VIEW_ISSUES_${workspaceViewId.toUpperCase()}_${paramsKey.toUpperCase()}`; +}; + export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`; export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => diff --git a/web/constants/project.ts b/web/constants/project.ts index b8e3be9d6..2f15b74bc 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -21,6 +21,8 @@ export const GROUP_CHOICES = { cancelled: "Cancelled", }; +export const STATE_GROUP = ["Backlog", "Unstarted", "Started", "Completed", "Cancelled"]; + export const PRIORITIES: TIssuePriorities[] = ["urgent", "high", "medium", "low", "none"]; export const MONTHS = [ diff --git a/web/hooks/use-sub-issue.tsx b/web/hooks/use-sub-issue.tsx index 8eb30fd0b..26b84ba5a 100644 --- a/web/hooks/use-sub-issue.tsx +++ b/web/hooks/use-sub-issue.tsx @@ -11,9 +11,9 @@ import { ISubIssueResponse } from "types"; // fetch-keys import { SUB_ISSUES } from "constants/fetch-keys"; -const useSubIssue = (issueId: string, isExpanded: boolean) => { +const useSubIssue = (projectId: string, issueId: string, isExpanded: boolean) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const shouldFetch = workspaceSlug && projectId && issueId && isExpanded; diff --git a/web/hooks/use-worskpace-issue-filter.tsx b/web/hooks/use-worskpace-issue-filter.tsx new file mode 100644 index 000000000..00af0a9f8 --- /dev/null +++ b/web/hooks/use-worskpace-issue-filter.tsx @@ -0,0 +1,113 @@ +import { useEffect, useCallback } from "react"; + +import useSWR, { mutate } from "swr"; + +// services +import workspaceService from "services/workspace.service"; +// types +import { IIssueFilterOptions, IView } from "types"; +// fetch-keys +import { WORKSPACE_VIEW_DETAILS } from "constants/fetch-keys"; + +const initialValues: IIssueFilterOptions = { + assignees: null, + created_by: null, + labels: null, + priority: null, + state: null, + state_group: null, + subscriber: null, + start_date: null, + target_date: null, + project: null, +}; + +const useWorkspaceIssuesFilters = ( + workspaceSlug: string | undefined, + workspaceViewId: string | undefined +) => { + const { data: workspaceViewDetails } = useSWR( + workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId) : null, + workspaceSlug && workspaceViewId + ? () => workspaceService.getViewDetails(workspaceSlug, workspaceViewId) + : null + ); + + const saveData = useCallback( + (data: Partial) => { + if (!workspaceSlug || !workspaceViewId || !workspaceViewDetails) return; + + const oldData = { ...workspaceViewDetails }; + + mutate( + WORKSPACE_VIEW_DETAILS(workspaceViewId), + (prevData) => { + if (!prevData) return; + return { + ...prevData, + query_data: { + ...prevData?.query_data, + ...data, + }, + }; + }, + false + ); + + workspaceService.updateView(workspaceSlug, workspaceViewId, { + query_data: { + ...oldData.query_data, + ...data, + }, + }); + }, + [workspaceViewDetails, workspaceSlug, workspaceViewId] + ); + + const filters = workspaceViewDetails?.query_data ?? initialValues; + + const setFilters = useCallback( + (updatedFilter: Partial) => { + if (!workspaceViewDetails) return; + + saveData({ + ...workspaceViewDetails?.query_data, + ...updatedFilter, + }); + }, + [workspaceViewDetails, saveData] + ); + + useEffect(() => { + if (!workspaceViewDetails || !workspaceSlug || !workspaceViewId) return; + + if (!workspaceViewDetails.query_data) { + workspaceService.updateView(workspaceSlug, workspaceViewId, { + query_data: { ...initialValues }, + }); + } + }, [workspaceViewDetails, workspaceViewId, workspaceSlug]); + + const params: any = { + assignees: filters?.assignees ? filters?.assignees.join(",") : undefined, + subscriber: filters?.subscriber ? filters?.subscriber.join(",") : undefined, + state: filters?.state ? filters?.state.join(",") : undefined, + state_group: filters?.state_group ? filters?.state_group.join(",") : undefined, + priority: filters?.priority ? filters?.priority.join(",") : undefined, + labels: filters?.labels ? filters?.labels.join(",") : undefined, + created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, + target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, + project: filters?.project ? filters?.project.join(",") : undefined, + sub_issue: false, + type: undefined, + }; + + return { + params, + filters, + setFilters, + }; +}; + +export default useWorkspaceIssuesFilters; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx index 9a5511037..ea37a777a 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx @@ -88,12 +88,14 @@ const ProjectViews: NextPage = () => { > setCreateUpdateViewModal(false)} data={selectedViewToUpdate} user={user} /> { handleEditView(view)} handleDeleteView={() => handleDeleteView(view)} /> diff --git a/web/pages/[workspaceSlug]/workspace-views/[workspaceViewId].tsx b/web/pages/[workspaceSlug]/workspace-views/[workspaceViewId].tsx new file mode 100644 index 000000000..cba4d802b --- /dev/null +++ b/web/pages/[workspaceSlug]/workspace-views/[workspaceViewId].tsx @@ -0,0 +1,322 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// hook +import useToast from "hooks/use-toast"; +import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter"; +import useProjects from "hooks/use-projects"; +import useUser from "hooks/use-user"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +import projectIssuesServices from "services/issues.service"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// components +import { FiltersList, SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option"; +import { CreateUpdateViewModal } from "components/views"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +// ui +import { EmptyState, PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +import { CheckCircle } from "lucide-react"; +// images +import emptyView from "public/empty-state/view.svg"; +// fetch-keys +import { + WORKSPACE_LABELS, + WORKSPACE_VIEWS_LIST, + WORKSPACE_VIEW_DETAILS, + WORKSPACE_VIEW_ISSUES, +} from "constants/fetch-keys"; +// constant +import { STATE_GROUP } from "constants/project"; +// types +import { IIssue, IIssueFilterOptions, IView } from "types"; + +const WorkspaceView: React.FC = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { memberRole } = useProjectMyMembership(); + + const { user } = useUser(); + const { setToastAlert } = useToast(); + + const { data: viewDetails, error } = useSWR( + workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null, + workspaceSlug && workspaceViewId + ? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString()) + : null + ); + + const { params, filters, setFilters } = useWorkspaceIssuesFilters( + workspaceSlug?.toString(), + workspaceViewId?.toString() + ); + + const { isGuest, isViewer } = useWorkspaceMembers( + workspaceSlug?.toString(), + Boolean(workspaceSlug) + ); + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug && viewDetails ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug && viewDetails + ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) + : null + ); + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => projectIssuesServices.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const updateView = async (payload: IIssueFilterOptions) => { + const payloadData = { + query_data: payload, + }; + + await workspaceService + .updateView(workspaceSlug as string, workspaceViewId as string, payloadData) + .then((res) => { + mutate( + WORKSPACE_VIEWS_LIST(workspaceSlug as string), + (prevData) => + prevData?.map((p) => { + if (p.id === res.id) return { ...p, ...payloadData }; + + return p; + }), + false + ); + setToastAlert({ + type: "success", + title: "Success!", + message: "View updated successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be updated. Please try again.", + }); + }); + }; + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + const nullFilters = + filters && + Object.keys(filters).filter((key) => filters[key as keyof IIssueFilterOptions] === null); + + const areFiltersApplied = + filters && + Object.keys(filters).length > 0 && + nullFilters.length !== Object.keys(filters).length; + + const isNotAllowed = isGuest || isViewer; + + return ( + + + + {viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"} + +
+ } + right={ +
+ + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + + Add Issue + +
+ } + > + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + viewType="workspace" + preLoadedData={createViewModal} + user={user} + /> +
+
+ setCreateViewModal(true)} /> + {error ? ( + router.push(`/${workspaceSlug}/workspace-views`), + }} + /> + ) : ( +
+ {areFiltersApplied && ( + <> +
+ setFilters(updatedFilter)} + labels={workspaceLabels} + members={workspaceMembers?.map((m) => m.member)} + stateGroup={STATE_GROUP} + project={joinedProjects} + clearAllFilters={() => + setFilters({ + assignees: null, + created_by: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + subscriber: null, + project: null, + }) + } + /> + { + if (workspaceViewId) { + updateView(filters); + } else + setCreateViewModal({ + query: filters, + }); + }} + className="flex items-center gap-2 text-sm" + > + {!workspaceViewId && } + {workspaceViewId ? "Update" : "Save"} view + +
+ {
} + + )} + +
+ )} +
+
+ + ); +}; + +export default WorkspaceView; diff --git a/web/pages/[workspaceSlug]/workspace-views/all-issues.tsx b/web/pages/[workspaceSlug]/workspace-views/all-issues.tsx new file mode 100644 index 000000000..763784b8e --- /dev/null +++ b/web/pages/[workspaceSlug]/workspace-views/all-issues.tsx @@ -0,0 +1,283 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +import useProjects from "hooks/use-projects"; +import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +import projectIssuesServices from "services/issues.service"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// components +import { FiltersList, SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateViewModal } from "components/views"; +import { CreateUpdateIssueModal, DeleteIssueModal, MyIssuesViewOptions } from "components/issues"; +// ui +import { EmptyState, PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +import { CheckCircle } from "lucide-react"; +// images +import emptyView from "public/empty-state/view.svg"; +// fetch-keys +import { + WORKSPACE_LABELS, + WORKSPACE_VIEW_DETAILS, + WORKSPACE_VIEW_ISSUES, +} from "constants/fetch-keys"; +// constants +import { STATE_GROUP } from "constants/project"; +// types +import { IIssue, IIssueFilterOptions } from "types"; + +const WorkspaceViewAllIssue: React.FC = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString()); + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const { data: viewDetails, error } = useSWR( + workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null, + workspaceSlug && workspaceViewId + ? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString()) + : null + ); + + const params: any = { + assignees: filters?.assignees ? filters?.assignees.join(",") : undefined, + subscriber: filters?.subscriber ? filters?.subscriber.join(",") : undefined, + state: filters?.state ? filters?.state.join(",") : undefined, + state_group: filters?.state_group ? filters?.state_group.join(",") : undefined, + priority: filters?.priority ? filters?.priority.join(",") : undefined, + labels: filters?.labels ? filters?.labels.join(",") : undefined, + created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, + target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, + project: filters?.project ? filters?.project.join(",") : undefined, + sub_issue: false, + type: undefined, + }; + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + const nullFilters = + filters && + Object.keys(filters).filter((key) => filters[key as keyof IIssueFilterOptions] === null); + + const areFiltersApplied = + filters && + Object.keys(filters).length > 0 && + nullFilters.length !== Object.keys(filters).length; + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => projectIssuesServices.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + return ( + + + + {viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"} + +
+ } + right={ +
+ + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + + Add Issue + +
+ } + > + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + viewType="workspace" + preLoadedData={createViewModal} + user={user} + /> +
+
+ setCreateViewModal(true)} /> + {error ? ( + router.push(`/${workspaceSlug}/workspace-views`), + }} + /> + ) : ( +
+ {areFiltersApplied && ( + <> +
+ setFilters(updatedFilter)} + labels={workspaceLabels} + members={workspaceMembers?.map((m) => m.member)} + stateGroup={STATE_GROUP} + project={joinedProjects} + clearAllFilters={() => + setFilters({ + assignees: null, + created_by: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + subscriber: null, + project: null, + }) + } + /> + { + setCreateViewModal({ + query: filters, + }); + }} + className="flex items-center gap-2 text-sm" + > + {!workspaceViewId && } + {workspaceViewId ? "Update" : "Save"} view + +
+ {
} + + )} + +
+ )} +
+
+ + ); +}; + +export default WorkspaceViewAllIssue; diff --git a/web/pages/[workspaceSlug]/workspace-views/assigned.tsx b/web/pages/[workspaceSlug]/workspace-views/assigned.tsx new file mode 100644 index 000000000..0cfd5a534 --- /dev/null +++ b/web/pages/[workspaceSlug]/workspace-views/assigned.tsx @@ -0,0 +1,205 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option"; +import { CreateUpdateViewModal } from "components/views"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +// ui +import { EmptyState, PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +import { CheckCircle } from "lucide-react"; +// images +import emptyView from "public/empty-state/view.svg"; +// fetch-keys +import { WORKSPACE_VIEW_DETAILS, WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +const WorkspaceViewAssignedIssue: React.FC = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { user } = useUser(); + + const { memberRole } = useProjectMyMembership(); + + const params: any = { + assignees: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewDetails, error } = useSWR( + workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null, + workspaceSlug && workspaceViewId + ? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString()) + : null + ); + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + return ( + + + + {viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"} + +
+ } + right={ +
+ + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + + Add Issue + +
+ } + > + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + viewType="workspace" + preLoadedData={createViewModal} + user={user} + /> +
+
+ setCreateViewModal(true)} /> + {error ? ( + router.push(`/${workspaceSlug}/workspace-views`), + }} + /> + ) : ( +
+ +
+ )} +
+
+ + ); +}; + +export default WorkspaceViewAssignedIssue; diff --git a/web/pages/[workspaceSlug]/workspace-views/created.tsx b/web/pages/[workspaceSlug]/workspace-views/created.tsx new file mode 100644 index 000000000..d41db871f --- /dev/null +++ b/web/pages/[workspaceSlug]/workspace-views/created.tsx @@ -0,0 +1,205 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option"; +import { CreateUpdateViewModal } from "components/views"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +// ui +import { EmptyState, PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +import { CheckCircle } from "lucide-react"; +// images +import emptyView from "public/empty-state/view.svg"; +// fetch-keys +import { WORKSPACE_VIEW_DETAILS, WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +const WorkspaceViewCreatedIssue: React.FC = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const params: any = { + created_by: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewDetails, error } = useSWR( + workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null, + workspaceSlug && workspaceViewId + ? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString()) + : null + ); + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + return ( + + + + {viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"} + +
+ } + right={ +
+ + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + + Add Issue + +
+ } + > + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + viewType="workspace" + preLoadedData={createViewModal} + user={user} + /> +
+
+ setCreateViewModal(true)} /> + {error ? ( + router.push(`/${workspaceSlug}/workspace-views`), + }} + /> + ) : ( +
+ +
+ )} +
+
+ + ); +}; + +export default WorkspaceViewCreatedIssue; diff --git a/web/pages/[workspaceSlug]/workspace-views/index.tsx b/web/pages/[workspaceSlug]/workspace-views/index.tsx new file mode 100644 index 000000000..30d8925b6 --- /dev/null +++ b/web/pages/[workspaceSlug]/workspace-views/index.tsx @@ -0,0 +1,213 @@ +import React, { useState } from "react"; + +import Link from "next/link"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import workspaceService from "services/workspace.service"; +// hooks +import useUser from "hooks/use-user"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// components +import { CreateUpdateViewModal, DeleteViewModal, SingleViewItem } from "components/views"; +import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option"; +// ui +import { EmptyState, Input, Loader, PrimaryButton } from "components/ui"; +// icons +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { PlusIcon } from "lucide-react"; +import { PhotoFilterOutlined } from "@mui/icons-material"; +// image +import emptyView from "public/empty-state/view.svg"; +// types +import type { NextPage } from "next"; +import { IView } from "types"; +// constants +import { WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; +// helper +import { truncateText } from "helpers/string.helper"; + +const WorkspaceViews: NextPage = () => { + const [query, setQuery] = useState(""); + + const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false); + const [selectedViewToUpdate, setSelectedViewToUpdate] = useState(null); + + const [deleteViewModal, setDeleteViewModal] = useState(false); + const [selectedViewToDelete, setSelectedViewToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + + const { data: workspaceViews } = useSWR( + workspaceSlug ? WORKSPACE_VIEWS_LIST(workspaceSlug as string) : null, + workspaceSlug ? () => workspaceService.getAllViews(workspaceSlug as string) : null + ); + + const defaultWorkspaceViewsList = [ + { + key: "all", + label: "All Issues", + href: `/${workspaceSlug}/workspace-views/all-issues`, + }, + { + key: "assigned", + label: "Assigned", + href: `/${workspaceSlug}/workspace-views/assigned`, + }, + { + key: "created", + label: "Created", + href: `/${workspaceSlug}/workspace-views/created`, + }, + { + key: "subscribed", + label: "Subscribed", + href: `/${workspaceSlug}/workspace-views/subscribed`, + }, + ]; + + const filteredDefaultOptions = + query === "" + ? defaultWorkspaceViewsList + : defaultWorkspaceViewsList?.filter((option) => + option.label.toLowerCase().includes(query.toLowerCase()) + ); + + const filteredOptions = + query === "" + ? workspaceViews + : workspaceViews?.filter((option) => option.name.toLowerCase().includes(query.toLowerCase())); + + const handleEditView = (view: IView) => { + setSelectedViewToUpdate(view); + setCreateUpdateViewModal(true); + }; + + const handleDeleteView = (view: IView) => { + setSelectedViewToDelete(view); + setDeleteViewModal(true); + }; + + return ( + + Workspace Views +
+ } + right={ +
+ + + setCreateUpdateViewModal(true)} + > + + New View + +
+ } + > + { + setCreateUpdateViewModal(false); + setSelectedViewToUpdate(null); + }} + data={selectedViewToUpdate} + viewType="workspace" + user={user} + /> + +
+
+
+ + setQuery(e.target.value)} + placeholder="Search" + mode="trueTransparent" + /> +
+
+ {filteredDefaultOptions && + filteredDefaultOptions.length > 0 && + filteredDefaultOptions.map((option) => ( + + ))} + + {filteredOptions ? ( + filteredOptions.length > 0 ? ( +
+ {filteredOptions.map((view) => ( + handleEditView(view)} + handleDeleteView={() => handleDeleteView(view)} + /> + ))} +
+ ) : ( + , + text: "New View", + onClick: () => setCreateUpdateViewModal(true), + }} + /> + ) + ) : ( + + + + + + + + )} +
+ + ); +}; + +export default WorkspaceViews; diff --git a/web/pages/[workspaceSlug]/workspace-views/subscribed.tsx b/web/pages/[workspaceSlug]/workspace-views/subscribed.tsx new file mode 100644 index 000000000..7a96b41ed --- /dev/null +++ b/web/pages/[workspaceSlug]/workspace-views/subscribed.tsx @@ -0,0 +1,205 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option"; +import { CreateUpdateViewModal } from "components/views"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +// ui +import { EmptyState, PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +import { CheckCircle } from "lucide-react"; +// images +import emptyView from "public/empty-state/view.svg"; +// fetch-keys +import { WORKSPACE_VIEW_DETAILS, WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +const WorkspaceViewSubscribedIssue: React.FC = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const params: any = { + subscriber: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewDetails, error } = useSWR( + workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null, + workspaceSlug && workspaceViewId + ? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString()) + : null + ); + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + return ( + + + + {viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"} + +
+ } + right={ +
+ + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + + Add Issue + +
+ } + > + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + viewType="workspace" + preLoadedData={createViewModal} + user={user} + /> +
+
+ setCreateViewModal(true)} /> + {error ? ( + router.push(`/${workspaceSlug}/workspace-views`), + }} + /> + ) : ( +
+ +
+ )} +
+
+ + ); +}; + +export default WorkspaceViewSubscribedIssue; diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index 57b724fda..a3aa56507 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -14,6 +14,8 @@ import { ICurrentUserResponse, IWorkspaceBulkInviteFormData, IWorkspaceViewProps, + IView, + IIssueFilterOptions, } from "types"; class WorkspaceService extends APIService { @@ -261,6 +263,56 @@ class WorkspaceService extends APIService { throw error?.response?.data; }); } + + async createView(workspaceSlug: string, data: IView): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/views/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateView(workspaceSlug: string, viewId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/views/${viewId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteView(workspaceSlug: string, viewId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getAllViews(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/views/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getViewDetails(workspaceSlug: string, viewId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/views/${viewId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getViewIssues(workspaceSlug: string, params: IIssueFilterOptions): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/issues/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new WorkspaceService(); diff --git a/web/types/view-props.d.ts b/web/types/view-props.d.ts index a02fc5540..ca5ce47ab 100644 --- a/web/types/view-props.d.ts +++ b/web/types/view-props.d.ts @@ -35,9 +35,10 @@ export interface IIssueFilterOptions { priority?: string[] | null; start_date?: string[] | null; state?: string[] | null; - state_group?: TStateGroups[] | null; + state_group?: string[] | null; subscriber?: string[] | null; target_date?: string[] | null; + project?: string[] | null; } export interface IIssueDisplayFilterOptions { diff --git a/web/types/views.d.ts b/web/types/views.d.ts index e1246af5a..e9741a270 100644 --- a/web/types/views.d.ts +++ b/web/types/views.d.ts @@ -1,3 +1,5 @@ +import { IIssueFilterOptions } from "./view-props"; + export interface IView { id: string; access: string; @@ -8,10 +10,15 @@ export interface IView { updated_by: string; name: string; description: string; - query: IQuery; - query_data: IQuery; + query: IIssueFilterOptions; + query_data: IIssueFilterOptions; project: string; workspace: string; + workspace_detail: { + id: string; + name: string; + slug: string; + }; } export interface IQuery { @@ -23,4 +30,5 @@ export interface IQuery { start_date: string[] | null; target_date: string[] | null; type: "active" | "backlog" | null; + project: string[] | null; } diff --git a/web/types/workspace.d.ts b/web/types/workspace.d.ts index de0bc93e2..66e1e1273 100644 --- a/web/types/workspace.d.ts +++ b/web/types/workspace.d.ts @@ -1,13 +1,4 @@ -import type { - IIssueFilterOptions, - IProjectMember, - IUser, - IUserMemberLite, - IWorkspaceViewProps, - TIssueGroupByOptions, - TIssueOrderByOptions, - TIssueViewOptions, -} from "types"; +import type { IProjectMember, IUser, IUserMemberLite, IWorkspaceViewProps } from "types"; export interface IWorkspace { readonly id: string;