forked from github/plane
feat: spreadsheet view (#1369)
* feat: spreadsheet view * fix: fix scroll and overflow issues, feat: updated issue properties component, style: ui improvements * feat: sub-issue toggle and sub-issue hook added, chore: code refactor * fix: only render parent issue * feat: sub issue fetching hook updated and nested sub issue added, chore: code refactor * style: title sticky to left on scroll and column styling * fix: tooltip , filter and view z-index fix * feat: spreadsheet view column sorting, fix: sticky scroll issue fix * feat: updated issue view filter for spreadsheet view * style: spreadsheet view column * feat: double click to edit title * fix: estimate sorting fix * style: spreadsheet view columns * fix: spreadsheet view mutation, feat: edit , copy and delete option added * fix: edit sub issue fix
This commit is contained in:
parent
0cb856b92f
commit
e08fc59114
@ -2,6 +2,7 @@ export * from "./board-view";
|
||||
export * from "./calendar-view";
|
||||
export * from "./gantt-chart-view";
|
||||
export * from "./list-view";
|
||||
export * from "./spreadsheet-view";
|
||||
export * from "./sidebar";
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
|
@ -10,7 +10,7 @@ import { Popover, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { SelectFilters } from "components/views";
|
||||
// ui
|
||||
import { CustomMenu, ToggleSwitch } from "components/ui";
|
||||
import { CustomMenu, Icon, ToggleSwitch } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@ -83,6 +83,15 @@ export const IssuesFilterView: React.FC = () => {
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
|
||||
issueView === "spreadsheet" ? "bg-brand-surface-2" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("spreadsheet")}
|
||||
>
|
||||
<Icon iconName="table_chart" className="text-brand-secondary" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${
|
||||
@ -146,10 +155,10 @@ export const IssuesFilterView: React.FC = () => {
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
|
||||
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
|
||||
<div className="relative divide-y-2 divide-brand-base">
|
||||
<div className="space-y-4 pb-3 text-xs">
|
||||
{issueView !== "calendar" && (
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-brand-secondary">Group by</h4>
|
||||
@ -221,7 +230,7 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
{issueView !== "calendar" && (
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-brand-secondary">Show empty states</h4>
|
||||
@ -252,6 +261,13 @@ export const IssuesFilterView: React.FC = () => {
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
|
||||
if (
|
||||
(issueView === "spreadsheet" && key === "sub_issue_count") ||
|
||||
key === "attachment_count" ||
|
||||
key === "link"
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
|
@ -19,7 +19,14 @@ import useToast from "hooks/use-toast";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// components
|
||||
import { AllLists, AllBoards, FilterList, CalendarView, GanttChartView } from "components/core";
|
||||
import {
|
||||
AllLists,
|
||||
AllBoards,
|
||||
FilterList,
|
||||
CalendarView,
|
||||
GanttChartView,
|
||||
SpreadsheetView,
|
||||
} from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
@ -563,6 +570,13 @@ export const IssuesView: React.FC<Props> = ({
|
||||
user={user}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : issueView === "spreadsheet" ? (
|
||||
<SpreadsheetView
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
user={user}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : (
|
||||
issueView === "gantt_chart" && <GanttChartView />
|
||||
)}
|
||||
|
4
apps/app/components/core/spreadsheet-view/index.ts
Normal file
4
apps/app/components/core/spreadsheet-view/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./spreadsheet-view";
|
||||
export * from "./single-issue";
|
||||
export * from "./spreadsheet-columns";
|
||||
export * from "./spreadsheet-issues";
|
266
apps/app/components/core/spreadsheet-view/single-issue.tsx
Normal file
266
apps/app/components/core/spreadsheet-view/single-issue.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
// icons
|
||||
import { CustomMenu, Icon } from "components/ui";
|
||||
import { LinkIcon, PencilIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// hooks
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// constant
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
|
||||
// helper
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
expanded: boolean;
|
||||
handleToggleExpand: (issueId: string) => void;
|
||||
properties: Properties;
|
||||
handleEditIssue: () => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
gridTemplateColumns: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
nestingLevel: number;
|
||||
};
|
||||
|
||||
export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
||||
issue,
|
||||
expanded,
|
||||
handleToggleExpand,
|
||||
properties,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
gridTemplateColumns,
|
||||
user,
|
||||
userAuth,
|
||||
nestingLevel,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { params } = useSpreadsheetIssuesView();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>, issueId: string) => {
|
||||
if (!workspaceSlug || !projectId) 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)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
fetchKey,
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issueId) {
|
||||
return {
|
||||
...p,
|
||||
...formData,
|
||||
};
|
||||
}
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData, user)
|
||||
.then(() => {
|
||||
mutate(fetchKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
|
||||
);
|
||||
|
||||
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 * 68}px`;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-brand-surface-2 border-b border-brand-base w-full min-w-max"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
<div className="flex gap-1.5 items-center px-4 sticky left-0 z-10 text-brand-secondary bg-brand-base group-hover:text-brand-base group-hover:bg-brand-surface-2 border-brand-base w-full">
|
||||
<span className="flex gap-1 items-center" style={issue.parent ? { paddingLeft } : {}}>
|
||||
{properties.key && (
|
||||
<>
|
||||
<div className="flex items-center cursor-pointer text-xs text-center hover:text-brand-base w-14 ">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-5 w-5">
|
||||
{issue.sub_issues_count > 0 && (
|
||||
<button
|
||||
className="h-5 w-5 hover:bg-brand-surface-1 hover:text-brand-base rounded-sm"
|
||||
onClick={() => handleToggleExpand(issue.id)}
|
||||
>
|
||||
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<a className="truncate text-brand-base cursor-pointer w-full text-[0.825rem]">
|
||||
{issue.name}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{properties.state && (
|
||||
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
customButton
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.priority && (
|
||||
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
noBorder
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
customButton
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.labels ? (
|
||||
issue.label_details.length > 0 ? (
|
||||
<div className="flex items-center gap-2 text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
{issue.label_details.slice(0, 4).map((label, index) => (
|
||||
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border group-hover:bg-brand-surface-2 border-brand-base
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{issue.label_details.length > 4 ? <span>+{issue.label_details.length - 4}</span> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
No Labels
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
noBorder
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.estimate && (
|
||||
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2.5 right-2.5 z-30 cursor-pointer opacity-0 group-hover:opacity-100">
|
||||
{!isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditIssue}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,241 @@
|
||||
import React from "react";
|
||||
// hooks
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// component
|
||||
import { CustomMenu, Icon } from "components/ui";
|
||||
// icon
|
||||
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { TIssueOrderByOptions } from "types";
|
||||
|
||||
type Props = {
|
||||
columnData: any;
|
||||
gridTemplateColumns: string;
|
||||
};
|
||||
|
||||
export const SpreadsheetColumns: React.FC<Props> = ({ columnData, gridTemplateColumns }) => {
|
||||
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
|
||||
"spreadsheetViewSorting",
|
||||
""
|
||||
);
|
||||
|
||||
const { orderBy, setOrderBy } = useSpreadsheetIssuesView();
|
||||
|
||||
const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => {
|
||||
setOrderBy(order);
|
||||
setSelectedMenuItem(`${order}_${itemKey}`);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`grid auto-rows-[minmax(36px,1fr)] w-full min-w-max`}
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{columnData.map((col: any) => {
|
||||
if (col.isActive) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-brand-surface-2 ${
|
||||
col.propertyName === "title" ? "sticky left-0 z-20 bg-brand-surface-2 pl-24" : ""
|
||||
}`}
|
||||
>
|
||||
{col.propertyName === "title" || col.propertyName === "priority" ? (
|
||||
<div
|
||||
className={`flex items-center justify-start gap-1.5 cursor-default text-sm text-brand-secondary text-current py-2.5 px-2`}
|
||||
>
|
||||
{col.icon ? (
|
||||
<col.icon
|
||||
className={`text-brand-secondary ${
|
||||
col.propertyName === "estimate" ? "-rotate-90" : ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
height="14"
|
||||
width="14"
|
||||
/>
|
||||
) : col.propertyName === "priority" ? (
|
||||
<span className="text-sm material-symbols-rounded text-brand-secondary">
|
||||
signal_cellular_alt
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
{col.colName}
|
||||
</div>
|
||||
) : (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div
|
||||
className={`group flex items-center justify-start gap-1.5 cursor-pointer text-sm text-brand-secondary text-current hover:text-brand-base py-2.5 px-2`}
|
||||
>
|
||||
{col.icon ? (
|
||||
<col.icon
|
||||
className={`text-brand-secondary group-hover:text-brand-base ${
|
||||
col.propertyName === "estimate" ? "-rotate-90" : ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
height="14"
|
||||
width="14"
|
||||
/>
|
||||
) : col.propertyName === "priority" ? (
|
||||
<span className="text-sm material-symbols-rounded text-brand-secondary">
|
||||
signal_cellular_alt
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
{col.colName}
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
}
|
||||
menuItemsWhiteBg
|
||||
width="xl"
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
className={`${
|
||||
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
|
||||
? "bg-brand-surface-2"
|
||||
: ""
|
||||
}`}
|
||||
key={col.propertyName}
|
||||
onClick={() => {
|
||||
handleOrderBy(col.ascendingOrder, col.propertyName);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
||||
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
|
||||
? "text-brand-base"
|
||||
: "text-brand-secondary hover:text-brand-base"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{col.propertyName === "assignee" || col.propertyName === "labels" ? (
|
||||
<>
|
||||
<span>A-Z</span>
|
||||
<span>Ascending</span>
|
||||
</>
|
||||
) : col.propertyName === "due_date" ? (
|
||||
<>
|
||||
<span>1-9</span>
|
||||
<span>Ascending</span>
|
||||
</>
|
||||
) : col.propertyName === "estimate" ? (
|
||||
<>
|
||||
<span>0</span>
|
||||
<Icon iconName="east" className="text-sm" />
|
||||
<span>10</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>First</span>
|
||||
<Icon iconName="east" className="text-sm" />
|
||||
<span>Last</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
||||
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
|
||||
? "opacity-100"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
className={`${
|
||||
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
|
||||
? "bg-brand-surface-2"
|
||||
: ""
|
||||
}`}
|
||||
key={col.property}
|
||||
onClick={() => {
|
||||
handleOrderBy(col.descendingOrder, col.propertyName);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
||||
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
|
||||
? "text-brand-base"
|
||||
: "text-brand-secondary hover:text-brand-base"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{col.propertyName === "assignee" || col.propertyName === "labels" ? (
|
||||
<>
|
||||
<span>Z-A</span>
|
||||
<span>Descending</span>
|
||||
</>
|
||||
) : col.propertyName === "due_date" ? (
|
||||
<>
|
||||
<span>9-1</span>
|
||||
<span>Descending</span>
|
||||
</>
|
||||
) : col.propertyName === "estimate" ? (
|
||||
<>
|
||||
<span>10</span>
|
||||
<Icon iconName="east" className="text-sm" />
|
||||
<span>0</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Last</span>
|
||||
<Icon iconName="east" className="text-sm" />
|
||||
<span>First</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
||||
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
|
||||
? "opacity-100"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
className={`${
|
||||
selectedMenuItem === `-created_at_${col.propertyName}`
|
||||
? "bg-brand-surface-2"
|
||||
: ""
|
||||
}`}
|
||||
key={col.property}
|
||||
onClick={() => {
|
||||
handleOrderBy("-created_at", col.propertyName);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`group flex gap-1.5 px-1 items-center justify-between ${
|
||||
selectedMenuItem === `-created_at_${col.propertyName}`
|
||||
? "text-brand-base"
|
||||
: "text-brand-secondary hover:text-brand-base"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<Icon iconName="block" className="text-sm" />
|
||||
<span>None</span>
|
||||
</div>
|
||||
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
|
||||
selectedMenuItem === `-created_at_${col.propertyName}`
|
||||
? "opacity-100"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,90 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// components
|
||||
import { SingleSpreadsheetIssue } from "components/core";
|
||||
// hooks
|
||||
import useSubIssue from "hooks/use-sub-issue";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
key: string;
|
||||
issue: IIssue;
|
||||
expandedIssues: string[];
|
||||
setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
properties: Properties;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
gridTemplateColumns: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
nestingLevel?: number;
|
||||
};
|
||||
|
||||
export const SpreadsheetIssues: React.FC<Props> = ({
|
||||
key,
|
||||
issue,
|
||||
expandedIssues,
|
||||
setExpandedIssues,
|
||||
gridTemplateColumns,
|
||||
properties,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
user,
|
||||
userAuth,
|
||||
nestingLevel = 0,
|
||||
}) => {
|
||||
const handleToggleExpand = (issueId: string) => {
|
||||
setExpandedIssues((prevState) => {
|
||||
const newArray = [...prevState];
|
||||
const index = newArray.indexOf(issueId);
|
||||
if (index > -1) {
|
||||
newArray.splice(index, 1);
|
||||
} else {
|
||||
newArray.push(issueId);
|
||||
}
|
||||
return newArray;
|
||||
});
|
||||
};
|
||||
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SingleSpreadsheetIssue
|
||||
issue={issue}
|
||||
expanded={isExpanded}
|
||||
handleToggleExpand={handleToggleExpand}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
properties={properties}
|
||||
handleEditIssue={() => handleEditIssue(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
nestingLevel={nestingLevel}
|
||||
/>
|
||||
|
||||
{isExpanded &&
|
||||
!isLoading &&
|
||||
subIssues &&
|
||||
subIssues.length > 0 &&
|
||||
subIssues.map((subIssue: IIssue, subIndex: number) => (
|
||||
<SpreadsheetIssues
|
||||
key={subIssue.id}
|
||||
issue={subIssue}
|
||||
expandedIssues={expandedIssues}
|
||||
setExpandedIssues={setExpandedIssues}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
properties={properties}
|
||||
handleEditIssue={() => handleEditIssue(subIssue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
nestingLevel={nestingLevel + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,94 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// components
|
||||
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
|
||||
import { Icon, Spinner } from "components/ui";
|
||||
// hooks
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
|
||||
// constants
|
||||
import { SPREADSHEET_COLUMN } from "constants/spreadsheet";
|
||||
// icon
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SpreadsheetView: React.FC<Props> = ({
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
user,
|
||||
userAuth,
|
||||
}) => {
|
||||
const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { spreadsheetIssues } = useSpreadsheetIssuesView();
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
const columnData = SPREADSHEET_COLUMN.map((column) => ({
|
||||
...column,
|
||||
isActive: properties
|
||||
? column.propertyName === "labels"
|
||||
? properties[column.propertyName as keyof Properties]
|
||||
: column.propertyName === "title"
|
||||
? true
|
||||
: properties[column.propertyName as keyof Properties]
|
||||
: false,
|
||||
}));
|
||||
|
||||
const gridTemplateColumns = columnData
|
||||
.filter((column) => column.isActive)
|
||||
.map((column) => column.colSize)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="h-full rounded-lg text-brand-secondary overflow-x-auto whitespace-nowrap bg-brand-base">
|
||||
<div className="sticky z-20 top-0 border-b border-brand-base bg-brand-surface-2 w-full min-w-max">
|
||||
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
|
||||
</div>
|
||||
{spreadsheetIssues ? (
|
||||
<div className="flex flex-col h-full w-full bg-brand-base rounded-sm ">
|
||||
{spreadsheetIssues.map((issue: IIssue, index) => (
|
||||
<SpreadsheetIssues
|
||||
key={`${issue.id}_${index}`}
|
||||
issue={issue}
|
||||
expandedIssues={expandedIssues}
|
||||
setExpandedIssues={setExpandedIssues}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
properties={properties}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
className="flex items-center gap-1.5 pl-7 py-2.5 text-sm text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 border-b border-brand-base w-full min-w-max"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "c" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -12,6 +12,7 @@ import issueServices from "services/issues.service";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
@ -41,6 +42,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
|
||||
const { issueView, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -74,6 +76,20 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== data.id),
|
||||
false
|
||||
);
|
||||
} else if (issueView === "spreadsheet") {
|
||||
const spreadsheetFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams)
|
||||
: moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
|
||||
: viewId
|
||||
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
spreadsheetFetchKey,
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== data.id),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
|
@ -17,6 +17,7 @@ import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
// types
|
||||
@ -79,6 +80,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { order_by, group_by, ...viewGanttParams } = params;
|
||||
const { params: inboxParams } = useInboxView();
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
||||
@ -211,6 +213,14 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
? VIEW_ISSUES(viewId.toString(), calendarParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams);
|
||||
|
||||
const spreadsheetFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams)
|
||||
: moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
|
||||
: viewId
|
||||
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams);
|
||||
|
||||
const ganttFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
|
||||
: moduleId
|
||||
@ -234,6 +244,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "gantt_chart") mutate(ganttFetchKey);
|
||||
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -264,6 +275,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
} else {
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,7 @@ type Props = {
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
tooltipPosition?: "left" | "right";
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -34,6 +35,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
tooltipPosition = "right",
|
||||
user,
|
||||
isNotAllowed,
|
||||
customButton = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -65,32 +67,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={issue.assignees}
|
||||
onChange={(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.id);
|
||||
|
||||
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
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
label={
|
||||
const assigneeLabel = (
|
||||
<Tooltip
|
||||
position={`top-${tooltipPosition}`}
|
||||
tooltipHeading="Assignees"
|
||||
@ -120,7 +97,34 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={issue.assignees}
|
||||
onChange={(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.id);
|
||||
|
||||
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
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
{...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })}
|
||||
multiple
|
||||
noChevron
|
||||
position={position}
|
||||
|
@ -12,6 +12,7 @@ import { ICurrentUserResponse, IIssue } from "types";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
noBorder?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -19,6 +20,7 @@ type Props = {
|
||||
export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
noBorder = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -62,6 +64,7 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
);
|
||||
}}
|
||||
className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"}
|
||||
noBorder={noBorder}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
@ -18,6 +18,7 @@ type Props = {
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -27,6 +28,7 @@ export const ViewEstimateSelect: React.FC<Props> = ({
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
customButton = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -37,6 +39,15 @@ export const ViewEstimateSelect: React.FC<Props> = ({
|
||||
|
||||
const estimateValue = estimatePoints?.find((e) => e.key === issue.estimate_point)?.value;
|
||||
|
||||
const estimateLabels = (
|
||||
<Tooltip tooltipHeading="Estimate" tooltipContent={estimateValue}>
|
||||
<div className="flex items-center gap-1 text-brand-secondary">
|
||||
<PlayIcon className="h-3.5 w-3.5 -rotate-90" />
|
||||
{estimateValue ?? "None"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
if (!isEstimateActive) return null;
|
||||
|
||||
return (
|
||||
@ -57,14 +68,7 @@ export const ViewEstimateSelect: React.FC<Props> = ({
|
||||
user
|
||||
);
|
||||
}}
|
||||
label={
|
||||
<Tooltip tooltipHeading="Estimate" tooltipContent={estimateValue}>
|
||||
<div className="flex items-center gap-1 text-brand-secondary">
|
||||
<PlayIcon className="h-3.5 w-3.5 -rotate-90" />
|
||||
{estimateValue ?? "Estimate"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
{...(customButton ? { customButton: estimateLabels } : { label: estimateLabels })}
|
||||
maxHeight="md"
|
||||
noChevron
|
||||
disabled={isNotAllowed}
|
||||
|
@ -12,12 +12,15 @@ import { ICurrentUserResponse, IIssue } from "types";
|
||||
import { PRIORITIES } from "constants/project";
|
||||
// services
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// helper
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
noBorder?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -27,6 +30,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
noBorder = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -55,10 +59,12 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-6 w-6 place-items-center rounded border ${
|
||||
className={`grid place-items-center rounded ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center shadow-sm ${
|
||||
issue.priority === "urgent"
|
||||
} ${noBorder ? "" : "h-6 w-6 border shadow-sm"} ${
|
||||
noBorder
|
||||
? ""
|
||||
: issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: issue.priority === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
@ -67,14 +73,19 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
: issue.priority === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-brand-base"
|
||||
}`}
|
||||
} items-center`}
|
||||
>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}>
|
||||
<span>
|
||||
<span className="flex gap-1 items-center text-brand-secondary text-xs">
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
{noBorder
|
||||
? issue.priority && issue.priority !== ""
|
||||
? capitalizeFirstLetter(issue.priority) ?? ""
|
||||
: "None"
|
||||
: ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
|
@ -22,6 +22,7 @@ type Props = {
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
position?: "left" | "right";
|
||||
selfPositioned?: boolean;
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -31,6 +32,7 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
customButton = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -58,6 +60,19 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
|
||||
const selectedOption = states?.find((s) => s.id === issue.state);
|
||||
|
||||
const stateLabel = (
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")}
|
||||
>
|
||||
<div className="flex items-center cursor-pointer gap-2 text-brand-secondary">
|
||||
{selectedOption &&
|
||||
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
|
||||
{selectedOption?.name ?? "State"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={issue.state}
|
||||
@ -101,18 +116,7 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
label={
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
{selectedOption &&
|
||||
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
|
||||
{selectedOption?.name ?? "State"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
{...(customButton ? { customButton: stateLabel } : { label: stateLabel })}
|
||||
position={position}
|
||||
disabled={isNotAllowed}
|
||||
noChevron
|
||||
|
@ -20,6 +20,7 @@ type Props = {
|
||||
position?: "left" | "right";
|
||||
verticalPosition?: "top" | "bottom";
|
||||
customButton?: JSX.Element;
|
||||
menuItemsWhiteBg?: boolean;
|
||||
};
|
||||
|
||||
type MenuItemProps = {
|
||||
@ -44,6 +45,7 @@ const CustomMenu = ({
|
||||
position = "right",
|
||||
verticalPosition = "bottom",
|
||||
customButton,
|
||||
menuItemsWhiteBg = false,
|
||||
}: Props) => (
|
||||
<Menu as="div" className={`relative w-min whitespace-nowrap text-left ${className}`}>
|
||||
{({ open }) => (
|
||||
@ -105,7 +107,7 @@ const CustomMenu = ({
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={`absolute z-20 overflow-y-scroll whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-1 p-1 text-xs shadow-lg focus:outline-none ${
|
||||
className={`absolute z-20 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none ${
|
||||
position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"
|
||||
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
|
||||
height === "sm"
|
||||
@ -127,6 +129,10 @@ const CustomMenu = ({
|
||||
: width === "xl"
|
||||
? "w-48"
|
||||
: "min-w-full"
|
||||
} ${
|
||||
menuItemsWhiteBg
|
||||
? "border-brand-surface-1 bg-brand-base"
|
||||
: "border-brand-base bg-brand-surface-1"
|
||||
}`}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
|
@ -11,6 +11,7 @@ type Props = {
|
||||
placeholder?: string;
|
||||
displayShortForm?: boolean;
|
||||
error?: boolean;
|
||||
noBorder?: boolean;
|
||||
className?: string;
|
||||
isClearable?: boolean;
|
||||
disabled?: boolean;
|
||||
@ -23,6 +24,7 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
placeholder = "Select date",
|
||||
displayShortForm = false,
|
||||
error = false,
|
||||
noBorder = false,
|
||||
className = "",
|
||||
isClearable = true,
|
||||
disabled = false,
|
||||
@ -44,7 +46,9 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
: ""
|
||||
} ${error ? "border-red-500 bg-red-100" : ""} ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} w-full rounded-md border border-brand-base bg-transparent caret-transparent ${className}`}
|
||||
} ${
|
||||
noBorder ? "" : "border border-brand-base"
|
||||
} w-full rounded-md bg-transparent caret-transparent ${className}`}
|
||||
dateFormat="dd-MM-yyyy"
|
||||
isClearable={isClearable}
|
||||
disabled={disabled}
|
||||
|
@ -35,7 +35,7 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
||||
const [openChildFor, setOpenChildFor] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative z-10 inline-block text-left">
|
||||
<Menu as="div" className="relative z-30 inline-block text-left">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
|
@ -42,7 +42,7 @@ export const Tooltip: React.FC<Props> = ({
|
||||
disabled={disabled}
|
||||
content={
|
||||
<div
|
||||
className={`${className} relative flex max-w-[600px] flex-col items-start justify-center gap-1 rounded-md p-2 text-left text-xs shadow-md ${
|
||||
className={`${className} relative z-50 flex max-w-[600px] flex-col items-start justify-center gap-1 rounded-md p-2 text-left text-xs shadow-md ${
|
||||
theme === "light" ? "text-brand-muted-1 bg-brand-surface-2" : "bg-black text-white"
|
||||
}`}
|
||||
>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { IAnalyticsParams, IJiraMetadata } from "types";
|
||||
|
||||
const paramsToKey = (params: any) => {
|
||||
const { state, priority, assignees, created_by, labels, target_date } = params;
|
||||
const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params;
|
||||
|
||||
let stateKey = state ? state.split(",") : [];
|
||||
let priorityKey = priority ? priority.split(",") : [];
|
||||
@ -12,6 +12,7 @@ const paramsToKey = (params: any) => {
|
||||
const type = params.type ? params.type.toUpperCase() : "NULL";
|
||||
const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL";
|
||||
const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL";
|
||||
const subIssue = sub_issue ? sub_issue.toUpperCase() : "NULL";
|
||||
|
||||
// sorting each keys in ascending order
|
||||
stateKey = stateKey.sort().join("_");
|
||||
@ -20,7 +21,7 @@ const paramsToKey = (params: any) => {
|
||||
createdByKey = createdByKey.sort().join("_");
|
||||
labelsKey = labelsKey.sort().join("_");
|
||||
|
||||
return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}`;
|
||||
return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}_${subIssue}`;
|
||||
};
|
||||
|
||||
const inboxParamsToKey = (params: any) => {
|
||||
|
60
apps/app/constants/spreadsheet.ts
Normal file
60
apps/app/constants/spreadsheet.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
PlayIcon,
|
||||
Squares2X2Icon,
|
||||
TagIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export const SPREADSHEET_COLUMN = [
|
||||
{
|
||||
propertyName: "title",
|
||||
colName: "Title",
|
||||
colSize: "440px",
|
||||
},
|
||||
{
|
||||
propertyName: "state",
|
||||
colName: "State",
|
||||
colSize: "128px",
|
||||
icon: Squares2X2Icon,
|
||||
ascendingOrder: "state__name",
|
||||
descendingOrder: "-state__name",
|
||||
},
|
||||
{
|
||||
propertyName: "priority",
|
||||
colName: "Priority",
|
||||
colSize: "128px",
|
||||
},
|
||||
{
|
||||
propertyName: "assignee",
|
||||
colName: "Assignees",
|
||||
colSize: "128px",
|
||||
icon: UserGroupIcon,
|
||||
ascendingOrder: "assignees__name",
|
||||
descendingOrder: "-assignees__name",
|
||||
},
|
||||
{
|
||||
propertyName: "labels",
|
||||
colName: "Labels",
|
||||
colSize: "128px",
|
||||
icon: TagIcon,
|
||||
ascendingOrder: "labels__name",
|
||||
descendingOrder: "-labels__name",
|
||||
},
|
||||
{
|
||||
propertyName: "due_date",
|
||||
colName: "Due Date",
|
||||
colSize: "128px",
|
||||
icon: CalendarDaysIcon,
|
||||
ascendingOrder: "target_date",
|
||||
descendingOrder: "-target_date",
|
||||
},
|
||||
{
|
||||
propertyName: "estimate",
|
||||
colName: "Estimate",
|
||||
colSize: "128px",
|
||||
icon: PlayIcon,
|
||||
ascendingOrder: "estimate_point",
|
||||
descendingOrder: "-estimate_point",
|
||||
},
|
||||
];
|
125
apps/app/hooks/use-spreadsheet-issues-view.tsx
Normal file
125
apps/app/hooks/use-spreadsheet-issues-view.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// contexts
|
||||
import { issueViewContext } from "contexts/issue-view.context";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
import modulesService from "services/modules.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
const useSpreadsheetIssuesView = () => {
|
||||
const {
|
||||
issueView,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
filters,
|
||||
setFilters,
|
||||
resetFilterToDefault,
|
||||
setNewFilterDefaultView,
|
||||
setIssueView,
|
||||
} = useContext(issueViewContext);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const params: any = {
|
||||
order_by: orderBy,
|
||||
assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
|
||||
state: filters?.state ? filters?.state.join(",") : undefined,
|
||||
priority: filters?.priority ? filters?.priority.join(",") : undefined,
|
||||
type: filters?.type ? filters?.type : undefined,
|
||||
labels: filters?.labels ? filters?.labels.join(",") : undefined,
|
||||
issue__assignees__id: filters?.issue__assignees__id
|
||||
? filters?.issue__assignees__id.join(",")
|
||||
: undefined,
|
||||
issue__labels__id: filters?.issue__labels__id
|
||||
? filters?.issue__labels__id.join(",")
|
||||
: undefined,
|
||||
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
|
||||
sub_issue: "false",
|
||||
};
|
||||
|
||||
const { data: projectSpreadsheetIssues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleSpreadsheetIssues } = useSWR(
|
||||
workspaceSlug && projectId && cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
|
||||
: null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () =>
|
||||
cyclesService.getCycleIssuesWithParams(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
cycleId.toString(),
|
||||
params
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleSpreadsheetIssues } = useSWR(
|
||||
workspaceSlug && projectId && moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
|
||||
: null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () =>
|
||||
modulesService.getModuleIssuesWithParams(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
moduleId.toString(),
|
||||
params
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: viewSpreadsheetIssues } = useSWR(
|
||||
workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null,
|
||||
workspaceSlug && projectId && viewId && params
|
||||
? () =>
|
||||
issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params)
|
||||
: null
|
||||
);
|
||||
|
||||
const spreadsheetIssues = cycleId
|
||||
? (cycleSpreadsheetIssues as IIssue[])
|
||||
: moduleId
|
||||
? (moduleSpreadsheetIssues as IIssue[])
|
||||
: viewId
|
||||
? (viewSpreadsheetIssues as IIssue[])
|
||||
: (projectSpreadsheetIssues as IIssue[]);
|
||||
|
||||
return {
|
||||
issueView,
|
||||
spreadsheetIssues: spreadsheetIssues ?? [],
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
filters,
|
||||
setFilters,
|
||||
params,
|
||||
resetFilterToDefault,
|
||||
setNewFilterDefaultView,
|
||||
setIssueView,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default useSpreadsheetIssuesView;
|
34
apps/app/hooks/use-sub-issue.tsx
Normal file
34
apps/app/hooks/use-sub-issue.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { ISubIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
const useSubIssue = (issueId: string, isExpanded: boolean) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const shouldFetch = workspaceSlug && projectId && issueId && isExpanded;
|
||||
|
||||
const { data: subIssuesResponse, isLoading } = useSWR<ISubIssueResponse>(
|
||||
shouldFetch ? SUB_ISSUES(issueId as string) : null,
|
||||
shouldFetch
|
||||
? () =>
|
||||
issuesService.subIssues(workspaceSlug as string, projectId as string, issueId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return {
|
||||
subIssues: subIssuesResponse?.sub_issues ?? [],
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubIssue;
|
18
apps/app/types/issues.d.ts
vendored
18
apps/app/types/issues.d.ts
vendored
@ -247,11 +247,25 @@ export interface IIssueFilterOptions {
|
||||
created_by: string[] | null;
|
||||
}
|
||||
|
||||
export type TIssueViewOptions = "list" | "kanban" | "calendar" | "gantt_chart";
|
||||
export type TIssueViewOptions = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart";
|
||||
|
||||
export type TIssueGroupByOptions = "state" | "priority" | "labels" | "created_by" | null;
|
||||
|
||||
export type TIssueOrderByOptions = "-created_at" | "-updated_at" | "priority" | "sort_order";
|
||||
export type TIssueOrderByOptions =
|
||||
| "-created_at"
|
||||
| "-updated_at"
|
||||
| "priority"
|
||||
| "sort_order"
|
||||
| "state__name"
|
||||
| "-state__name"
|
||||
| "assignees__name"
|
||||
| "-assignees__name"
|
||||
| "labels__name"
|
||||
| "-labels__name"
|
||||
| "target_date"
|
||||
| "-target_date"
|
||||
| "estimate__point"
|
||||
| "-estimate__point";
|
||||
|
||||
export interface IIssueViewOptions {
|
||||
group_by: TIssueGroupByOptions;
|
||||
|
Loading…
Reference in New Issue
Block a user