From ece4d5b1ed5eb085405836210de1da7e92919efe Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:19:19 +0530 Subject: [PATCH 1/5] chore: Refactor Spreadsheet view for better code maintainability and performance (#3322) * refcator spreadsheet to use table and roow based approach rather than column based * update spreadsheet and optimized layout * fix issues in spread sheet * close quick action menu on click --------- Co-authored-by: Rahul R --- packages/ui/package.json | 1 + packages/ui/src/dropdowns/custom-menu.tsx | 60 +++-- packages/ui/src/dropdowns/helper.tsx | 2 + .../issue-layouts/list/list-view-types.d.ts | 1 + .../quick-action-dropdowns/all-issue.tsx | 18 +- .../quick-action-dropdowns/archived-issue.tsx | 14 +- .../quick-action-dropdowns/cycle-issue.tsx | 20 +- .../quick-action-dropdowns/module-issue.tsx | 18 +- .../quick-action-dropdowns/project-issue.tsx | 18 +- .../roots/all-issue-layout-root.tsx | 6 +- .../spreadsheet/base-spreadsheet-root.tsx | 40 +-- .../spreadsheet/columns/assignee-column.tsx | 62 ++--- .../spreadsheet/columns/attachment-column.tsx | 39 +-- .../spreadsheet/columns/columns-list.tsx | 176 ------------- .../spreadsheet/columns/created-on-column.tsx | 37 +-- .../spreadsheet/columns/due-date-column.tsx | 57 ++--- .../spreadsheet/columns/estimate-column.tsx | 61 ++--- .../spreadsheet/columns/header-column.tsx | 123 +++++++++ .../spreadsheet/columns/index.ts | 4 +- .../spreadsheet/columns/issue/index.ts | 2 - .../columns/issue/issue-column.tsx | 114 --------- .../issue/spreadsheet-issue-column.tsx | 81 ------ .../spreadsheet/columns/label-column.tsx | 77 ++---- .../spreadsheet/columns/link-column.tsx | 39 +-- .../spreadsheet/columns/priority-column.tsx | 55 ++--- .../spreadsheet/columns/start-date-column.tsx | 60 ++--- .../spreadsheet/columns/state-column.tsx | 63 ++--- .../spreadsheet/columns/sub-issue-column.tsx | 37 +-- .../spreadsheet/columns/updated-on-column.tsx | 42 +--- .../issues/issue-layouts/spreadsheet/index.ts | 1 - .../issue-layouts/spreadsheet/issue-row.tsx | 186 ++++++++++++++ .../spreadsheet/spreadsheet-column.tsx | 233 ------------------ .../spreadsheet/spreadsheet-header.tsx | 59 +++++ .../spreadsheet/spreadsheet-view.tsx | 173 +++++-------- web/constants/spreadsheet.ts | 44 +++- 35 files changed, 749 insertions(+), 1274 deletions(-) delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/issue-row.tsx delete mode 100644 web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index b643d47d4..def464623 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,6 +36,7 @@ "@headlessui/react": "^1.7.17", "@popperjs/core": "^2.11.8", "react-color": "^2.19.3", + "react-dom": "^18.2.0", "react-popper": "^2.3.0" } } diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index d6b0281ce..094a8092f 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -1,5 +1,5 @@ import * as React from "react"; - +import ReactDOM from "react-dom"; // react-poppper import { usePopper } from "react-popper"; // hooks @@ -29,8 +29,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { optionsClassName = "", verticalEllipsis = false, width = "auto", + portalElement, menuButtonOnClick, tabIndex, + closeOnSelect, } = props; const [referenceElement, setReferenceElement] = React.useState(null); @@ -51,6 +53,39 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); useOutsideClickDetector(dropdownRef, closeDropdown); + let menuItems = ( + { + if (closeOnSelect) closeDropdown(); + }} + static + > +
+ {children} +
+
+ ); + + if (portalElement) { + menuItems = ReactDOM.createPortal(menuItems, portalElement); + } + return ( { )} )} - {isOpen && ( - -
- {children} -
-
- )} + {isOpen && menuItems} )}
diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 618d5b6bd..9c0ae0566 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -24,6 +24,8 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + closeOnSelect?: boolean; + portalElement?: Element | null; } export interface ICustomSelectProps extends IDropdownProps { diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index 674ae92d1..9e3bb8701 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -4,4 +4,5 @@ export interface IQuickActionProps { handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; customActionButton?: React.ReactElement; + portalElement?: HTMLDivElement | null; } diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index efd9490d7..d8448cc0e 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -13,7 +13,7 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; export const AllIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton } = props; + const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -59,11 +59,15 @@ export const AllIssueQuickActions: React.FC = (props) => { if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -74,8 +78,6 @@ export const AllIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -87,8 +89,6 @@ export const AllIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > @@ -99,8 +99,6 @@ export const AllIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 8d6735277..100ae99db 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -12,7 +12,7 @@ import { copyUrlToClipboard } from "helpers/string.helper"; import { IQuickActionProps } from "../list/list-view-types"; export const ArchivedIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, customActionButton } = props; + const { issue, handleDelete, customActionButton, portalElement } = props; const router = useRouter(); const { workspaceSlug } = router.query; @@ -40,11 +40,15 @@ export const ArchivedIssueQuickActions: React.FC = (props) => handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -55,8 +59,6 @@ export const ArchivedIssueQuickActions: React.FC = (props) => { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 6d7e08152..7d708145b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -13,7 +13,7 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; export const CycleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; + const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -59,11 +59,15 @@ export const CycleIssueQuickActions: React.FC = (props) => { if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -74,8 +78,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit({ ...issue, cycle: cycleId?.toString() ?? null, @@ -90,8 +92,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); handleRemoveFromView && handleRemoveFromView(); }} > @@ -102,8 +102,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > @@ -114,8 +112,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 27d16c781..ac12c0d9b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -13,7 +13,7 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; export const ModuleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; + const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -59,11 +59,15 @@ export const ModuleIssueQuickActions: React.FC = (props) => { if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -74,8 +78,6 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null }); setCreateUpdateIssueModal(true); }} @@ -87,8 +89,6 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); handleRemoveFromView && handleRemoveFromView(); }} > @@ -99,8 +99,6 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 083a22e35..c0fa556b3 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -16,7 +16,7 @@ import { IQuickActionProps } from "../list/list-view-types"; import { EUserProjectRoles } from "constants/project"; export const ProjectIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton } = props; + const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -68,11 +68,15 @@ export const ProjectIssueQuickActions: React.FC = (props) => if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -85,8 +89,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => <> { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -98,8 +100,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > @@ -110,8 +110,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index 0b4e334c7..cf585a6fc 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; // hooks -import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; +import { useGlobalView, useIssues, useUser } from "hooks/store"; // components import { GlobalViewsAppliedFiltersRoot } from "components/issues"; import { SpreadsheetView } from "components/issues/issue-layouts"; @@ -37,9 +37,6 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); const { fetchAllGlobalViews } = useGlobalView(); - const { - workspace: { workspaceLabels }, - } = useLabel(); // derived values const currentIssueView = type ?? globalViewId; @@ -134,7 +131,6 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)} /> )} - labels={workspaceLabels || undefined} handleIssues={handleIssues} canEditProperties={canEditProperties} viewId={currentIssueView} diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 543e33aad..31c27e729 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -2,7 +2,7 @@ import { FC, useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useIssues, useLabel, useProjectState, useUser } from "hooks/store"; +import { useIssues, useUser } from "hooks/store"; // views import { SpreadsheetView } from "./spreadsheet-view"; // types @@ -40,10 +40,6 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { const { membership: { currentProjectRole }, } = useUser(); - const { - project: { projectLabels }, - } = useLabel(); - const { projectStates } = useProjectState(); // derived values const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issueStore?.viewFlags || {}; // user role validation @@ -86,27 +82,31 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [issueFiltersStore, projectId, workspaceSlug, viewId] ); + const renderQuickActions = useCallback( + (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + handleIssues(issue, EIssueActions.DELETE)} + handleUpdate={ + issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined + } + handleRemoveFromView={ + issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined + } + portalElement={portalElement} + /> + ), + [handleIssues] + ); + return ( ( - handleIssues(issue, EIssueActions.DELETE)} - handleUpdate={ - issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined - } - handleRemoveFromView={ - issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined - } - /> - )} - labels={projectLabels ?? []} - states={projectStates} + quickActions={renderQuickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} quickAddCallback={issueStore.quickAddIssue} diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index 89d8367f3..2656143ac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -1,56 +1,34 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { ProjectMemberDropdown } from "components/dropdowns"; // types import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetAssigneeColumn: React.FC = ({ issueId, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
- onChange(issueDetail, { assignee_ids: data })} - projectId={issueDetail?.project_id} - disabled={disabled} - multiple - placeholder="Assignees" - buttonVariant={issueDetail.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"} - buttonClassName="text-left" - buttonContainerClassName="w-full" - /> -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
+ onChange(issue, { assignee_ids: data })} + projectId={issue?.project_id} + disabled={disabled} + multiple + placeholder="Assignees" + buttonVariant={ + issue?.assignee_ids && issue.assignee_ids.length > 0 ? "transparent-without-text" : "transparent-with-text" + } + buttonClassName="text-left" + buttonContainerClassName="w-full" + /> +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx index 4b4bdbb53..c17a433b8 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx @@ -1,39 +1,18 @@ import React from "react"; -// hooks +import { observer } from "mobx-react-lite"; // types -import { useIssueDetail } from "hooks/store"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetAttachmentColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); +export const SpreadsheetAttachmentColumn: React.FC = observer((props) => { + const { issue } = props; return ( - <> -
- {issueDetail?.attachment_count} {issueDetail?.attachment_count === 1 ? "attachment" : "attachments"} -
- - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
- -
- ))} - +
+ {issue?.attachment_count} {issue?.attachment_count === 1 ? "attachment" : "attachments"} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx deleted file mode 100644 index e7f046796..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { observer } from "mobx-react-lite"; -// hooks -import { useProject } from "hooks/store"; -// components -import { SpreadsheetColumn } from "components/issues"; -// types -import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState } from "@plane/types"; - -type Props = { - displayFilters: IIssueDisplayFilterOptions; - displayProperties: IIssueDisplayProperties; - canEditProperties: (projectId: string | undefined) => boolean; - expandedIssues: string[]; - handleDisplayFilterUpdate: (data: Partial) => void; - handleUpdateIssue: (issue: TIssue, data: Partial) => void; - issues: TIssue[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; -}; - -export const SpreadsheetColumnsList: React.FC = observer((props) => { - const { - canEditProperties, - displayFilters, - displayProperties, - expandedIssues, - handleDisplayFilterUpdate, - handleUpdateIssue, - issues, - labels, - states, - } = props; - // store hooks - const { currentProjectDetails } = useProject(); - - const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - - return ( - <> - {displayProperties.state && ( - - )} - {displayProperties.priority && ( - - )} - {displayProperties.assignee && ( - - )} - {displayProperties.labels && ( - - )}{" "} - {displayProperties.start_date && ( - - )} - {displayProperties.due_date && ( - - )} - {displayProperties.estimate && isEstimateEnabled && ( - - )} - {displayProperties.created_on && ( - - )} - {displayProperties.updated_on && ( - - )} - {displayProperties.link && ( - - )} - {displayProperties.attachment_count && ( - - )} - {displayProperties.sub_issue_count && ( - - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx index 176b8ea14..8d373efb4 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx @@ -1,38 +1,19 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; // types +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetCreatedOnColumn: React.FC = ({ issueId, expandedIssues }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - +export const SpreadsheetCreatedOnColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> - {issueDetail && ( -
- {renderFormattedDate(issueDetail.created_at)} -
- )} - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
- -
- ))} - +
+ {renderFormattedDate(issue.created_at)} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 32c871b90..dbc27a3db 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -1,6 +1,5 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { DateDropdown } from "components/dropdowns"; // helpers @@ -9,49 +8,25 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetDueDateColumn: React.FC = ({ issueId, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
- onChange(issueDetail, { target_date: data ? renderFormattedPayloadDate(data) : null })} - disabled={disabled} - placeholder="Due date" - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
+ onChange(issue, { target_date: data ? renderFormattedPayloadDate(data) : null })} + disabled={disabled} + placeholder="Due date" + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index 041da65c6..50878ccce 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -1,56 +1,29 @@ -// hooks -import { useIssueDetail } from "hooks/store"; // components import { EstimateDropdown } from "components/dropdowns"; +import { observer } from "mobx-react-lite"; // types import { TIssue } from "@plane/types"; type Props = { - issueId: string; - onChange: (issue: TIssue, formData: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetEstimateColumn: React.FC = (props) => { - const { issueId, onChange, expandedIssues, disabled } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetEstimateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
- onChange(issueDetail, { estimate_point: data })} - projectId={issueDetail.project_id} - disabled={disabled} - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
+ onChange(issue, { estimate_point: data })} + projectId={issue.project_id} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx new file mode 100644 index 000000000..040000218 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -0,0 +1,123 @@ +//ui +import { CustomMenu } from "@plane/ui"; +import { + ArrowDownWideNarrow, + ArrowUpNarrowWide, + CheckIcon, + ChevronDownIcon, + Eraser, + ListFilter, + MoveRight, +} from "lucide-react"; +//hooks +import useLocalStorage from "hooks/use-local-storage"; +//types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueOrderByOptions } from "@plane/types"; +//constants +import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; + +interface Props { + property: keyof IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; +} + +export const SpreadsheetHeaderColumn = (props: Props) => { + const { displayFilters, handleDisplayFilterUpdate, property } = props; + + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( + "spreadsheetViewActiveSortingProperty", + "" + ); + const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; + + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + handleDisplayFilterUpdate({ order_by: order }); + + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + return ( + +
+ {} + {propertyDetails.title} +
+
+ {activeSortingProperty === property && ( +
+ +
+ )} +
+ + } + width="xl" + placement="bottom-end" + > + handleOrderBy(propertyDetails.ascendingOrderKey, property)}> +
+
+ + {propertyDetails.ascendingOrderTitle} + + {propertyDetails.descendingOrderTitle} +
+ + {selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` && } +
+
+ handleOrderBy(propertyDetails.descendingOrderKey, property)}> +
+
+ + {propertyDetails.descendingOrderTitle} + + {propertyDetails.ascendingOrderTitle} +
+ + {selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` && ( + + )} +
+
+ {selectedMenuItem && + selectedMenuItem !== "" && + displayFilters?.order_by !== "-created_at" && + selectedMenuItem.includes(property) && ( + handleOrderBy("-created_at", property)} + > +
+ + Clear sorting +
+
+ )} +
+ ); +}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts index a6c4979b3..acfd02fc5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts @@ -1,7 +1,5 @@ -export * from "./issue"; export * from "./assignee-column"; export * from "./attachment-column"; -export * from "./columns-list"; export * from "./created-on-column"; export * from "./due-date-column"; export * from "./estimate-column"; @@ -11,4 +9,4 @@ export * from "./priority-column"; export * from "./start-date-column"; export * from "./state-column"; export * from "./sub-issue-column"; -export * from "./updated-on-column"; +export * from "./updated-on-column"; \ No newline at end of file diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts deleted file mode 100644 index b8d09d1df..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./spreadsheet-issue-column"; -export * from "./issue-column"; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx deleted file mode 100644 index 612bba9df..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useRef, useState } from "react"; -import { useRouter } from "next/router"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; -// components -import { Tooltip } from "@plane/ui"; -// hooks -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// types -import { TIssue, IIssueDisplayProperties } from "@plane/types"; -import { useProject } from "hooks/store"; - -type Props = { - issue: TIssue; - expanded: boolean; - handleToggleExpand: (issueId: string) => void; - properties: IIssueDisplayProperties; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - canEditProperties: (projectId: string | undefined) => boolean; - nestingLevel: number; -}; - -export const IssueColumn: React.FC = ({ - issue, - expanded, - handleToggleExpand, - properties, - quickActions, - canEditProperties, - nestingLevel, -}) => { - // router - const router = useRouter(); - // hooks - const { getProjectById } = useProject(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - - const menuActionRef = useRef(null); - - const handleIssuePeekOverview = (issue: TIssue) => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, - }); - }; - - const paddingLeft = `${nestingLevel * 54}px`; - - useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); - - const customActionButton = ( -
setIsMenuActive(!isMenuActive)} - > - -
- ); - - return ( - <> -
- {properties.key && ( -
-
- - {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} - - - {canEditProperties(issue.project_id) && ( - - )} -
- - {issue.sub_issues_count > 0 && ( -
- -
- )} -
- )} -
- -
handleIssuePeekOverview(issue)} - > - {issue.name} -
-
-
-
- - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx deleted file mode 100644 index d906e522a..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from "react"; - -// components -import { IssueColumn } from "components/issues"; -// hooks -import { useIssueDetail } from "hooks/store"; -// types -import { TIssue, IIssueDisplayProperties } from "@plane/types"; - -type Props = { - issueId: string; - expandedIssues: string[]; - setExpandedIssues: React.Dispatch>; - properties: IIssueDisplayProperties; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - canEditProperties: (projectId: string | undefined) => boolean; - nestingLevel?: number; -}; - -export const SpreadsheetIssuesColumn: React.FC = ({ - issueId, - expandedIssues, - setExpandedIssues, - properties, - quickActions, - canEditProperties, - 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(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - return ( - <> - {issueDetail && ( - - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - - ))} - - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index a2fef5a5e..82015056e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -1,70 +1,39 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components import { IssuePropertyLabels } from "../../properties"; // hooks -import { useIssueDetail, useLabel } from "hooks/store"; +import { useLabel } from "hooks/store"; // types -import { TIssue, IIssueLabel } from "@plane/types"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - onChange: (issue: TIssue, formData: Partial) => void; - labels: IIssueLabel[] | undefined; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetLabelColumn: React.FC = (props) => { - const { issueId, onChange, labels, expandedIssues, disabled } = props; +export const SpreadsheetLabelColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; // hooks const { labelMap } = useLabel(); - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - const defaultLabelOptions = issueDetail?.label_ids?.map((id) => labelMap[id]) || []; + const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; return ( - <> - {issueDetail && ( - { - onChange(issueDetail, { label_ids: data }); - }} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="px-2.5 h-full" - hideDropdownArrow - maxRender={1} - disabled={disabled} - placeholderText="Select labels" - /> - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
- -
- ))} - + { + onChange(issue, { label_ids: data }); + }} + className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" + buttonClassName="px-2.5 h-full" + hideDropdownArrow + maxRender={1} + disabled={disabled} + placeholderText="Select labels" + /> ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx index a86dcedd7..2d3e7b670 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx @@ -1,39 +1,18 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // types +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetLinkColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetLinkColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
- {issueDetail?.link_count} {issueDetail?.link_count === 1 ? "link" : "links"} -
- - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
- -
- ))} - +
+ {issue?.link_count} {issue?.link_count === 1 ? "link" : "links"} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 5462a9e13..0a8321740 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -1,54 +1,29 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { PriorityDropdown } from "components/dropdowns"; // types import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetPriorityColumn: React.FC = (props) => { - const { issueId, onChange, expandedIssues, disabled } = props; - // store hooks - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - // derived values - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - const isExpanded = expandedIssues.indexOf(issueId) > -1; +export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
- onChange(issueDetail, { priority: data })} - disabled={disabled} - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - - ))} - +
+ onChange(issue, { priority: data })} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index 09248e320..778f9cdac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -1,6 +1,5 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { DateDropdown } from "components/dropdowns"; // helpers @@ -9,50 +8,25 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { TIssue } from "@plane/types"; type Props = { - issueId: string; - onChange: (issue: TIssue, formData: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetStartDateColumn: React.FC = ({ issueId, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetStartDateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
- onChange(issueDetail, { start_date: data ? renderFormattedPayloadDate(data) : null })} - disabled={disabled} - placeholder="Start date" - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
+ onChange(issue, { start_date: data ? renderFormattedPayloadDate(data) : null })} + disabled={disabled} + placeholder="Start date" + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 39508ca37..0050c8acf 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -1,59 +1,30 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { StateDropdown } from "components/dropdowns"; // types -import { TIssue, IState } from "@plane/types"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - states: IState[] | undefined; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetStateColumn: React.FC = (props) => { - const { issueId, onChange, states, expandedIssues, disabled } = props; - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); +export const SpreadsheetStateColumn: React.FC = observer((props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
- onChange(issueDetail, { state_id: data })} - disabled={disabled} - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
+ onChange(issue, { state_id: data })} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx index e641c1e01..c0e41d2c0 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx @@ -1,37 +1,18 @@ import React from "react"; +import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail } from "hooks/store"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetSubIssueColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
- {issueDetail?.sub_issues_count} {issueDetail?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
- -
- ))} - +
+ {issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx index 3ce036d69..f84989192 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx @@ -1,43 +1,19 @@ import React from "react"; -// hooks -// import useSubIssue from "hooks/use-sub-issue"; +import { observer } from "mobx-react-lite"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { useIssueDetail } from "hooks/store"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetUpdatedOnColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - +export const SpreadsheetUpdatedOnColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> - {issueDetail && ( -
- {renderFormattedDate(issueDetail.updated_at)} -
- )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
- -
- ))} - +
+ {renderFormattedDate(issue.updated_at)} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/index.ts b/web/components/issues/issue-layouts/spreadsheet/index.ts index 10fc26219..8f7c4a7fd 100644 --- a/web/components/issues/issue-layouts/spreadsheet/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/index.ts @@ -1,5 +1,4 @@ export * from "./columns"; export * from "./roots"; -export * from "./spreadsheet-column"; export * from "./spreadsheet-view"; export * from "./quick-add-issue-form"; diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx new file mode 100644 index 000000000..0091f000c --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -0,0 +1,186 @@ +import { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types"; +import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { Tooltip } from "@plane/ui"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useIssueDetail, useProject } from "hooks/store"; +import { useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { EIssueActions } from "../types"; +import { observer } from "mobx-react-lite"; + +interface Props { + displayProperties: IIssueDisplayProperties; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + canEditProperties: (projectId: string | undefined) => boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + portalElement: React.MutableRefObject; + nestingLevel: number; + issueId: string; +} + +export const SpreadsheetIssueRow = observer((props: Props) => { + const { + displayProperties, + issueId, + isEstimateEnabled, + nestingLevel, + portalElement, + handleIssues, + quickActions, + canEditProperties, + } = props; + // router + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const { getProjectById } = useProject(); + // states + const [isMenuActive, setIsMenuActive] = useState(false); + const [isExpanded, setExpanded] = useState(false); + + const menuActionRef = useRef(null); + + const handleIssuePeekOverview = (issue: TIssue) => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, + }); + }; + + const { subIssues: subIssuesStore, issue } = useIssueDetail(); + + const issueDetail = issue.getIssueById(issueId); + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + + const paddingLeft = `${nestingLevel * 54}px`; + + useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); + + const handleToggleExpand = () => { + setExpanded((prevState) => { + if (!prevState && workspaceSlug && issueDetail) + subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issueDetail.project_id, issueDetail.id); + return !prevState; + }); + }; + + const customActionButton = ( +
setIsMenuActive(!isMenuActive)} + > + +
+ ); + + if (!issueDetail) return null; + + const disableUserActions = !canEditProperties(issueDetail.project_id); + + return ( + <> + + {/* first column/ issue name and key column */} + + +
+
+ + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} + + + {canEditProperties(issueDetail.project_id) && ( + + )} +
+ + {issueDetail.sub_issues_count > 0 && ( +
+ +
+ )} +
+
+
+ +
handleIssuePeekOverview(issueDetail)} + > + {issueDetail.name} +
+
+
+ + {/* Rest of the columns */} + {SPREADSHEET_PROPERTY_LIST.map((property) => { + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + ) => + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE) + } + disabled={disableUserActions} + /> + + + ); + })} + + + {isExpanded && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssueId: string) => ( + + ))} + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx deleted file mode 100644 index 0a0fbe9c0..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - ArrowDownWideNarrow, - ArrowUpNarrowWide, - CheckIcon, - ChevronDownIcon, - Eraser, - ListFilter, - MoveRight, -} from "lucide-react"; -// hooks -import useLocalStorage from "hooks/use-local-storage"; -// components -import { - SpreadsheetAssigneeColumn, - SpreadsheetAttachmentColumn, - SpreadsheetCreatedOnColumn, - SpreadsheetDueDateColumn, - SpreadsheetEstimateColumn, - SpreadsheetLabelColumn, - SpreadsheetLinkColumn, - SpreadsheetPriorityColumn, - SpreadsheetStartDateColumn, - SpreadsheetStateColumn, - SpreadsheetSubIssueColumn, - SpreadsheetUpdatedOnColumn, -} from "components/issues"; -// ui -import { CustomMenu } from "@plane/ui"; -// types -import { TIssue, IIssueDisplayFilterOptions, IIssueLabel, IState, TIssueOrderByOptions } from "@plane/types"; -// constants -import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; - -type Props = { - canEditProperties: (projectId: string | undefined) => boolean; - displayFilters: IIssueDisplayFilterOptions; - expandedIssues: string[]; - handleDisplayFilterUpdate: (data: Partial) => void; - handleUpdateIssue: (issue: TIssue, data: Partial) => void; - issues: TIssue[] | undefined; - property: string; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; -}; - -export const SpreadsheetColumn: React.FC = (props) => { - const { - canEditProperties, - displayFilters, - expandedIssues, - handleDisplayFilterUpdate, - handleUpdateIssue, - issues, - property, - labels, - states, - } = props; - - const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( - "spreadsheetViewSorting", - "" - ); - const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( - "spreadsheetViewActiveSortingProperty", - "" - ); - - const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { - handleDisplayFilterUpdate({ order_by: order }); - - setSelectedMenuItem(`${order}_${itemKey}`); - setActiveSortingProperty(order === "-created_at" ? "" : itemKey); - }; - - const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; - - return ( -
-
- -
- {} - {propertyDetails.title} -
-
- {activeSortingProperty === property && ( -
- -
- )} -
-
- } - width="xl" - placement="bottom-end" - > - handleOrderBy(propertyDetails.ascendingOrderKey, property)}> -
-
- - {propertyDetails.ascendingOrderTitle} - - {propertyDetails.descendingOrderTitle} -
- - {selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` && ( - - )} -
-
- handleOrderBy(propertyDetails.descendingOrderKey, property)}> -
-
- - {propertyDetails.descendingOrderTitle} - - {propertyDetails.ascendingOrderTitle} -
- - {selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` && ( - - )} -
-
- {selectedMenuItem && - selectedMenuItem !== "" && - displayFilters?.order_by !== "-created_at" && - selectedMenuItem.includes(property) && ( - handleOrderBy("-created_at", property)} - > -
- - Clear sorting -
-
- )} - -
- -
- {issues?.map((issue) => { - const disableUserActions = !canEditProperties(issue.project_id); - return ( -
- {property === "state" ? ( - ) => handleUpdateIssue(issue, data)} - states={states} - /> - ) : property === "priority" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "estimate" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "assignee" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "labels" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "start_date" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "due_date" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "created_on" ? ( - - ) : property === "updated_on" ? ( - - ) : property === "link" ? ( - - ) : property === "attachment_count" ? ( - - ) : property === "sub_issue_count" ? ( - - ) : null} -
- ); - })} -
- - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx new file mode 100644 index 000000000..704c9f904 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -0,0 +1,59 @@ +// ui +import { LayersIcon } from "@plane/ui"; +// types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +// constants +import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { SpreadsheetHeaderColumn } from "./columns/header-column"; + + +interface Props { + displayProperties: IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; + isEstimateEnabled: boolean; +} + +export const SpreadsheetHeader = (props: Props) => { + const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled } = props; + + return ( + + + + + + #ID + + + + + Issue + + + + {SPREADSHEET_PROPERTY_LIST.map((property) => { + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + + + + ); + })} + + + ); +}; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 0e5d2ba94..adf1e53f4 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,20 +1,26 @@ import React, { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; // components -import { SpreadsheetColumnsList, SpreadsheetIssuesColumn, SpreadsheetQuickAddIssueForm } from "components/issues"; -import { Spinner, LayersIcon } from "@plane/ui"; +import { Spinner } from "@plane/ui"; +import { SpreadsheetQuickAddIssueForm } from "components/issues"; // types -import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState } from "@plane/types"; +import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; import { EIssueActions } from "../types"; +import { useProject } from "hooks/store"; +import { SpreadsheetHeader } from "./spreadsheet-header"; +import { SpreadsheetIssueRow } from "./issue-row"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; type Props = { displayProperties: IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; issues: TIssue[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; - quickActions: (issue: TIssue, customActionButton: any) => React.ReactNode; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; handleIssues: (issue: TIssue, action: EIssueActions) => Promise; openIssuesListModal?: (() => void) | null; quickAddCallback?: ( @@ -35,8 +41,6 @@ export const SpreadsheetView: React.FC = observer((props) => { displayFilters, handleDisplayFilterUpdate, issues, - labels, - states, quickActions, handleIssues, quickAddCallback, @@ -46,16 +50,36 @@ export const SpreadsheetView: React.FC = observer((props) => { disableIssueCreation, } = props; // states - const [expandedIssues, setExpandedIssues] = useState([]); - const [isScrolled, setIsScrolled] = useState(false); + const isScrolled = useRef(false); // refs - const containerRef = useRef(null); + const containerRef = useRef(null); + const portalRef = useRef(null); + + const { currentProjectDetails } = useProject(); + + const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; const handleScroll = () => { if (!containerRef.current) return; - const scrollLeft = containerRef.current.scrollLeft; - setIsScrolled(scrollLeft > 0); + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firtColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firtColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firtColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } }; useEffect(() => { @@ -76,105 +100,38 @@ export const SpreadsheetView: React.FC = observer((props) => { ); return ( -
-
-
- {issues && issues.length > 0 && ( - <> -
-
-
- {displayProperties.key && ( - - #ID - - )} - - - Issue - -
- - {issues.map((issue, index) => - issue ? ( - - ) : null - )} -
-
- - +
+
+ + + + {issues.map(({ id }) => ( + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE)} - issues={issues} - labels={labels} - states={states} + nestingLevel={0} + isEstimateEnabled={isEstimateEnabled} + handleIssues={handleIssues} + portalElement={portalRef} /> - + ))} + +
+
+
+
+ {enableQuickCreateIssue && !disableIssueCreation && ( + )} -
{/* empty div to show right most border */} -
- -
-
- {enableQuickCreateIssue && !disableIssueCreation && ( - - )} -
- - {/* {!disableUserActions && - !isInlineCreateIssueFormOpen && - (type === "issue" ? ( - - ) : ( - - - New Issue - - } - optionsClassName="left-5 !w-36" - noBorder - > - setIsInlineCreateIssueFormOpen(true)}> - Create new - - {openIssuesListModal && ( - Add an existing issue - )} - - ))} */}
diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 1f759b43c..6a5e55a62 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -1,8 +1,22 @@ -import { TIssueOrderByOptions } from "@plane/types"; +import { IIssueDisplayProperties, TIssue, TIssueOrderByOptions } from "@plane/types"; import { LayersIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; import { CalendarDays, Link2, Signal, Tag, Triangle, Paperclip, CalendarClock, CalendarCheck } from "lucide-react"; import { FC } from "react"; import { ISvgIcons } from "@plane/ui/src/icons/type"; +import { + SpreadsheetAssigneeColumn, + SpreadsheetAttachmentColumn, + SpreadsheetCreatedOnColumn, + SpreadsheetDueDateColumn, + SpreadsheetEstimateColumn, + SpreadsheetLabelColumn, + SpreadsheetLinkColumn, + SpreadsheetPriorityColumn, + SpreadsheetStartDateColumn, + SpreadsheetStateColumn, + SpreadsheetSubIssueColumn, + SpreadsheetUpdatedOnColumn, +} from "components/issues/issue-layouts/spreadsheet"; export const SPREADSHEET_PROPERTY_DETAILS: { [key: string]: { @@ -12,6 +26,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: TIssueOrderByOptions; descendingOrderTitle: string; icon: FC; + Column: React.FC<{ issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; disabled: boolean }>; }; } = { assignee: { @@ -21,6 +36,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-assignees__first_name", descendingOrderTitle: "Z", icon: UserGroupIcon, + Column: SpreadsheetAssigneeColumn, }, created_on: { title: "Created on", @@ -29,6 +45,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "created_at", descendingOrderTitle: "Old", icon: CalendarDays, + Column: SpreadsheetCreatedOnColumn, }, due_date: { title: "Due date", @@ -37,6 +54,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "target_date", descendingOrderTitle: "Old", icon: CalendarCheck, + Column: SpreadsheetDueDateColumn, }, estimate: { title: "Estimate", @@ -45,6 +63,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-estimate_point", descendingOrderTitle: "High", icon: Triangle, + Column: SpreadsheetEstimateColumn, }, labels: { title: "Labels", @@ -53,6 +72,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-labels__name", descendingOrderTitle: "Z", icon: Tag, + Column: SpreadsheetLabelColumn, }, priority: { title: "Priority", @@ -61,6 +81,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-priority", descendingOrderTitle: "Urgent", icon: Signal, + Column: SpreadsheetPriorityColumn, }, start_date: { title: "Start date", @@ -69,6 +90,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "start_date", descendingOrderTitle: "Old", icon: CalendarClock, + Column: SpreadsheetStartDateColumn, }, state: { title: "State", @@ -77,6 +99,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-state__name", descendingOrderTitle: "Z", icon: DoubleCircleIcon, + Column: SpreadsheetStateColumn, }, updated_on: { title: "Updated on", @@ -85,6 +108,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "updated_at", descendingOrderTitle: "Old", icon: CalendarDays, + Column: SpreadsheetUpdatedOnColumn, }, link: { title: "Link", @@ -93,6 +117,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "link_count", descendingOrderTitle: "Least", icon: Link2, + Column: SpreadsheetLinkColumn, }, attachment_count: { title: "Attachment", @@ -101,6 +126,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "attachment_count", descendingOrderTitle: "Least", icon: Paperclip, + Column: SpreadsheetAttachmentColumn, }, sub_issue_count: { title: "Sub-issue", @@ -109,5 +135,21 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "sub_issues_count", descendingOrderTitle: "Least", icon: LayersIcon, + Column: SpreadsheetSubIssueColumn, }, }; + +export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ + "state", + "priority", + "assignee", + "labels", + "start_date", + "due_date", + "estimate", + "created_on", + "updated_on", + "link", + "attachment_count", + "sub_issue_count", +]; From 3c9926d383719893e688aee5a3fa7698237d29de Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:21:41 +0530 Subject: [PATCH 2/5] update swr config to not fetch everything on focus (#3350) Co-authored-by: Rahul R --- .../roots/project-layout-root.tsx | 20 ++++++------------- web/constants/swr-config.ts | 3 +++ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index da9811c61..bfff19cd8 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -27,20 +27,12 @@ export const ProjectLayoutRoot: FC = observer(() => { // hooks const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const {} = useSWR( - workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, - async () => { - if (workspaceSlug && projectId) { - await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader" - ); - } - }, - { revalidateOnFocus: false, refreshInterval: 600000, revalidateOnMount: true } - ); + useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { + if (workspaceSlug && projectId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues(workspaceSlug.toString(), projectId.toString(), issues?.groupedIssueIds ? "mutation" : "init-loader"); + } + }); const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; diff --git a/web/constants/swr-config.ts b/web/constants/swr-config.ts index 063d5db54..38478fcea 100644 --- a/web/constants/swr-config.ts +++ b/web/constants/swr-config.ts @@ -1,5 +1,8 @@ export const SWR_CONFIG = { refreshWhenHidden: false, revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, errorRetryCount: 3, }; From 57d5ff7646d030f512482eac490fac720c9d48b6 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Thu, 11 Jan 2024 18:26:58 +0530 Subject: [PATCH 3/5] chore: Error Handling and Validation Updates (#3351) * fix: handled undefined issue_id in list layout * chore: updated label select dropdown in the issue detail * fix: peekoverview issue is resolved * chore: user role validation for issue details. * fix: Link, Attachement, parent mutation * build-error: build error resolved in peekoverview * chore: user role validation for issue details. * chore: user role validation for `issue description`, `parent`, `relation` and `subscription`. * chore: issue subscription mutation * chore: user role validation for `labels` in issue details. --------- Co-authored-by: Prateek Shourya --- .../issues/attachment/attachment-detail.tsx | 28 +-- .../issues/attachment/attachments-list.tsx | 20 ++- .../delete-attachment-confirmation-modal.tsx | 7 +- web/components/issues/attachment/root.tsx | 13 +- web/components/issues/description-form.tsx | 65 +++---- .../issue-detail/label/create-label.tsx | 8 +- .../issues/issue-detail/label/index.ts | 2 + .../issue-detail/label/label-list-item.tsx | 15 +- .../issues/issue-detail/label/label-list.tsx | 4 +- .../issues/issue-detail/label/root.tsx | 27 ++- .../issue-detail/label/select-existing.tsx | 9 - .../label/select/label-select.tsx | 159 ++++++++++++++++++ .../issues/issue-detail/label/select/root.tsx | 24 +++ .../issues/issue-detail/links/links.tsx | 9 +- .../issues/issue-detail/links/root.tsx | 15 +- .../issues/issue-detail/main-content.tsx | 14 +- .../issues/issue-detail/parent-select.tsx | 14 +- .../issues/issue-detail/relation-select.tsx | 34 ++-- web/components/issues/issue-detail/root.tsx | 25 ++- .../issues/issue-detail/sidebar.tsx | 45 ++--- .../issues/issue-detail/subscription.tsx | 27 ++- .../issues/peek-overview/properties.tsx | 2 - web/components/issues/peek-overview/root.tsx | 54 +++--- 23 files changed, 431 insertions(+), 189 deletions(-) delete mode 100644 web/components/issues/issue-detail/label/select-existing.tsx create mode 100644 web/components/issues/issue-detail/label/select/label-select.tsx create mode 100644 web/components/issues/issue-detail/label/select/root.tsx diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 58ded14e1..bd07f6a44 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -13,16 +13,20 @@ import { getFileIcon } from "components/icons"; import { truncateText } from "helpers/string.helper"; import { renderFormattedDate } from "helpers/date-time.helper"; import { convertBytesToSize, getFileExtension, getFileName } from "helpers/attachment.helper"; -// type -import { TIssueAttachmentsList } from "./attachments-list"; +// types +import { TAttachmentOperations } from "./root"; -export type TIssueAttachmentsDetail = TIssueAttachmentsList & { +type TAttachmentOperationsRemoveModal = Exclude; + +type TIssueAttachmentsDetail = { attachmentId: string; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; }; export const IssueAttachmentsDetail: FC = (props) => { // props - const { attachmentId, handleAttachmentOperations } = props; + const { attachmentId, handleAttachmentOperations, disabled } = props; // store hooks const { getUserDetails } = useMember(); const { @@ -75,13 +79,15 @@ export const IssueAttachmentsDetail: FC = (props) => {
- + {!disabled && ( + + )}
); diff --git a/web/components/issues/attachment/attachments-list.tsx b/web/components/issues/attachment/attachments-list.tsx index 6644d7e8c..2129a4f61 100644 --- a/web/components/issues/attachment/attachments-list.tsx +++ b/web/components/issues/attachment/attachments-list.tsx @@ -7,25 +7,35 @@ import { IssueAttachmentsDetail } from "./attachment-detail"; // types import { TAttachmentOperations } from "./root"; -export type TAttachmentOperationsRemoveModal = Exclude; +type TAttachmentOperationsRemoveModal = Exclude; -export type TIssueAttachmentsList = { +type TIssueAttachmentsList = { + issueId: string; handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; }; export const IssueAttachmentsList: FC = observer((props) => { - const { handleAttachmentOperations } = props; + const { issueId, handleAttachmentOperations, disabled } = props; // store hooks const { - attachment: { issueAttachments }, + attachment: { getAttachmentsByIssueId }, } = useIssueDetail(); + const issueAttachments = getAttachmentsByIssueId(issueId); + + if (!issueAttachments) return <>; + return ( <> {issueAttachments && issueAttachments.length > 0 && issueAttachments.map((attachmentId) => ( - + ))} ); diff --git a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx b/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx index 6c26bf850..e01d2828e 100644 --- a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx +++ b/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx @@ -8,12 +8,15 @@ import { Button } from "@plane/ui"; import { getFileName } from "helpers/attachment.helper"; // types import type { TIssueAttachment } from "@plane/types"; -import { TIssueAttachmentsList } from "./attachments-list"; +import { TAttachmentOperations } from "./root"; -type Props = TIssueAttachmentsList & { +export type TAttachmentOperationsRemoveModal = Exclude; + +type Props = { isOpen: boolean; setIsOpen: Dispatch>; data: TIssueAttachment; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; }; export const IssueAttachmentDeleteModal: FC = (props) => { diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index 209058f9f..79a6dc840 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -10,8 +10,7 @@ export type TIssueAttachmentRoot = { workspaceSlug: string; projectId: string; issueId: string; - is_archived: boolean; - is_editable: boolean; + disabled?: boolean; }; export type TAttachmentOperations = { @@ -21,7 +20,7 @@ export type TAttachmentOperations = { export const IssueAttachmentRoot: FC = (props) => { // props - const { workspaceSlug, projectId, issueId, is_archived, is_editable } = props; + const { workspaceSlug, projectId, issueId, disabled = false } = props; // hooks const { createAttachment, removeAttachment } = useIssueDetail(); const { setToastAlert } = useToast(); @@ -72,10 +71,14 @@ export const IssueAttachmentRoot: FC = (props) => {
+ -
); diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index cd678735d..8dc3d01d3 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -5,7 +5,7 @@ import useReloadConfirmations from "hooks/use-reload-confirmation"; import debounce from "lodash/debounce"; // components import { TextArea } from "@plane/ui"; -import { RichTextEditor } from "@plane/rich-text-editor"; +import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; // types import { TIssue } from "@plane/types"; import { TIssueOperations } from "./issue-detail"; @@ -29,7 +29,7 @@ export interface IssueDetailsProps { project_id?: string; }; issueOperations: TIssueOperations; - isAllowed: boolean; + disabled: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } @@ -37,7 +37,7 @@ export interface IssueDetailsProps { const fileService = new FileService(); export const IssueDescriptionForm: FC = (props) => { - const { workspaceSlug, projectId, issueId, issue, issueOperations, isAllowed, isSubmitting, setIsSubmitting } = props; + const { workspaceSlug, projectId, issueId, issue, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; // states const [characterLimit, setCharacterLimit] = useState(false); @@ -119,7 +119,7 @@ export const IssueDescriptionForm: FC = (props) => { return (
- {isAllowed ? ( + {!disabled ? ( = (props) => { className="min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary" hasError={Boolean(errors?.name)} role="textbox" - disabled={!isAllowed} /> )} /> ) : (

{issue.name}

)} - {characterLimit && isAllowed && ( + {characterLimit && !disabled && (
255 ? "text-red-500" : ""}`}> {watch("name").length} @@ -162,29 +161,37 @@ export const IssueDescriptionForm: FC = (props) => { ( - { - setShowAlert(true); - setIsSubmitting("submitting"); - onChange(description_html); - debouncedFormSave(); - }} - mentionSuggestions={mentionSuggestions} - mentionHighlights={mentionHighlights} - /> - )} + render={({ field: { onChange } }) => + !disabled ? ( + { + setShowAlert(true); + setIsSubmitting("submitting"); + onChange(description_html); + debouncedFormSave(); + }} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} + /> + ) : ( + + ) + } />
diff --git a/web/components/issues/issue-detail/label/create-label.tsx b/web/components/issues/issue-detail/label/create-label.tsx index 94af347d6..7babaee00 100644 --- a/web/components/issues/issue-detail/label/create-label.tsx +++ b/web/components/issues/issue-detail/label/create-label.tsx @@ -74,15 +74,11 @@ export const LabelCreate: FC = (props) => { return ( <>
- {isCreateToggle ? ( - - ) : ( - - )} + {isCreateToggle ? : }
{isCreateToggle ? "Cancel" : "New"}
diff --git a/web/components/issues/issue-detail/label/index.ts b/web/components/issues/issue-detail/label/index.ts index 005620ddd..83f1e73bc 100644 --- a/web/components/issues/issue-detail/label/index.ts +++ b/web/components/issues/issue-detail/label/index.ts @@ -3,3 +3,5 @@ export * from "./root"; export * from "./label-list"; export * from "./label-list-item"; export * from "./create-label"; +export * from "./select/root"; +export * from "./select/label-select"; diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx index 3368e9a56..926d287aa 100644 --- a/web/components/issues/issue-detail/label/label-list-item.tsx +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -10,10 +10,11 @@ type TLabelListItem = { issueId: string; labelId: string; labelOperations: TLabelOperations; + disabled: boolean; }; export const LabelListItem: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelId, labelOperations } = props; + const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props; // hooks const { issue: { getIssueById }, @@ -34,7 +35,9 @@ export const LabelListItem: FC = (props) => { return (
= (props) => { }} />
{label.name}
-
- -
+ {!disabled && ( +
+ +
+ )}
); }; diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx index b29e9b920..fd714e002 100644 --- a/web/components/issues/issue-detail/label/label-list.tsx +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -11,10 +11,11 @@ type TLabelList = { projectId: string; issueId: string; labelOperations: TLabelOperations; + disabled: boolean; }; export const LabelList: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelOperations } = props; + const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props; // hooks const { issue: { getIssueById }, @@ -33,6 +34,7 @@ export const LabelList: FC = (props) => { issueId={issueId} labelId={labelId} labelOperations={labelOperations} + disabled={disabled} /> ))} diff --git a/web/components/issues/issue-detail/label/root.tsx b/web/components/issues/issue-detail/label/root.tsx index f0ffdd19d..93e303f61 100644 --- a/web/components/issues/issue-detail/label/root.tsx +++ b/web/components/issues/issue-detail/label/root.tsx @@ -1,8 +1,7 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; // components -import { LabelList, LabelCreate } from "./"; - +import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./"; // hooks import { useIssueDetail, useLabel } from "hooks/store"; // types @@ -77,16 +76,26 @@ export const IssueLabel: FC = observer((props) => { projectId={projectId} issueId={issueId} labelOperations={labelOperations} + disabled={disabled} /> - {/*
select existing labels
*/} + {!disabled && ( + + )} - + {!disabled && ( + + )}
); }); diff --git a/web/components/issues/issue-detail/label/select-existing.tsx b/web/components/issues/issue-detail/label/select-existing.tsx deleted file mode 100644 index f4c287e86..000000000 --- a/web/components/issues/issue-detail/label/select-existing.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FC } from "react"; - -type TLabelExistingSelect = {}; - -export const LabelExistingSelect: FC = (props) => { - const {} = props; - - return <>; -}; diff --git a/web/components/issues/issue-detail/label/select/label-select.tsx b/web/components/issues/issue-detail/label/select/label-select.tsx new file mode 100644 index 000000000..c553ef333 --- /dev/null +++ b/web/components/issues/issue-detail/label/select/label-select.tsx @@ -0,0 +1,159 @@ +import { Fragment, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { usePopper } from "react-popper"; +import { Check, Search, Tag } from "lucide-react"; +// hooks +import { useIssueDetail, useLabel } from "hooks/store"; +// components +import { Combobox } from "@headlessui/react"; + +export interface IIssueLabelSelect { + workspaceSlug: string; + projectId: string; + issueId: string; + onSelect: (_labelIds: string[]) => void; +} + +export const IssueLabelSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, onSelect } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { + project: { fetchProjectLabels, projectLabels }, + } = useLabel(); + // states + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [query, setQuery] = useState(""); + + const issue = getIssueById(issueId); + + const fetchLabels = () => { + setIsLoading(true); + if (workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + }; + + const options = (projectLabels ?? []).map((label) => ({ + value: label.id, + query: label.name, + content: ( +
+ +
{label.name}
+
+ ), + })); + + const filteredOptions = + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const issueLabels = issue?.label_ids ?? []; + + const label = ( +
+
+ +
+
Select Label
+
+ ); + + if (!issue) return <>; + + return ( + <> + onSelect(value)} + multiple + > + + + + + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {isLoading ? ( +

Loading...

+ ) : filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ + selected ? "text-custom-text-100" : "text-custom-text-200" + }` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && ( +
+ +
+ )} + + )} +
+ )) + ) : ( + +

No matching results

+
+ )} +
+
+
+
+ + ); +}); diff --git a/web/components/issues/issue-detail/label/select/root.tsx b/web/components/issues/issue-detail/label/select/root.tsx new file mode 100644 index 000000000..c31e1bc61 --- /dev/null +++ b/web/components/issues/issue-detail/label/select/root.tsx @@ -0,0 +1,24 @@ +import { FC } from "react"; +// components +import { IssueLabelSelect } from "./label-select"; +// types +import { TLabelOperations } from "../root"; + +type TIssueLabelSelectRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; +}; + +export const IssueLabelSelectRoot: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations } = props; + + const handleLabel = async (_labelIds: string[]) => { + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds }); + }; + + return ( + + ); +}; diff --git a/web/components/issues/issue-detail/links/links.tsx b/web/components/issues/issue-detail/links/links.tsx index dbcb411ce..368bddb91 100644 --- a/web/components/issues/issue-detail/links/links.tsx +++ b/web/components/issues/issue-detail/links/links.tsx @@ -9,20 +9,25 @@ import { TLinkOperations } from "./root"; export type TLinkOperationsModal = Exclude; export type TIssueLinkList = { + issueId: string; linkOperations: TLinkOperationsModal; }; export const IssueLinkList: FC = observer((props) => { // props - const { linkOperations } = props; + const { issueId, linkOperations } = props; // hooks const { - link: { issueLinks }, + link: { getLinksByIssueId }, } = useIssueDetail(); const { membership: { currentProjectRole }, } = useUser(); + const issueLinks = getLinksByIssueId(issueId); + + if (!issueLinks) return <>; + return (
{issueLinks && diff --git a/web/components/issues/issue-detail/links/root.tsx b/web/components/issues/issue-detail/links/root.tsx index 5a0fb2bdf..1c226b7a7 100644 --- a/web/components/issues/issue-detail/links/root.tsx +++ b/web/components/issues/issue-detail/links/root.tsx @@ -19,13 +19,12 @@ export type TIssueLinkRoot = { workspaceSlug: string; projectId: string; issueId: string; - is_editable: boolean; - is_archived: boolean; + disabled?: boolean; }; export const IssueLinkRoot: FC = (props) => { // props - const { workspaceSlug, projectId, issueId, is_editable, is_archived } = props; + const { workspaceSlug, projectId, issueId, disabled = false } = props; // hooks const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail(); // state @@ -108,17 +107,17 @@ export const IssueLinkRoot: FC = (props) => { linkOperations={handleLinkOperations} /> -
+

Links

- {is_editable && ( + {!disabled && ( @@ -126,7 +125,7 @@ export const IssueLinkRoot: FC = (props) => {
- +
diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx index 116a0a006..6e7ac4289 100644 --- a/web/components/issues/issue-detail/main-content.tsx +++ b/web/components/issues/issue-detail/main-content.tsx @@ -11,8 +11,6 @@ import { SubIssuesRoot } from "../sub-issues"; import { StateGroupIcon } from "@plane/ui"; // types import { TIssueOperations } from "./root"; -// constants -import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; @@ -28,10 +26,7 @@ export const IssueMainContent: React.FC = observer((props) => { // states const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); + const { currentUser } = useUser(); const { getProjectById } = useProject(); const { projectStates } = useProjectState(); const { @@ -44,8 +39,6 @@ export const IssueMainContent: React.FC = observer((props) => { const projectDetails = projectId ? getProjectById(projectId) : null; const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - return ( <>
@@ -78,7 +71,7 @@ export const IssueMainContent: React.FC = observer((props) => { isSubmitting={isSubmitting} issue={issue} issueOperations={issueOperations} - isAllowed={isAllowed || !is_editable} + disabled={!is_editable} /> {currentUser && ( @@ -107,8 +100,7 @@ export const IssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - is_archived={is_archived} - is_editable={is_editable} + disabled={!is_editable} /> {/*
diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index ad1bb6dda..2a7fb3d83 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -30,16 +30,20 @@ export const IssueParentSelect: React.FC = observer( const issue = getIssueById(issueId); - const parentIssue = issue && issue.parent_id ? getIssueById(issue.parent_id) : undefined; + const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; const parentIssueProjectDetails = parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined; const handleParentIssue = async (_issueId: string | null = null) => { setUpdating(true); - await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }).finally(() => { + try { + await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); + await issueOperations.fetch(workspaceSlug, projectId, issueId); toggleParentIssueModal(false); setUpdating(false); - }); + } catch (error) { + console.error("something went wrong while fetching the issue"); + } }; if (!issue) return <>; @@ -61,14 +65,14 @@ export const IssueParentSelect: React.FC = observer( disabled={disabled} >
toggleParentIssueModal(true)}> - {parentIssue ? ( + {issue?.parent_id && parentIssue ? ( `${parentIssueProjectDetails?.identifier}-${parentIssue.sequence_id}` ) : ( Select issue )}
- {parentIssue && ( + {issue?.parent_id && parentIssue && !disabled && (
handleParentIssue(null)}>
diff --git a/web/components/issues/issue-detail/relation-select.tsx b/web/components/issues/issue-detail/relation-select.tsx index 801c04ebd..30a81f2dd 100644 --- a/web/components/issues/issue-detail/relation-select.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -126,22 +126,24 @@ export const IssueRelationSelect: React.FC = observer((pro {issueRelationObject[relationKey].icon(10)} {`${projectDetails?.identifier}-${currentIssue?.sequence_id}`} - + {!disabled && ( + + )}
); }) diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 876f55369..b52857e0a 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -8,12 +8,15 @@ import { EmptyState } from "components/common"; // images import emptyIssue from "public/empty-state/issue.svg"; // hooks -import { useIssueDetail } from "hooks/store"; +import { useIssueDetail, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // types import { TIssue } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; export type TIssueOperations = { + fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise; update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; @@ -27,16 +30,16 @@ export type TIssueDetailRoot = { projectId: string; issueId: string; is_archived?: boolean; - is_editable?: boolean; }; export const IssueDetailRoot: FC = (props) => { - const { workspaceSlug, projectId, issueId, is_archived = false, is_editable = true } = props; + const { workspaceSlug, projectId, issueId, is_archived = false } = props; // router const router = useRouter(); // hooks const { issue: { getIssueById }, + fetchIssue, updateIssue, removeIssue, addIssueToCycle, @@ -45,9 +48,19 @@ export const IssueDetailRoot: FC = (props) => { removeIssueFromModule, } = useIssueDetail(); const { setToastAlert } = useToast(); + const { + membership: { currentProjectRole }, + } = useUser(); const issueOperations: TIssueOperations = useMemo( () => ({ + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await fetchIssue(workspaceSlug, projectId, issueId); + } catch (error) { + console.error("Error fetching the parent issue"); + } + }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { await updateIssue(workspaceSlug, projectId, issueId, data); @@ -146,6 +159,7 @@ export const IssueDetailRoot: FC = (props) => { }, }), [ + fetchIssue, updateIssue, removeIssue, addIssueToCycle, @@ -156,7 +170,10 @@ export const IssueDetailRoot: FC = (props) => { ] ); + // Issue details const issue = getIssueById(issueId); + // Check if issue is editable, based on user role + const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( <> @@ -189,7 +206,7 @@ export const IssueDetailRoot: FC = (props) => { issueId={issueId} issueOperations={issueOperations} is_archived={is_archived} - is_editable={true} + is_editable={is_editable} />
diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index ce4071f06..a80f88730 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -25,8 +25,6 @@ import { ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon import { copyTextToClipboard } from "helpers/string.helper"; // types import type { TIssueOperations } from "./root"; -// fetch-keys -import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; @@ -72,10 +70,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const { inboxIssueId } = router.query; // store hooks const { getProjectById } = useProject(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); + const { currentUser } = useUser(); const { projectStates } = useProjectState(); const { areEstimatesEnabledForCurrentProject } = useEstimate(); const { setToastAlert } = useToast(); @@ -124,8 +119,6 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); return ( @@ -166,7 +159,6 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} currentUserId={currentUser?.id} - disabled={!isAllowed || !is_editable} /> )} @@ -193,7 +185,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
-
+
{showFirstSection && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( @@ -208,7 +200,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { value={issue?.state_id ?? undefined} onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} projectId={projectId?.toString() ?? ""} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} buttonVariant="background-with-text" />
@@ -228,7 +220,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val }) } - disabled={!isAllowed || !is_editable} + disabled={!is_editable} projectId={projectId?.toString() ?? ""} placeholder="Assignees" multiple @@ -252,7 +244,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} buttonVariant="background-with-text" />
@@ -274,7 +266,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val }) } projectId={projectId} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} buttonVariant="background-with-text" />
@@ -297,7 +289,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
@@ -309,7 +301,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="blocking" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -319,7 +311,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="blocked_by" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -329,7 +321,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="duplicate" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -339,7 +331,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="relates_to" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -358,7 +350,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { } className="border-none bg-custom-background-80" maxDate={maxDate ?? undefined} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
@@ -379,7 +371,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { } className="border-none bg-custom-background-80" minDate={minDate ?? undefined} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> @@ -401,7 +393,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> @@ -419,7 +411,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> @@ -429,7 +421,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( -
+

Label

@@ -439,7 +431,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
@@ -450,8 +442,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - is_editable={is_editable} - is_archived={is_archived} + disabled={!is_editable} /> )}
diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index 8f76eca25..7093f8627 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -5,17 +5,17 @@ import { observer } from "mobx-react-lite"; import { Button } from "@plane/ui"; // hooks import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; export type TIssueSubscription = { workspaceSlug: string; projectId: string; issueId: string; currentUserId: string; - disabled?: boolean; }; export const IssueSubscription: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, currentUserId, disabled } = props; + const { workspaceSlug, projectId, issueId, currentUserId } = props; // hooks const { issue: { getIssueById }, @@ -23,16 +23,32 @@ export const IssueSubscription: FC = observer((props) => { createSubscription, removeSubscription, } = useIssueDetail(); + const { setToastAlert } = useToast(); // state const [loading, setLoading] = useState(false); const issue = getIssueById(issueId); const subscription = getSubscriptionByIssueId(issueId); - const handleSubscription = () => { + const handleSubscription = async () => { setLoading(true); - if (subscription?.subscribed) removeSubscription(workspaceSlug, projectId, issueId); - else createSubscription(workspaceSlug, projectId, issueId); + try { + if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId); + else await createSubscription(workspaceSlug, projectId, issueId); + setToastAlert({ + type: "success", + title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, + message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, + }); + setLoading(false); + } catch (error) { + setLoading(false); + setToastAlert({ + type: "error", + title: "Error", + message: "Something went wrong. Please try again later.", + }); + } }; if (issue?.created_by === currentUserId || issue?.assignee_ids.includes(currentUserId)) return <>; @@ -45,7 +61,6 @@ export const IssueSubscription: FC = observer((props) => { variant="outline-primary" className="hover:!bg-custom-primary-100/20" onClick={handleSubscription} - disabled={disabled} > {loading ? "Loading..." : subscription?.subscribed ? "Unsubscribe" : "Subscribe"} diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 7f21f01b7..e6c6d88f3 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -258,8 +258,6 @@ export const PeekOverviewProperties: FC = observer((pro workspaceSlug={workspaceSlug?.toString() ?? ""} projectId={projectId?.toString() ?? ""} issueId={issue?.id} - is_editable={uneditable} - is_archived={isAllowed} /> diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 8253600fd..ab177ed8c 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -71,32 +71,6 @@ export const IssuePeekOverview: FC = observer((props) => { }); } }, [peekIssue, fetchIssue]); - if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <>; - - const issue = getIssueById(peekIssue.issueId) || undefined; - - const redirectToIssueDetail = () => { - router.push({ - pathname: `/${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${ - isArchived ? "archived-issues" : "issues" - }/${peekIssue.issueId}`, - }); - }; - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard( - `${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${isArchived ? "archived-issues" : "issues"}/${ - peekIssue.issueId - }` - ).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Issue link copied to clipboard.", - }); - }); - }; const issueOperations: TIssuePeekOperations = useMemo( () => ({ @@ -168,6 +142,34 @@ export const IssuePeekOverview: FC = observer((props) => { [addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule, setToastAlert] ); + if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <>; + + const issue = getIssueById(peekIssue.issueId) || undefined; + + const redirectToIssueDetail = () => { + router.push({ + pathname: `/${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${ + isArchived ? "archived-issues" : "issues" + }/${peekIssue.issueId}`, + }); + }; + + const handleCopyText = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + copyUrlToClipboard( + `${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${isArchived ? "archived-issues" : "issues"}/${ + peekIssue.issueId + }` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + const issueUpdate = async (_data: Partial) => { if (!issue) return; await updateIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, _data); From 3d489e186f4826eb95bdca7280ed4185d45998cd Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:29:41 +0530 Subject: [PATCH 4/5] fix: inline code blocks, code blocks and links have saner behaviour (#3318) * fix: removed backticks in inline code blocks * added better error handling while cancelling uploads * fix: inline code blocks, code blocks and links have saner behaviour - Inline code blocks are now exitable, don't have backticks, have better padding vertically and better regex matching - Code blocks on the top and bottom of the document are now exitable via Up and Down Arrow keys - Links are now exitable while being autolinkable via a custom re-write of the tiptap-link-extension * fix: more robust link checking --- packages/editor/core/package.json | 3 +- packages/editor/core/src/styles/editor.css | 5 + .../src/ui/extensions/code-inline/index.tsx | 31 +++ .../core/src/ui/extensions/code/index.tsx | 76 +++++- .../custom-link/helpers/autolink.ts | 118 ++++++++++ .../custom-link/helpers/clickHandler.ts | 42 ++++ .../custom-link/helpers/pasteHandler.ts | 52 +++++ .../src/ui/extensions/custom-link/index.tsx | 219 ++++++++++++++++++ .../editor/core/src/ui/extensions/index.tsx | 23 +- .../core/src/ui/read-only/extensions.tsx | 4 +- web/services/file.service.ts | 14 +- yarn.lock | 11 +- 12 files changed, 567 insertions(+), 31 deletions(-) create mode 100644 packages/editor/core/src/ui/extensions/code-inline/index.tsx create mode 100644 packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-link/index.tsx diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index ef2be61e3..7d640e333 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -30,10 +30,10 @@ "dependencies": { "@tiptap/core": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13", + "@tiptap/extension-code": "^2.1.13", "@tiptap/extension-code-block-lowlight": "^2.1.13", "@tiptap/extension-color": "^2.1.13", "@tiptap/extension-image": "^2.1.13", - "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-task-item": "^2.1.13", @@ -48,6 +48,7 @@ "clsx": "^1.2.1", "highlight.js": "^11.8.0", "jsx-dom-cjs": "^8.0.3", + "linkifyjs": "^4.1.3", "lowlight": "^3.0.0", "lucide-react": "^0.294.0", "react-moveable": "^0.54.2", diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index 86822664b..b0d2a1021 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -12,6 +12,11 @@ display: none; } +.ProseMirror code::before, +.ProseMirror code::after { + display: none; +} + .ProseMirror .is-empty::before { content: attr(data-placeholder); float: left; diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/core/src/ui/extensions/code-inline/index.tsx new file mode 100644 index 000000000..539dc9346 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code-inline/index.tsx @@ -0,0 +1,31 @@ +import { markInputRule, markPasteRule } from "@tiptap/core"; +import Code from "@tiptap/extension-code"; + +export const inputRegex = /(? { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + // Use ProseMirror's insertText transaction to insert the tab character + const tr = state.tr.insertText("\t", $from.pos, $from.pos); + editor.view.dispatch(tr); + + return true; + }, + ArrowUp: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtStart = $from.parentOffset === 0; + + if (!isAtStart) { + return false; + } + + // Check if codeBlock is the first node + const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0; + + if (isFirstNode) { + // Insert a new paragraph at the start of the document and move the cursor to it + return editor.commands.command(({ tr }) => { + const node = editor.schema.nodes.paragraph.create(); + tr.insert(0, node); + tr.setSelection(Selection.near(tr.doc.resolve(1))); + return true; + }); + } + + return false; + }, + ArrowDown: ({ editor }) => { + if (!this.options.exitOnArrowDown) { + return false; + } + const { state } = editor; const { selection, doc } = state; const { $from, empty } = selection; @@ -18,7 +69,28 @@ export const CustomCodeBlock = CodeBlockLowlight.extend({ return false; } - return editor.commands.insertContent(" "); + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + + if (!isAtEnd) { + return false; + } + + const after = $from.after(); + + if (after === undefined) { + return false; + } + + const nodeAfter = doc.nodeAt(after); + + if (nodeAfter) { + return editor.commands.command(({ tr }) => { + tr.setSelection(Selection.near(doc.resolve(after))); + return true; + }); + } + + return editor.commands.exitCode(); }, }; }, diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts new file mode 100644 index 000000000..cf67e13d9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts @@ -0,0 +1,118 @@ +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, + getMarksBetween, + NodeWithPos, +} from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type AutolinkOptions = { + type: MarkType; + validate?: (url: string) => boolean; +}; + +export function autolink(options: AutolinkOptions): Plugin { + return new Plugin({ + key: new PluginKey("autolink"), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); + const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink")); + + if (!docChanges || preventAutolink) { + return; + } + + const { tr } = newState; + const transform = combineTransactionSteps(oldState.doc, [...transactions]); + const changes = getChangedRanges(transform); + + changes.forEach(({ newRange }) => { + // Now let’s see if we can add new links. + const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock); + + let textBlock: NodeWithPos | undefined; + let textBeforeWhitespace: string | undefined; + + if (nodesInChangedRanges.length > 1) { + // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter). + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween( + textBlock.pos, + textBlock.pos + textBlock.node.nodeSize, + undefined, + " " + ); + } else if ( + nodesInChangedRanges.length && + // We want to make sure to include the block seperator argument to treat hard breaks like spaces. + newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ") + ) { + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " "); + } + + if (textBlock && textBeforeWhitespace) { + const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== ""); + + if (wordsBeforeWhitespace.length <= 0) { + return false; + } + + const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]; + const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace); + + if (!lastWordBeforeSpace) { + return false; + } + + find(lastWordBeforeSpace) + .filter((link) => link.isLink) + // Calculate link position. + .map((link) => ({ + ...link, + from: lastWordAndBlockOffset + link.start + 1, + to: lastWordAndBlockOffset + link.end + 1, + })) + // ignore link inside code mark + .filter((link) => { + if (!newState.schema.marks.code) { + return true; + } + + return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code); + }) + // validate link + .filter((link) => { + if (options.validate) { + return options.validate(link.value); + } + return true; + }) + // Add link mark. + .forEach((link) => { + if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) { + return; + } + + tr.addMark( + link.from, + link.to, + options.type.create({ + href: link.href, + }) + ); + }); + } + }); + + if (!tr.steps.length) { + return; + } + + return tr; + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts new file mode 100644 index 000000000..0854092a9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts @@ -0,0 +1,42 @@ +import { getAttributes } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +type ClickHandlerOptions = { + type: MarkType; +}; + +export function clickHandler(options: ClickHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handleClickLink"), + props: { + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return false; + } + + const eventTarget = event.target as HTMLElement; + + if (eventTarget.nodeName !== "A") { + return false; + } + + const attrs = getAttributes(view.state, options.type.name); + const link = event.target as HTMLLinkElement; + + const href = link?.href ?? attrs.href; + const target = link?.target ?? attrs.target; + + if (link && href) { + if (view.editable) { + window.open(href, target); + } + + return true; + } + + return false; + }, + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts new file mode 100644 index 000000000..83e38054c --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts @@ -0,0 +1,52 @@ +import { Editor } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type PasteHandlerOptions = { + editor: Editor; + type: MarkType; +}; + +export function pasteHandler(options: PasteHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handlePasteLink"), + props: { + handlePaste: (view, event, slice) => { + const { state } = view; + const { selection } = state; + const { empty } = selection; + + if (empty) { + return false; + } + + let textContent = ""; + + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + const link = find(textContent).find((item) => item.isLink && item.value === textContent); + + if (!textContent || !link) { + return false; + } + + const html = event.clipboardData?.getData("text/html"); + + const hrefRegex = /href="([^"]*)"/; + + const existingLink = html?.match(hrefRegex); + + const url = existingLink ? existingLink[1] : link.href; + + options.editor.commands.setMark(options.type, { + href: url, + }); + + return true; + }, + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/index.tsx b/packages/editor/core/src/ui/extensions/custom-link/index.tsx new file mode 100644 index 000000000..e66d18904 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/index.tsx @@ -0,0 +1,219 @@ +import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { find, registerCustomProtocol, reset } from "linkifyjs"; + +import { autolink } from "src/ui/extensions/custom-link/helpers/autolink"; +import { clickHandler } from "src/ui/extensions/custom-link/helpers/clickHandler"; +import { pasteHandler } from "src/ui/extensions/custom-link/helpers/pasteHandler"; + +export interface LinkProtocolOptions { + scheme: string; + optionalSlashes?: boolean; +} + +export interface LinkOptions { + autolink: boolean; + inclusive: boolean; + protocols: Array; + openOnClick: boolean; + linkOnPaste: boolean; + HTMLAttributes: Record; + validate?: (url: string) => boolean; +} + +declare module "@tiptap/core" { + interface Commands { + link: { + setLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + toggleLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + unsetLink: () => ReturnType; + }; + } +} + +export const CustomLinkExtension = Mark.create({ + name: "link", + + priority: 1000, + + keepOnSplit: false, + + onCreate() { + this.options.protocols.forEach((protocol) => { + if (typeof protocol === "string") { + registerCustomProtocol(protocol); + return; + } + registerCustomProtocol(protocol.scheme, protocol.optionalSlashes); + }); + }, + + onDestroy() { + reset(); + }, + + inclusive() { + return this.options.inclusive; + }, + + addOptions() { + return { + openOnClick: true, + linkOnPaste: true, + autolink: true, + inclusive: false, + protocols: [], + HTMLAttributes: { + target: "_blank", + rel: "noopener noreferrer nofollow", + class: null, + }, + validate: undefined, + }; + }, + + addAttributes() { + return { + href: { + default: null, + }, + target: { + default: this.options.HTMLAttributes.target, + }, + rel: { + default: this.options.HTMLAttributes.rel, + }, + class: { + default: this.options.HTMLAttributes.class, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "a[href]", + getAttrs: (node) => { + if (typeof node === "string" || !(node instanceof HTMLElement)) { + return null; + } + const href = node.getAttribute("href")?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return false; + } + return {}; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const href = HTMLAttributes.href?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0]; + } + return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setLink: + (attributes) => + ({ chain }) => + chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(), + + toggleLink: + (attributes) => + ({ chain }) => + chain() + .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) + .setMeta("preventAutolink", true) + .run(), + + unsetLink: + () => + ({ chain }) => + chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(), + }; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: (text) => + find(text) + .filter((link) => { + if (this.options.validate) { + return this.options.validate(link.value); + } + return true; + }) + .filter((link) => link.isLink) + .map((link) => ({ + text: link.value, + index: link.start, + data: link, + })), + type: this.type, + getAttributes: (match, pasteEvent) => { + const html = pasteEvent?.clipboardData?.getData("text/html"); + const hrefRegex = /href="([^"]*)"/; + + const existingLink = html?.match(hrefRegex); + + if (existingLink) { + return { + href: existingLink[1], + }; + } + + return { + href: match.data?.href, + }; + }, + }), + ]; + }, + + addProseMirrorPlugins() { + const plugins: Plugin[] = []; + + if (this.options.autolink) { + plugins.push( + autolink({ + type: this.type, + validate: this.options.validate, + }) + ); + } + + if (this.options.openOnClick) { + plugins.push( + clickHandler({ + type: this.type, + }) + ); + } + + if (this.options.linkOnPaste) { + plugins.push( + pasteHandler({ + editor: this.editor, + type: this.type, + }) + ); + } + + return plugins; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 396d0a821..fab0d5b74 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -1,5 +1,4 @@ import StarterKit from "@tiptap/starter-kit"; -import TiptapLink from "@tiptap/extension-link"; import TiptapUnderline from "@tiptap/extension-underline"; import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; @@ -19,13 +18,15 @@ import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; import { CustomKeymap } from "src/ui/extensions/keymap"; -import { CustomCodeBlock } from "src/ui/extensions/code"; +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; import { CustomQuoteExtension } from "src/ui/extensions/quote"; import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; import { DeleteImage } from "src/types/delete-image"; import { IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; +import { CustomCodeInlineExtension } from "./code-inline"; export const CoreEditorExtensions = ( mentionConfig: { @@ -52,12 +53,7 @@ export const CoreEditorExtensions = ( class: "leading-normal -mb-2", }, }, - code: { - HTMLAttributes: { - class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, + code: false, codeBlock: false, horizontalRule: false, dropcursor: { @@ -70,10 +66,12 @@ export const CoreEditorExtensions = ( }), CustomKeymap, ListKeymap, - TiptapLink.configure({ - autolink: false, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url), HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", @@ -92,13 +90,14 @@ export const CoreEditorExtensions = ( class: "not-prose pl-2", }, }), - CustomCodeBlock, TaskItem.configure({ HTMLAttributes: { class: "flex items-start my-4", }, nested: true, }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, Markdown.configure({ html: true, transformCopiedText: true, diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 5795d6c4a..b0879d8cd 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -1,5 +1,4 @@ import StarterKit from "@tiptap/starter-kit"; -import TiptapLink from "@tiptap/extension-link"; import TiptapUnderline from "@tiptap/extension-underline"; import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; @@ -18,6 +17,7 @@ import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image" import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionSuggestions: IMentionSuggestion[]; @@ -59,7 +59,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { gapcursor: false, }), Gapcursor, - TiptapLink.configure({ + CustomLinkExtension.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), HTMLAttributes: { diff --git a/web/services/file.service.ts b/web/services/file.service.ts index 6c75094cc..4085a7309 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -65,12 +65,16 @@ export class FileService extends APIService { getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { return async (file: File) => { - const formData = new FormData(); - formData.append("asset", file); - formData.append("attributes", JSON.stringify({})); + try { + const formData = new FormData(); + formData.append("asset", file); + formData.append("attributes", JSON.stringify({})); - const data = await this.uploadFile(workspaceSlug, formData); - return data.asset; + const data = await this.uploadFile(workspaceSlug, formData); + return data.asset; + } catch (e) { + console.error(e); + } }; } diff --git a/yarn.lock b/yarn.lock index 4318bee68..282a02537 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2492,13 +2492,6 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.1.13.tgz#1e9521dea002c8d6de833d9fd928d4617623eab8" integrity sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw== -"@tiptap/extension-link@^2.1.13": - version "2.1.13" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.1.13.tgz#ae4abd7c43292e3a1841488bfc7a687b2f014249" - integrity sha512-wuGMf3zRtMHhMrKm9l6Tft5M2N21Z0UP1dZ5t1IlOAvOeYV2QZ5UynwFryxGKLO0NslCBLF/4b/HAdNXbfXWUA== - dependencies: - linkifyjs "^4.1.0" - "@tiptap/extension-list-item@^2.1.13": version "2.1.13" resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.13.tgz#3c62127df97974f3196866ec00ee397f4c9acdc4" @@ -2802,7 +2795,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": +"@types/react@*", "@types/react@^18.2.42": version "18.2.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA== @@ -6071,7 +6064,7 @@ linkify-it@^5.0.0: dependencies: uc.micro "^2.0.0" -linkifyjs@^4.1.0: +linkifyjs@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.3.tgz#0edbc346428a7390a23ea2e5939f76112c9ae07f" integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg== From da1496fe6508aece1e1b724adc8a406b2b05b54d Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 11 Jan 2024 18:40:26 +0530 Subject: [PATCH 5/5] fix: create sync action (#3353) * fix: create sync action changes * fix: typo changes --- .github/workflows/create-sync-pr.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 0f85e940c..add08d1ed 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -3,14 +3,14 @@ name: Create Sync Action on: pull_request: branches: - - develop # Change this to preview + - preview types: - closed -env: +env: SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}} jobs: - create_pr: + sync_changes: # Only run the job when a PR is merged if: github.event.pull_request.merged == true runs-on: ubuntu-latest @@ -33,23 +33,14 @@ jobs: sudo apt update sudo apt install gh -y - - name: Create Pull Request + - name: Push Changes to Target Repo env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" - TARGET_BASE_BRANCH="${{ secrets.SYNC_TARGET_BASE_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH - - PR_TITLE=${{secrets.SYNC_PR_TITLE}} - - gh pr create \ - --base $TARGET_BASE_BRANCH \ - --head $TARGET_BRANCH \ - --title "$PR_TITLE" \ - --repo $TARGET_REPO