feat: raw deals dnd between statuses

This commit is contained in:
2025-08-01 10:01:39 +04:00
parent 8af4a908e6
commit 5fe9ea6747
14 changed files with 507 additions and 7 deletions

View File

@ -3,12 +3,12 @@
import React from "react";
import { ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/Board/Board";
import useBoards from "@/app/deals/hooks/useBoards";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SortableDnd from "@/components/SortableDnd";
import { BoardSchema } from "@/types/BoardSchema";
const Boards = () => {
const { boards, setBoards } = useBoards();
const { boards } = useBoardsContext();
const renderBoard = (board: BoardSchema) => {
return <Board board={board} />;

View File

@ -0,0 +1,12 @@
import { Card } from "@mantine/core";
import { DealSchema } from "@/types/DealSchema";
type Props = {
deal: DealSchema;
};
const DealCard = ({ deal }: Props) => {
return <Card>{deal.name}</Card>;
};
export default DealCard;

View File

@ -0,0 +1,37 @@
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
type SortableTaskItemProps = {
children: React.ReactNode;
id: string;
};
const SortableItem = ({ children, id }: SortableTaskItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}>
{children}
</div>
);
};
export default SortableItem;

View File

@ -0,0 +1,48 @@
import React, { useEffect, useState } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Box, Text } from "@mantine/core";
import DealCard from "@/app/deals/components/DealCard/DealCard";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
import { DealSchema } from "@/types/DealSchema";
import { sortByLexorank } from "@/utils/lexorank";
type BoardSectionProps = {
id: string;
title: string;
deals: DealSchema[];
};
const StatusColumn = ({ id, title, deals }: BoardSectionProps) => {
const { setNodeRef } = useDroppable({ id });
const [sortedDeals, setSortedDeals] = useState<DealSchema[]>([]);
useEffect(() => {
setSortedDeals(sortByLexorank(deals));
}, [deals]);
return (
<Box style={{ backgroundColor: "#eee", padding: 2 }}>
<Text>{title}</Text>
<SortableContext
id={id}
items={sortedDeals}
strategy={verticalListSortingStrategy}>
<div ref={setNodeRef}>
{sortedDeals.map(deal => (
<Box key={deal.id}>
<SortableItem id={deal.id}>
<DealCard deal={deal} />
</SortableItem>
</Box>
))}
</div>
</SortableContext>
</Box>
);
};
export default StatusColumn;

View File

@ -0,0 +1,25 @@
"use client";
import StatusColumnsDnd from "@/app/deals/components/StatusColumnsDnd/StatusColumnsDnd";
const StatusColumns = () => {
const onDealDragEnd = (
dealId: number,
statusId: number,
lexorank?: string
) => {
// Send request to server
console.log(
"onDealDragEnd. dealId =",
dealId,
", statusId =",
statusId,
", lexorank =",
lexorank
);
};
return <StatusColumnsDnd onDealDragEnd={onDealDragEnd} />;
};
export default StatusColumns;

View File

@ -0,0 +1,210 @@
"use client";
import React, { FC, useState } from "react";
import {
closestCorners,
defaultDropAnimation,
DndContext,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { Group } from "@mantine/core";
import DealCard from "@/app/deals/components/DealCard/DealCard";
import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { DealSchema } from "@/types/DealSchema";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
type Props = {
onDealDragEnd: (
dealId: number,
statusId: number,
lexorank?: string
) => void;
};
const StatusColumnsDnd: FC<Props> = props => {
const { statuses, deals, setDeals } = useBoardsContext();
const [activeDealId, setActiveDealId] = useState<null | number>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = ({ active }: DragStartEvent) => {
setActiveDealId(active.id as number);
};
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?.id) return;
const activeDealId = Number(active.id);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatusId, newLexorank } = getDropTarget(
over.id,
activeDealId,
activeStatusId
);
if (!overStatusId) return;
setDeals(deals =>
deals.map(deal =>
deal.id === activeDealId
? {
...deal,
statusId: overStatusId,
rank: newLexorank || deal.rank,
}
: deal
)
);
};
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 handleDragEnd = ({ active, over }: DragOverEvent) => {
setActiveDealId(null);
if (!over?.id) return;
const activeDealId = Number(active.id);
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 deal = activeDealId
? deals.find(deal => deal.id === activeDealId)
: null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}>
<Group
gap={"xs"}
justify={"start"}>
{statuses.map(status => (
<StatusColumn
key={status.id}
id={`${status.id}-status`}
title={status.name}
deals={deals.filter(
deal => deal.statusId === status.id
)}
/>
))}
<DragOverlay dropAnimation={defaultDropAnimation}>
{deal ? <DealCard deal={deal} /> : null}
</DragOverlay>
</Group>
</DndContext>
);
};
export default StatusColumnsDnd;

View File

@ -0,0 +1,92 @@
"use client";
import React, {
createContext,
FC,
useContext,
useEffect,
useState,
} from "react";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import useBoards from "@/app/deals/hooks/useBoards";
import useDeals from "@/app/deals/hooks/useDeals";
import useStatuses from "@/app/deals/hooks/useStatuses";
import { BoardSchema } from "@/types/BoardSchema";
import { DealSchema } from "@/types/DealSchema";
import { StatusSchema } from "@/types/StatusSchema";
type BoardsContextState = {
boards: BoardSchema[];
setBoards: React.Dispatch<React.SetStateAction<BoardSchema[]>>;
selectedBoard: BoardSchema | null;
setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>;
statuses: StatusSchema[];
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
deals: DealSchema[];
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>;
};
const BoardsContext = createContext<BoardsContextState | undefined>(undefined);
const useBoardsContextState = () => {
const { boards, setBoards } = useBoards();
const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>(
null
);
const { selectedProject: project } = useProjectsContext();
const { statuses, setStatuses } = useStatuses();
const { deals, setDeals } = useDeals();
useEffect(() => {
if (boards.length > 0 && selectedBoard === null) {
setSelectedBoard(boards[0]);
return;
}
if (selectedBoard) {
let newBoard = boards.find(board => board.id === selectedBoard.id);
if (!newBoard && boards.length > 0) {
newBoard = boards[0];
}
setSelectedBoard(newBoard ?? null);
}
}, [boards]);
return {
boards,
setBoards,
selectedBoard,
setSelectedBoard,
statuses,
setStatuses,
deals,
setDeals,
};
};
type BoardsContextProviderProps = {
children: React.ReactNode;
};
export const BoardsContextProvider: FC<BoardsContextProviderProps> = ({
children,
}) => {
const state = useBoardsContextState();
return (
<BoardsContext.Provider value={state}>
{children}
</BoardsContext.Provider>
);
};
export const useBoardsContext = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error(
"useBoardsContext must be used within a BoardsContextProvider"
);
}
return context;
};

View File

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { DealSchema } from "@/types/DealSchema";
const useDeals = () => {
const [deals, setDeals] = useState<DealSchema[]>([]);
useEffect(() => {
const INITIAL_DEALS: DealSchema[] = [
{
id: 1,
name: "Deal 1",
rank: "0|gggggg:",
statusId: 1,
},
{
id: 2,
name: "Deal 2",
rank: "0|mmmmmm:",
statusId: 1,
},
{
id: 3,
name: "Deal 3",
rank: "0|ssssss:",
statusId: 2,
},
];
setDeals(INITIAL_DEALS);
}, []);
return {
deals,
setDeals,
};
};
export default useDeals;

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
import { StatusSchema } from "@/types/StatusSchema";
const useStatuses = () => {
const [statuses, setStatuses] = useState<StatusSchema[]>([]);
useEffect(() => {
setStatuses([
{ id: 1, name: "Todo", rank: "0|aaaaaa:" },
{ id: 2, name: "In progress", rank: "0|gggggg:" },
{ id: 3, name: "Testing", rank: "0|mmmmmm:" },
{ id: 4, name: "Ready", rank: "0|ssssss:" },
]);
}, []);
return {
statuses,
setStatuses,
};
};
export default useStatuses;

View File

@ -1,5 +1,8 @@
import { Divider } from "@mantine/core";
import Boards from "@/app/deals/components/Boards/Boards";
import Header from "@/app/deals/components/Header/Header";
import StatusColumns from "@/app/deals/components/StatusColumns/StatusColumns";
import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
import PageBlock from "@/components/PageBlock/PageBlock";
import PageContainer from "@/components/PageContainer/PageContainer";
@ -9,8 +12,12 @@ export default function DealsPage() {
<PageContainer>
<PageBlock>
<ProjectsContextProvider>
<Header />
<Boards />
<BoardsContextProvider>
<Header />
<Boards />
<Divider my={"xl"} />
<StatusColumns />
</BoardsContextProvider>
</ProjectsContextProvider>
</PageBlock>
</PageContainer>

View File

@ -54,7 +54,6 @@ const SortableDnd = <T extends BaseItem>({
);
useEffect(() => {
console.log(sortByLexorank(initialItems));
setItems(sortByLexorank(initialItems));
}, [initialItems]);

6
src/types/DealSchema.ts Normal file
View File

@ -0,0 +1,6 @@
export type DealSchema = {
id: number;
name: string;
rank: string;
statusId: number;
};

View File

@ -0,0 +1,5 @@
export type StatusSchema = {
id: number;
name: string;
rank: string;
};

View File

@ -27,10 +27,10 @@ export function getNewLexorank(
): LexoRank {
if (right) {
if (left) return left?.between(right);
return right.genPrev();
return right.between(LexoRank.min());
}
if (left) {
return left.genNext();
return left.between(LexoRank.max());
}
return LexoRank.middle();
}