cursor pagination

This commit is contained in:
hamid zarghami
2026-07-14 14:52:40 +03:30
parent 1d92a954d0
commit a8830ea70d
10 changed files with 294 additions and 99 deletions
+3 -2
View File
@@ -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'
#VITE_BASE_URL = 'https://api.danakcorp.com'
VITE_BASE_URL = 'http://10.85.127.88:3500'
+39 -12
View File
@@ -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<Props> = ({
column,
projectId,
tasks,
draggedTaskId,
draggedColumnId,
onDragStart,
@@ -42,11 +40,25 @@ const Column: FC<Props> = ({
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<Props> = ({
const dragImageRef = useRef<HTMLElement | null>(null);
const isDraggingColumn = draggedColumnId === column.id;
const handleTasksScroll = (event: React.UIEvent<HTMLDivElement>) => {
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<Props> = ({
projectId,
},
{
onSuccess: (data) => {
const task = (data?.data ?? data) as TaskItemType;
onTaskCreated?.(task, column.id);
onSuccess: () => {
toast.success(t("success"));
resetTaskForm();
},
@@ -225,8 +244,13 @@ const Column: FC<Props> = ({
label={t("taskmanager.delete_column_confirm")}
/>
<div className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4">
{tasks.map((task, index) => (
<div
className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4"
onScroll={handleTasksScroll}
>
{isTasksPending
? null
: tasks.map((task, index) => (
<div
key={task.id}
onDragOver={(e) => {
@@ -245,6 +269,9 @@ const Column: FC<Props> = ({
/>
</div>
))}
{isFetchingNextPage ? (
<div className="text-center text-description text-xs py-2">{t("loading")}</div>
) : null}
</div>
{isAdding ? (
+2 -2
View File
@@ -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<Props> = ({ task, isDragging, onDragStart, onDragEnd, onClick })
draggable
onDragStart={() => {
hasDraggedRef.current = true;
onDragStart(task.id);
onDragStart(task);
}}
onDragEnd={() => {
onDragEnd();
@@ -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"] });
},
});
};
@@ -8,6 +8,7 @@ import {
CreateTaskRemarkType,
CreateTaskType,
GetTaskDetailResponse,
GetTasksByTaskPhaseResponse,
UpdateCheckListItemResponse,
UpdateCheckListItemType,
UpdateTaskType,
@@ -23,6 +24,20 @@ export const getTaskDetail = async (id: string): Promise<GetTaskDetailResponse>
return data;
};
export const getTasksByTaskPhase = async (
taskPhaseId: string,
cursor?: string | null,
limit = 10,
): Promise<GetTasksByTaskPhaseResponse> => {
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;
+25 -2
View File
@@ -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<TaskDetailType> {
statusCode: number;
}
export interface GetTasksByTaskPhaseResponse extends IResponse<TaskPhaseTasksDataType> {
statusCode: number;
}
@@ -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 ?? "",
@@ -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 }));
};
@@ -0,0 +1,80 @@
import type { InfiniteData } from "@tanstack/react-query";
import type { GetTasksByTaskPhaseResponse, TaskItemType } from "../task/types/TaskTypes";
export type TasksByTaskPhaseInfiniteData = InfiniteData<GetTasksByTaskPhaseResponse, string | undefined>;
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),
};
};
+74 -61
View File
@@ -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<ColumnType[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(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<Record<string, Task[]>>((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<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) =>
updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index)),
);
return;
}
if (!columnChanged) return;
const previousSource = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>([
"tasks-by-task-phase",
sourceColumnId,
]);
const previousDest = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>([
"tasks-by-task-phase",
columnId,
]);
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId], (current) =>
updateTaskPhaseItems(current, (items) => items.filter((task) => task.id !== activeTask.id)),
);
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["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 <PageLoading />;
}
@@ -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}
/>
))}