Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
parseInitialCategories,
parseInitialGrades,
parseTab,
reCalcPriorities,
resolveDragEndAction,
saveUpdates,
TAB_CONFIGS,
} from '../_utils/kanban';
Expand Down Expand Up @@ -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;
}
}
</script>
Expand Down
11 changes: 10 additions & 1 deletion src/routes/(admin)/workbooks/order/_fixtures/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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],
],
};
6 changes: 6 additions & 0 deletions src/routes/(admin)/workbooks/order/_types/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
109 changes: 90 additions & 19 deletions src/routes/(admin)/workbooks/order/_utils/kanban.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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,
parseInitialCategories,
parseInitialGrades,
parseTab,
reCalcPriorities,
resolveDragEndAction,
} from './kanban';

describe('parseTab', () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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 }],
});
});
});
});
31 changes: 31 additions & 0 deletions src/routes/(admin)/workbooks/order/_utils/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ActiveTab,
KanbanColumns,
ColumnKey,
DragEndAction,
PlacementUpdates,
TabConfig,
} from '../_types/kanban';
Expand Down Expand Up @@ -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.
Expand Down
Loading