feat(18.1-01): task 4 flat reorder in the admin SPA

- ReorderView reads flat from the reorder data; ReorderBranch takes a flat
  prop and shows only move up and move down
- ArrowRight and ArrowLeft do nothing, an inside drop lands after the row
  (tree.ts flatZone, flatMove) and flatten sends parent_id null
- vitest flat cases; docs describe the flat page; rebuilt dist
This commit is contained in:
Jakub Zych
2026-10-14 16:05:10 +02:00
parent 9edff8890f
commit c3834cb42c
8 changed files with 142 additions and 24 deletions

View File

@@ -1,26 +1,28 @@
<script setup lang="ts">
import { inject } from 'vue'
import { computed, inject } from 'vue'
import { ChevronDown, ChevronUp, GripVertical, IndentDecrease, IndentIncrease } from '@lucide/vue'
import { t } from '../../app/i18n'
import { REORDER_PAGE } from './context'
import type { Move, TreeNode } from './tree'
import { flatMove, type Move, type TreeNode } from './tree'
// One level of the reorder tree (UI-SPEC "Tree"): a list item per node with
// its row (drag handle, name, move up, move down, outdent, indent) and its
// children's list inside it. The page owns the tree and every move.
defineProps<{ nodes: TreeNode[]; depth: number }>()
// children's list inside it. The page owns the tree and every move. A flat
// reorder (D-17) shows only move up and move down.
const props = withDefaults(defineProps<{ nodes: TreeNode[]; depth: number; flat?: boolean }>(), { flat: false })
const page = inject(REORDER_PAGE)!
const iconButton =
'inline-flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-40'
const moves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action: string }> = [
const allMoves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action: string }> = [
{ move: 'up', icon: ChevronUp, label: 'backend::lang.reorder.move_up', action: 'reorder-up' },
{ move: 'down', icon: ChevronDown, label: 'backend::lang.reorder.move_down', action: 'reorder-down' },
{ move: 'outdent', icon: IndentDecrease, label: 'backend::lang.reorder.outdent', action: 'reorder-outdent' },
{ move: 'indent', icon: IndentIncrease, label: 'backend::lang.reorder.indent', action: 'reorder-indent' },
]
const moves = computed(() => allMoves.filter((item) => flatMove(item.move, props.flat)))
</script>
<template>
@@ -80,7 +82,7 @@ const moves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action:
</button>
</div>
</div>
<ReorderBranch v-if="node.children.length > 0" :nodes="node.children" :depth="depth + 1" />
<ReorderBranch v-if="node.children.length > 0" :nodes="node.children" :depth="depth + 1" :flat="flat" />
</li>
</ul>
</template>

View File

@@ -58,12 +58,15 @@ export function buildTree(nodes: readonly AdminReorderNode[]): TreeNode[] {
return roots
}
/** The tree as sent: pre-order, sort_order counting from 1, parent null at the top. */
export function flatten(tree: readonly TreeNode[]): FlatNode[] {
/**
* The tree as sent: pre-order, sort_order counting from 1, parent null at the
* top. A flat reorder (D-17) sends parent null on every node.
*/
export function flatten(tree: readonly TreeNode[], flat = false): FlatNode[] {
const out: FlatNode[] = []
const walk = (list: readonly TreeNode[], parent: number | null) => {
for (const node of list) {
out.push({ id: node.id, parent_id: parent, sort_order: out.length + 1 })
out.push({ id: node.id, parent_id: flat ? null : parent, sort_order: out.length + 1 })
walk(node.children, node.id)
}
}
@@ -150,6 +153,16 @@ export function applyMove(tree: TreeNode[], id: number, move: Move): boolean {
}
}
/** The drop zone a flat reorder (D-17) uses: it never nests, so inside lands after the row. */
export function flatZone(zone: Zone, flat: boolean): Zone {
return flat && zone === 'inside' ? 'after' : zone
}
/** The moves a flat reorder offers: siblings only, never indent or outdent. */
export function flatMove(move: Move, flat: boolean): boolean {
return !flat || move === 'up' || move === 'down'
}
/** Whether node `dragged` may land on `target` in zone: never in its own subtree. */
export function canDrop(tree: TreeNode[], dragged: number, target: number): boolean {
const at = locate(tree, dragged)

View File

@@ -14,6 +14,8 @@ import {
canDrop,
canMove,
cloneTree,
flatMove,
flatZone,
flatten,
locate,
buildTree,
@@ -36,7 +38,10 @@ import { showToast } from '../state/useToasts'
// than the screen. A failed save restores the last saved tree unless a later
// save (which carries the whole tree) is waiting; a 409 (the records changed
// meanwhile) reloads it. A response that arrives after the page reloaded or
// switched partition leaves the screen alone.
// switched partition leaves the screen alone. A flat reorder (D-17, the
// data's `flat`) is a plain ordered list: no indent, outdent or inside drop,
// ArrowRight and ArrowLeft do nothing, and every node is sent with
// parent_id null.
const SAVE_DEBOUNCE_MS = 400
const SAVED_SHOWN_MS = 2000
@@ -56,6 +61,7 @@ type Status = 'loading' | 'ready' | 'failed' | 'forbidden'
const status = ref<Status>('loading')
const title = ref('')
const partition = ref<AdminReorderPartition | null>(null)
const flat = ref(false)
const tree = ref<TreeNode[]>([])
// The tree as last saved (or loaded): a failed save goes back to it.
let savedTree: TreeNode[] = []
@@ -99,6 +105,7 @@ async function load(): Promise<void> {
}
title.value = data.data.title
partition.value = (data.data.partition as AdminReorderPartition | null) ?? null
flat.value = data.data.flat === true
tree.value = buildTree(data.data.nodes ?? [])
savedTree = cloneTree(tree.value)
status.value = 'ready'
@@ -149,7 +156,7 @@ function failed(job: SaveJob): void {
async function send(job: SaveJob): Promise<void> {
inFlight = true
const body = { nodes: flatten(job.sent), partition: job.partition } as unknown as AdminReorderRequest
const body = { nodes: flatten(job.sent, flat.value), partition: job.partition } as unknown as AdminReorderRequest
savedShown.value = false
saving.value++
try {
@@ -239,6 +246,9 @@ async function moved(id: number, kind: 'moved' | 'nested' | 'unnested'): Promise
}
function move(id: number, kind: Move): void {
if (!flatMove(kind, flat.value)) {
return
}
const next = cloneTree(tree.value)
if (!applyMove(next, id, kind)) {
return
@@ -251,7 +261,7 @@ const KEY_MOVES: Record<string, Move> = { ArrowUp: 'up', ArrowDown: 'down', Arro
function onHandleKeydown(event: KeyboardEvent, id: number): void {
const kind = KEY_MOVES[event.key]
if (kind) {
if (kind && flatMove(kind, flat.value)) {
event.preventDefault()
move(id, kind)
}
@@ -288,7 +298,7 @@ function onDragOver(event: DragEvent, id: number): void {
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
const offset = event.clientY - box.top
const zone: Zone = offset < box.height / 3 ? 'before' : offset > (box.height * 2) / 3 ? 'after' : 'inside'
dropTarget.value = { id, zone }
dropTarget.value = { id, zone: flatZone(zone, flat.value) }
}
function onDragLeave(id: number): void {
@@ -324,7 +334,7 @@ provide(REORDER_PAGE, {
dropTarget,
blocked,
nameOf,
canMove: (id: number, kind: Move) => canMove(tree.value, id, kind),
canMove: (id: number, kind: Move) => flatMove(kind, flat.value) && canMove(tree.value, id, kind),
move,
onHandleKeydown,
onDragStart,
@@ -437,7 +447,14 @@ void load()
<p class="font-semibold">{{ t('backend::lang.reorder.empty') }}</p>
<p class="max-w-[420px] text-[13px] text-muted">{{ t('backend::lang.reorder.empty_hint') }}</p>
</div>
<ReorderBranch v-else :nodes="tree" :depth="0" :aria-label="t('backend::lang.reorder.tree')" data-reorder-tree />
<ReorderBranch
v-else
:nodes="tree"
:depth="0"
:flat="flat"
:aria-label="t('backend::lang.reorder.tree')"
data-reorder-tree
/>
</div>
<p class="sr-only" aria-live="polite" data-reorder-live>{{ announcement }}</p>

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import ReorderBranch from '../../src/components/reorder/ReorderBranch.vue'
import { REORDER_PAGE, type ReorderPage } from '../../src/components/reorder/context'
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree'
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatMove, flatZone, flatten, type TreeNode } from '../../src/components/reorder/tree'
import { setSettings } from '../../src/state/useSettings'
import ReorderView from '../../src/views/ReorderView.vue'
import { toasts } from '../../src/state/useToasts'
@@ -465,6 +465,90 @@ describe('reorder tree rules', () => {
})
})
// Phase 18.1 smoke specs for the flat (WinterCMS Sortable) reorder (D-17):
// the data's `flat` turns the page into a plain ordered list.
function flatTree(): Reply {
return {
body: {
data: {
title: 'Reorder links',
partition: null,
flat: true,
nodes: [
{ id: 1, name: 'Alpha', parent_id: null, sort_order: 1 },
{ id: 2, name: 'Beta', parent_id: null, sort_order: 2 },
{ id: 3, name: 'Gamma', parent_id: null, sort_order: 3 },
],
},
meta: {},
},
}
}
describe('ReorderView flat', () => {
it('offers no indent or outdent, ignores ArrowRight, drops a middle drag after the row and sends parent_id null', async () => {
let body: unknown = null
const { wrapper, calls } = await mountApp(
'/acme/companions/links/reorder',
{
[`GET ${REORDER}`]: flatTree(),
[`POST ${REORDER}`]: async (request) => {
body = await request.json()
return flatTree()
},
},
{ attach: true },
)
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
expect(wrapper.find('[data-action="reorder-indent"]').exists()).toBe(false)
expect(wrapper.find('[data-action="reorder-outdent"]').exists()).toBe(false)
expect(wrapper.findAll('[data-action="reorder-up"]')).toHaveLength(3)
expect(wrapper.findAll('[data-action="reorder-down"]')).toHaveLength(3)
keydown(handle(wrapper, 2).element, 'ArrowRight')
keydown(handle(wrapper, 2).element, 'ArrowLeft')
await flushPromises()
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
// A drag of Alpha onto the middle third of Beta's row lands after Beta.
const row = wrapper.find('[data-reorder-node="2"]')
vi.spyOn(row.element, 'getBoundingClientRect').mockReturnValue({ top: 0, height: 30, bottom: 30, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) } as DOMRect)
await handle(wrapper, 1).trigger('dragstart')
await row.trigger('dragover', { clientY: 15 })
expect(row.find('[data-reorder-line]').exists()).toBe(true)
await row.trigger('drop')
await flushPromises()
expect(shape(wrapper)).toBe('Beta Alpha Gamma')
await vi.waitFor(() => {
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
})
expect(body).toEqual({
partition: null,
nodes: [
{ id: 2, parent_id: null, sort_order: 1 },
{ id: 1, parent_id: null, sort_order: 2 },
{ id: 3, parent_id: null, sort_order: 3 },
],
})
})
it('maps the inside zone to after, keeps sibling moves and flattens with null parents', () => {
expect(flatZone('inside', true)).toBe('after')
expect(flatZone('inside', false)).toBe('inside')
expect(flatZone('before', true)).toBe('before')
expect(flatMove('indent', true)).toBe(false)
expect(flatMove('outdent', true)).toBe(false)
expect(flatMove('up', true)).toBe(true)
expect(flatMove('indent', false)).toBe(true)
const nested: TreeNode[] = [{ id: 1, name: 'A', children: [{ id: 2, name: 'B', children: [] }] }]
expect(flatten(nested, true)).toEqual([
{ id: 1, parent_id: null, sort_order: 1 },
{ id: 2, parent_id: null, sort_order: 2 },
])
})
})
// The reorder modules imported by path (T-10-25): the page the route renders,
// its tree level and the injection key they share.
describe('reorder modules', () => {