feat(17-02): tree reorder page and singleton form in the admin

- ReorderView: indented tree with drag handle, move up/down, outdent and
  indent (buttons and arrow keys on the handle), pointer drag before, after
  or inside a row and never into its own subtree; one POST with the whole
  node set 400 ms after the last move, flushed on leave; a failed save
  restores the saved tree, a 409 reloads; loading, empty, load-failure and
  403 states; partition selector
- ListView: outline Reorder button before New record, passing the filtered
  partition
- SettingsIndexView opens a singleton controller's record through
  GET …/singleton; FormView in singleton mode has no Delete and returns to
  the settings index
- reorder.* and messages.list.reorder phrase keys (en, pl), smoke spec, docs
This commit is contained in:
2026-10-12 16:18:31 +02:00
parent 204149ccd2
commit 872fd6535f
15 changed files with 1092 additions and 19 deletions

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { 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'
// 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 }>()
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 }> = [
{ 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' },
]
</script>
<template>
<ul role="list" class="m-0 list-none p-0">
<li v-for="(node, index) in nodes" :key="node.id">
<div
:data-reorder-node="node.id"
:class="[
depth === 0 && index === 0 ? '' : 'border-t border-border',
page.dropTarget.value?.id === node.id && page.dropTarget.value.zone === 'inside'
? 'bg-sel outline-2 -outline-offset-2 outline-primary outline-dashed'
: 'hover:bg-hover',
page.dragging.value === node.id ? 'opacity-50' : '',
page.blocked.value === node.id ? 'cursor-not-allowed' : '',
]"
class="relative flex min-h-row items-center gap-2 pr-4"
:style="{ paddingInlineStart: `calc(16px + ${depth} * 24px)` }"
@dragover="page.onDragOver($event, node.id)"
@dragleave="page.onDragLeave(node.id)"
@drop="page.onDrop($event)"
>
<span
v-if="page.dropTarget.value?.id === node.id && page.dropTarget.value.zone !== 'inside'"
data-reorder-line
:class="page.dropTarget.value.zone === 'before' ? 'top-0' : 'bottom-0'"
class="pointer-events-none absolute right-0 h-0.5 bg-primary"
:style="{ left: `calc(16px + ${depth} * 24px)` }"
/>
<button
type="button"
draggable="true"
:data-reorder-handle="node.id"
:aria-label="t('backend::lang.reorder.move', { name: page.nameOf(node) })"
:class="iconButton"
class="cursor-grab"
@keydown="page.onHandleKeydown($event, node.id)"
@dragstart="page.onDragStart($event, node.id)"
@dragend="page.onDragEnd"
>
<GripVertical :size="16" aria-hidden="true" />
</button>
<span class="min-w-0 flex-1 [overflow-wrap:anywhere]" :class="node.name === '' ? 'text-muted' : ''">
{{ page.nameOf(node) }}
</span>
<div class="flex gap-1">
<button
v-for="item in moves"
:key="item.move"
type="button"
:data-action="item.action"
:aria-label="t(item.label, { name: page.nameOf(node) })"
:disabled="!page.canMove(node.id, item.move)"
:class="iconButton"
@click="page.move(node.id, item.move)"
>
<component :is="item.icon" :size="16" aria-hidden="true" />
</button>
</div>
</div>
<ReorderBranch v-if="node.children.length > 0" :nodes="node.children" :depth="depth + 1" />
</li>
</ul>
</template>

View File

@@ -0,0 +1,23 @@
// What ReorderView shares with every level of its tree (ReorderBranch).
import type { InjectionKey, Ref } from 'vue'
import type { Move, TreeNode, Zone } from './tree'
export interface ReorderPage {
/** The node being dragged, or null. */
dragging: Readonly<Ref<number | null>>
/** The row and zone a drop would land in, or null. */
dropTarget: Readonly<Ref<{ id: number; zone: Zone } | null>>
/** The row under the pointer that refuses the drop (the dragged node's own subtree). */
blocked: Readonly<Ref<number | null>>
nameOf(node: TreeNode): string
canMove(id: number, move: Move): boolean
move(id: number, move: Move): void
onHandleKeydown(event: KeyboardEvent, id: number): void
onDragStart(event: DragEvent, id: number): void
onDragOver(event: DragEvent, id: number): void
onDragLeave(id: number): void
onDrop(event: DragEvent): void
onDragEnd(): void
}
export const REORDER_PAGE: InjectionKey<ReorderPage> = Symbol('summer.reorder.page')

View File

@@ -0,0 +1,173 @@
// The tree of the reorder page (D-09, UI-SPEC "Reorder page"): built from
// the flat nodes GET …/reorder answers, edited by the page's moves and sent
// back flat by POST …/reorder. Children always move with their parent.
import type { AdminReorderNode, AdminReorderNodeInput } from '../../api/types'
export interface TreeNode {
id: number
name: string
children: TreeNode[]
}
/** Where a dragged node lands relative to the row under the pointer. */
export type Zone = 'before' | 'after' | 'inside'
/** The keyboard and button moves: siblings, nest under the previous one, out one level. */
export type Move = 'up' | 'down' | 'indent' | 'outdent'
/** A node as sent: the server's AdminReorderNodeInput, with a null parent at the top. */
export type FlatNode = Omit<AdminReorderNodeInput, 'parent_id'> & { parent_id: number | null }
/** The nested tree of flat nodes: by sort order, then id; an unknown parent puts a node at the top. */
export function buildTree(nodes: readonly AdminReorderNode[]): TreeNode[] {
const byId = new Map<number, TreeNode>()
for (const node of nodes) {
byId.set(node.id, { id: node.id, name: node.name ?? '', children: [] })
}
const ordered = [...nodes].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id)
const roots: TreeNode[] = []
for (const flat of ordered) {
const node = byId.get(flat.id)!
const parentId = (flat.parent_id as number | null | undefined) ?? null
const parent = parentId !== null ? byId.get(parentId) : undefined
if (parent && parent !== node) {
parent.children.push(node)
} else {
roots.push(node)
}
}
// Nodes caught in a parent cycle reach no root: they join the top level.
const reached = new Set<number>()
const walk = (list: TreeNode[]) => {
for (const node of list) {
reached.add(node.id)
walk(node.children)
}
}
walk(roots)
for (const flat of ordered) {
if (!reached.has(flat.id)) {
const node = byId.get(flat.id)!
for (const other of byId.values()) {
other.children = other.children.filter((child) => child.id !== node.id)
}
roots.push(node)
walk([node])
}
}
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[] {
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 })
walk(node.children, node.id)
}
}
walk(tree, null)
return out
}
export function cloneTree(tree: readonly TreeNode[]): TreeNode[] {
return tree.map((node) => ({ id: node.id, name: node.name, children: cloneTree(node.children) }))
}
export interface Located {
node: TreeNode
list: TreeNode[]
index: number
parent: TreeNode | null
/** 0 at the top level. */
depth: number
}
export function locate(tree: TreeNode[], id: number, parent: TreeNode | null = null, depth = 0): Located | null {
for (let index = 0; index < tree.length; index++) {
const node = tree[index]!
if (node.id === id) {
return { node, list: tree, index, parent, depth }
}
const found = locate(node.children, id, node, depth + 1)
if (found) {
return found
}
}
return null
}
/** Whether id is node itself or anywhere below it. */
export function contains(node: TreeNode, id: number): boolean {
return node.id === id || node.children.some((child) => contains(child, id))
}
/** Whether a move can run: first or last among its siblings, at the top, or no previous sibling. */
export function canMove(tree: TreeNode[], id: number, move: Move): boolean {
const at = locate(tree, id)
if (!at) {
return false
}
switch (move) {
case 'up':
case 'indent':
return at.index > 0
case 'down':
return at.index < at.list.length - 1
case 'outdent':
return at.parent !== null
}
}
/** Runs a move on tree in place; false when it cannot run. */
export function applyMove(tree: TreeNode[], id: number, move: Move): boolean {
if (!canMove(tree, id, move)) {
return false
}
const at = locate(tree, id)!
switch (move) {
case 'up':
at.list.splice(at.index, 1)
at.list.splice(at.index - 1, 0, at.node)
return true
case 'down':
at.list.splice(at.index, 1)
at.list.splice(at.index + 1, 0, at.node)
return true
case 'indent': {
const previous = at.list[at.index - 1]!
at.list.splice(at.index, 1)
previous.children.push(at.node)
return true
}
case 'outdent': {
const parent = locate(tree, at.parent!.id)!
at.list.splice(at.index, 1)
parent.list.splice(parent.index + 1, 0, at.node)
return true
}
}
}
/** 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)
return !!at && !contains(at.node, target)
}
/** Moves `dragged` before, after or inside (as last child of) `target`, in place. */
export function applyDrop(tree: TreeNode[], dragged: number, target: number, zone: Zone): boolean {
if (!canDrop(tree, dragged, target)) {
return false
}
const from = locate(tree, dragged)!
from.list.splice(from.index, 1)
const to = locate(tree, target)!
if (zone === 'inside') {
to.node.children.push(from.node)
} else {
to.list.splice(zone === 'before' ? to.index : to.index + 1, 0, from.node)
}
return true
}