feat: raw statuses dnd

This commit is contained in:
2025-08-01 17:50:27 +04:00
parent 943b2d63f5
commit 586af488da
3 changed files with 175 additions and 33 deletions

View File

@ -14,13 +14,15 @@ type BoardSectionProps = {
id: string; id: string;
title: string; title: string;
deals: DealSchema[]; deals: DealSchema[];
isDragging?: boolean;
}; };
const StatusColumn = ({ id, title, deals }: BoardSectionProps) => { const StatusColumn = ({ id, title, deals, isDragging }: BoardSectionProps) => {
const { setNodeRef } = useDroppable({ id }); const { setNodeRef } = useDroppable({ id });
const sortedDeals = useMemo(() => sortByLexorank(deals), [deals]); const sortedDeals = useMemo(() => sortByLexorank(deals), [deals]);
console.log("rerender");
return ( return (
<Box <Box
style={{ style={{
@ -29,7 +31,14 @@ const StatusColumn = ({ id, title, deals }: BoardSectionProps) => {
width: "15vw", width: "15vw",
minWidth: 150, minWidth: 150,
}}> }}>
<Text>{title}</Text> <Text
style={{
cursor: "grab",
userSelect: "none",
opacity: isDragging ? 0.5 : 1,
}}>
{title}
</Text>
<SortableContext <SortableContext
id={id} id={id}
items={sortedDeals} items={sortedDeals}

View File

@ -9,18 +9,25 @@ import {
DragOverlay, DragOverlay,
DragStartEvent, DragStartEvent,
KeyboardSensor, KeyboardSensor,
Over,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { LexoRank } from "lexorank"; import { LexoRank } from "lexorank";
import { throttle } from "lodash"; 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 DealCard from "@/app/deals/components/DealCard/DealCard";
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 { SortableItem } from "@/components/SortableDnd/SortableItem";
import { DealSchema } from "@/types/DealSchema"; import { DealSchema } from "@/types/DealSchema";
import { StatusSchema } from "@/types/StatusSchema";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
type Props = { type Props = {
@ -29,11 +36,15 @@ type Props = {
statusId: number, statusId: number,
lexorank?: string lexorank?: string
) => void; ) => void;
onStatusDragEnd?: (statusId: number, newRank: string) => void;
}; };
const StatusColumnsDnd: FC<Props> = props => { const StatusColumnsDnd: FC<Props> = props => {
const { statuses, deals, setDeals } = useStatusesContext(); const { statuses, deals, setDeals, setStatuses } = useStatusesContext();
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null); const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const throttledSetDeals = useMemo( const throttledSetDeals = useMemo(
() => throttle(setDeals, 200), () => throttle(setDeals, 200),
[setDeals] [setDeals]
@ -47,9 +58,18 @@ const StatusColumnsDnd: FC<Props> = props => {
); );
const handleDragStart = ({ active }: DragStartEvent) => { const handleDragStart = ({ active }: DragStartEvent) => {
setActiveDeal( const activeId = active.id as string | number;
deals.find(deal => deal.id === (active.id as number)) ?? null
); 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 getStatusByDealId = (dealId: number) => {
@ -59,9 +79,18 @@ const StatusColumnsDnd: FC<Props> = props => {
}; };
const handleDragOver = ({ active, over }: DragOverEvent) => { const handleDragOver = ({ active, over }: DragOverEvent) => {
if (!over?.id) return; if (!over) return;
const activeId = active.id as string | number;
const activeDealId = Number(active.id); 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; const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return; if (!activeStatusId) return;
@ -85,6 +114,32 @@ const StatusColumnsDnd: FC<Props> = props => {
); );
}; };
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;
setStatuses(statuses =>
statuses.map(status =>
status.id === activeStatusId
? { ...status, rank: newRank }
: status
)
);
};
const getDropTarget = ( const getDropTarget = (
overId: string | number, overId: string | number,
activeDealId: number, activeDealId: number,
@ -164,11 +219,68 @@ const StatusColumnsDnd: FC<Props> = props => {
return getNewLexorank(leftLexorank, rightLexorank).toString(); 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) => { const handleDragEnd = ({ active, over }: DragOverEvent) => {
setActiveDeal(null); setActiveDeal(null);
if (!over?.id) return; setActiveStatus(null);
if (!over) return;
const activeDealId = Number(active.id); 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; const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return; if (!activeStatusId) return;
@ -192,26 +304,47 @@ const StatusColumnsDnd: FC<Props> = props => {
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd}> onDragEnd={handleDragEnd}>
<Group <SortableContext
gap={"xs"} items={sortedStatuses.map(status => `${status.id}-status`)}
wrap={"nowrap"} strategy={horizontalListSortingStrategy}>
align={"start"}> <Group
{statuses.map(status => ( gap={"xs"}
<StatusColumn wrap={"nowrap"}
key={status.id} align={"start"}>
id={`${status.id}-status`} {sortedStatuses.map(status => (
title={status.name} <SortableItem
deals={deals.filter( key={status.id}
deal => deal.statusId === status.id id={`${status.id}-status`}>
)} <StatusColumn
/> id={`${status.id}-status`}
))} title={status.name}
<DragOverlay dropAnimation={defaultDropAnimation}> deals={deals.filter(
<div style={{ cursor: "grabbing" }}> deal => deal.statusId === status.id
{activeDeal ? <DealCard deal={activeDeal} /> : null} )}
</div> isDragging={activeStatus?.id === status.id}
</DragOverlay> />
</Group> </SortableItem>
))}
<DragOverlay dropAnimation={defaultDropAnimation}>
<div style={{ cursor: "grabbing" }}>
{activeDeal ? (
<DealCard deal={activeDeal} />
) : activeStatus ? (
<StatusColumn
id={`${activeStatus.id}-status`}
title={activeStatus.name}
deals={deals.filter(
deal =>
deal.statusId ===
activeStatus.id
)}
isDragging
/>
) : null}
</div>
</DragOverlay>
</Group>
</SortableContext>
</DndContext> </DndContext>
</ScrollArea> </ScrollArea>
); );

View File

@ -5,7 +5,7 @@ import DragHandle from "@/components/SortableDnd/DragHandle";
import SortableItemContext from "./SortableItemContext"; import SortableItemContext from "./SortableItemContext";
type Props = { type Props = {
id: number; id: number | string;
itemStyle?: CSSProperties; itemStyle?: CSSProperties;
}; };