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', () => {

View File

@@ -241,6 +241,8 @@ The reorder is flat when the file names no `parentColumn` and the controller's r
The list schema's `reorder` and the data of both reorder routes carry `flat: true`; a tree reorder omits the key, so its payloads are unchanged. Every node of a flat reorder has `parent_id: null`. A save writes only the sort column, without running the model's save hooks. A node whose `parent_id` is not null is a 422 `validation_failed` on `nodes` with `reorder.flat_parent`; the duplicate-id 422 and the 409 `reorder_stale` set rule are the same as on a tree.
In the admin, a flat reorder page is a plain ordered list: every row has the drag handle and the move up and move down buttons, but no outdent or indent buttons; ArrowUp and ArrowDown on a handle move the row, ArrowRight and ArrowLeft do nothing, and a row dragged onto the middle of another lands after it. Saving works as on a tree, with `parent_id: null` on every node.
## Remembered list state
The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-CpHJmq2q.js"></script>
<script type="module" crossorigin src="./assets/index-Bu2-b1tB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CS4sv1WG.css">
</head>
<body>