feat: workspace global view, style: spreadsheet view revamp (#2273)

* chore: workspace view types, services and hooks added

* style: spreadsheet view revamp and code refactor

* feat: workspace view

* fix: build fix

* chore: sidebar workspace issues redirection updated
This commit is contained in:
Anmol Singh Bhatia 2023-09-26 19:56:59 +05:30 committed by GitHub
parent a187e7765c
commit 3a6d72e4b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 4253 additions and 733 deletions

View File

@ -161,6 +161,7 @@ export const CommandPalette: React.FC = observer(() => {
/> />
<CreateUpdateViewModal <CreateUpdateViewModal
handleClose={() => setIsCreateViewModalOpen(false)} handleClose={() => setIsCreateViewModalOpen(false)}
viewType="project"
isOpen={isCreateViewModalOpen} isOpen={isCreateViewModalOpen}
user={user} user={user}
/> />

View File

@ -10,7 +10,14 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types // types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types"; import {
IIssueFilterOptions,
IIssueLabels,
IProject,
IState,
IUserLite,
TStateGroups,
} from "types";
// constants // constants
import { STATE_GROUP_COLORS } from "constants/state"; import { STATE_GROUP_COLORS } from "constants/state";
@ -20,7 +27,9 @@ type Props = {
clearAllFilters: (...args: any) => void; clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined; labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined; members: IUserLite[] | undefined;
states: IState[] | undefined; states?: IState[] | undefined;
stateGroup?: string[] | undefined;
project?: IProject[] | undefined;
}; };
export const FiltersList: React.FC<Props> = ({ export const FiltersList: React.FC<Props> = ({
@ -30,6 +39,7 @@ export const FiltersList: React.FC<Props> = ({
labels, labels,
members, members,
states, states,
project,
}) => { }) => {
if (!filters) return <></>; if (!filters) return <></>;
@ -155,6 +165,29 @@ export const FiltersList: React.FC<Props> = ({
: key === "assignees" : key === "assignees"
? filters.assignees?.map((memberId: string) => { ? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId); const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return ( return (
<div <div
@ -298,6 +331,30 @@ export const FiltersList: React.FC<Props> = ({
</div> </div>
); );
}) })
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
console.log("currentProject", currentProject);
console.log("currentProject", projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")} : (filters[key] as any)?.join(", ")}
<button <button
type="button" type="button"

View File

@ -12,6 +12,7 @@ import stateService from "services/state.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context"; import { useProjectMyMembership } from "contexts/project-member.context";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// components // components
import { import {
AllLists, AllLists,
@ -86,6 +87,8 @@ export const AllViews: React.FC<Props> = ({
const { groupedIssues, isEmpty, displayFilters } = viewProps; const { groupedIssues, isEmpty, displayFilters } = viewProps;
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const { data: stateGroups } = useSWR( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug workspaceSlug
@ -174,6 +177,8 @@ export const AllViews: React.FC<Props> = ({
) : displayFilters?.layout === "spreadsheet" ? ( ) : displayFilters?.layout === "spreadsheet" ? (
<SpreadsheetView <SpreadsheetView
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
spreadsheetIssues={spreadsheetIssues}
mutateIssues={mutateIssues}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user} user={user}

View File

@ -481,6 +481,7 @@ export const IssuesView: React.FC<Props> = ({
<CreateUpdateViewModal <CreateUpdateViewModal
isOpen={createViewModal !== null} isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)} handleClose={() => setCreateViewModal(null)}
viewType="project"
preLoadedData={createViewModal} preLoadedData={createViewModal}
user={user} user={user}
/> />

View File

@ -0,0 +1,72 @@
import React from "react";
import { useRouter } from "next/router";
// components
import { MembersSelect } from "components/project";
// services
import trackEventServices from "services/track-event.service";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const AssigneeColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const handleAssigneeChange = (data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.assignee && (
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-assignee-column";
export * from "./assignee-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { AssigneeColumn } 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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetAssigneeColumn: React.FC<Props> = ({
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 (
<div>
<AssigneeColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetAssigneeColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const CreatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.created_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.created_at)}
</div>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-created-on-column";
export * from "./created-on-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { CreatedOnColumn } 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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetCreatedOnColumn: React.FC<Props> = ({
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 (
<div>
<CreatedOnColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetCreatedOnColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
// components
import { ViewDueDateSelect } from "components/issues";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const DueDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-due-date-column";
export * from "./due-date-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { DueDateColumn } 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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetDueDateColumn: React.FC<Props> = ({
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 (
<div>
<DueDateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetDueDateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from "react";
// components
import { ViewEstimateSelect } from "components/issues";
// types
import { ICurrentUserResponse, IIssue, Properties } from "types";
type Props = {
issue: IIssue;
projectId: string;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const EstimateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-estimate-column";
export * from "./estimate-column";

View File

@ -0,0 +1,62 @@
import React from "react";
// components
import { EstimateColumn } 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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetEstimateColumn: React.FC<Props> = ({
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 (
<div>
<EstimateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetEstimateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -1,4 +1,14 @@
export * from "./assignee-column";
export * from "./created-on-column";
export * from "./due-date-column";
export * from "./estimate-column";
export * from "./issue-column";
export * from "./label-column";
export * from "./priority-column";
export * from "./start-date-column";
export * from "./state-column";
export * from "./updated-on-column";
export * from "./spreadsheet-view"; export * from "./spreadsheet-view";
export * from "./single-issue"; export * from "./issue-column/issue-column";
export * from "./spreadsheet-columns"; export * from "./spreadsheet-columns";
export * from "./spreadsheet-issues"; export * from "./issue-column/spreadsheet-issue-column";

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-issue-column";
export * from "./issue-column";

View File

@ -0,0 +1,179 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// components
import { Popover2 } from "@blueprintjs/popover2";
// icons
import { Icon } from "components/ui";
import {
EllipsisHorizontalIcon,
LinkIcon,
PencilIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// hooks
import useToast from "hooks/use-toast";
// types
import { IIssue, Properties, UserAuth } from "types";
// helper
import { copyTextToClipboard } from "helpers/string.helper";
type Props = {
issue: IIssue;
projectId: string;
expanded: boolean;
handleToggleExpand: (issueId: string) => void;
properties: Properties;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
setCurrentProjectId: React.Dispatch<React.SetStateAction<string | null>>;
disableUserActions: boolean;
userAuth: UserAuth;
nestingLevel: number;
};
export const IssueColumn: React.FC<Props> = ({
issue,
projectId,
expanded,
handleToggleExpand,
properties,
handleEditIssue,
handleDeleteIssue,
setCurrentProjectId,
disableUserActions,
userAuth,
nestingLevel,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const openPeekOverview = () => {
const { query } = router;
setCurrentProjectId(issue.project_detail.id);
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const paddingLeft = `${nestingLevel * 54}px`;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div className="group flex items-center w-[28rem] text-sm h-11 sticky top-0 bg-custom-background-100 truncate border-b border-r border-custom-border-200 ">
<div
className="flex gap-1.5 px-4 pr-0 py-2.5 items-center"
style={issue.parent ? { paddingLeft } : {}}
>
<div className="relative flex items-center cursor-pointer text-xs text-center hover:text-custom-text-100">
{properties.key && (
<span className="flex items-center justify-center font-medium opacity-100 group-hover:opacity-0">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
{!isNotAllowed && !disableUserActions && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2
isOpen={isOpen}
canEscapeKeyClose
onInteraction={(nextOpenState) => setIsOpen(nextOpenState)}
content={
<div
className={`flex flex-col gap-1.5 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none max-h-44 min-w-full border-custom-border-200 bg-custom-background-90`}
>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleEditIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</button>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleDeleteIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</button>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleCopyText();
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</div>
</button>
</div>
}
placement="bottom-start"
>
<EllipsisHorizontalIcon className="h-5 w-5 text-custom-text-200" />
</Popover2>
</div>
)}
</div>
{issue.sub_issues_count > 0 && (
<div className="h-6 w-6 flex justify-center items-center">
<button
className="h-5 w-5 hover:bg-custom-background-90 hover:text-custom-text-100 rounded-sm cursor-pointer"
onClick={() => handleToggleExpand(issue.id)}
>
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
</button>
</div>
)}
</div>
<span className="flex items-center px-4 py-2.5 h-full truncate flex-grow">
<button
type="button"
className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]"
onClick={openPeekOverview}
>
{issue.name}
</button>
</span>
</div>
);
};

View File

@ -1,36 +1,34 @@
import React, { useState } from "react"; import React from "react";
// components // components
import { SingleSpreadsheetIssue } from "components/core"; import { IssueColumn } from "components/core";
// hooks // hooks
import useSubIssue from "hooks/use-sub-issue"; import useSubIssue from "hooks/use-sub-issue";
// types // types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; import { IIssue, Properties, UserAuth } from "types";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
index: number; projectId: string;
expandedIssues: string[]; expandedIssues: string[];
setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>; setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>;
properties: Properties; properties: Properties;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
gridTemplateColumns: string; setCurrentProjectId: React.Dispatch<React.SetStateAction<string | null>>;
disableUserActions: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
nestingLevel?: number; nestingLevel?: number;
}; };
export const SpreadsheetIssues: React.FC<Props> = ({ export const SpreadsheetIssuesColumn: React.FC<Props> = ({
index,
issue, issue,
projectId,
expandedIssues, expandedIssues,
setExpandedIssues, setExpandedIssues,
gridTemplateColumns,
properties, properties,
handleIssueAction, handleIssueAction,
setCurrentProjectId,
disableUserActions, disableUserActions,
user,
userAuth, userAuth,
nestingLevel = 0, nestingLevel = 0,
}) => { }) => {
@ -49,22 +47,20 @@ export const SpreadsheetIssues: React.FC<Props> = ({
const isExpanded = expandedIssues.indexOf(issue.id) > -1; 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 ( return (
<div> <div>
<SingleSpreadsheetIssue <IssueColumn
issue={issue} issue={issue}
projectId={issue.project_detail.id} projectId={projectId}
index={index}
expanded={isExpanded} expanded={isExpanded}
handleToggleExpand={handleToggleExpand} handleToggleExpand={handleToggleExpand}
gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleEditIssue={() => handleIssueAction(issue, "edit")} handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
setCurrentProjectId={setCurrentProjectId}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user}
userAuth={userAuth} userAuth={userAuth}
nestingLevel={nestingLevel} nestingLevel={nestingLevel}
/> />
@ -74,17 +70,16 @@ export const SpreadsheetIssues: React.FC<Props> = ({
subIssues && subIssues &&
subIssues.length > 0 && subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => ( subIssues.map((subIssue: IIssue) => (
<SpreadsheetIssues <SpreadsheetIssuesColumn
key={subIssue.id} key={subIssue.id}
issue={subIssue} issue={subIssue}
index={index} projectId={subIssue.project_detail.id}
expandedIssues={expandedIssues} expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues} setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
setCurrentProjectId={setCurrentProjectId}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
user={user}
userAuth={userAuth} userAuth={userAuth}
nestingLevel={nestingLevel + 1} nestingLevel={nestingLevel + 1}
/> />

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-label-column";
export * from "./label-column";

View File

@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const LabelColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const handleLabelChange = (data: any) => {
partialUpdateIssue({ labels_list: data }, issue);
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.labels && (
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={1}
user={user}
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetLabelColumn: React.FC<Props> = ({
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 (
<div>
<LabelColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetLabelColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-priority-column";
export * from "./priority-column";

View File

@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const PriorityColumn: React.FC<Props> = ({
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 (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.priority && (
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetPriorityColumn: React.FC<Props> = ({
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 (
<div>
<PriorityColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetPriorityColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -1,23 +1,52 @@
import React, { useState } from "react"; import React, { useCallback, useState } from "react";
// next // next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { KeyedMutator, mutate } from "swr";
// components // components
import { SpreadsheetColumns, SpreadsheetIssues, ListInlineCreateIssueForm } from "components/core"; import {
import { CustomMenu, Spinner } from "components/ui"; SpreadsheetAssigneeColumn,
SpreadsheetCreatedOnColumn,
SpreadsheetDueDateColumn,
SpreadsheetEstimateColumn,
SpreadsheetIssuesColumn,
SpreadsheetLabelColumn,
SpreadsheetPriorityColumn,
SpreadsheetStartDateColumn,
SpreadsheetStateColumn,
SpreadsheetUpdatedOnColumn,
} from "components/core";
import { Spinner } from "components/ui";
import { IssuePeekOverview } from "components/issues"; import { IssuePeekOverview } from "components/issues";
// hooks // hooks
import useIssuesProperties from "hooks/use-issue-properties"; import useIssuesProperties from "hooks/use-issue-properties";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// types // types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, ISubIssueResponse, UserAuth } from "types";
// constants import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter";
import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; 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 // icon
import { PlusIcon } from "@heroicons/react/24/outline";
type Props = { type Props = {
spreadsheetIssues: IIssue[];
mutateIssues: KeyedMutator<
| IIssue[]
| {
[key: string]: IIssue[];
}
>;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
disableUserActions: boolean; disableUserActions: boolean;
@ -26,6 +55,8 @@ type Props = {
}; };
export const SpreadsheetView: React.FC<Props> = ({ export const SpreadsheetView: React.FC<Props> = ({
spreadsheetIssues,
mutateIssues,
handleIssueAction, handleIssueAction,
openIssuesListModal, openIssuesListModal,
disableUserActions, disableUserActions,
@ -33,126 +64,220 @@ export const SpreadsheetView: React.FC<Props> = ({
userAuth, userAuth,
}) => { }) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]); const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId, workspaceViewId } = router.query;
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const columnData = SPREADSHEET_COLUMN.map((column) => ({ const workspaceIssuesPath = [
...column, {
isActive: properties params: {
? column.propertyName === "labels" sub_issue: false,
? properties[column.propertyName as keyof Properties] },
: column.propertyName === "title" path: "workspace-views/all-issues",
? true },
: properties[column.propertyName as keyof Properties] {
: false, 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 const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) =>
.filter((column) => column.isActive) router.pathname.includes(path.path)
.map((column) => column.colSize) );
.join(" ");
const { params: workspaceViewParams } = useWorkspaceIssuesFilters(
workspaceSlug?.toString(),
workspaceViewId?.toString()
);
const { params } = useSpreadsheetIssuesView();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, 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<ISubIssueResponse>(
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<IIssue[]>(
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<any>) => (
<div className="relative flex flex-col h-max w-full bg-custom-background-100 rounded-sm">
<div className="flex items-center min-w-[9rem] px-4 py-2.5 text-sm font-medium z-[1] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-200">
{header}
</div>
<div className="h-full min-w-[9rem] w-full">
{spreadsheetIssues.map((issue: IIssue, index) => (
<Component
key={`${issue.id}_${index}`}
issue={issue}
projectId={issue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
</div>
);
return ( return (
<> <>
<IssuePeekOverview <IssuePeekOverview
handleMutation={() => mutateIssues()} handleMutation={() => mutateIssues()}
projectId={projectId?.toString() ?? ""} projectId={currentProjectId ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions} readOnly={disableUserActions}
/> />
<div <div className="relative flex h-full w-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
className={`h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100 ${
isInlineCreateIssueFormOpen ? "mb-24" : "mb-12"
}`}
>
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
</div>
{spreadsheetIssues ? ( {spreadsheetIssues ? (
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm"> <>
{spreadsheetIssues.map((issue: IIssue, index) => ( <div className="sticky left-0 w-[28rem] z-[2]">
<SpreadsheetIssues <div className="relative flex flex-col h-max w-full bg-custom-background-100 rounded-sm z-[2]">
key={`${issue.id}_${index}`} <div className="flex items-center text-sm font-medium z-[2] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-200">
index={index} <span className="flex items-center px-4 py-2.5 h-full w-20 flex-shrink-0">
issue={issue} ID
expandedIssues={expandedIssues} </span>
setExpandedIssues={setExpandedIssues} <span className="flex items-center px-4 py-2.5 h-full w-full flex-grow">
gridTemplateColumns={gridTemplateColumns} Issue
properties={properties} </span>
handleIssueAction={handleIssueAction} </div>
disableUserActions={disableUserActions}
user={user} {spreadsheetIssues.map((issue: IIssue, index) => (
userAuth={userAuth} <SpreadsheetIssuesColumn
/> key={`${issue.id}_${index}`}
))} issue={issue}
</div> projectId={issue.project_detail.id}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
setCurrentProjectId={setCurrentProjectId}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
userAuth={userAuth}
/>
))}
</div>
</div>
{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)}
</>
) : ( ) : (
<Spinner /> <div className="flex flex-col justify-center items-center h-full w-full">
<Spinner />
</div>
)} )}
</div> </div>
<div
className={`absolute bottom-0 left-0 z-10 group hover:rounded-sm bg-custom-background-100 hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max ${
isInlineCreateIssueFormOpen ? "pb-2" : ""
}`}
>
<ListInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
{type === "issue"
? !disableUserActions &&
!isInlineCreateIssueFormOpen && (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 border-custom-border-200 w-full"
onClick={() => setIsInlineCreateIssueFormOpen(true)}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
)
: !disableUserActions &&
!isInlineCreateIssueFormOpen && (
<CustomMenu
className="sticky left-0 z-[1] !w-full"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
verticalPosition="top"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem onClick={() => setIsInlineCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</> </>
); );
}; };

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-start-date-column";
export * from "./start-date-column";

View File

@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetStartDateColumn: React.FC<Props> = ({
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 (
<div>
<StartDateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetStartDateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const StartDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.due_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-state-column";
export * from "./state-column";

View File

@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetStateColumn: React.FC<Props> = ({
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 (
<div>
<StateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetStateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const StateColumn: React.FC<Props> = ({
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 (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.state && (
<StateSelect
value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./spreadsheet-updated-on-column";
export * from "./updated-on-column";

View File

@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetUpdatedOnColumn: React.FC<Props> = ({
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 (
<div>
<UpdatedOnColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetUpdatedOnColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

View File

@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const UpdatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.updated_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.updated_at)}
</div>
)}
</span>
</div>
);

View File

@ -4,18 +4,21 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hook
import useProjects from "hooks/use-projects";
import useWorkspaceMembers from "hooks/use-workspace-members";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// components // components
import { DateFilterModal } from "components/core"; import { DateFilterModal } from "components/core";
// ui // ui
import { MultiLevelDropdown } from "components/ui"; import { Avatar, MultiLevelDropdown } from "components/ui";
// icons // icons
import { PriorityIcon, StateGroupIcon } from "components/icons"; import { PriorityIcon, StateGroupIcon } from "components/icons";
// helpers // helpers
import { checkIfArraysHaveSameElements } from "helpers/array.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types // types
import { IIssueFilterOptions, IQuery, TStateGroups } from "types"; import { IIssueFilterOptions, TStateGroups } from "types";
// fetch-keys // fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys"; import { WORKSPACE_LABELS } from "constants/fetch-keys";
// constants // constants
@ -23,7 +26,7 @@ import { GROUP_CHOICES, PRIORITIES } from "constants/project";
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
filters: Partial<IIssueFilterOptions> | IQuery; filters: Partial<IIssueFilterOptions>;
onSelect: (option: any) => void; onSelect: (option: any) => void;
direction?: "left" | "right"; direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg"; height?: "sm" | "md" | "rg" | "lg";
@ -55,6 +58,11 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
: null : null
); );
const { projects: allProjects } = useProjects();
const joinedProjects = allProjects?.filter((p) => p.is_member);
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
return ( return (
<> <>
{isDateFilterModalOpen && ( {isDateFilterModalOpen && (
@ -74,25 +82,19 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
height={height} height={height}
options={[ options={[
{ {
id: "priority", id: "project",
label: "Priority", label: "Project",
value: PRIORITIES, value: joinedProjects,
hasChildren: true, hasChildren: true,
children: [ children: joinedProjects?.map((project) => ({
...PRIORITIES.map((priority) => ({ id: project.id,
id: priority === null ? "null" : priority, label: <div className="flex items-center gap-2">{project.name}</div>,
label: ( value: {
<div className="flex items-center gap-2 capitalize"> key: "project",
<PriorityIcon priority={priority} /> {priority ?? "None"} value: project.id,
</div> },
), selected: filters?.project?.includes(project.id),
value: { })),
key: "priority",
value: priority === null ? "null" : priority,
},
selected: filters?.priority?.includes(priority === null ? "null" : priority),
})),
],
}, },
{ {
id: "state_group", id: "state_group",
@ -142,6 +144,87 @@ export const MyIssuesSelectFilters: React.FC<Props> = ({
selected: filters?.labels?.includes(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: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority} /> {priority ?? "None"}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
value: {
key: "subscriber",
value: member.member.id,
},
selected: filters?.subscriber?.includes(member.member.id),
})),
},
{ {
id: "start_date", id: "start_date",
label: "Start date", label: "Start date",

View File

@ -2,25 +2,20 @@ import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// hooks // hooks
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
import useEstimateOption from "hooks/use-estimate-option";
// components // components
import { MyIssuesSelectFilters } from "components/issues"; import { MyIssuesSelectFilters } from "components/issues";
// ui // ui
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui"; import { Tooltip } from "components/ui";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { FormatListBulletedOutlined } from "@mui/icons-material";
import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material"; import { CreditCard } from "lucide-react";
// helpers // helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types // types
import { Properties, TIssueViewOptions } from "types"; import { TIssueViewOptions } from "types";
// constants
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
{ {
@ -28,19 +23,26 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
Icon: FormatListBulletedOutlined, Icon: FormatListBulletedOutlined,
}, },
{ {
type: "kanban", type: "spreadsheet",
Icon: GridViewOutlined, Icon: CreditCard,
}, },
]; ];
export const MyIssuesViewOptions: React.FC = () => { export const MyIssuesViewOptions: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, workspaceViewId } = router.query;
const { displayFilters, setDisplayFilters, properties, setProperty, filters, setFilters } = const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters(
useMyIssuesFilters(workspaceSlug?.toString()); 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -49,250 +51,65 @@ export const MyIssuesViewOptions: React.FC = () => {
<Tooltip <Tooltip
key={option.type} key={option.type}
tooltipContent={ tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} Layout</span> <span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
} }
position="bottom" position="bottom"
> >
<button <button
type="button" type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${ className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-100 duration-300 ${
displayFilters?.layout === option.type displayFilters?.layout === option.type
? "bg-custom-sidebar-background-80" ? "bg-custom-sidebar-background-100 shadow-sm"
: "text-custom-sidebar-text-200" : "text-custom-sidebar-text-200"
}`} }`}
onClick={() => setDisplayFilters({ layout: option.type })} onClick={() => {
setDisplayFilters({ layout: option.type });
if (option.type === "spreadsheet")
router.push(`/${workspaceSlug}/workspace-views/all-issues`);
else router.push(`/${workspaceSlug}/workspace-views`);
}}
> >
<option.Icon <option.Icon
sx={{ sx={{
fontSize: 16, fontSize: 16,
}} }}
className={option.type === "gantt_chart" ? "rotate-90" : ""} className={option.type === "spreadsheet" ? "h-4 w-4" : ""}
/> />
</button> </button>
</Tooltip> </Tooltip>
))} ))}
</div> </div>
<MyIssuesSelectFilters {showFilters && (
filters={filters} <MyIssuesSelectFilters
onSelect={(option) => { filters={filters}
const key = option.key as keyof typeof filters; onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "start_date" || key === "target_date") { if (key === "start_date" || key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value);
setFilters({
[key]: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters({ setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter( [key]: valueExists ? null : option.value,
(val) => val !== option.value
),
}); });
else } else {
setFilters({ const valueExists = filters[key]?.includes(option.value);
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="left"
height="rg"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200"
}`}
>
Display
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition if (valueExists)
as={React.Fragment} setFilters({
enter="transition ease-out duration-200" [option.key]: ((filters[key] ?? []) as any[])?.filter(
enterFrom="opacity-0 translate-y-1" (val) => val !== option.value
enterTo="opacity-100 translate-y-0" ),
leave="transition ease-in duration-150" });
leaveFrom="opacity-100 translate-y-0" else
leaveTo="opacity-0 translate-y-1" setFilters({
> [option.key]: [...((filters[key] ?? []) as any[]), option.value],
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg"> });
<div className="relative divide-y-2 divide-custom-border-200"> }
<div className="space-y-4 pb-3 text-xs"> }}
{displayFilters?.layout !== "calendar" && direction="left"
displayFilters?.layout !== "spreadsheet" && ( height="rg"
<> />
<div className="flex items-center justify-between"> )}
<h4 className="text-custom-text-200">Group by</h4>
<div className="w-28">
<CustomMenu
label={
displayFilters?.group_by === "project"
? "Project"
: GROUP_BY_OPTIONS.find(
(option) => 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 (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setDisplayFilters({ group_by: option.key })}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find(
(option) => 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 (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setDisplayFilters({ order_by: option.key });
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find(
(option) => option.key === displayFilters?.type
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setDisplayFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{displayFilters?.layout !== "calendar" &&
displayFilters?.layout !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty groups</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters?.show_empty_groups ?? true}
onChange={() =>
setDisplayFilters({
show_empty_groups: !displayFilters?.show_empty_groups,
})
}
/>
</div>
</div>
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{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 (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperty(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div> </div>
); );
}; };

View File

@ -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 (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x- px-1 py-0.5 rounded bg-custom-sidebar-background-90 ">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-100 duration-300 ${
displayFilters?.layout === option.type
? "bg-custom-sidebar-background-100 shadow-sm"
: "text-custom-sidebar-text-200"
}`}
onClick={() => {
setDisplayFilters({ layout: option.type });
if (option.type === "spreadsheet")
router.push(`/${workspaceSlug}/workspace-views/all-issues`);
else router.push(`/${workspaceSlug}/workspace-views`);
}}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "spreadsheet" ? "h-4 w-4" : ""}
/>
</button>
</Tooltip>
))}
</div>
{showFilters && (
<>
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
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"
/>
</>
)}
</div>
);
};

View File

@ -8,6 +8,7 @@ import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
import viewsService from "services/views.service"; import viewsService from "services/views.service";
import workspaceService from "services/workspace.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
@ -17,16 +18,17 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types // types
import type { ICurrentUserResponse, IView } from "types"; import type { ICurrentUserResponse, IView } from "types";
// fetch-keys // fetch-keys
import { VIEWS_LIST } from "constants/fetch-keys"; import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
viewType: "project" | "workspace";
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data: IView | null; data: IView | null;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, user }) => { export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, viewType, user }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@ -41,34 +43,64 @@ export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, user
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
if (!workspaceSlug || !data || !projectId) return;
await viewsService if (viewType === "project") {
.deleteView(workspaceSlug as string, projectId as string, data.id, user) if (!workspaceSlug || !data || !projectId) return;
.then(() => {
mutate<IView[]>(
VIEWS_LIST(projectId as string),
(views) => views?.filter((view) => view.id !== data.id)
);
handleClose(); await viewsService
.deleteView(workspaceSlug as string, projectId as string, data.id, user)
.then(() => {
mutate<IView[]>(VIEWS_LIST(projectId as string), (views) =>
views?.filter((view) => view.id !== data.id)
);
setToastAlert({ handleClose();
type: "success",
title: "Success!", setToastAlert({
message: "View deleted successfully.", 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);
}); });
}) } else {
.catch(() => { if (!workspaceSlug || !data) return;
setToastAlert({
type: "error", await workspaceService
title: "Error!", .deleteView(workspaceSlug as string, data.id)
message: "View could not be deleted. Please try again.", .then(() => {
mutate<IView[]>(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 ( return (

View File

@ -10,6 +10,8 @@ import { useForm } from "react-hook-form";
import stateService from "services/state.service"; import stateService from "services/state.service";
// hooks // hooks
import useProjectMembers from "hooks/use-project-members"; import useProjectMembers from "hooks/use-project-members";
import useProjects from "hooks/use-projects";
import useWorkspaceMembers from "hooks/use-workspace-members";
// components // components
import { FiltersList } from "components/core"; import { FiltersList } from "components/core";
import { SelectFilters } from "components/views"; import { SelectFilters } from "components/views";
@ -22,13 +24,14 @@ import { getStatesList } from "helpers/state.helper";
import { IQuery, IView } from "types"; import { IQuery, IView } from "types";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// fetch-keys // 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 = { type Props = {
handleFormSubmit: (values: IView) => Promise<void>; handleFormSubmit: (values: IView) => Promise<void>;
handleClose: () => void; handleClose: () => void;
status: boolean; status: boolean;
data?: IView | null; data?: IView | null;
viewType?: "workspace" | "project";
preLoadedData?: Partial<IView> | null; preLoadedData?: Partial<IView> | null;
}; };
@ -42,6 +45,7 @@ export const ViewForm: React.FC<Props> = ({
handleClose, handleClose,
status, status,
data, data,
viewType,
preLoadedData, preLoadedData,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -77,8 +81,26 @@ export const ViewForm: React.FC<Props> = ({
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null : 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 { 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) => { const handleCreateUpdateView = async (formData: IView) => {
await handleFormSubmit(formData); await handleFormSubmit(formData);
@ -91,12 +113,14 @@ export const ViewForm: React.FC<Props> = ({
setValue("query", { setValue("query", {
assignees: null, assignees: null,
created_by: null, created_by: null,
subscriber: null,
labels: null, labels: null,
priority: null, priority: null,
state: null, state: null,
state_group: null,
start_date: null, start_date: null,
target_date: null, target_date: null,
type: null, project: null,
}); });
}; };
@ -185,9 +209,10 @@ export const ViewForm: React.FC<Props> = ({
<div> <div>
<FiltersList <FiltersList
filters={filters} filters={filters}
labels={labels} labels={labelOptions}
members={members?.map((m) => m.member)} members={memberOptions}
states={states} states={states}
project={joinedProjects}
clearAllFilters={clearAllFilters} clearAllFilters={clearAllFilters}
setFilters={(query: any) => { setFilters={(query: any) => {
setValue("query", { setValue("query", {

View File

@ -8,6 +8,7 @@ import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
import viewsService from "services/views.service"; import viewsService from "services/views.service";
import workspaceService from "services/workspace.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
@ -15,10 +16,11 @@ import { ViewForm } from "components/views";
// types // types
import { ICurrentUserResponse, IView } from "types"; import { ICurrentUserResponse, IView } from "types";
// fetch-keys // fetch-keys
import { VIEWS_LIST } from "constants/fetch-keys"; import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
viewType: "project" | "workspace";
handleClose: () => void; handleClose: () => void;
data?: IView | null; data?: IView | null;
preLoadedData?: Partial<IView> | null; preLoadedData?: Partial<IView> | null;
@ -27,6 +29,7 @@ type Props = {
export const CreateUpdateViewModal: React.FC<Props> = ({ export const CreateUpdateViewModal: React.FC<Props> = ({
isOpen, isOpen,
viewType,
handleClose, handleClose,
data, data,
preLoadedData, preLoadedData,
@ -46,25 +49,48 @@ export const CreateUpdateViewModal: React.FC<Props> = ({
...payload, ...payload,
query_data: payload.query, query_data: payload.query,
}; };
await viewsService
.createView(workspaceSlug as string, projectId as string, payload, user)
.then(() => {
mutate(VIEWS_LIST(projectId as string));
handleClose();
setToastAlert({ if (viewType === "project") {
type: "success", await viewsService
title: "Success!", .createView(workspaceSlug as string, projectId as string, payload, user)
message: "View created successfully.", .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.",
});
}); });
}) } else {
.catch(() => { await workspaceService
setToastAlert({ .createView(workspaceSlug as string, payload)
type: "error", .then(() => {
title: "Error!", mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string));
message: "View could not be created. Please try again.", 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) => { const updateView = async (payload: IView) => {
@ -72,41 +98,79 @@ export const CreateUpdateViewModal: React.FC<Props> = ({
...payload, ...payload,
query_data: payload.query, query_data: payload.query,
}; };
await viewsService if (viewType === "project") {
.updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user) await viewsService
.then((res) => { .updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user)
mutate<IView[]>( .then((res) => {
VIEWS_LIST(projectId as string), mutate<IView[]>(
(prevData) => VIEWS_LIST(projectId as string),
prevData?.map((p) => { (prevData) =>
if (p.id === res.id) return { ...p, ...payloadData }; prevData?.map((p) => {
if (p.id === res.id) return { ...p, ...payloadData };
return p; return p;
}), }),
false false
); );
onClose(); onClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
message: "View updated successfully.", message: "View updated successfully.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "View could not be updated. Please try again.",
});
}); });
}) } else {
.catch(() => { await workspaceService
setToastAlert({ .updateView(workspaceSlug as string, data?.id ?? "", payloadData)
type: "error", .then((res) => {
title: "Error!", mutate<IView[]>(
message: "View could not be updated. Please try again.", 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) => { const handleFormSubmit = async (formData: IView) => {
if (!workspaceSlug || !projectId) return; if (viewType === "project") {
if (!workspaceSlug || !projectId) return;
if (!data) await createView(formData); if (!data) await createView(formData);
else await updateView(formData); else await updateView(formData);
} else {
if (!workspaceSlug) return;
if (!data) await createView(formData);
else await updateView(formData);
}
}; };
return ( return (
@ -141,6 +205,7 @@ export const CreateUpdateViewModal: React.FC<Props> = ({
handleClose={handleClose} handleClose={handleClose}
status={data ? true : false} status={data ? true : false}
data={data} data={data}
viewType={viewType}
preLoadedData={preLoadedData} preLoadedData={preLoadedData}
/> />
</Dialog.Panel> </Dialog.Panel>

View File

@ -4,6 +4,9 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hook
import useProjects from "hooks/use-projects";
import useWorkspaceMembers from "hooks/use-workspace-members";
// services // services
import stateService from "services/state.service"; import stateService from "services/state.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
@ -18,15 +21,20 @@ import { PriorityIcon, StateGroupIcon } from "components/icons";
import { getStatesList } from "helpers/state.helper"; import { getStatesList } from "helpers/state.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types // types
import { IIssueFilterOptions, IQuery } from "types"; import { IIssueFilterOptions, TStateGroups } from "types";
// fetch-keys // 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 // constants
import { PRIORITIES } from "constants/project"; import { GROUP_CHOICES, PRIORITIES } from "constants/project";
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
filters: Partial<IIssueFilterOptions> | IQuery; filters: Partial<IIssueFilterOptions>;
onSelect: (option: any) => void; onSelect: (option: any) => void;
direction?: "left" | "right"; direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg"; height?: "sm" | "md" | "rg" | "lg";
@ -48,7 +56,7 @@ export const SelectFilters: React.FC<Props> = ({
}); });
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, workspaceViewId } = router.query;
const { data: states } = useSWR( const { data: states } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
@ -58,6 +66,20 @@ export const SelectFilters: React.FC<Props> = ({
); );
const statesList = getStatesList(states); 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( const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null, projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId workspaceSlug && projectId
@ -65,6 +87,8 @@ export const SelectFilters: React.FC<Props> = ({
: null : null
); );
const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
const { data: issueLabels } = useSWR( const { data: issueLabels } = useSWR(
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
@ -72,6 +96,413 @@ export const SelectFilters: React.FC<Props> = ({
: null : 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: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority} />
{priority ?? "None"}
</div>
),
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: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state.group} color={state.color} />
{state.name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Start date",
type: "start_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
{
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Due date",
type: "target_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
];
const workspaceFilterOption = [
{
id: "project",
label: "Project",
value: joinedProjects,
hasChildren: true,
children: joinedProjects?.map((project) => ({
id: project.id,
label: <div className="flex items-center gap-2">{project.name}</div>,
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: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={key as TStateGroups} />
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
</div>
),
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: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
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: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority} />
{priority ?? "None"}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Start date",
type: "start_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
{
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Due date",
type: "target_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
];
const filterOption = isWorkspaceView ? workspaceFilterOption : projectFilterOption;
return ( return (
<> <>
{isDateFilterModalOpen && ( {isDateFilterModalOpen && (
@ -89,185 +520,7 @@ export const SelectFilters: React.FC<Props> = ({
onSelect={onSelect} onSelect={onSelect}
direction={direction} direction={direction}
height={height} height={height}
options={[ options={filterOption}
{
id: "priority",
label: "Priority",
value: PRIORITIES,
hasChildren: true,
children: PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority,
label: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority} />
{priority ?? "None"}
</div>
),
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: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state.group} color={state.color} />
{state.name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
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: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Start date",
type: "start_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
{
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: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Due date",
type: "target_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
]}
/> />
</> </>
); );

View File

@ -5,9 +5,9 @@ import { useRouter } from "next/router";
// icons // icons
import { TrashIcon, StarIcon, PencilIcon } from "@heroicons/react/24/outline"; import { TrashIcon, StarIcon, PencilIcon } from "@heroicons/react/24/outline";
import { StackedLayersIcon } from "components/icons"; import { PhotoFilterOutlined } from "@mui/icons-material";
//components //components
import { CustomMenu, Tooltip } from "components/ui"; import { CustomMenu } from "components/ui";
// services // services
import viewsService from "services/views.service"; import viewsService from "services/views.service";
// types // types
@ -18,15 +18,20 @@ import { VIEWS_LIST } from "constants/fetch-keys";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderShortDateWithYearFormat, render24HourFormatTime } from "helpers/date-time.helper";
type Props = { type Props = {
view: IView; view: IView;
viewType: "project" | "workspace";
handleEditView: () => void; handleEditView: () => void;
handleDeleteView: () => void; handleDeleteView: () => void;
}; };
export const SingleViewItem: React.FC<Props> = ({ view, handleEditView, handleDeleteView }) => { export const SingleViewItem: React.FC<Props> = ({
view,
viewType,
handleEditView,
handleDeleteView,
}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -82,38 +87,46 @@ export const SingleViewItem: React.FC<Props> = ({ view, handleEditView, handleDe
}); });
}; };
const viewRedirectionUrl =
viewType === "project"
? `/${workspaceSlug}/projects/${projectId}/views/${view.id}`
: `/${workspaceSlug}/workspace-views/${view.id}`;
return ( return (
<div className="first:rounded-t-[10px] last:rounded-b-[10px] hover:bg-custom-background-90"> <div className="group hover:bg-custom-background-90 border-b border-custom-border-200">
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}> <Link href={viewRedirectionUrl}>
<a> <a className="flex items-center justify-between relative rounded px-5 py-4 w-full">
<div className="relative rounded p-4"> <div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div
<StackedLayersIcon height={18} width={18} /> className={`flex items-center justify-center h-10 w-10 rounded bg-custom-background-90 group-hover:bg-custom-background-100`}
<p className="mr-2 truncate text-sm">{truncateText(view.name, 75)}</p> >
<PhotoFilterOutlined className="!text-base !leading-6" />
</div> </div>
<div className="ml-2 flex flex-shrink-0"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <p className="truncate text-sm leading-4 font-medium">
<p className="rounded-full bg-custom-background-80 py-0.5 px-2 text-xs text-custom-text-200"> {truncateText(view.name, 75)}
{Object.keys(view.query_data) </p>
.map((key: string) => {view?.description && (
view.query_data[key as keyof typeof view.query_data] !== null <p className="text-xs text-custom-text-200">{view.description}</p>
? (view.query_data[key as keyof typeof view.query_data] as any).length )}
: 0 </div>
) </div>
.reduce((curr, prev) => curr + prev, 0)}{" "} <div className="ml-2 flex flex-shrink-0">
filters <div className="flex items-center gap-4">
</p> <p className="rounded bg-custom-background-80 py-1 px-2 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100">
<Tooltip {Object.keys(view.query_data)
tooltipContent={`Last updated at ${render24HourFormatTime( .map((key: string) =>
view.updated_at view.query_data[key as keyof typeof view.query_data] !== null
)} ${renderShortDateWithYearFormat(view.updated_at)}`} ? (view.query_data[key as keyof typeof view.query_data] as any).length
> : 0
<p className="text-sm text-custom-text-200"> )
{render24HourFormatTime(view.updated_at)} .reduce((curr, prev) => curr + prev, 0)}{" "}
</p> filters
</Tooltip> </p>
{view.is_favorite ? (
{viewType === "project" ? (
view.is_favorite ? (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@ -135,41 +148,36 @@ export const SingleViewItem: React.FC<Props> = ({ view, handleEditView, handleDe
> >
<StarIcon className="h-4 w-4 " color="rgb(var(--color-text-200))" /> <StarIcon className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button> </button>
)} )
<CustomMenu width="auto" verticalEllipsis> ) : null}
<CustomMenu.MenuItem <CustomMenu width="auto" ellipsis>
onClick={(e: any) => { <CustomMenu.MenuItem
e.preventDefault(); onClick={(e: any) => {
e.stopPropagation(); e.preventDefault();
handleEditView(); e.stopPropagation();
}} handleEditView();
> }}
<span className="flex items-center justify-start gap-2"> >
<PencilIcon className="h-3.5 w-3.5" /> <span className="flex items-center justify-start gap-2">
<span>Edit View</span> <PencilIcon className="h-3.5 w-3.5" />
</span> <span>Edit View</span>
</CustomMenu.MenuItem> </span>
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={(e: any) => { <CustomMenu.MenuItem
e.preventDefault(); onClick={(e: any) => {
e.stopPropagation(); e.preventDefault();
handleDeleteView(); e.stopPropagation();
}} handleDeleteView();
> }}
<span className="flex items-center justify-start gap-2"> >
<TrashIcon className="h-3.5 w-3.5" /> <span className="flex items-center justify-start gap-2">
<span>Delete View</span> <TrashIcon className="h-3.5 w-3.5" />
</span> <span>Delete View</span>
</CustomMenu.MenuItem> </span>
</CustomMenu> </CustomMenu.MenuItem>
</div> </CustomMenu>
</div> </div>
</div> </div>
{view?.description && (
<p className="px-[27px] text-sm font-normal leading-5 text-custom-text-200">
{view.description}
</p>
)}
</div> </div>
</a> </a>
</Link> </Link>

View File

@ -34,8 +34,8 @@ const workspaceLinks = (workspaceSlug: string) => [
}, },
{ {
Icon: TaskAltOutlined, Icon: TaskAltOutlined,
name: "My Issues", name: "Issues",
href: `/${workspaceSlug}/me/my-issues`, href: `/${workspaceSlug}/workspace-views/all-issues`,
}, },
]; ];

View File

@ -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<Props> = ({ 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 (
<div className="group flex items-center overflow-x-scroll">
{tabsList.map((tab) => (
<button
key={tab.key}
type="button"
onClick={tab.onClick}
className={`border-b-2 min-w-[96px] p-4 text-sm font-medium outline-none whitespace-nowrap ${
tab.selected
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-primary-100 hover:text-custom-primary-100"
}`}
>
{tab.label}
</button>
))}
{workspaceViews &&
workspaceViews.length > 0 &&
workspaceViews?.map((view) => (
<button
className={`border-b-2 min-w-[96px] p-4 text-sm font-medium outline-none whitespace-nowrap ${
view.id === workspaceViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-primary-100 hover:text-custom-primary-100"
}`}
onClick={() => router.push(`/${workspaceSlug}/workspace-views/${view.id}`)}
>
{view.name}
</button>
))}
<button type="button" className="min-w-[96px] " onClick={handleAddView}>
<PlusIcon className="h-4 w-4 text-custom-primary-200 hover:text-current" />
</button>
</div>
);
};

View File

@ -4,6 +4,7 @@ import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types";
const paramsToKey = (params: any) => { const paramsToKey = (params: any) => {
const { const {
state, state,
state_group,
priority, priority,
assignees, assignees,
created_by, created_by,
@ -12,9 +13,12 @@ const paramsToKey = (params: any) => {
target_date, target_date,
sub_issue, sub_issue,
start_target_date, start_target_date,
project,
} = params; } = params;
let projectKey = project ? project.split(",") : [];
let stateKey = state ? state.split(",") : []; let stateKey = state ? state.split(",") : [];
let stateGroupKey = state_group ? state_group.split(",") : [];
let priorityKey = priority ? priority.split(",") : []; let priorityKey = priority ? priority.split(",") : [];
let assigneesKey = assignees ? assignees.split(",") : []; let assigneesKey = assignees ? assignees.split(",") : [];
let createdByKey = created_by ? created_by.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"; const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL";
// sorting each keys in ascending order // sorting each keys in ascending order
projectKey = projectKey.sort().join("_");
stateKey = stateKey.sort().join("_"); stateKey = stateKey.sort().join("_");
stateGroupKey = stateGroupKey.sort().join("_");
priorityKey = priorityKey.sort().join("_"); priorityKey = priorityKey.sort().join("_");
assigneesKey = assigneesKey.sort().join("_"); assigneesKey = assigneesKey.sort().join("_");
createdByKey = createdByKey.sort().join("_"); createdByKey = createdByKey.sort().join("_");
labelsKey = labelsKey.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) => { 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}`; 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) => export const PROJECT_ISSUES_DETAILS = (issueId: string) =>
`PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`; `PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`;
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>

View File

@ -21,6 +21,8 @@ export const GROUP_CHOICES = {
cancelled: "Cancelled", cancelled: "Cancelled",
}; };
export const STATE_GROUP = ["Backlog", "Unstarted", "Started", "Completed", "Cancelled"];
export const PRIORITIES: TIssuePriorities[] = ["urgent", "high", "medium", "low", "none"]; export const PRIORITIES: TIssuePriorities[] = ["urgent", "high", "medium", "low", "none"];
export const MONTHS = [ export const MONTHS = [

View File

@ -11,9 +11,9 @@ import { ISubIssueResponse } from "types";
// fetch-keys // fetch-keys
import { SUB_ISSUES } from "constants/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 router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
const shouldFetch = workspaceSlug && projectId && issueId && isExpanded; const shouldFetch = workspaceSlug && projectId && issueId && isExpanded;

View File

@ -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<IIssueFilterOptions>) => {
if (!workspaceSlug || !workspaceViewId || !workspaceViewDetails) return;
const oldData = { ...workspaceViewDetails };
mutate<IView>(
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<IIssueFilterOptions>) => {
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;

View File

@ -88,12 +88,14 @@ const ProjectViews: NextPage = () => {
> >
<CreateUpdateViewModal <CreateUpdateViewModal
isOpen={createUpdateViewModal} isOpen={createUpdateViewModal}
viewType="project"
handleClose={() => setCreateUpdateViewModal(false)} handleClose={() => setCreateUpdateViewModal(false)}
data={selectedViewToUpdate} data={selectedViewToUpdate}
user={user} user={user}
/> />
<DeleteViewModal <DeleteViewModal
isOpen={deleteViewModal} isOpen={deleteViewModal}
viewType="project"
data={selectedViewToDelete} data={selectedViewToDelete}
setIsOpen={setDeleteViewModal} setIsOpen={setDeleteViewModal}
user={user} user={user}
@ -107,6 +109,7 @@ const ProjectViews: NextPage = () => {
<SingleViewItem <SingleViewItem
key={view.id} key={view.id}
view={view} view={view}
viewType="project"
handleEditView={() => handleEditView(view)} handleEditView={() => handleEditView(view)}
handleDeleteView={() => handleDeleteView(view)} handleDeleteView={() => handleDeleteView(view)}
/> />

View File

@ -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<any>(null);
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { 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<IIssue | null>(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<IView[]>(
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 (
<WorkspaceAuthorizationLayout
breadcrumbs={
<div className="flex gap-2 items-center">
<CheckCircle className="h-[18px] w-[18px] stroke-[1.5]" />
<span className="text-sm font-medium">
{viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"}
</span>
</div>
}
right={
<div className="flex items-center gap-2">
<WorkspaceIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</div>
}
>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
viewType="workspace"
preLoadedData={createViewModal}
user={user}
/>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full border-b border-custom-border-300">
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
{error ? (
<EmptyState
image={emptyView}
title="View does not exist"
description="The view you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other views",
onClick: () => router.push(`/${workspaceSlug}/workspace-views`),
}}
/>
) : (
<div className="h-full w-full flex flex-col">
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FiltersList
filters={filters}
setFilters={(updatedFilter) => 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,
})
}
/>
<PrimaryButton
onClick={() => {
if (workspaceViewId) {
updateView(filters);
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!workspaceViewId && <PlusIcon className="h-4 w-4" />}
{workspaceViewId ? "Update" : "Save"} view
</PrimaryButton>
</div>
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
<SpreadsheetView
spreadsheetIssues={viewIssues}
mutateIssues={mutateIssues}
handleIssueAction={handleIssueAction}
disableUserActions={isNotAllowed ?? false}
user={user}
userAuth={memberRole}
/>
</div>
)}
</div>
</div>
</WorkspaceAuthorizationLayout>
);
};
export default WorkspaceView;

View File

@ -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<any>(null);
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { 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<IIssue | null>(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 (
<WorkspaceAuthorizationLayout
breadcrumbs={
<div className="flex gap-2 items-center">
<CheckCircle className="h-[18px] w-[18px] stroke-[1.5]" />
<span className="text-sm font-medium">
{viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"}
</span>
</div>
}
right={
<div className="flex items-center gap-2">
<MyIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</div>
}
>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
viewType="workspace"
preLoadedData={createViewModal}
user={user}
/>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full border-b border-custom-border-300">
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
{error ? (
<EmptyState
image={emptyView}
title="View does not exist"
description="The view you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other views",
onClick: () => router.push(`/${workspaceSlug}/workspace-views`),
}}
/>
) : (
<div className="h-full w-full flex flex-col">
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FiltersList
filters={filters}
setFilters={(updatedFilter) => 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,
})
}
/>
<PrimaryButton
onClick={() => {
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!workspaceViewId && <PlusIcon className="h-4 w-4" />}
{workspaceViewId ? "Update" : "Save"} view
</PrimaryButton>
</div>
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
<SpreadsheetView
spreadsheetIssues={viewIssues}
mutateIssues={mutateIssues}
handleIssueAction={handleIssueAction}
disableUserActions={false}
user={user}
userAuth={memberRole}
/>
</div>
)}
</div>
</div>
</WorkspaceAuthorizationLayout>
);
};
export default WorkspaceViewAllIssue;

View File

@ -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<any>(null);
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { 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<IIssue | null>(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 (
<WorkspaceAuthorizationLayout
breadcrumbs={
<div className="flex gap-2 items-center">
<CheckCircle className="h-[18px] w-[18px] stroke-[1.5]" />
<span className="text-sm font-medium">
{viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"}
</span>
</div>
}
right={
<div className="flex items-center gap-2">
<WorkspaceIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</div>
}
>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
viewType="workspace"
preLoadedData={createViewModal}
user={user}
/>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full border-b border-custom-border-300">
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
{error ? (
<EmptyState
image={emptyView}
title="View does not exist"
description="The view you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other views",
onClick: () => router.push(`/${workspaceSlug}/workspace-views`),
}}
/>
) : (
<div className="h-full w-full flex flex-col">
<SpreadsheetView
spreadsheetIssues={viewIssues}
mutateIssues={mutateIssues}
handleIssueAction={handleIssueAction}
disableUserActions={false}
user={user}
userAuth={memberRole}
/>
</div>
)}
</div>
</div>
</WorkspaceAuthorizationLayout>
);
};
export default WorkspaceViewAssignedIssue;

View File

@ -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<any>(null);
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { 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<IIssue | null>(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 (
<WorkspaceAuthorizationLayout
breadcrumbs={
<div className="flex gap-2 items-center">
<CheckCircle className="h-[18px] w-[18px] stroke-[1.5]" />
<span className="text-sm font-medium">
{viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"}
</span>
</div>
}
right={
<div className="flex items-center gap-2">
<WorkspaceIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</div>
}
>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
viewType="workspace"
preLoadedData={createViewModal}
user={user}
/>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full border-b border-custom-border-300">
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
{error ? (
<EmptyState
image={emptyView}
title="View does not exist"
description="The view you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other views",
onClick: () => router.push(`/${workspaceSlug}/workspace-views`),
}}
/>
) : (
<div className="h-full w-full flex flex-col">
<SpreadsheetView
spreadsheetIssues={viewIssues}
mutateIssues={mutateIssues}
handleIssueAction={handleIssueAction}
disableUserActions={false}
user={user}
userAuth={memberRole}
/>
</div>
)}
</div>
</div>
</WorkspaceAuthorizationLayout>
);
};
export default WorkspaceViewCreatedIssue;

View File

@ -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<IView | null>(null);
const [deleteViewModal, setDeleteViewModal] = useState(false);
const [selectedViewToDelete, setSelectedViewToDelete] = useState<IView | null>(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 (
<WorkspaceAuthorizationLayout
breadcrumbs={
<div className="flex gap-2 items-center">
<span className="text-sm font-medium">Workspace Views</span>
</div>
}
right={
<div className="flex items-center gap-2">
<WorkspaceIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => setCreateUpdateViewModal(true)}
>
<PlusIcon className="h-4 w-4" />
New View
</PrimaryButton>
</div>
}
>
<CreateUpdateViewModal
isOpen={createUpdateViewModal}
handleClose={() => {
setCreateUpdateViewModal(false);
setSelectedViewToUpdate(null);
}}
data={selectedViewToUpdate}
viewType="workspace"
user={user}
/>
<DeleteViewModal
isOpen={deleteViewModal}
data={selectedViewToDelete}
setIsOpen={setDeleteViewModal}
viewType="workspace"
user={user}
/>
<div className="flex flex-col">
<div className="h-full w-full flex flex-col overflow-hidden">
<div className="flex items-center gap-2.5 w-full px-5 py-3 border-b border-custom-border-200">
<MagnifyingGlassIcon className="h-4 w-4 text-custom-text-200" />
<Input
className="w-full bg-transparent text-xs leading-5 text-custom-text-200 placeholder:text-custom-text-400 !p-0 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
mode="trueTransparent"
/>
</div>
</div>
{filteredDefaultOptions &&
filteredDefaultOptions.length > 0 &&
filteredDefaultOptions.map((option) => (
<div className="group hover:bg-custom-background-90 border-b border-custom-border-200">
<Link href={option.href}>
<a className="flex items-center justify-between relative rounded px-5 py-4 w-full">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<div
className={`flex items-center justify-center h-10 w-10 rounded bg-custom-background-90 group-hover:bg-custom-background-100`}
>
<PhotoFilterOutlined className="!text-base !leading-6" />
</div>
<div className="flex flex-col">
<p className="truncate text-sm leading-4 font-medium">
{truncateText(option.label, 75)}
</p>
</div>
</div>
</div>
</a>
</Link>
</div>
))}
{filteredOptions ? (
filteredOptions.length > 0 ? (
<div>
{filteredOptions.map((view) => (
<SingleViewItem
key={view.id}
view={view}
viewType="workspace"
handleEditView={() => handleEditView(view)}
handleDeleteView={() => handleDeleteView(view)}
/>
))}
</div>
) : (
<EmptyState
title="Get focused with views"
description="Views aid in saving your issues by applying various filters and grouping options."
image={emptyView}
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "New View",
onClick: () => setCreateUpdateViewModal(true),
}}
/>
)
) : (
<Loader className="space-y-1.5">
<Loader.Item height="72px" />
<Loader.Item height="72px" />
<Loader.Item height="72px" />
<Loader.Item height="72px" />
<Loader.Item height="72px" />
</Loader>
)}
</div>
</WorkspaceAuthorizationLayout>
);
};
export default WorkspaceViews;

View File

@ -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<any>(null);
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { 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<IIssue | null>(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 (
<WorkspaceAuthorizationLayout
breadcrumbs={
<div className="flex gap-2 items-center">
<CheckCircle className="h-[18px] w-[18px] stroke-[1.5]" />
<span className="text-sm font-medium">
{viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"}
</span>
</div>
}
right={
<div className="flex items-center gap-2">
<WorkspaceIssuesViewOptions />
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</div>
}
>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
onSubmit={async () => {
mutateIssues();
}}
/>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
viewType="workspace"
preLoadedData={createViewModal}
user={user}
/>
<div className="h-full flex flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full border-b border-custom-border-300">
<WorkspaceViewsNavigation handleAddView={() => setCreateViewModal(true)} />
{error ? (
<EmptyState
image={emptyView}
title="View does not exist"
description="The view you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other views",
onClick: () => router.push(`/${workspaceSlug}/workspace-views`),
}}
/>
) : (
<div className="h-full w-full flex flex-col">
<SpreadsheetView
spreadsheetIssues={viewIssues}
mutateIssues={mutateIssues}
handleIssueAction={handleIssueAction}
disableUserActions={false}
user={user}
userAuth={memberRole}
/>
</div>
)}
</div>
</div>
</WorkspaceAuthorizationLayout>
);
};
export default WorkspaceViewSubscribedIssue;

View File

@ -14,6 +14,8 @@ import {
ICurrentUserResponse, ICurrentUserResponse,
IWorkspaceBulkInviteFormData, IWorkspaceBulkInviteFormData,
IWorkspaceViewProps, IWorkspaceViewProps,
IView,
IIssueFilterOptions,
} from "types"; } from "types";
class WorkspaceService extends APIService { class WorkspaceService extends APIService {
@ -261,6 +263,56 @@ class WorkspaceService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async createView(workspaceSlug: string, data: IView): Promise<any> {
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<IView>): Promise<any> {
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<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getAllViews(workspaceSlug: string): Promise<IView[]> {
return this.get(`/api/workspaces/${workspaceSlug}/views/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getViewDetails(workspaceSlug: string, viewId: string): Promise<IView> {
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<any> {
return this.get(`/api/workspaces/${workspaceSlug}/issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }
export default new WorkspaceService(); export default new WorkspaceService();

View File

@ -35,9 +35,10 @@ export interface IIssueFilterOptions {
priority?: string[] | null; priority?: string[] | null;
start_date?: string[] | null; start_date?: string[] | null;
state?: string[] | null; state?: string[] | null;
state_group?: TStateGroups[] | null; state_group?: string[] | null;
subscriber?: string[] | null; subscriber?: string[] | null;
target_date?: string[] | null; target_date?: string[] | null;
project?: string[] | null;
} }
export interface IIssueDisplayFilterOptions { export interface IIssueDisplayFilterOptions {

12
web/types/views.d.ts vendored
View File

@ -1,3 +1,5 @@
import { IIssueFilterOptions } from "./view-props";
export interface IView { export interface IView {
id: string; id: string;
access: string; access: string;
@ -8,10 +10,15 @@ export interface IView {
updated_by: string; updated_by: string;
name: string; name: string;
description: string; description: string;
query: IQuery; query: IIssueFilterOptions;
query_data: IQuery; query_data: IIssueFilterOptions;
project: string; project: string;
workspace: string; workspace: string;
workspace_detail: {
id: string;
name: string;
slug: string;
};
} }
export interface IQuery { export interface IQuery {
@ -23,4 +30,5 @@ export interface IQuery {
start_date: string[] | null; start_date: string[] | null;
target_date: string[] | null; target_date: string[] | null;
type: "active" | "backlog" | null; type: "active" | "backlog" | null;
project: string[] | null;
} }

View File

@ -1,13 +1,4 @@
import type { import type { IProjectMember, IUser, IUserMemberLite, IWorkspaceViewProps } from "types";
IIssueFilterOptions,
IProjectMember,
IUser,
IUserMemberLite,
IWorkspaceViewProps,
TIssueGroupByOptions,
TIssueOrderByOptions,
TIssueViewOptions,
} from "types";
export interface IWorkspace { export interface IWorkspace {
readonly id: string; readonly id: string;