From 459487a8966171783862451be6839e4d4ac2c988 Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Sat, 2 Aug 2025 10:58:24 +0400 Subject: [PATCH] refactor: refactoring of deals and statuses dnd --- .../components/DndOverlay/DndOverlay.tsx | 37 ++ .../components/SortableItem/SortableItem.tsx | 4 +- .../components/StatusColumn/StatusColumn.tsx | 4 +- .../StatusColumnsDnd/StatusColumnsDnd.tsx | 321 ++---------------- src/app/deals/hooks/useDnd.ts | 226 ++++++++++++ src/app/deals/hooks/useGetNewRank.ts | 78 +++++ src/app/deals/hooks/useSensors.ts | 26 ++ src/app/deals/utils/statusId.ts | 6 + 8 files changed, 396 insertions(+), 306 deletions(-) create mode 100644 src/app/deals/components/DndOverlay/DndOverlay.tsx create mode 100644 src/app/deals/hooks/useDnd.ts create mode 100644 src/app/deals/hooks/useGetNewRank.ts create mode 100644 src/app/deals/hooks/useSensors.ts create mode 100644 src/app/deals/utils/statusId.ts diff --git a/src/app/deals/components/DndOverlay/DndOverlay.tsx b/src/app/deals/components/DndOverlay/DndOverlay.tsx new file mode 100644 index 0000000..a431224 --- /dev/null +++ b/src/app/deals/components/DndOverlay/DndOverlay.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core"; +import DealCard from "@/app/deals/components/DealCard/DealCard"; +import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn"; +import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; +import { DealSchema } from "@/types/DealSchema"; +import { StatusSchema } from "@/types/StatusSchema"; + +type Props = { + activeDeal: DealSchema | null; + activeStatus: StatusSchema | null; +}; + +const DndOverlay = ({ activeStatus, activeDeal }: Props) => { + const { deals } = useStatusesContext(); + + return ( + +
+ {activeDeal ? ( + + ) : activeStatus ? ( + deal.statusId === activeStatus.id + )} + isDragging + /> + ) : null} +
+
+ ); +}; + +export default DndOverlay; diff --git a/src/app/deals/components/SortableItem/SortableItem.tsx b/src/app/deals/components/SortableItem/SortableItem.tsx index 8a348a8..25c3af1 100644 --- a/src/app/deals/components/SortableItem/SortableItem.tsx +++ b/src/app/deals/components/SortableItem/SortableItem.tsx @@ -2,12 +2,12 @@ import React from "react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -type SortableTaskItemProps = { +type Props = { children: React.ReactNode; id: string; }; -const SortableItem = ({ children, id }: SortableTaskItemProps) => { +const SortableItem = ({ children, id }: Props) => { const { attributes, listeners, diff --git a/src/app/deals/components/StatusColumn/StatusColumn.tsx b/src/app/deals/components/StatusColumn/StatusColumn.tsx index 1d00219..d1199c2 100644 --- a/src/app/deals/components/StatusColumn/StatusColumn.tsx +++ b/src/app/deals/components/StatusColumn/StatusColumn.tsx @@ -10,14 +10,14 @@ import { DealSchema } from "@/types/DealSchema"; import { StatusSchema } from "@/types/StatusSchema"; import { sortByLexorank } from "@/utils/lexorank"; -type BoardSectionProps = { +type Props = { id: string; status: StatusSchema; deals: DealSchema[]; isDragging?: boolean; }; -const StatusColumn = ({ id, status, deals, isDragging }: BoardSectionProps) => { +const StatusColumn = ({ id, status, deals, isDragging }: Props) => { const { setNodeRef } = useDroppable({ id }); const sortedDeals = useMemo( () => sortByLexorank(deals.filter(deal => deal.statusId === status.id)), diff --git a/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx b/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx index 0fd263d..ad4452f 100644 --- a/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx +++ b/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx @@ -1,35 +1,18 @@ "use client"; -import React, { FC, useMemo, useState } from "react"; -import { - closestCorners, - defaultDropAnimation, - DndContext, - DragOverEvent, - DragOverlay, - DragStartEvent, - KeyboardSensor, - Over, - PointerSensor, - TouchSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; +import React, { FC } from "react"; +import { closestCorners, DndContext } from "@dnd-kit/core"; import { horizontalListSortingStrategy, SortableContext, - sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; -import { LexoRank } from "lexorank"; -import { throttle } from "lodash"; import { Group, ScrollArea } from "@mantine/core"; -import DealCard from "@/app/deals/components/DealCard/DealCard"; +import DndOverlay from "@/app/deals/components/DndOverlay/DndOverlay"; import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; +import useDnd from "@/app/deals/hooks/useDnd"; import { SortableItem } from "@/components/SortableDnd/SortableItem"; -import { DealSchema } from "@/types/DealSchema"; -import { StatusSchema } from "@/types/StatusSchema"; -import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; +import useDndSensors from "../../hooks/useSensors"; type Props = { onDealDragEnd: ( @@ -41,270 +24,18 @@ type Props = { }; const StatusColumnsDnd: FC = props => { - const { statuses, deals, setDeals, setStatuses } = useStatusesContext(); - const [activeDeal, setActiveDeal] = useState(null); - const [activeStatus, setActiveStatus] = useState(null); - const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]); + const { deals } = useStatusesContext(); - const throttledSetStatuses = useMemo( - () => throttle(setStatuses, 200), - [setStatuses] - ); - const throttledSetDeals = useMemo( - () => throttle(setDeals, 200), - [setDeals] - ); + const { + sortedStatuses, + handleDragStart, + handleDragOver, + handleDragEnd, + activeStatus, + activeDeal, + } = useDnd(props); - const sensorOptions = { - activationConstraint: { - distance: 5, - }, - }; - - const sensors = useSensors( - useSensor(PointerSensor, sensorOptions), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - useSensor(TouchSensor, sensorOptions) - ); - - const handleDragStart = ({ active }: DragStartEvent) => { - const activeId = active.id as string | number; - - if (typeof activeId === "string" && activeId.endsWith("-status")) { - const statusId = Number(activeId.replace("-status", "")); - setActiveStatus( - statuses.find(status => status.id === statusId) ?? null - ); - } else { - setActiveDeal( - deals.find(deal => deal.id === (activeId as number)) ?? null - ); - } - }; - - const getStatusByDealId = (dealId: number) => { - const deal = deals.find(deal => deal.id === dealId); - if (!deal) return; - return statuses.find(status => status.id === deal.statusId); - }; - - const handleDragOver = ({ active, over }: DragOverEvent) => { - if (!over) return; - const activeId = active.id as string | number; - - if (typeof activeId === "string" && activeId.endsWith("-status")) { - handleColumnDragOver(activeId, over); - } else { - handleDealDragOver(activeId, over); - } - }; - - const handleDealDragOver = (activeId: string | number, over: Over) => { - const activeDealId = Number(activeId); - const activeStatusId = getStatusByDealId(activeDealId)?.id; - if (!activeStatusId) return; - - const { overStatusId, newLexorank } = getDropTarget( - over.id, - activeDealId, - activeStatusId - ); - if (!overStatusId) return; - - throttledSetDeals(deals => - deals.map(deal => - deal.id === activeDealId - ? { - ...deal, - statusId: overStatusId, - rank: newLexorank || deal.rank, - } - : deal - ) - ); - }; - - const handleColumnDragOver = (activeId: string, over: Over) => { - const activeStatusId = Number(activeId.replace("-status", "")); - let overStatusId: number; - - if (typeof over.id === "string" && over.id.endsWith("-status")) { - overStatusId = Number(over.id.replace("-status", "")); - } else { - const deal = deals.find(deal => deal.id === over.id); - if (!deal) return; - overStatusId = deal.statusId; - } - - if (!overStatusId || activeStatusId === overStatusId) return; - - const newRank = getNewStatusRank(activeStatusId, overStatusId); - if (!newRank) return; - - throttledSetStatuses(statuses => - statuses.map(status => - status.id === activeStatusId - ? { ...status, rank: newRank } - : status - ) - ); - }; - - const getDropTarget = ( - overId: string | number, - activeDealId: number, - activeStatusId: number - ) => { - if (typeof overId === "string") { - return { - overStatusId: Number(overId.replace(/-status$/, "")), - newLexorank: undefined, - }; - } - - const overDealId = Number(overId); - const overStatusId = getStatusByDealId(overDealId)?.id; - - if (!overStatusId || activeDealId === overDealId) { - return { overStatusId: undefined, newLexorank: undefined }; - } - - const statusDeals = sortByLexorank( - deals.filter(deal => deal.statusId === overStatusId) - ); - const overDealIndex = statusDeals.findIndex( - deal => deal.id === overDealId - ); - - let newLexorank; - if (activeStatusId === overStatusId) { - newLexorank = getNewRankForSameStatus( - statusDeals, - overDealIndex, - activeDealId - ); - } else { - newLexorank = getNewRankForAnotherStatus( - statusDeals, - overDealIndex - ); - } - - return { overStatusId, newLexorank }; - }; - - const getNewRankForSameStatus = ( - statusDeals: DealSchema[], - overDealIndex: number, - activeDealId: number - ) => { - const activeDealIndex = statusDeals.findIndex( - deal => deal.id === activeDealId - ); - const [leftIndex, rightIndex] = - overDealIndex < activeDealIndex - ? [overDealIndex - 1, overDealIndex] - : [overDealIndex, overDealIndex + 1]; - - const leftLexorank = - leftIndex >= 0 ? LexoRank.parse(deals[leftIndex].rank) : null; - const rightLexorank = - rightIndex < deals.length - ? LexoRank.parse(deals[rightIndex].rank) - : null; - - return getNewLexorank(leftLexorank, rightLexorank).toString(); - }; - - const getNewRankForAnotherStatus = ( - statusDeals: DealSchema[], - overDealIndex: number - ) => { - const leftLexorank = - overDealIndex > 0 - ? LexoRank.parse(statusDeals[overDealIndex - 1].rank) - : null; - const rightLexorank = LexoRank.parse(statusDeals[overDealIndex].rank); - - return getNewLexorank(leftLexorank, rightLexorank).toString(); - }; - - const getNewStatusRank = (activeStatusId: number, overStatusId: number) => { - const sortedStatusList = sortByLexorank(statuses); - const overIndex = sortedStatusList.findIndex( - s => s.id === overStatusId - ); - const activeIndex = sortedStatusList.findIndex( - s => s.id === activeStatusId - ); - - if (overIndex === -1 || activeIndex === -1) return null; - - const [leftIndex, rightIndex] = - overIndex < activeIndex - ? [overIndex - 1, overIndex] - : [overIndex, overIndex + 1]; - - const leftLexorank = - leftIndex >= 0 ? LexoRank.parse(statuses[leftIndex].rank) : null; - const rightLexorank = - rightIndex < statuses.length - ? LexoRank.parse(statuses[rightIndex].rank) - : null; - - return getNewLexorank(leftLexorank, rightLexorank).toString(); - }; - - const handleDragEnd = ({ active, over }: DragOverEvent) => { - setActiveDeal(null); - setActiveStatus(null); - if (!over) return; - - const activeId: string | number = active.id; - - if (typeof activeId === "string" && activeId.endsWith("-status")) { - handleStatusColumnDragEnd(activeId, over); - } else { - handleDealDragEnd(activeId, over); - } - }; - - const handleStatusColumnDragEnd = (activeId: string, over: Over) => { - const activeStatusId = Number(activeId.replace("-status", "")); - let overStatusId: number; - - if (typeof over.id === "string" && over.id.endsWith("-status")) { - overStatusId = Number(over.id.replace("-status", "")); - } else { - const deal = deals.find(deal => deal.statusId === over.id); - if (!deal) return; - overStatusId = deal.statusId; - } - - if (!overStatusId || activeStatusId === overStatusId) return; - - const newRank = getNewStatusRank(activeStatusId, overStatusId); - if (!newRank) return; - - props.onStatusDragEnd?.(activeStatusId, newRank); - }; - - const handleDealDragEnd = (activeId: number | string, over: Over) => { - const activeDealId = Number(activeId); - const activeStatusId = getStatusByDealId(activeDealId)?.id; - if (!activeStatusId) return; - - const { overStatusId, newLexorank } = getDropTarget( - over.id, - activeDealId, - activeStatusId - ); - if (!overStatusId) return; - - props.onDealDragEnd(activeDealId, overStatusId, newLexorank); - }; + const sensors = useDndSensors(); return ( = props => { /> ))} - -
- {activeDeal ? ( - - ) : activeStatus ? ( - - deal.statusId === - activeStatus.id - )} - isDragging - /> - ) : null} -
-
+ diff --git a/src/app/deals/hooks/useDnd.ts b/src/app/deals/hooks/useDnd.ts new file mode 100644 index 0000000..00d3206 --- /dev/null +++ b/src/app/deals/hooks/useDnd.ts @@ -0,0 +1,226 @@ +import { useMemo, useState } from "react"; +import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core"; +import { throttle } from "lodash"; +import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; +import useGetNewRank from "@/app/deals/hooks/useGetNewRank"; +import { getStatusId, isStatusId } from "@/app/deals/utils/statusId"; +import { DealSchema } from "@/types/DealSchema"; +import { StatusSchema } from "@/types/StatusSchema"; +import { sortByLexorank } from "@/utils/lexorank"; + +type Props = { + onDealDragEnd: ( + dealId: number, + statusId: number, + lexorank?: string + ) => void; + onStatusDragEnd: (statusId: number, lexorank: string) => void; +}; + +const useDnd = (props: Props) => { + const [activeDeal, setActiveDeal] = useState(null); + const [activeStatus, setActiveStatus] = useState(null); + const { statuses, deals, setDeals, setStatuses } = useStatusesContext(); + const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]); + + const { + getNewRankForSameStatus, + getNewRankForAnotherStatus, + getNewStatusRank, + } = useGetNewRank(); + + const throttledSetStatuses = useMemo( + () => throttle(setStatuses, 200), + [setStatuses] + ); + const throttledSetDeals = useMemo( + () => throttle(setDeals, 200), + [setDeals] + ); + + const getStatusByDealId = (dealId: number) => { + const deal = deals.find(deal => deal.id === dealId); + if (!deal) return; + return statuses.find(status => status.id === deal.statusId); + }; + + const handleDragOver = ({ active, over }: DragOverEvent) => { + if (!over) return; + const activeId = active.id as string | number; + + if (typeof activeId === "string" && isStatusId(activeId)) { + handleColumnDragOver(activeId, over); + } else { + handleDealDragOver(activeId, over); + } + }; + + const handleDealDragOver = (activeId: string | number, over: Over) => { + const activeDealId = Number(activeId); + const activeStatusId = getStatusByDealId(activeDealId)?.id; + if (!activeStatusId) return; + + const { overStatusId, newLexorank } = getDropTarget( + over.id, + activeDealId, + activeStatusId + ); + if (!overStatusId) return; + + throttledSetDeals(deals => + deals.map(deal => + deal.id === activeDealId + ? { + ...deal, + statusId: overStatusId, + rank: newLexorank || deal.rank, + } + : deal + ) + ); + }; + + const handleColumnDragOver = (activeId: string, over: Over) => { + const activeStatusId = getStatusId(activeId); + let overStatusId: number; + + if (typeof over.id === "string" && isStatusId(over.id)) { + overStatusId = getStatusId(over.id); + } else { + const deal = deals.find(deal => deal.id === over.id); + if (!deal) return; + overStatusId = deal.statusId; + } + + if (!overStatusId || activeStatusId === overStatusId) return; + + const newRank = getNewStatusRank(activeStatusId, overStatusId); + if (!newRank) return; + + throttledSetStatuses(statuses => + statuses.map(status => + status.id === activeStatusId + ? { ...status, rank: newRank } + : status + ) + ); + }; + + const getDropTarget = ( + overId: string | number, + activeDealId: number, + activeStatusId: number + ) => { + if (typeof overId === "string") { + return { + overStatusId: getStatusId(overId), + newLexorank: undefined, + }; + } + + const overDealId = Number(overId); + const overStatusId = getStatusByDealId(overDealId)?.id; + + if (!overStatusId || activeDealId === overDealId) { + return { overStatusId: undefined, newLexorank: undefined }; + } + + const statusDeals = sortByLexorank( + deals.filter(deal => deal.statusId === overStatusId) + ); + const overDealIndex = statusDeals.findIndex( + deal => deal.id === overDealId + ); + + let newLexorank; + if (activeStatusId === overStatusId) { + newLexorank = getNewRankForSameStatus( + statusDeals, + overDealIndex, + activeDealId + ); + } else { + newLexorank = getNewRankForAnotherStatus( + statusDeals, + overDealIndex + ); + } + + return { overStatusId, newLexorank }; + }; + + const handleDragEnd = ({ active, over }: DragOverEvent) => { + setActiveDeal(null); + setActiveStatus(null); + if (!over) return; + + const activeId: string | number = active.id; + + if (typeof activeId === "string" && isStatusId(activeId)) { + handleStatusColumnDragEnd(activeId, over); + } else { + handleDealDragEnd(activeId, over); + } + }; + + const handleStatusColumnDragEnd = (activeId: string, over: Over) => { + const activeStatusId = getStatusId(activeId); + let overStatusId: number; + + if (typeof over.id === "string" && isStatusId(over.id)) { + overStatusId = getStatusId(over.id); + } else { + const deal = deals.find(deal => deal.statusId === over.id); + if (!deal) return; + overStatusId = deal.statusId; + } + + if (!overStatusId || activeStatusId === overStatusId) return; + + const newRank = getNewStatusRank(activeStatusId, overStatusId); + if (!newRank) return; + + props.onStatusDragEnd?.(activeStatusId, newRank); + }; + + const handleDealDragEnd = (activeId: number | string, over: Over) => { + const activeDealId = Number(activeId); + const activeStatusId = getStatusByDealId(activeDealId)?.id; + if (!activeStatusId) return; + + const { overStatusId, newLexorank } = getDropTarget( + over.id, + activeDealId, + activeStatusId + ); + if (!overStatusId) return; + + props.onDealDragEnd(activeDealId, overStatusId, newLexorank); + }; + + const handleDragStart = ({ active }: DragStartEvent) => { + const activeId = active.id as string | number; + + if (typeof activeId === "string" && isStatusId(activeId)) { + const statusId = getStatusId(activeId); + setActiveStatus( + statuses.find(status => status.id === statusId) ?? null + ); + } else { + setActiveDeal( + deals.find(deal => deal.id === (activeId as number)) ?? null + ); + } + }; + + return { + sortedStatuses, + handleDragStart, + handleDragOver, + handleDragEnd, + activeStatus, + activeDeal, + }; +}; + +export default useDnd; diff --git a/src/app/deals/hooks/useGetNewRank.ts b/src/app/deals/hooks/useGetNewRank.ts new file mode 100644 index 0000000..da86b02 --- /dev/null +++ b/src/app/deals/hooks/useGetNewRank.ts @@ -0,0 +1,78 @@ +import { LexoRank } from "lexorank"; +import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; +import { DealSchema } from "@/types/DealSchema"; +import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; + +const useGetNewRank = () => { + const { deals, statuses } = useStatusesContext(); + + const getNewRankForSameStatus = ( + statusDeals: DealSchema[], + overDealIndex: number, + activeDealId: number + ) => { + const activeDealIndex = statusDeals.findIndex( + deal => deal.id === activeDealId + ); + const [leftIndex, rightIndex] = + overDealIndex < activeDealIndex + ? [overDealIndex - 1, overDealIndex] + : [overDealIndex, overDealIndex + 1]; + + const leftLexorank = + leftIndex >= 0 ? LexoRank.parse(deals[leftIndex].rank) : null; + const rightLexorank = + rightIndex < deals.length + ? LexoRank.parse(deals[rightIndex].rank) + : null; + + return getNewLexorank(leftLexorank, rightLexorank).toString(); + }; + + const getNewRankForAnotherStatus = ( + statusDeals: DealSchema[], + overDealIndex: number + ) => { + const leftLexorank = + overDealIndex > 0 + ? LexoRank.parse(statusDeals[overDealIndex - 1].rank) + : null; + const rightLexorank = LexoRank.parse(statusDeals[overDealIndex].rank); + + return getNewLexorank(leftLexorank, rightLexorank).toString(); + }; + + const getNewStatusRank = (activeStatusId: number, overStatusId: number) => { + const sortedStatusList = sortByLexorank(statuses); + const overIndex = sortedStatusList.findIndex( + s => s.id === overStatusId + ); + const activeIndex = sortedStatusList.findIndex( + s => s.id === activeStatusId + ); + + if (overIndex === -1 || activeIndex === -1) return null; + + const [leftIndex, rightIndex] = + overIndex < activeIndex + ? [overIndex - 1, overIndex] + : [overIndex, overIndex + 1]; + + const leftLexorank = + leftIndex >= 0 ? LexoRank.parse(statuses[leftIndex].rank) : null; + const rightLexorank = + rightIndex < statuses.length + ? LexoRank.parse(statuses[rightIndex].rank) + : null; + + return getNewLexorank(leftLexorank, rightLexorank).toString(); + }; + + return { + getNewRankForSameStatus, + getNewRankForAnotherStatus, + getNewStatusRank, + }; +}; + +export default useGetNewRank; diff --git a/src/app/deals/hooks/useSensors.ts b/src/app/deals/hooks/useSensors.ts new file mode 100644 index 0000000..48ce3f4 --- /dev/null +++ b/src/app/deals/hooks/useSensors.ts @@ -0,0 +1,26 @@ +import { + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; + +const useDndSensors = () => { + const sensorOptions = { + activationConstraint: { + distance: 5, + }, + }; + + return useSensors( + useSensor(PointerSensor, sensorOptions), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + useSensor(TouchSensor, sensorOptions) + ); +}; + +export default useDndSensors; diff --git a/src/app/deals/utils/statusId.ts b/src/app/deals/utils/statusId.ts new file mode 100644 index 0000000..8479c16 --- /dev/null +++ b/src/app/deals/utils/statusId.ts @@ -0,0 +1,6 @@ +const STATUS_POSTFIX = "-status"; + +export const isStatusId = (rawId: string) => rawId.endsWith(STATUS_POSTFIX); + +export const getStatusId = (rawId: string) => + Number(rawId.replace(STATUS_POSTFIX, ""));