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:
Anmol Singh Bhatia 2023-06-23 17:20:05 +05:30 committed by GitHub
parent 0cb856b92f
commit e08fc59114
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1093 additions and 69 deletions

View file

@ -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";

View file

@ -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}

View file

@ -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 />
)}

View file

@ -0,0 +1,4 @@
export * from "./spreadsheet-view";
export * from "./single-issue";
export * from "./spreadsheet-columns";
export * from "./spreadsheet-issues";

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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));

View file

@ -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));
}

View file

@ -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,6 +67,38 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
),
}));
const assigneeLabel = (
<Tooltip
position={`top-${tooltipPosition}`}
tooltipHeading="Assignees"
tooltipContent={
issue.assignee_details.length > 0
? issue.assignee_details
.map((assignee) =>
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
)
.join(", ")
: "No Assignee"
}
>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-2 text-brand-secondary`}
>
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList userIds={issue.assignees} length={5} showLength={true} />
</div>
) : (
<div className="flex items-center justify-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
</div>
)}
</div>
</Tooltip>
);
return (
<CustomSearchSelect
value={issue.assignees}
@ -90,37 +124,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
);
}}
options={options}
label={
<Tooltip
position={`top-${tooltipPosition}`}
tooltipHeading="Assignees"
tooltipContent={
issue.assignee_details.length > 0
? issue.assignee_details
.map((assignee) =>
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
)
.join(", ")
: "No Assignee"
}
>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-2 text-brand-secondary`}
>
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList userIds={issue.assignees} length={5} showLength={true} />
</div>
) : (
<div className="flex items-center justify-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
</div>
)}
</div>
</Tooltip>
}
{...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })}
multiple
noChevron
position={position}

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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"
}`}
>