diff --git a/src/app/deals/components/Boards/Boards.tsx b/src/app/deals/components/Boards/Boards.tsx index d1205cd..6630c75 100644 --- a/src/app/deals/components/Boards/Boards.tsx +++ b/src/app/deals/components/Boards/Boards.tsx @@ -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 ; diff --git a/src/app/deals/components/DealCard/DealCard.tsx b/src/app/deals/components/DealCard/DealCard.tsx new file mode 100644 index 0000000..64fc7c1 --- /dev/null +++ b/src/app/deals/components/DealCard/DealCard.tsx @@ -0,0 +1,12 @@ +import { Card } from "@mantine/core"; +import { DealSchema } from "@/types/DealSchema"; + +type Props = { + deal: DealSchema; +}; + +const DealCard = ({ deal }: Props) => { + return {deal.name}; +}; + +export default DealCard; diff --git a/src/app/deals/components/SortableItem/SortableItem.tsx b/src/app/deals/components/SortableItem/SortableItem.tsx new file mode 100644 index 0000000..8a348a8 --- /dev/null +++ b/src/app/deals/components/SortableItem/SortableItem.tsx @@ -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 ( +
+ {children} +
+ ); +}; + +export default SortableItem; diff --git a/src/app/deals/components/StatusColumn/StatusColumn.tsx b/src/app/deals/components/StatusColumn/StatusColumn.tsx new file mode 100644 index 0000000..823ddd2 --- /dev/null +++ b/src/app/deals/components/StatusColumn/StatusColumn.tsx @@ -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([]); + + useEffect(() => { + setSortedDeals(sortByLexorank(deals)); + }, [deals]); + + return ( + + {title} + +
+ {sortedDeals.map(deal => ( + + + + + + ))} +
+
+
+ ); +}; + +export default StatusColumn; diff --git a/src/app/deals/components/StatusColumns/StatusColumns.tsx b/src/app/deals/components/StatusColumns/StatusColumns.tsx new file mode 100644 index 0000000..d87607e --- /dev/null +++ b/src/app/deals/components/StatusColumns/StatusColumns.tsx @@ -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 ; +}; + +export default StatusColumns; diff --git a/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx b/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx new file mode 100644 index 0000000..dd1b13c --- /dev/null +++ b/src/app/deals/components/StatusColumnsDnd/StatusColumnsDnd.tsx @@ -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 => { + const { statuses, deals, setDeals } = useBoardsContext(); + const [activeDealId, setActiveDealId] = useState(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 ( + + + {statuses.map(status => ( + deal.statusId === status.id + )} + /> + ))} + + {deal ? : null} + + + + ); +}; + +export default StatusColumnsDnd; diff --git a/src/app/deals/contexts/BoardsContext.tsx b/src/app/deals/contexts/BoardsContext.tsx new file mode 100644 index 0000000..8e0f890 --- /dev/null +++ b/src/app/deals/contexts/BoardsContext.tsx @@ -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>; + selectedBoard: BoardSchema | null; + setSelectedBoard: React.Dispatch>; + statuses: StatusSchema[]; + setStatuses: React.Dispatch>; + deals: DealSchema[]; + setDeals: React.Dispatch>; +}; + +const BoardsContext = createContext(undefined); + +const useBoardsContextState = () => { + const { boards, setBoards } = useBoards(); + const [selectedBoard, setSelectedBoard] = useState( + 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 = ({ + children, +}) => { + const state = useBoardsContextState(); + + return ( + + {children} + + ); +}; + +export const useBoardsContext = () => { + const context = useContext(BoardsContext); + if (!context) { + throw new Error( + "useBoardsContext must be used within a BoardsContextProvider" + ); + } + return context; +}; diff --git a/src/app/deals/hooks/useDeals.ts b/src/app/deals/hooks/useDeals.ts new file mode 100644 index 0000000..c1f59a7 --- /dev/null +++ b/src/app/deals/hooks/useDeals.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { DealSchema } from "@/types/DealSchema"; + +const useDeals = () => { + const [deals, setDeals] = useState([]); + + 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; diff --git a/src/app/deals/hooks/useStatuses.ts b/src/app/deals/hooks/useStatuses.ts new file mode 100644 index 0000000..7c3def4 --- /dev/null +++ b/src/app/deals/hooks/useStatuses.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { StatusSchema } from "@/types/StatusSchema"; + +const useStatuses = () => { + const [statuses, setStatuses] = useState([]); + + 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; diff --git a/src/app/deals/page.tsx b/src/app/deals/page.tsx index a95f206..da6665f 100644 --- a/src/app/deals/page.tsx +++ b/src/app/deals/page.tsx @@ -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() { -
- + +
+ + + + diff --git a/src/components/SortableDnd/SortableDnd.tsx b/src/components/SortableDnd/SortableDnd.tsx index 62eec41..5de115d 100644 --- a/src/components/SortableDnd/SortableDnd.tsx +++ b/src/components/SortableDnd/SortableDnd.tsx @@ -54,7 +54,6 @@ const SortableDnd = ({ ); useEffect(() => { - console.log(sortByLexorank(initialItems)); setItems(sortByLexorank(initialItems)); }, [initialItems]); diff --git a/src/types/DealSchema.ts b/src/types/DealSchema.ts new file mode 100644 index 0000000..a4f7656 --- /dev/null +++ b/src/types/DealSchema.ts @@ -0,0 +1,6 @@ +export type DealSchema = { + id: number; + name: string; + rank: string; + statusId: number; +}; diff --git a/src/types/StatusSchema.ts b/src/types/StatusSchema.ts new file mode 100644 index 0000000..1f44b73 --- /dev/null +++ b/src/types/StatusSchema.ts @@ -0,0 +1,5 @@ +export type StatusSchema = { + id: number; + name: string; + rank: string; +}; diff --git a/src/utils/lexorank.ts b/src/utils/lexorank.ts index ac6b8bd..8909ada 100644 --- a/src/utils/lexorank.ts +++ b/src/utils/lexorank.ts @@ -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(); }