From 1b554119199fa955224d452d84ac11c2b9178434 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 3 May 2024 15:12:06 +0530 Subject: [PATCH] [WEB-1136] chore: Kanban drag and drop improvements (#4350) * Kanban DnD improvement * minor fixes for kanban dnd improvement * change scroll duration * fix feedback on the UX * add highlight before drop * add toast message explain drag and drop is currently disabled * Change warning dnd message * add comments * fix minor build error --- .../issue-layouts/kanban/base-kanban-root.tsx | 6 ++- .../issues/issue-layouts/kanban/block.tsx | 27 +++++++++---- .../issues/issue-layouts/kanban/default.tsx | 7 ++++ .../issue-layouts/kanban/kanban-group.tsx | 38 ++++++++++++++++--- .../issues/issue-layouts/kanban/swimlanes.tsx | 11 +++++- .../issues/issue-layouts/kanban/utils.ts | 28 ++++++++++++-- web/package.json | 1 + web/styles/globals.css | 5 +++ yarn.lock | 21 +++++++++- 9 files changed, 123 insertions(+), 21 deletions(-) diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 0d394cbaa..b33a74660 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -75,6 +75,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const sub_group_by = displayFilters?.sub_group_by; const group_by = displayFilters?.group_by; + const orderBy = displayFilters?.order_by; + const userDisplayFilters = displayFilters || null; const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan; @@ -157,7 +159,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas issues.getIssueIds, updateIssue, group_by, - sub_group_by + sub_group_by, + orderBy !== "sort_order" ).catch((err) => { setToast({ title: "Error", @@ -259,6 +262,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas displayProperties={displayProperties} sub_group_by={sub_group_by} group_by={group_by} + orderBy={orderBy} updateIssue={updateIssue} quickActions={renderQuickActions} handleKanbanFilters={handleKanbanFilters} diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 82e7dc19c..752cb670c 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -4,10 +4,11 @@ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-d import { observer } from "mobx-react-lite"; import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; // hooks -import { ControlLink, DropIndicator, Tooltip } from "@plane/ui"; +import { ControlLink, DropIndicator, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { cn } from "@/helpers/common.helper"; import { useApplication, useIssueDetail, useKanbanView, useProject } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // components import { TRenderQuickActions } from "../list/list-view-types"; @@ -131,6 +132,10 @@ export const KanbanIssueBlock: React.FC = observer((props) => { const isDragAllowed = !isDragDisabled && !issue?.tempId && canEditIssueProperties; + useOutsideClickDetector(cardRef, () => { + cardRef?.current?.classList?.remove("highlight"); + }); + // Make Issue block both as as Draggable and, // as a DropTarget for other issues being dragged to get the location of drop useEffect(() => { @@ -177,7 +182,15 @@ export const KanbanIssueBlock: React.FC = observer((props) => {
isDragAllowed && setIsCurrentBlockDragging(true)} + onDragStart={() => { + if (isDragAllowed) setIsCurrentBlockDragging(true); + else + setToast({ + type: TOAST_TYPE.WARNING, + title: "Cannot move issue", + message: "Drag and drop is disabled for the current grouping", + }); + }} > = observer((props) => { }`} ref={cardRef} className={cn( - "block rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400", - { - "hover:cursor-pointer": isDragAllowed, - "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id), - "bg-custom-background-80 z-[100]": isCurrentBlockDragging, - } + "block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400", + { "hover:cursor-pointer": isDragAllowed }, + { "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) }, + { "bg-custom-background-80 z-[100]": isCurrentBlockDragging } )} target="_blank" onClick={() => handleIssuePeekOverview(issue)} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 82aec5c5a..6378339ac 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -11,6 +11,7 @@ import { TUnGroupedIssues, TIssueKanbanFilters, TIssueGroupByOptions, + TIssueOrderByOptions, } from "@plane/types"; // constants // hooks @@ -31,6 +32,7 @@ export interface IGroupByKanBan { displayProperties: IIssueDisplayProperties | undefined; sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; + orderBy: TIssueOrderByOptions | undefined; sub_group_id: string; isDragDisabled: boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; @@ -79,6 +81,7 @@ const GroupByKanBan: React.FC = observer((props) => { handleOnDrop, showEmptyGroup = true, subGroupIssueHeaderCount, + orderBy, } = props; const member = useMember(); @@ -170,6 +173,7 @@ const GroupByKanBan: React.FC = observer((props) => { displayProperties={displayProperties} sub_group_by={sub_group_by} group_by={group_by} + orderBy={orderBy} sub_group_id={sub_group_id} isDragDisabled={isDragDisabled} updateIssue={updateIssue} @@ -196,6 +200,7 @@ export interface IKanBan { displayProperties: IIssueDisplayProperties | undefined; sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; + orderBy: TIssueOrderByOptions | undefined; sub_group_id?: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; @@ -242,6 +247,7 @@ export const KanBan: React.FC = observer((props) => { handleOnDrop, showEmptyGroup, subGroupIssueHeaderCount, + orderBy, } = props; const issueKanBanView = useKanbanView(); @@ -253,6 +259,7 @@ export const KanBan: React.FC = observer((props) => { displayProperties={displayProperties} group_by={group_by} sub_group_by={sub_group_by} + orderBy={orderBy} sub_group_id={sub_group_id} isDragDisabled={!issueKanBanView?.getCanUserDragDrop(group_by, sub_group_by)} updateIssue={updateIssue} diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 85f24d90b..c9e04c819 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -11,14 +11,21 @@ import { TSubGroupedIssues, TUnGroupedIssues, TIssueGroupByOptions, + TIssueOrderByOptions, } from "@plane/types"; +import { ISSUE_ORDER_BY_OPTIONS } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { useProjectState } from "@/hooks/store"; //components import { TRenderQuickActions } from "../list/list-view-types"; -import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; +import { + KanbanDropLocation, + getSourceFromDropPayload, + getDestinationFromDropPayload, + highlightIssueOnDrop, +} from "./utils"; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; interface IKanbanGroup { @@ -45,6 +52,7 @@ interface IKanbanGroup { groupByVisibilityToggle?: boolean; scrollableContainerRef?: MutableRefObject; handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; + orderBy: TIssueOrderByOptions | undefined; } export const KanbanGroup = (props: IKanbanGroup) => { @@ -52,6 +60,7 @@ export const KanbanGroup = (props: IKanbanGroup) => { groupId, sub_group_id, group_by, + orderBy, sub_group_by, issuesMap, displayProperties, @@ -101,13 +110,15 @@ export const KanbanGroup = (props: IKanbanGroup) => { if (!source || !destination) return; handleOnDrop(source, destination); + + highlightIssueOnDrop(payload.source.element.id, orderBy !== "sort_order"); }, }), autoScrollForElements({ element, }) ); - }, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn]); + }, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn, orderBy]); const prePopulateQuickAddData = ( groupByKey: string | undefined, @@ -161,16 +172,33 @@ export const KanbanGroup = (props: IKanbanGroup) => { return preloadedData; }; + const shouldOverlay = isDraggingOverColumn && orderBy !== "sort_order"; + const readableOrderBy = ISSUE_ORDER_BY_OPTIONS.find((orderByObj) => orderByObj.key === orderBy)?.title; + return (
+
+ {readableOrderBy && The layout is ordered by {readableOrderBy}.} + Drop here to move the issue. +
{ updateIssue={updateIssue} quickActions={quickActions} canEditProperties={canEditProperties} - scrollableContainerRef={scrollableContainerRef} + scrollableContainerRef={sub_group_by ? scrollableContainerRef : columnRef} /> {enableQuickIssueCreate && !disableIssueCreation && ( diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index ae881b9ed..5669ac3fb 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -11,6 +11,7 @@ import { TUnGroupedIssues, TIssueKanbanFilters, TIssueGroupByOptions, + TIssueOrderByOptions, } from "@plane/types"; // components import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; @@ -114,6 +115,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { disableIssueCreation?: boolean; storeType: KanbanStoreType; enableQuickIssueCreate: boolean; + orderBy: TIssueOrderByOptions | undefined; canEditProperties: (projectId: string | undefined) => boolean; addIssuesToView?: (issueIds: string[]) => Promise; quickAddCallback?: ( @@ -146,6 +148,7 @@ const SubGroupSwimlane: React.FC = observer((props) => { viewId, scrollableContainerRef, handleOnDrop, + orderBy, } = props; const calculateIssueCount = (column_id: string) => { @@ -181,7 +184,7 @@ const SubGroupSwimlane: React.FC = observer((props) => { if (subGroupByVisibilityToggle.showGroup === false) return <>; return (
-
+
= observer((props) => { viewId={viewId} scrollableContainerRef={scrollableContainerRef} handleOnDrop={handleOnDrop} + orderBy={orderBy} subGroupIssueHeaderCount={(groupByListId: string) => getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId) } @@ -254,6 +258,7 @@ export interface IKanBanSwimLanes { viewId?: string; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; + orderBy: TIssueOrderByOptions | undefined; } export const KanBanSwimLanes: React.FC = observer((props) => { @@ -263,6 +268,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { displayProperties, sub_group_by, group_by, + orderBy, updateIssue, storeType, quickActions, @@ -313,7 +319,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { return (
-
+
= observer((props) => { displayProperties={displayProperties} group_by={group_by} sub_group_by={sub_group_by} + orderBy={orderBy} updateIssue={updateIssue} quickActions={quickActions} kanbanFilters={kanbanFilters} diff --git a/web/components/issues/issue-layouts/kanban/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts index 3981455db..cf4745f63 100644 --- a/web/components/issues/issue-layouts/kanban/utils.ts +++ b/web/components/issues/issue-layouts/kanban/utils.ts @@ -1,4 +1,5 @@ import pull from "lodash/pull"; +import scrollIntoView from "smooth-scroll-into-view-if-needed"; import { IPragmaticDropPayload, TIssue, TIssueGroupByOptions } from "@plane/types"; import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; @@ -87,14 +88,17 @@ export const getDestinationFromDropPayload = (payload: IPragmaticDropPayload): K const handleSortOrder = ( destinationIssues: string[], destinationIssueId: string | undefined, - getIssueById: (issueId: string) => TIssue | undefined + getIssueById: (issueId: string) => TIssue | undefined, + shouldAddIssueAtTop = false ) => { const sortOrderDefaultValue = 65535; let currentIssueState = {}; const destinationIndex = destinationIssueId ? destinationIssues.indexOf(destinationIssueId) - : destinationIssues.length; + : shouldAddIssueAtTop + ? 0 + : destinationIssues.length; if (destinationIssues && destinationIssues.length > 0) { if (destinationIndex === 0) { @@ -145,7 +149,8 @@ export const handleDragDrop = async ( getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined, updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined, groupBy: TIssueGroupByOptions | undefined, - subGroupBy: TIssueGroupByOptions | undefined + subGroupBy: TIssueGroupByOptions | undefined, + shouldAddIssueAtTop = false ) => { if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return; @@ -165,7 +170,7 @@ export const handleDragDrop = async ( // for both horizontal and vertical dnd updatedIssue = { ...updatedIssue, - ...handleSortOrder(destinationIssues, destination.id, getIssueById), + ...handleSortOrder(destinationIssues, destination.id, getIssueById, shouldAddIssueAtTop), }; if (source.groupId && destination.groupId && source.groupId !== destination.groupId) { @@ -207,3 +212,18 @@ export const handleDragDrop = async ( ); } }; + +/** + * This Method finds the DOM element with elementId, scrolls to it and highlights the issue block + * @param elementId + * @param shouldScrollIntoView + */ +export const highlightIssueOnDrop = (elementId: string | undefined, shouldScrollIntoView = true) => { + setTimeout(async () => { + const sourceElementId = elementId ?? ""; + const sourceElement = document.getElementById(sourceElementId); + sourceElement?.classList?.add("highlight"); + if (shouldScrollIntoView && sourceElement) + await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 }); + }, 200); +}; diff --git a/web/package.json b/web/package.json index a4c94d051..881c2ceac 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "react-markdown": "^8.0.7", "react-popper": "^2.3.0", "sharp": "^0.32.1", + "smooth-scroll-into-view-if-needed": "^2.0.2", "swr": "^2.1.3", "tailwind-merge": "^2.0.0", "use-debounce": "^9.0.4", diff --git a/web/styles/globals.css b/web/styles/globals.css index 1c4d03854..c3f7a0baf 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -632,3 +632,8 @@ div.web-view-spinner div.bar12 { .scrollbar-lg::-webkit-scrollbar-thumb { border: 4px solid rgba(0, 0, 0, 0); } + +/* highlight class */ +.highlight { + border: 1px solid rgb(var(--color-primary-100)) !important; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index a33c24f88..17b430963 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2755,7 +2755,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== @@ -3699,6 +3699,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +compute-scroll-into-view@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87" + integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -7633,6 +7638,13 @@ schema-utils@^3.1.1: ajv "^6.12.5" ajv-keywords "^3.5.2" +scroll-into-view-if-needed@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz#fa9524518c799b45a2ef6bbffb92bcad0296d01f" + integrity sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ== + dependencies: + compute-scroll-into-view "^3.0.2" + selecto@~1.26.3: version "1.26.3" resolved "https://registry.yarnpkg.com/selecto/-/selecto-1.26.3.tgz#12f259112b943d395731524e3bb0115da7372212" @@ -7774,6 +7786,13 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +smooth-scroll-into-view-if-needed@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/smooth-scroll-into-view-if-needed/-/smooth-scroll-into-view-if-needed-2.0.2.tgz#5bd4ebef668474d6618ce8704650082e93068371" + integrity sha512-z54WzUSlM+xHHvJu3lMIsh+1d1kA4vaakcAtQvqzeGJ5Ffau7EKjpRrMHh1/OBo5zyU2h30ZYEt77vWmPHqg7Q== + dependencies: + scroll-into-view-if-needed "^3.1.0" + snake-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"