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:
86
admin/src/components/reorder/ReorderBranch.vue
Normal file
86
admin/src/components/reorder/ReorderBranch.vue
Normal 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>
|
||||
23
admin/src/components/reorder/context.ts
Normal file
23
admin/src/components/reorder/context.ts
Normal 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')
|
||||
173
admin/src/components/reorder/tree.ts
Normal file
173
admin/src/components/reorder/tree.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user