paseo-kanban 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/README.zh-CN.md +111 -0
- package/client/i18n.tsx +328 -0
- package/client/kanban-board.tsx +545 -0
- package/client/kanban-card.tsx +326 -0
- package/client/kanban-drag.ts +1393 -0
- package/client/kanban-lane.tsx +240 -0
- package/client/kanban-overlay.tsx +186 -0
- package/client/kanban-session.ts +696 -0
- package/client/lane-modal.tsx +267 -0
- package/client/task-modal.tsx +498 -0
- package/client/use-projects.ts +112 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +7 -0
- package/package.json +41 -0
- package/paseo-plugin.json +6 -0
- package/shared/kanban.ts +465 -0
package/shared/kanban.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { defineSettings } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_LANES = [
|
|
5
|
+
{ id: "to-plan", title: "待规划" },
|
|
6
|
+
{ id: "in-progress", title: "执行中" },
|
|
7
|
+
{ id: "done", title: "已完成" },
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export const SubtaskSchema = z.object({
|
|
11
|
+
id: z.string().trim().min(1, "Subtask ID cannot be empty"),
|
|
12
|
+
title: z.string().trim().min(1, "Subtask title cannot be empty"),
|
|
13
|
+
completed: z.boolean().default(false),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const TaskSchema = z.object({
|
|
17
|
+
id: z.string().trim().min(1, "Task ID cannot be empty"),
|
|
18
|
+
title: z.string().trim().min(1, "Task title cannot be empty"),
|
|
19
|
+
description: z.string().default(""),
|
|
20
|
+
laneId: z.string().trim().min(1, "Task lane ID cannot be empty"),
|
|
21
|
+
projectId: z.string().trim().min(1).nullable().default(null),
|
|
22
|
+
subtasks: z.array(SubtaskSchema).default([]),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const LaneSchema = z.object({
|
|
26
|
+
id: z.string().trim().min(1, "Lane ID cannot be empty"),
|
|
27
|
+
title: z.string().trim().min(1, "Lane title cannot be empty"),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const KanbanBoardSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
lanes: z
|
|
33
|
+
.array(LaneSchema)
|
|
34
|
+
.min(1, "Kanban board must have at least one lane")
|
|
35
|
+
.default([...DEFAULT_LANES]),
|
|
36
|
+
tasks: z.array(TaskSchema).default([]),
|
|
37
|
+
})
|
|
38
|
+
.superRefine((board, ctx) => {
|
|
39
|
+
const laneIds = new Set<string>();
|
|
40
|
+
for (let i = 0; i < board.lanes.length; i++) {
|
|
41
|
+
const lane = board.lanes[i];
|
|
42
|
+
if (laneIds.has(lane.id)) {
|
|
43
|
+
ctx.addIssue({
|
|
44
|
+
code: z.ZodIssueCode.custom,
|
|
45
|
+
message: `Duplicate lane ID: "${lane.id}"`,
|
|
46
|
+
path: ["lanes", i, "id"],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
laneIds.add(lane.id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const taskIds = new Set<string>();
|
|
53
|
+
for (let i = 0; i < board.tasks.length; i++) {
|
|
54
|
+
const task = board.tasks[i];
|
|
55
|
+
if (taskIds.has(task.id)) {
|
|
56
|
+
ctx.addIssue({
|
|
57
|
+
code: z.ZodIssueCode.custom,
|
|
58
|
+
message: `Duplicate task ID: "${task.id}"`,
|
|
59
|
+
path: ["tasks", i, "id"],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
taskIds.add(task.id);
|
|
63
|
+
|
|
64
|
+
if (!laneIds.has(task.laneId)) {
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: z.ZodIssueCode.custom,
|
|
67
|
+
message: `Task "${task.id}" references non-existent lane "${task.laneId}"`,
|
|
68
|
+
path: ["tasks", i, "laneId"],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const subtaskIds = new Set<string>();
|
|
73
|
+
for (let j = 0; j < task.subtasks.length; j++) {
|
|
74
|
+
const subtask = task.subtasks[j];
|
|
75
|
+
if (subtaskIds.has(subtask.id)) {
|
|
76
|
+
ctx.addIssue({
|
|
77
|
+
code: z.ZodIssueCode.custom,
|
|
78
|
+
message: `Duplicate subtask ID "${subtask.id}" in task "${task.id}"`,
|
|
79
|
+
path: ["tasks", i, "subtasks", j, "id"],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
subtaskIds.add(subtask.id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export type KanbanSubtask = z.infer<typeof SubtaskSchema>;
|
|
88
|
+
export type KanbanTask = z.infer<typeof TaskSchema>;
|
|
89
|
+
export type KanbanLane = z.infer<typeof LaneSchema>;
|
|
90
|
+
export type KanbanBoard = z.infer<typeof KanbanBoardSchema>;
|
|
91
|
+
|
|
92
|
+
export type ProjectFilter =
|
|
93
|
+
| { type: "all" }
|
|
94
|
+
| { type: "unassigned" }
|
|
95
|
+
| { type: "project"; projectId: string };
|
|
96
|
+
|
|
97
|
+
export function isTaskMatchingFilter(
|
|
98
|
+
task: KanbanTask,
|
|
99
|
+
filter?: ProjectFilter | string | null
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!filter) return true;
|
|
102
|
+
if (typeof filter === "string") {
|
|
103
|
+
if (filter === "all") return true;
|
|
104
|
+
if (filter === "unassigned") return task.projectId === null;
|
|
105
|
+
return task.projectId === filter;
|
|
106
|
+
}
|
|
107
|
+
if (filter.type === "all") return true;
|
|
108
|
+
if (filter.type === "unassigned") return task.projectId === null;
|
|
109
|
+
return task.projectId === filter.projectId;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function filterTasksByProject(
|
|
113
|
+
tasks: KanbanTask[],
|
|
114
|
+
filter?: ProjectFilter | string | null
|
|
115
|
+
): KanbanTask[] {
|
|
116
|
+
if (!filter || filter === "all" || (typeof filter === "object" && filter.type === "all")) {
|
|
117
|
+
return tasks;
|
|
118
|
+
}
|
|
119
|
+
return tasks.filter((t) => isTaskMatchingFilter(t, filter));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const kanbanSettings = defineSettings({
|
|
123
|
+
id: "kanban",
|
|
124
|
+
scope: "host",
|
|
125
|
+
version: 1,
|
|
126
|
+
schema: KanbanBoardSchema,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Pure State Transition Functions
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export function addLane(
|
|
134
|
+
board: KanbanBoard,
|
|
135
|
+
lane: { id: string; title: string }
|
|
136
|
+
): KanbanBoard {
|
|
137
|
+
const trimmedId = lane.id.trim();
|
|
138
|
+
const trimmedTitle = lane.title.trim();
|
|
139
|
+
if (!trimmedId) {
|
|
140
|
+
throw new Error("Lane ID cannot be empty");
|
|
141
|
+
}
|
|
142
|
+
if (!trimmedTitle) {
|
|
143
|
+
throw new Error("Lane title cannot be empty");
|
|
144
|
+
}
|
|
145
|
+
if (board.lanes.some((l) => l.id === trimmedId)) {
|
|
146
|
+
throw new Error(`Lane with ID "${trimmedId}" already exists`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const nextLanes = [...board.lanes, { id: trimmedId, title: trimmedTitle }];
|
|
150
|
+
return KanbanBoardSchema.parse({
|
|
151
|
+
lanes: nextLanes,
|
|
152
|
+
tasks: board.tasks,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function updateLane(
|
|
157
|
+
board: KanbanBoard,
|
|
158
|
+
laneId: string,
|
|
159
|
+
patch: { title: string }
|
|
160
|
+
): KanbanBoard {
|
|
161
|
+
const trimmedTitle = patch.title.trim();
|
|
162
|
+
if (!trimmedTitle) {
|
|
163
|
+
throw new Error("Lane title cannot be empty");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let found = false;
|
|
167
|
+
const nextLanes = board.lanes.map((lane) => {
|
|
168
|
+
if (lane.id === laneId) {
|
|
169
|
+
found = true;
|
|
170
|
+
return { ...lane, title: trimmedTitle };
|
|
171
|
+
}
|
|
172
|
+
return lane;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!found) {
|
|
176
|
+
throw new Error(`Lane "${laneId}" not found`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return KanbanBoardSchema.parse({
|
|
180
|
+
lanes: nextLanes,
|
|
181
|
+
tasks: board.tasks,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function deleteLane(board: KanbanBoard, laneId: string): KanbanBoard {
|
|
186
|
+
const laneIndex = board.lanes.findIndex((l) => l.id === laneId);
|
|
187
|
+
if (laneIndex === -1) {
|
|
188
|
+
throw new Error(`Lane "${laneId}" not found`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (board.lanes.length <= 1) {
|
|
192
|
+
throw new Error("Cannot delete the last remaining lane");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const hasTasks = board.tasks.some((t) => t.laneId === laneId);
|
|
196
|
+
if (hasTasks) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Cannot delete lane "${laneId}" because it contains tasks. Move or delete tasks first.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const nextLanes = board.lanes.filter((l) => l.id !== laneId);
|
|
203
|
+
return KanbanBoardSchema.parse({
|
|
204
|
+
lanes: nextLanes,
|
|
205
|
+
tasks: board.tasks,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function addTask(
|
|
210
|
+
board: KanbanBoard,
|
|
211
|
+
task: {
|
|
212
|
+
id: string;
|
|
213
|
+
title: string;
|
|
214
|
+
description?: string;
|
|
215
|
+
laneId: string;
|
|
216
|
+
projectId?: string | null;
|
|
217
|
+
subtasks?: Array<{ id: string; title: string; completed?: boolean }>;
|
|
218
|
+
}
|
|
219
|
+
): KanbanBoard {
|
|
220
|
+
const trimmedId = task.id.trim();
|
|
221
|
+
const trimmedTitle = task.title.trim();
|
|
222
|
+
if (!trimmedId) {
|
|
223
|
+
throw new Error("Task ID cannot be empty");
|
|
224
|
+
}
|
|
225
|
+
if (!trimmedTitle) {
|
|
226
|
+
throw new Error("Task title cannot be empty");
|
|
227
|
+
}
|
|
228
|
+
if (board.tasks.some((t) => t.id === trimmedId)) {
|
|
229
|
+
throw new Error(`Task with ID "${trimmedId}" already exists`);
|
|
230
|
+
}
|
|
231
|
+
if (!board.lanes.some((l) => l.id === task.laneId)) {
|
|
232
|
+
throw new Error(`Lane "${task.laneId}" does not exist`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const newTask: KanbanTask = {
|
|
236
|
+
id: trimmedId,
|
|
237
|
+
title: trimmedTitle,
|
|
238
|
+
description: task.description ?? "",
|
|
239
|
+
laneId: task.laneId,
|
|
240
|
+
projectId: task.projectId ?? null,
|
|
241
|
+
subtasks: (task.subtasks ?? []).map((s) => ({
|
|
242
|
+
id: s.id.trim(),
|
|
243
|
+
title: s.title.trim(),
|
|
244
|
+
completed: s.completed ?? false,
|
|
245
|
+
})),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return KanbanBoardSchema.parse({
|
|
249
|
+
lanes: board.lanes,
|
|
250
|
+
tasks: [...board.tasks, newTask],
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function updateTask(
|
|
255
|
+
board: KanbanBoard,
|
|
256
|
+
taskId: string,
|
|
257
|
+
patch: {
|
|
258
|
+
title?: string;
|
|
259
|
+
description?: string;
|
|
260
|
+
laneId?: string;
|
|
261
|
+
projectId?: string | null;
|
|
262
|
+
subtasks?: KanbanSubtask[];
|
|
263
|
+
}
|
|
264
|
+
): KanbanBoard {
|
|
265
|
+
let found = false;
|
|
266
|
+
if (patch.title !== undefined && !patch.title.trim()) {
|
|
267
|
+
throw new Error("Task title cannot be empty");
|
|
268
|
+
}
|
|
269
|
+
if (patch.laneId !== undefined && !board.lanes.some((l) => l.id === patch.laneId)) {
|
|
270
|
+
throw new Error(`Target lane "${patch.laneId}" does not exist`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const nextTasks = board.tasks.map((task) => {
|
|
274
|
+
if (task.id === taskId) {
|
|
275
|
+
found = true;
|
|
276
|
+
return {
|
|
277
|
+
...task,
|
|
278
|
+
title: patch.title !== undefined ? patch.title.trim() : task.title,
|
|
279
|
+
description: patch.description !== undefined ? patch.description : task.description,
|
|
280
|
+
laneId: patch.laneId !== undefined ? patch.laneId : task.laneId,
|
|
281
|
+
projectId: patch.projectId !== undefined ? patch.projectId : task.projectId,
|
|
282
|
+
subtasks: patch.subtasks !== undefined ? patch.subtasks : task.subtasks,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return task;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (!found) {
|
|
289
|
+
throw new Error(`Task "${taskId}" not found`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return KanbanBoardSchema.parse({
|
|
293
|
+
lanes: board.lanes,
|
|
294
|
+
tasks: nextTasks,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function deleteTask(board: KanbanBoard, taskId: string): KanbanBoard {
|
|
299
|
+
const nextTasks = board.tasks.filter((t) => t.id !== taskId);
|
|
300
|
+
if (nextTasks.length === board.tasks.length) {
|
|
301
|
+
throw new Error(`Task "${taskId}" not found`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return KanbanBoardSchema.parse({
|
|
305
|
+
lanes: board.lanes,
|
|
306
|
+
tasks: nextTasks,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function reorderTask(
|
|
311
|
+
board: KanbanBoard,
|
|
312
|
+
taskId: string,
|
|
313
|
+
targetLaneId: string,
|
|
314
|
+
targetIndex?: number,
|
|
315
|
+
visibleTaskIdsOrFilter?: string[] | ProjectFilter | string
|
|
316
|
+
): KanbanBoard {
|
|
317
|
+
const taskToMove = board.tasks.find((t) => t.id === taskId);
|
|
318
|
+
if (!taskToMove) {
|
|
319
|
+
throw new Error(`Task with ID "${taskId}" not found`);
|
|
320
|
+
}
|
|
321
|
+
if (!board.lanes.some((l) => l.id === targetLaneId)) {
|
|
322
|
+
throw new Error(`Target lane "${targetLaneId}" does not exist`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (targetIndex !== undefined) {
|
|
326
|
+
if (
|
|
327
|
+
typeof targetIndex !== "number" ||
|
|
328
|
+
!Number.isFinite(targetIndex) ||
|
|
329
|
+
!Number.isInteger(targetIndex)
|
|
330
|
+
) {
|
|
331
|
+
throw new Error("Target index must be a finite integer");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const isSameLane = taskToMove.laneId === targetLaneId;
|
|
336
|
+
const remainingTasks = board.tasks.filter((t) => t.id !== taskId);
|
|
337
|
+
const targetLaneTasks = remainingTasks.filter((t) => t.laneId === targetLaneId);
|
|
338
|
+
|
|
339
|
+
// Determine visibility predicate
|
|
340
|
+
let isTaskVisible: (t: KanbanTask) => boolean;
|
|
341
|
+
let isFilterActive = false;
|
|
342
|
+
|
|
343
|
+
if (Array.isArray(visibleTaskIdsOrFilter)) {
|
|
344
|
+
const idSet = new Set(visibleTaskIdsOrFilter);
|
|
345
|
+
isTaskVisible = (t: KanbanTask) => idSet.has(t.id);
|
|
346
|
+
isFilterActive = true;
|
|
347
|
+
} else if (visibleTaskIdsOrFilter) {
|
|
348
|
+
isTaskVisible = (t: KanbanTask) => isTaskMatchingFilter(t, visibleTaskIdsOrFilter);
|
|
349
|
+
isFilterActive =
|
|
350
|
+
typeof visibleTaskIdsOrFilter === "string"
|
|
351
|
+
? visibleTaskIdsOrFilter !== "all"
|
|
352
|
+
: visibleTaskIdsOrFilter.type !== "all";
|
|
353
|
+
} else {
|
|
354
|
+
isTaskVisible = () => true;
|
|
355
|
+
isFilterActive = false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Check same-lane self-only visible no-op rule:
|
|
359
|
+
// "同泳道只有被拖任务可见时,唯一可见槽位代表原位,不为了无可见锚点而改变该任务与隐藏任务的位置。明确原位操作不产生无意义写入。"
|
|
360
|
+
if (isSameLane && isFilterActive) {
|
|
361
|
+
const visibleTargetTasks = targetLaneTasks.filter(isTaskVisible);
|
|
362
|
+
if (visibleTargetTasks.length === 0) {
|
|
363
|
+
return board;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const updatedTask: KanbanTask = {
|
|
368
|
+
...taskToMove,
|
|
369
|
+
laneId: targetLaneId,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
let insertionGlobalIndex: number;
|
|
373
|
+
|
|
374
|
+
if (targetLaneTasks.length === 0) {
|
|
375
|
+
const targetLaneIdx = board.lanes.findIndex((l) => l.id === targetLaneId);
|
|
376
|
+
let insertAfterTask: KanbanTask | undefined;
|
|
377
|
+
for (let i = targetLaneIdx - 1; i >= 0; i--) {
|
|
378
|
+
const prevLaneId = board.lanes[i].id;
|
|
379
|
+
const prevLaneTasks = remainingTasks.filter((t) => t.laneId === prevLaneId);
|
|
380
|
+
if (prevLaneTasks.length > 0) {
|
|
381
|
+
insertAfterTask = prevLaneTasks[prevLaneTasks.length - 1];
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (insertAfterTask) {
|
|
386
|
+
insertionGlobalIndex = remainingTasks.indexOf(insertAfterTask) + 1;
|
|
387
|
+
} else {
|
|
388
|
+
let insertBeforeTask: KanbanTask | undefined;
|
|
389
|
+
for (let i = targetLaneIdx + 1; i < board.lanes.length; i++) {
|
|
390
|
+
const nextLaneId = board.lanes[i].id;
|
|
391
|
+
const nextLaneTasks = remainingTasks.filter((t) => t.laneId === nextLaneId);
|
|
392
|
+
if (nextLaneTasks.length > 0) {
|
|
393
|
+
insertBeforeTask = nextLaneTasks[0];
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (insertBeforeTask) {
|
|
398
|
+
insertionGlobalIndex = remainingTasks.indexOf(insertBeforeTask);
|
|
399
|
+
} else {
|
|
400
|
+
insertionGlobalIndex = remainingTasks.length;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
let referenceTask: KanbanTask;
|
|
405
|
+
let insertBefore = true;
|
|
406
|
+
|
|
407
|
+
if (isFilterActive) {
|
|
408
|
+
const visibleTargetTasks = targetLaneTasks.filter(isTaskVisible);
|
|
409
|
+
if (visibleTargetTasks.length === 0) {
|
|
410
|
+
// Cross-lane move to a lane with hidden tasks but NO visible tasks:
|
|
411
|
+
// Append to the end of target lane's existing tasks!
|
|
412
|
+
referenceTask = targetLaneTasks[targetLaneTasks.length - 1];
|
|
413
|
+
insertBefore = false;
|
|
414
|
+
} else {
|
|
415
|
+
const clampedVisibleIdx = Math.max(
|
|
416
|
+
0,
|
|
417
|
+
Math.min(targetIndex ?? visibleTargetTasks.length, visibleTargetTasks.length)
|
|
418
|
+
);
|
|
419
|
+
if (clampedVisibleIdx >= visibleTargetTasks.length) {
|
|
420
|
+
referenceTask = visibleTargetTasks[visibleTargetTasks.length - 1];
|
|
421
|
+
insertBefore = false;
|
|
422
|
+
} else {
|
|
423
|
+
referenceTask = visibleTargetTasks[clampedVisibleIdx];
|
|
424
|
+
insertBefore = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
const clampedLaneIdx = Math.max(
|
|
429
|
+
0,
|
|
430
|
+
Math.min(targetIndex ?? targetLaneTasks.length, targetLaneTasks.length)
|
|
431
|
+
);
|
|
432
|
+
if (clampedLaneIdx >= targetLaneTasks.length) {
|
|
433
|
+
referenceTask = targetLaneTasks[targetLaneTasks.length - 1];
|
|
434
|
+
insertBefore = false;
|
|
435
|
+
} else {
|
|
436
|
+
referenceTask = targetLaneTasks[clampedLaneIdx];
|
|
437
|
+
insertBefore = true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const refGlobalIdx = remainingTasks.indexOf(referenceTask);
|
|
442
|
+
insertionGlobalIndex = insertBefore ? refGlobalIdx : refGlobalIdx + 1;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const nextTasks = [
|
|
446
|
+
...remainingTasks.slice(0, insertionGlobalIndex),
|
|
447
|
+
updatedTask,
|
|
448
|
+
...remainingTasks.slice(insertionGlobalIndex),
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
if (
|
|
452
|
+
nextTasks.length === board.tasks.length &&
|
|
453
|
+
nextTasks.every((t, idx) => t.id === board.tasks[idx].id && t.laneId === board.tasks[idx].laneId)
|
|
454
|
+
) {
|
|
455
|
+
return board;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return KanbanBoardSchema.parse({
|
|
459
|
+
lanes: board.lanes,
|
|
460
|
+
tasks: nextTasks,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
|