diff --git a/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte b/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte index 34e7ffdea..c39bb9616 100644 --- a/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte +++ b/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte @@ -31,7 +31,7 @@ parseInitialCategories, parseInitialGrades, parseTab, - reCalcPriorities, + resolveDragEndAction, saveUpdates, TAB_CONFIGS, } from '../_utils/kanban'; @@ -92,34 +92,35 @@ } async function onDragEnd(event: DragEndEventArg) { - if (!event.operation?.source || !event.operation?.target) { - return; - } - const capturedTab = activeTab; const capturedSnapshot = snapshot; + // Clear before awaiting so a drag started during the save keeps its own snapshot + snapshot = null; + + const action = resolveDragEndAction({ + canceled: event.canceled, + before: capturedSnapshot, + after: allItems[capturedTab], + columnKey: TAB_CONFIGS[capturedTab].columnKey, + }); + + if (action.kind === 'restore') { + allItems[capturedTab] = action.columns; + return; + } - const updates = reCalcPriorities( - capturedSnapshot ?? {}, - allItems[capturedTab], - TAB_CONFIGS[capturedTab].columnKey, - ); - - if (updates.length === 0) { - snapshot = null; + if (action.kind === 'none') { return; } try { - await saveUpdates(updates); + await saveUpdates(action.updates); } catch { if (capturedSnapshot) { allItems[capturedTab] = capturedSnapshot; } errorMessage = '保存に失敗しました'; - } finally { - snapshot = null; } } diff --git a/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts b/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts index 5940b9779..e2785b4dc 100644 --- a/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts +++ b/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts @@ -70,7 +70,7 @@ export const workbooks: WorkbooksWithPlacement = [ }, ]; -// KanbanColumns snapshot used as the "before drag" baseline in reCalcPriorities tests. +// "Before drag" baseline: GRAPH has two cards and PENDING has one. export const solutionColumnsBefore: KanbanColumns = { [SolutionCategory.GRAPH]: [ { id: 101, workBookId: 1, title: 'Graph Basics', isPublished: true }, @@ -80,3 +80,12 @@ export const solutionColumnsBefore: KanbanColumns = { { id: 103, workBookId: 3, title: 'Pending Book', isPublished: true }, ], }; + +// solutionColumnsBefore with the two GRAPH cards swapped; PENDING is unchanged. +export const solutionColumnsGraphSwapped: KanbanColumns = { + ...solutionColumnsBefore, + [SolutionCategory.GRAPH]: [ + solutionColumnsBefore[SolutionCategory.GRAPH][1], + solutionColumnsBefore[SolutionCategory.GRAPH][0], + ], +}; diff --git a/src/routes/(admin)/workbooks/order/_types/kanban.ts b/src/routes/(admin)/workbooks/order/_types/kanban.ts index af4231461..3b89ba402 100644 --- a/src/routes/(admin)/workbooks/order/_types/kanban.ts +++ b/src/routes/(admin)/workbooks/order/_types/kanban.ts @@ -30,6 +30,12 @@ export type PlacementUpdate = { export type PlacementUpdates = PlacementUpdate[]; +// What the board does when a drag ends +export type DragEndAction = + | { kind: 'none' } + | { kind: 'restore'; columns: KanbanColumns } + | { kind: 'save'; updates: PlacementUpdates }; + // Props required for dnd-kit sortable positioning export type SortableProps = { columnId: string; // droppable zone ID (the column this card belongs to) diff --git a/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts b/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts index 0bd533308..f91d03e23 100644 --- a/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts +++ b/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts @@ -3,7 +3,7 @@ import { describe, test, expect } from 'vitest'; import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; import { TaskGrade } from '$lib/types/task'; -import { workbooks, solutionColumnsBefore } from '../_fixtures/kanban'; +import { workbooks, solutionColumnsBefore, solutionColumnsGraphSwapped } from '../_fixtures/kanban'; import { buildKanbanItems, buildUpdatedUrl, @@ -11,6 +11,7 @@ import { parseInitialGrades, parseTab, reCalcPriorities, + resolveDragEndAction, } from './kanban'; describe('parseTab', () => { @@ -234,15 +235,7 @@ describe('reCalcPriorities', () => { }); test('returns updates for reordered cards within a column', () => { - const after = { - ...before, - [SolutionCategory.GRAPH]: [ - before[SolutionCategory.GRAPH][1], - before[SolutionCategory.GRAPH][0], - ], // swapped - }; - - const updates = reCalcPriorities(before, after, 'solutionCategory'); + const updates = reCalcPriorities(before, solutionColumnsGraphSwapped, 'solutionCategory'); expect(updates).toHaveLength(2); expect(updates[0]).toMatchObject({ id: 102, @@ -259,15 +252,8 @@ describe('reCalcPriorities', () => { }); test('returns updates only for changed columns', () => { - const after = { - [SolutionCategory.GRAPH]: [ - before[SolutionCategory.GRAPH][1], - before[SolutionCategory.GRAPH][0], - ], // changed - [SolutionCategory.PENDING]: before[SolutionCategory.PENDING], // unchanged - }; - - const updates = reCalcPriorities(before, after, 'solutionCategory'); + // GRAPH is swapped, PENDING is unchanged + const updates = reCalcPriorities(before, solutionColumnsGraphSwapped, 'solutionCategory'); const updatedIds = updates.map((update) => update.id); expect(updatedIds).not.toContain(103); expect(updatedIds).toContain(101); @@ -336,3 +322,88 @@ describe('reCalcPriorities', () => { expect(reCalcPriorities({}, {}, 'solutionCategory')).toEqual([]); }); }); + +describe('resolveDragEndAction', () => { + const before = solutionColumnsBefore; + + describe('canceled drag', () => { + test('returns restore with the snapshot when a reordered drag is canceled', () => { + const action = resolveDragEndAction({ + canceled: true, + before, + after: solutionColumnsGraphSwapped, + columnKey: 'solutionCategory', + }); + + expect(action).toEqual({ + kind: 'restore', + columns: { + [SolutionCategory.GRAPH]: [ + { id: 101, workBookId: 1, title: 'Graph Basics', isPublished: true }, + { id: 102, workBookId: 2, title: 'Graph Advanced', isPublished: false }, + ], + [SolutionCategory.PENDING]: [ + { id: 103, workBookId: 3, title: 'Pending Book', isPublished: true }, + ], + }, + }); + }); + + test('returns none when the drag is canceled without a snapshot', () => { + const action = resolveDragEndAction({ + canceled: true, + before: null, + after: solutionColumnsGraphSwapped, + columnKey: 'solutionCategory', + }); + + expect(action).toEqual({ kind: 'none' }); + }); + }); + + describe('completed drag', () => { + test('returns none when the order is unchanged', () => { + const action = resolveDragEndAction({ + canceled: false, + before, + after: structuredClone(before), + columnKey: 'solutionCategory', + }); + + expect(action).toEqual({ kind: 'none' }); + }); + + test('returns save with recalculated updates when cards were reordered', () => { + const action = resolveDragEndAction({ + canceled: false, + before, + after: solutionColumnsGraphSwapped, + columnKey: 'solutionCategory', + }); + + expect(action).toEqual({ + kind: 'save', + updates: [ + { id: 102, priority: 1, solutionCategory: SolutionCategory.GRAPH, taskGrade: null }, + { id: 101, priority: 2, solutionCategory: SolutionCategory.GRAPH, taskGrade: null }, + ], + }); + }); + + test('treats a missing snapshot as an empty board when not canceled', () => { + const action = resolveDragEndAction({ + canceled: false, + before: null, + after: { + [TaskGrade.Q10]: [{ id: 201, workBookId: 4, title: 'Curriculum Q10', isPublished: true }], + }, + columnKey: 'taskGrade', + }); + + expect(action).toEqual({ + kind: 'save', + updates: [{ id: 201, priority: 1, solutionCategory: null, taskGrade: TaskGrade.Q10 }], + }); + }); + }); +}); diff --git a/src/routes/(admin)/workbooks/order/_utils/kanban.ts b/src/routes/(admin)/workbooks/order/_utils/kanban.ts index f2be07383..0567362b2 100644 --- a/src/routes/(admin)/workbooks/order/_utils/kanban.ts +++ b/src/routes/(admin)/workbooks/order/_utils/kanban.ts @@ -9,6 +9,7 @@ import type { ActiveTab, KanbanColumns, ColumnKey, + DragEndAction, PlacementUpdates, TabConfig, } from '../_types/kanban'; @@ -174,6 +175,36 @@ export function reCalcPriorities( }); } +/** + * Decides what the board does when a drag ends. + * + * A canceled drag restores the snapshot, because dnd-kit skips its own DOM revert once + * onDragOver has changed the app state. A drop outside every column saves the order as displayed. + * + * @param params.before - Snapshot taken on drag start, or `null` if none was taken + * @param params.after - Current state after the drag operation + */ +export function resolveDragEndAction(params: { + canceled: boolean; + before: KanbanColumns | null; + after: KanbanColumns; + columnKey: ColumnKey; +}): DragEndAction { + const { canceled, before, after, columnKey } = params; + + if (canceled) { + return before ? { kind: 'restore', columns: before } : { kind: 'none' }; + } + + const updates = reCalcPriorities(before ?? {}, after, columnKey); + + if (updates.length === 0) { + return { kind: 'none' }; + } + + return { kind: 'save', updates }; +} + /** * Sends placement updates to the server. * Throws if the response is not OK.