From a8830ea70d6b974052cdcdf33a4e3a47bb4c6798 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Tue, 14 Jul 2026 14:52:40 +0330 Subject: [PATCH] cursor pagination --- .env | 5 +- src/pages/taskmanager/components/Column.tsx | 87 +++++++---- src/pages/taskmanager/components/Task.tsx | 4 +- .../taskmanager/task/hooks/useTaskData.ts | 20 ++- .../taskmanager/task/service/TaskService.ts | 15 ++ src/pages/taskmanager/task/types/TaskTypes.ts | 27 +++- .../task/utils/mapTaskItemToTask.ts | 2 +- .../taskmanager/utils/reorderTaskItems.ts | 18 +++ .../taskmanager/utils/taskPhaseQueryCache.ts | 80 +++++++++++ src/pages/taskmanager/workspace.tsx | 135 ++++++++++-------- 10 files changed, 294 insertions(+), 99 deletions(-) create mode 100644 src/pages/taskmanager/utils/reorderTaskItems.ts create mode 100644 src/pages/taskmanager/utils/taskPhaseQueryCache.ts diff --git a/.env b/.env index 7aacf45..61c7058 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ VITE_TOKEN_NAME = 'admin_token' VITE_REFRESH_TOKEN_NAME = 'admin_refresh_token' -VITE_BASE_URL = 'https://api.danakcorp.com' -#VITE_BASE_URL = 'http://localhost:3500' \ No newline at end of file +#VITE_BASE_URL = 'https://api.danakcorp.com' + +VITE_BASE_URL = 'http://10.85.127.88:3500' \ No newline at end of file diff --git a/src/pages/taskmanager/components/Column.tsx b/src/pages/taskmanager/components/Column.tsx index b33a880..2ac33f9 100644 --- a/src/pages/taskmanager/components/Column.tsx +++ b/src/pages/taskmanager/components/Column.tsx @@ -1,11 +1,12 @@ import { Add, AddCircle, CloseCircle } from "iconsax-react"; -import { useRef, useState, type FC } from "react"; +import { useMemo, useRef, useState, type FC } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; import ModalConfrim from "../../../components/ModalConfrim"; import { ErrorType } from "../../../helpers/types"; -import { useCreateTask } from "../task/hooks/useTaskData"; +import { useCreateTask, useGetTasksByTaskPhase } from "../task/hooks/useTaskData"; import type { TaskItemType } from "../task/types/TaskTypes"; +import { mapTaskItemToTask } from "../task/utils/mapTaskItemToTask"; import { useDeleteTaskPhase } from "../task-phase/hooks/useTaskPhaseData"; import type { Column as ColumnType, Task as TaskType } from "../types"; import ColumnMenu from "./ColumnMenu"; @@ -14,10 +15,9 @@ import Task from "./Task"; type Props = { column: ColumnType; projectId: string; - tasks: TaskType[]; draggedTaskId: string | null; draggedColumnId: string | null; - onDragStart: (taskId: string) => void; + onDragStart: (task: TaskType) => void; onDragEnd: () => void; onDrop: (columnId: string, index: number) => void; onColumnDragStart: (columnId: string) => void; @@ -25,13 +25,11 @@ type Props = { onColumnDrop: (overColumnId: string) => void; onTaskClick?: (task: TaskType) => void; onColumnDeleted?: (columnId: string) => void; - onTaskCreated?: (task: TaskItemType, taskPhaseId: string) => void; }; const Column: FC = ({ column, projectId, - tasks, draggedTaskId, draggedColumnId, onDragStart, @@ -42,11 +40,25 @@ const Column: FC = ({ onColumnDrop, onTaskClick, onColumnDeleted, - onTaskCreated, }) => { const { t } = useTranslation("global"); const deleteTaskPhase = useDeleteTaskPhase(); const createTask = useCreateTask(); + const { + data: tasksData, + isPending: isTasksPending, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useGetTasksByTaskPhase(column.id); + + const tasks = useMemo(() => { + const items = tasksData?.pages.flatMap((page) => page.data?.items ?? []) ?? []; + + return [...items] + .sort((a, b) => (a.order ?? a.priority ?? 0) - (b.order ?? b.priority ?? 0)) + .map((task, index) => mapTaskItemToTask(task as TaskItemType, column.id, index)); + }, [tasksData?.pages, column.id]); const [isAdding, setIsAdding] = useState(false); const [title, setTitle] = useState(""); @@ -57,6 +69,15 @@ const Column: FC = ({ const dragImageRef = useRef(null); const isDraggingColumn = draggedColumnId === column.id; + const handleTasksScroll = (event: React.UIEvent) => { + const target = event.currentTarget; + const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 80; + + if (isNearBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + const handleColumnDragStart = (e: React.DragEvent) => { const columnEl = columnRef.current; if (columnEl) { @@ -153,9 +174,7 @@ const Column: FC = ({ projectId, }, { - onSuccess: (data) => { - const task = (data?.data ?? data) as TaskItemType; - onTaskCreated?.(task, column.id); + onSuccess: () => { toast.success(t("success")); resetTaskForm(); }, @@ -225,26 +244,34 @@ const Column: FC = ({ label={t("taskmanager.delete_column_confirm")} /> -
- {tasks.map((task, index) => ( -
{ - if (draggedColumnId) return; - e.preventDefault(); - e.stopPropagation(); - }} - onDrop={(e) => handleDrop(e, index)} - > - -
- ))} +
+ {isTasksPending + ? null + : tasks.map((task, index) => ( +
{ + if (draggedColumnId) return; + e.preventDefault(); + e.stopPropagation(); + }} + onDrop={(e) => handleDrop(e, index)} + > + +
+ ))} + {isFetchingNextPage ? ( +
{t("loading")}
+ ) : null}
{isAdding ? ( diff --git a/src/pages/taskmanager/components/Task.tsx b/src/pages/taskmanager/components/Task.tsx index 69f060c..3d85189 100644 --- a/src/pages/taskmanager/components/Task.tsx +++ b/src/pages/taskmanager/components/Task.tsx @@ -5,7 +5,7 @@ import type { Task as TaskType } from "../types"; type Props = { task: TaskType; isDragging?: boolean; - onDragStart: (taskId: string) => void; + onDragStart: (task: TaskType) => void; onDragEnd: () => void; onClick?: (task: TaskType) => void; }; @@ -23,7 +23,7 @@ const Task: FC = ({ task, isDragging, onDragStart, onDragEnd, onClick }) draggable onDragStart={() => { hasDraggedRef.current = true; - onDragStart(task.id); + onDragStart(task); }} onDragEnd={() => { onDragEnd(); diff --git a/src/pages/taskmanager/task/hooks/useTaskData.ts b/src/pages/taskmanager/task/hooks/useTaskData.ts index dba1e7e..e73fcf7 100644 --- a/src/pages/taskmanager/task/hooks/useTaskData.ts +++ b/src/pages/taskmanager/task/hooks/useTaskData.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as api from "../service/TaskService"; import { ChangeTaskPhaseType, @@ -18,6 +18,20 @@ export const useGetTaskDetail = (id: string, enabled = true) => { }); }; +export const useGetTasksByTaskPhase = (taskPhaseId: string, enabled = true) => { + return useInfiniteQuery({ + queryKey: ["tasks-by-task-phase", taskPhaseId], + queryFn: ({ pageParam }) => api.getTasksByTaskPhase(taskPhaseId, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => { + const pager = lastPage.data?.pager; + if (!pager?.hasNextPage) return undefined; + return pager.nextCursor ?? undefined; + }, + enabled: !!taskPhaseId && enabled, + }); +}; + export const useCreateTask = () => { const queryClient = useQueryClient(); @@ -25,6 +39,7 @@ export const useCreateTask = () => { mutationFn: (variables: CreateTaskType) => api.createTask(variables), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] }); + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", variables.taskPhaseId] }); }, }); }; @@ -37,6 +52,7 @@ export const useUpdateTask = () => { onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] }); queryClient.invalidateQueries({ queryKey: ["project"] }); + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase"] }); }, }); }; @@ -51,6 +67,7 @@ export const useChangeTaskPhase = () => { queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] }); queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] }); queryClient.invalidateQueries({ queryKey: ["project"] }); + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", variables.params.taskPhaseId] }); }, }); }; @@ -64,6 +81,7 @@ export const useDeleteTask = () => { queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] }); queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] }); queryClient.invalidateQueries({ queryKey: ["project"] }); + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase"] }); }, }); }; diff --git a/src/pages/taskmanager/task/service/TaskService.ts b/src/pages/taskmanager/task/service/TaskService.ts index 78fb239..c89f215 100644 --- a/src/pages/taskmanager/task/service/TaskService.ts +++ b/src/pages/taskmanager/task/service/TaskService.ts @@ -8,6 +8,7 @@ import { CreateTaskRemarkType, CreateTaskType, GetTaskDetailResponse, + GetTasksByTaskPhaseResponse, UpdateCheckListItemResponse, UpdateCheckListItemType, UpdateTaskType, @@ -23,6 +24,20 @@ export const getTaskDetail = async (id: string): Promise return data; }; +export const getTasksByTaskPhase = async ( + taskPhaseId: string, + cursor?: string | null, + limit = 10, +): Promise => { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) { + params.set("cursor", cursor); + } + + const { data } = await axios.get(`/task-manager/tasks/task-phase/${taskPhaseId}?${params.toString()}`); + return data; +}; + export const updateTask = async (id: string, params: UpdateTaskType) => { const { data } = await axios.patch(`/task-manager/tasks/${id}`, params); return data; diff --git a/src/pages/taskmanager/task/types/TaskTypes.ts b/src/pages/taskmanager/task/types/TaskTypes.ts index 20ea861..3f784a1 100644 --- a/src/pages/taskmanager/task/types/TaskTypes.ts +++ b/src/pages/taskmanager/task/types/TaskTypes.ts @@ -11,15 +11,34 @@ export type TaskItemType = { id: string; title: string; taskPhaseId: string; - order: number; + order?: number; + priority?: number; + createdAt?: string; + updatedAt?: string; + description?: string | null; + color?: string | null; + startDate?: string | null; + endDate?: string | null; tag?: string; dateRange?: string; - color?: string | null; attachments?: number; checklistDone?: number; checklistTotal?: number; }; +export type TaskPhaseTasksPagerType = { + limit: number; + nextCursor: string | null; + previousCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +export type TaskPhaseTasksDataType = { + items: TaskItemType[]; + pager: TaskPhaseTasksPagerType; +}; + export type TaskLabelType = { id: string; title: string; @@ -122,3 +141,7 @@ export type UpdateTaskType = Partial< export interface GetTaskDetailResponse extends IResponse { statusCode: number; } + +export interface GetTasksByTaskPhaseResponse extends IResponse { + statusCode: number; +} diff --git a/src/pages/taskmanager/task/utils/mapTaskItemToTask.ts b/src/pages/taskmanager/task/utils/mapTaskItemToTask.ts index 2bc043f..bb1bd99 100644 --- a/src/pages/taskmanager/task/utils/mapTaskItemToTask.ts +++ b/src/pages/taskmanager/task/utils/mapTaskItemToTask.ts @@ -4,7 +4,7 @@ import type { TaskItemType } from "../types/TaskTypes"; export const mapTaskItemToTask = (task: TaskItemType, taskPhaseId: string, fallbackOrder: number): Task => ({ id: task.id, columnId: task.taskPhaseId ?? taskPhaseId, - order: task.order ?? fallbackOrder, + order: task.order ?? task.priority ?? fallbackOrder, title: task.title, tag: task.tag ?? "", dateRange: task.dateRange ?? "", diff --git a/src/pages/taskmanager/utils/reorderTaskItems.ts b/src/pages/taskmanager/utils/reorderTaskItems.ts new file mode 100644 index 0000000..24994ea --- /dev/null +++ b/src/pages/taskmanager/utils/reorderTaskItems.ts @@ -0,0 +1,18 @@ +import type { TaskItemType } from "../task/types/TaskTypes"; + +const getTaskSortOrder = (task: TaskItemType) => task.order ?? task.priority ?? 0; + +export const reorderTaskItems = ( + tasks: TaskItemType[], + taskId: string, + destIndex: number, +): TaskItemType[] => { + const task = tasks.find((item) => item.id === taskId); + if (!task) return tasks; + + const without = tasks.filter((item) => item.id !== taskId); + const sorted = [...without].sort((a, b) => getTaskSortOrder(a) - getTaskSortOrder(b)); + sorted.splice(destIndex, 0, task); + + return sorted.map((item, index) => ({ ...item, order: index, priority: index })); +}; diff --git a/src/pages/taskmanager/utils/taskPhaseQueryCache.ts b/src/pages/taskmanager/utils/taskPhaseQueryCache.ts new file mode 100644 index 0000000..a573e99 --- /dev/null +++ b/src/pages/taskmanager/utils/taskPhaseQueryCache.ts @@ -0,0 +1,80 @@ +import type { InfiniteData } from "@tanstack/react-query"; +import type { GetTasksByTaskPhaseResponse, TaskItemType } from "../task/types/TaskTypes"; + +export type TasksByTaskPhaseInfiniteData = InfiniteData; + +export const getTaskPhaseItems = (data?: TasksByTaskPhaseInfiniteData): TaskItemType[] => + data?.pages.flatMap((page) => page.data?.items ?? []) ?? []; + +const redistributeItemsAcrossPages = ( + pages: GetTasksByTaskPhaseResponse[], + items: TaskItemType[], +): GetTasksByTaskPhaseResponse[] => { + let offset = 0; + + return pages.map((page) => { + const pageSize = page.data?.items.length ?? 0; + const pageItems = items.slice(offset, offset + pageSize); + offset += pageSize; + + return { + ...page, + data: { + ...page.data, + items: pageItems, + }, + }; + }); +}; + +export const updateTaskPhaseItems = ( + current: TasksByTaskPhaseInfiniteData | undefined, + updater: (items: TaskItemType[]) => TaskItemType[], +): TasksByTaskPhaseInfiniteData | undefined => { + if (!current?.pages.length) return current; + + const updatedItems = updater(getTaskPhaseItems(current)); + + return { + ...current, + pages: redistributeItemsAcrossPages(current.pages, updatedItems), + }; +}; + +export const insertTaskPhaseItem = ( + current: TasksByTaskPhaseInfiniteData | undefined, + item: TaskItemType, + index: number, +): TasksByTaskPhaseInfiniteData => { + const items = getTaskPhaseItems(current); + const nextItems = [...items]; + nextItems.splice(index, 0, item); + + if (!current?.pages.length) { + return { + pages: [ + { + status: 200, + success: true, + statusCode: 200, + data: { + items: nextItems, + pager: { + limit: 10, + nextCursor: null, + previousCursor: null, + hasNextPage: false, + hasPreviousPage: false, + }, + }, + }, + ], + pageParams: [undefined], + }; + } + + return { + ...current, + pages: redistributeItemsAcrossPages(current.pages, nextItems), + }; +}; diff --git a/src/pages/taskmanager/workspace.tsx b/src/pages/taskmanager/workspace.tsx index 38c39f6..19305b0 100644 --- a/src/pages/taskmanager/workspace.tsx +++ b/src/pages/taskmanager/workspace.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState, type FC } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState, type FC } from "react"; import { useParams } from "react-router-dom"; import { toast } from "react-toastify"; import PageLoading from "../../components/PageLoading"; @@ -10,21 +11,24 @@ import TaskDetailModal from "./components/task-detail/TaskDetailModal"; import { useGetProject } from "./project/hooks/useProjectData"; import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData"; import { useChangeTaskPhase } from "./task/hooks/useTaskData"; -import { mapTaskItemToTask } from "./task/utils/mapTaskItemToTask"; -import type { TaskItemType } from "./task/types/TaskTypes"; import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes"; import type { Column as ColumnType, Task } from "./types"; import { reorderColumns } from "./utils/reorderColumns"; -import { reorderTasks } from "./utils/reorderTasks"; +import { reorderTaskItems } from "./utils/reorderTaskItems"; +import { + insertTaskPhaseItem, + type TasksByTaskPhaseInfiniteData, + updateTaskPhaseItems, +} from "./utils/taskPhaseQueryCache"; const Workspace: FC = () => { + const queryClient = useQueryClient(); const { slug: projectId = "" } = useParams<{ slug: string }>(); const { data: project, isPending } = useGetProject(projectId); const changeTaskPhase = useChangeTaskPhase(); const updateTaskPhase = useUpdateTaskPhase(); const [columns, setColumns] = useState([]); - const [tasks, setTasks] = useState([]); - const [draggedTaskId, setDraggedTaskId] = useState(null); + const [draggedTask, setDraggedTask] = useState(null); const [draggedColumnId, setDraggedColumnId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); @@ -43,60 +47,79 @@ const Workspace: FC = () => { ); }, [project?.data?.taskPhases]); - useEffect(() => { - if (!project?.data?.taskPhases) return; - - const apiTasks = project.data.taskPhases.flatMap((phase) => - (phase.tasks ?? []).map((task, index) => - mapTaskItemToTask(task as TaskItemType, phase.id, index), - ), - ); - - setTasks(apiTasks); - }, [project?.data?.taskPhases]); - - const tasksByColumn = useMemo(() => { - return columns.reduce>((acc, column) => { - acc[column.id] = tasks.filter((task) => task.columnId === column.id).sort((a, b) => a.order - b.order); - return acc; - }, {}); - }, [columns, tasks]); - - const handleDragStart = (taskId: string) => { - setDraggedTaskId(taskId); + const handleDragStart = (task: Task) => { + setDraggedTask(task); }; const handleDragEnd = () => { - setDraggedTaskId(null); + setDraggedTask(null); }; const handleDrop = (columnId: string, index: number) => { - if (!draggedTaskId) return; - - const draggedTask = tasks.find((task) => task.id === draggedTaskId); if (!draggedTask) return; - const previousTasks = tasks; - const columnChanged = draggedTask.columnId !== columnId; - const nextTasks = reorderTasks(tasks, draggedTaskId, columnId, index); - const movedTask = nextTasks.find((task) => task.id === draggedTaskId) ?? null; + const sourceColumnId = draggedTask.columnId; + const columnChanged = sourceColumnId !== columnId; + const activeTask = draggedTask; - setTasks(nextTasks); - setDraggedTaskId(null); + setDraggedTask(null); - if (selectedTask?.id === draggedTaskId && movedTask) { - setSelectedTask(movedTask); + if (!columnChanged) { + queryClient.setQueryData(["tasks-by-task-phase", columnId], (current) => + updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index)), + ); + return; } - if (!columnChanged) return; + const previousSource = queryClient.getQueryData([ + "tasks-by-task-phase", + sourceColumnId, + ]); + const previousDest = queryClient.getQueryData([ + "tasks-by-task-phase", + columnId, + ]); + + queryClient.setQueryData(["tasks-by-task-phase", sourceColumnId], (current) => + updateTaskPhaseItems(current, (items) => items.filter((task) => task.id !== activeTask.id)), + ); + + queryClient.setQueryData(["tasks-by-task-phase", columnId], (current) => + insertTaskPhaseItem(current, { + id: activeTask.id, + title: activeTask.title, + taskPhaseId: columnId, + order: index, + priority: index, + tag: activeTask.tag, + dateRange: activeTask.dateRange, + color: activeTask.color, + attachments: activeTask.attachments, + checklistDone: activeTask.checklistDone, + checklistTotal: activeTask.checklistTotal, + }, index), + ); + + if (selectedTask?.id === activeTask.id) { + setSelectedTask({ ...activeTask, columnId }); + } changeTaskPhase.mutate( - { id: draggedTaskId, params: { taskPhaseId: columnId }, projectId }, + { id: activeTask.id, params: { taskPhaseId: columnId }, projectId }, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", sourceColumnId] }); + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", columnId] }); + }, onError: (error: ErrorType) => { - setTasks(previousTasks); - if (selectedTask?.id === draggedTaskId) { - setSelectedTask(draggedTask); + if (previousSource) { + queryClient.setQueryData(["tasks-by-task-phase", sourceColumnId], previousSource); + } + if (previousDest) { + queryClient.setQueryData(["tasks-by-task-phase", columnId], previousDest); + } + if (selectedTask?.id === activeTask.id) { + setSelectedTask(activeTask); } toast.error(error.response?.data?.error.message[0]); }, @@ -105,13 +128,12 @@ const Workspace: FC = () => { }; const handleTaskPhaseChanged = (taskId: string, taskPhaseId: string) => { - setTasks((prev) => { - const task = prev.find((item) => item.id === taskId); - if (!task || task.columnId === taskPhaseId) return prev; + const sourceColumnId = selectedTask?.columnId; - const destinationIndex = prev.filter((item) => item.columnId === taskPhaseId).length; - return reorderTasks(prev, taskId, taskPhaseId, destinationIndex); - }); + if (sourceColumnId) { + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", sourceColumnId] }); + } + queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", taskPhaseId] }); setSelectedTask((prev) => (prev?.id === taskId ? { ...prev, columnId: taskPhaseId } : prev)); }; @@ -161,19 +183,12 @@ const Workspace: FC = () => { const handleColumnDeleted = (columnId: string) => { setColumns((prev) => prev.filter((column) => column.id !== columnId)); - setTasks((prev) => prev.filter((task) => task.columnId !== columnId)); + queryClient.removeQueries({ queryKey: ["tasks-by-task-phase", columnId] }); if (selectedTask?.columnId === columnId) { setSelectedTask(null); } }; - const handleTaskCreated = (task: TaskItemType, taskPhaseId: string) => { - setTasks((prev) => [ - ...prev, - mapTaskItemToTask(task, taskPhaseId, prev.filter((item) => item.columnId === taskPhaseId).length), - ]); - }; - if (isPending) { return ; } @@ -188,8 +203,7 @@ const Workspace: FC = () => { key={column.id} column={column} projectId={projectId} - tasks={tasksByColumn[column.id] ?? []} - draggedTaskId={draggedTaskId} + draggedTaskId={draggedTask?.id ?? null} draggedColumnId={draggedColumnId} onDragStart={handleDragStart} onDragEnd={handleDragEnd} @@ -199,7 +213,6 @@ const Workspace: FC = () => { onColumnDrop={handleColumnDrop} onTaskClick={setSelectedTask} onColumnDeleted={handleColumnDeleted} - onTaskCreated={handleTaskCreated} /> ))}