refactor: refactoring of deals and statuses dnd

This commit is contained in:
2025-08-02 10:58:24 +04:00
parent 8ae198897d
commit 459487a896
8 changed files with 396 additions and 306 deletions

View File

@ -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 (
<DragOverlay dropAnimation={defaultDropAnimation}>
<div style={{ cursor: "grabbing" }}>
{activeDeal ? (
<DealCard deal={activeDeal} />
) : activeStatus ? (
<StatusColumn
id={`${activeStatus.id}-status`}
status={activeStatus}
deals={deals.filter(
deal => deal.statusId === activeStatus.id
)}
isDragging
/>
) : null}
</div>
</DragOverlay>
);
};
export default DndOverlay;

View File

@ -2,12 +2,12 @@ import React from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
type SortableTaskItemProps = { type Props = {
children: React.ReactNode; children: React.ReactNode;
id: string; id: string;
}; };
const SortableItem = ({ children, id }: SortableTaskItemProps) => { const SortableItem = ({ children, id }: Props) => {
const { const {
attributes, attributes,
listeners, listeners,

View File

@ -10,14 +10,14 @@ import { DealSchema } from "@/types/DealSchema";
import { StatusSchema } from "@/types/StatusSchema"; import { StatusSchema } from "@/types/StatusSchema";
import { sortByLexorank } from "@/utils/lexorank"; import { sortByLexorank } from "@/utils/lexorank";
type BoardSectionProps = { type Props = {
id: string; id: string;
status: StatusSchema; status: StatusSchema;
deals: DealSchema[]; deals: DealSchema[];
isDragging?: boolean; isDragging?: boolean;
}; };
const StatusColumn = ({ id, status, deals, isDragging }: BoardSectionProps) => { const StatusColumn = ({ id, status, deals, isDragging }: Props) => {
const { setNodeRef } = useDroppable({ id }); const { setNodeRef } = useDroppable({ id });
const sortedDeals = useMemo( const sortedDeals = useMemo(
() => sortByLexorank(deals.filter(deal => deal.statusId === status.id)), () => sortByLexorank(deals.filter(deal => deal.statusId === status.id)),

View File

@ -1,35 +1,18 @@
"use client"; "use client";
import React, { FC, useMemo, useState } from "react"; import React, { FC } from "react";
import { import { closestCorners, DndContext } from "@dnd-kit/core";
closestCorners,
defaultDropAnimation,
DndContext,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
Over,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
SortableContext, SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { throttle } from "lodash";
import { Group, ScrollArea } from "@mantine/core"; 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 StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useDnd from "@/app/deals/hooks/useDnd";
import { SortableItem } from "@/components/SortableDnd/SortableItem"; import { SortableItem } from "@/components/SortableDnd/SortableItem";
import { DealSchema } from "@/types/DealSchema"; import useDndSensors from "../../hooks/useSensors";
import { StatusSchema } from "@/types/StatusSchema";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
type Props = { type Props = {
onDealDragEnd: ( onDealDragEnd: (
@ -41,270 +24,18 @@ type Props = {
}; };
const StatusColumnsDnd: FC<Props> = props => { const StatusColumnsDnd: FC<Props> = props => {
const { statuses, deals, setDeals, setStatuses } = useStatusesContext(); const { deals } = useStatusesContext();
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const throttledSetStatuses = useMemo( const {
() => throttle(setStatuses, 200), sortedStatuses,
[setStatuses] handleDragStart,
); handleDragOver,
const throttledSetDeals = useMemo( handleDragEnd,
() => throttle(setDeals, 200), activeStatus,
[setDeals] activeDeal,
); } = useDnd(props);
const sensorOptions = { const sensors = useDndSensors();
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);
};
return ( return (
<ScrollArea <ScrollArea
@ -335,24 +66,10 @@ const StatusColumnsDnd: FC<Props> = props => {
/> />
</SortableItem> </SortableItem>
))} ))}
<DragOverlay dropAnimation={defaultDropAnimation}> <DndOverlay
<div style={{ cursor: "grabbing" }}> activeStatus={activeStatus}
{activeDeal ? ( activeDeal={activeDeal}
<DealCard deal={activeDeal} />
) : activeStatus ? (
<StatusColumn
id={`${activeStatus.id}-status`}
status={activeStatus}
deals={deals.filter(
deal =>
deal.statusId ===
activeStatus.id
)}
isDragging
/> />
) : null}
</div>
</DragOverlay>
</Group> </Group>
</SortableContext> </SortableContext>
</DndContext> </DndContext>

View File

@ -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<DealSchema | null>(null);
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(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;

View File

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

View File

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

View File

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