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

@@ -170,14 +170,31 @@ const title = computed(() => {
return message(forms, undefined, { name: recordName.value })
})
// A singleton controller (D-11, pact.AdminSingleton) edits its one record,
// opened from the settings index: no Delete, and back, Cancel and Save and
// close return to the settings index.
const singleton = computed(() => mode === 'update' && !!schema.value?.singleton)
const SETTINGS_PATH = '/settings'
const subtitle = computed(() =>
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
mode === 'update' && !singleton.value ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
)
// An update form of a record that has a preview screen is entered from it,
// so back and Cancel return there (UI-SPEC S3); a delete still goes to the list.
const backToPreview = computed(() => mode === 'update' && !!schema.value?.preview)
const backPath = computed(() => (backToPreview.value ? `${listPath}/${recordId}/preview` : listPath))
const backToPreview = computed(() => mode === 'update' && !singleton.value && !!schema.value?.preview)
const backPath = computed(() => {
if (singleton.value) {
return SETTINGS_PATH
}
return backToPreview.value ? `${listPath}/${recordId}/preview` : listPath
})
const backLabel = computed(() => {
if (singleton.value) {
return t('backend::lang.settings.back')
}
return t(backToPreview.value ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')
})
const dirty = computed(
() =>
@@ -437,7 +454,7 @@ async function onSave(): Promise<void> {
async function onSaveAndClose(): Promise<void> {
const record = await save()
if (record) {
await go(redirectTarget('redirectClose', record.data.id))
await go(singleton.value ? SETTINGS_PATH : redirectTarget('redirectClose', record.data.id))
}
}
@@ -534,7 +551,7 @@ void load()
<Button
variant="outline"
data-action="back"
:aria-label="t(backToPreview ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')"
:aria-label="backLabel"
class="size-10! px-0!"
@click="onLeave"
>
@@ -579,7 +596,7 @@ void load()
class="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>
<Button
v-if="mode === 'update'"
v-if="mode === 'update' && !singleton"
variant="danger"
data-action="delete"
:icon="Trash2"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Plus, SearchX } from '@lucide/vue'
import { ArrowUpDown, Plus, SearchX } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, BulkActionResult, ListMeta, ListQuery, ListSchema } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
@@ -79,6 +79,18 @@ const subtitle = computed(() => (meta.value ? message(messages.value?.recordCoun
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
// The tree reorder page (D-09): offered when the list declares it; a list
// filtered on the reorder partition column opens that partition.
const reorderLink = computed(() => {
const reorder = schema.value?.reorder
if (!reorder) {
return null
}
const column = reorder.partition?.column
const filter = column ? schema.value?.filters.find((item) => item.column === column || item.name === column) : undefined
const value = filter ? query.value.filter?.[filter.name] : undefined
return { path: `${basePath}/reorder`, query: value !== undefined && value !== '' ? { partition: String(value) } : {} }
})
// Delete and the registered actions the admin may run, in declared order
// (D-12). An action missing from toolbarActions is not rendered at all.
const toolbarActions = computed(() => schema.value?.toolbarActions ?? [])
@@ -408,7 +420,10 @@ async function onAction(name: string): Promise<void> {
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
<p v-if="subtitle" data-record-count class="text-muted">{{ subtitle }}</p>
</div>
<div v-if="headingButtons.length > 0" class="flex items-center gap-2.5">
<div v-if="headingButtons.length > 0 || reorderLink" class="flex items-center gap-2.5">
<Button v-if="reorderLink" data-action="reorder" variant="outline" :icon="ArrowUpDown" :to="reorderLink">
{{ t('backend::lang.messages.list.reorder') }}
</Button>
<Button
v-for="button in headingButtons"
:key="button"

View File

@@ -0,0 +1,390 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, provide, ref, watch, watchEffect } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ArrowUpDown, CircleAlert, LoaderCircle } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminReorderPartition, AdminReorderRequest } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import ReorderBranch from '../components/reorder/ReorderBranch.vue'
import { REORDER_PAGE } from '../components/reorder/context'
import {
applyDrop,
applyMove,
canDrop,
canMove,
cloneTree,
flatten,
locate,
buildTree,
type Move,
type TreeNode,
type Zone,
} from '../components/reorder/tree'
import Button from '../components/ui/Button.vue'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
import { showToast } from '../state/useToasts'
// The tree reorder page (D-09, UI-SPEC "Reorder page"), reached from a list
// whose schema declares `reorder`. Rows move by pointer drag (before, after
// or inside a row, never into the node's own subtree), by the arrow keys on
// the handle and by the four buttons. There is no Save button: every move is
// saved by one POST with the whole scoped node set, 400 ms after the last
// move, and a pending save is flushed when the page is left. A failed save
// restores the last saved tree; a 409 (the records changed meanwhile)
// reloads it.
const SAVE_DEBOUNCE_MS = 400
const SAVED_SHOWN_MS = 2000
const route = useRoute()
const router = useRouter()
const path = {
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}
const controllerId = controllerIdFromParams(path)
const listPath = controllerPath(controllerId) ?? '/'
type Status = 'loading' | 'ready' | 'failed' | 'forbidden'
const status = ref<Status>('loading')
const title = ref('')
const partition = ref<AdminReorderPartition | null>(null)
const tree = ref<TreeNode[]>([])
// The tree as last saved (or loaded): a failed save goes back to it.
let savedTree: TreeNode[] = []
const saving = ref(0)
const savedShown = ref(false)
const announcement = ref('')
let saveTimer: ReturnType<typeof setTimeout> | null = null
let savedTimer: ReturnType<typeof setTimeout> | null = null
let announceTimer: ReturnType<typeof setTimeout> | null = null
let generation = 0
const requestedPartition = computed(() => {
const raw = route.query.partition
const value = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isInteger(value) && value > 0 ? value : undefined
})
async function load(): Promise<void> {
const current = ++generation
status.value = 'loading'
try {
const query = requestedPartition.value !== undefined ? { partition: requestedPartition.value } : undefined
const { data, response } = await api.GET('/{vendor}/{plugin}/{controller}/reorder', { params: { path, query } })
if (current !== generation) {
return
}
if (!data) {
status.value = response.status === 403 ? 'forbidden' : 'failed'
return
}
title.value = data.data.title
partition.value = (data.data.partition as AdminReorderPartition | null) ?? null
tree.value = buildTree(data.data.nodes ?? [])
savedTree = cloneTree(tree.value)
status.value = 'ready'
} catch {
if (current === generation) {
status.value = 'failed'
}
}
}
function showSaved(): void {
savedShown.value = true
if (savedTimer !== null) {
clearTimeout(savedTimer)
}
savedTimer = setTimeout(() => {
savedTimer = null
savedShown.value = false
}, SAVED_SHOWN_MS)
}
async function save(): Promise<void> {
saveTimer = null
const sent = cloneTree(tree.value)
const body = { nodes: flatten(sent), partition: partition.value?.value ?? null } as unknown as AdminReorderRequest
savedShown.value = false
saving.value++
try {
const { data, response } = await api.POST('/{vendor}/{plugin}/{controller}/reorder', { params: { path }, body })
if (data) {
savedTree = sent
showSaved()
return
}
if (response.status === 409) {
showToast(t('backend::lang.reorder.stale'), 'danger')
await load()
return
}
tree.value = cloneTree(savedTree)
showToast(t('backend::lang.reorder.failed'), 'danger')
} catch {
tree.value = cloneTree(savedTree)
showToast(t('backend::lang.reorder.failed'), 'danger')
} finally {
saving.value--
}
}
function scheduleSave(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
}
saveTimer = setTimeout(() => void save(), SAVE_DEBOUNCE_MS)
}
/** Sends a pending save at once (leaving the page, changing the partition). */
function flush(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
void save()
}
}
function announce(text: string): void {
announcement.value = ''
if (announceTimer !== null) {
clearTimeout(announceTimer)
}
announceTimer = setTimeout(() => {
announceTimer = null
announcement.value = text
}, 50)
}
function nameOf(node: TreeNode): string {
return node.name !== '' ? node.name : t('backend::lang.reorder.untitled', { id: node.id })
}
/** After a move: save later, keep focus on the moved handle, announce where it went. */
async function moved(id: number, kind: 'moved' | 'nested' | 'unnested'): Promise<void> {
scheduleSave()
const at = locate(tree.value, id)
if (!at) {
return
}
const name = nameOf(at.node)
if (kind === 'nested' && at.parent) {
announce(t('backend::lang.reorder.nested', { name, parent: nameOf(at.parent) }))
} else if (kind === 'unnested') {
announce(t('backend::lang.reorder.unnested', { name, level: at.depth + 1 }))
} else {
announce(t('backend::lang.reorder.moved', { name, position: at.index + 1, total: at.list.length }))
}
await nextTick()
document.querySelector<HTMLElement>(`[data-reorder-handle="${id}"]`)?.focus()
}
function move(id: number, kind: Move): void {
const next = cloneTree(tree.value)
if (!applyMove(next, id, kind)) {
return
}
tree.value = next
void moved(id, kind === 'indent' ? 'nested' : kind === 'outdent' ? 'unnested' : 'moved')
}
const KEY_MOVES: Record<string, Move> = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }
function onHandleKeydown(event: KeyboardEvent, id: number): void {
const kind = KEY_MOVES[event.key]
if (kind) {
event.preventDefault()
move(id, kind)
}
}
const dragging = ref<number | null>(null)
const dropTarget = ref<{ id: number; zone: Zone } | null>(null)
const blocked = ref<number | null>(null)
function onDragStart(event: DragEvent, id: number): void {
dragging.value = id
dropTarget.value = null
event.dataTransfer?.setData('text/plain', String(id))
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(event: DragEvent, id: number): void {
if (dragging.value === null) {
return
}
if (!canDrop(tree.value, dragging.value, id)) {
// Into its own subtree: no indicator, and no preventDefault, so no drop.
blocked.value = id
dropTarget.value = null
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'none'
}
return
}
event.preventDefault()
blocked.value = null
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 }
}
function onDragLeave(id: number): void {
if (blocked.value === id) {
blocked.value = null
}
}
function onDrop(event: DragEvent): void {
const dragged = dragging.value
const target = dropTarget.value
onDragEnd()
if (dragged === null || !target) {
return
}
event.preventDefault()
const next = cloneTree(tree.value)
if (!applyDrop(next, dragged, target.id, target.zone)) {
return
}
tree.value = next
void moved(dragged, target.zone === 'inside' ? 'nested' : 'moved')
}
function onDragEnd(): void {
dragging.value = null
dropTarget.value = null
blocked.value = null
}
provide(REORDER_PAGE, {
dragging,
dropTarget,
blocked,
nameOf,
canMove: (id: number, kind: Move) => canMove(tree.value, id, kind),
move,
onHandleKeydown,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
})
function onPartition(event: Event): void {
const value = (event.target as HTMLSelectElement).value
flush()
void router.replace({ path: route.path, query: { ...route.query, partition: value } })
}
watch(requestedPartition, (next, previous) => {
if (next !== previous) {
void load()
}
})
function back(): void {
void router.push(listPath)
}
// Leaving never asks: the pending save goes out at once.
onBeforeRouteLeave(() => {
flush()
return true
})
onBeforeUnmount(() => {
flush()
for (const timer of [savedTimer, announceTimer]) {
if (timer !== null) {
clearTimeout(timer)
}
}
clearRecordCrumb()
})
watchEffect(() => setRecordCrumb(status.value === 'ready' ? title.value : ''))
void load()
</script>
<template>
<section class="flex w-full flex-col gap-5">
<header class="flex flex-wrap items-center gap-4">
<Button
variant="outline"
data-action="back"
:aria-label="t('backend::lang.form.return_to_list')"
class="size-10! px-0!"
@click="back"
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
<div class="flex min-w-0 flex-1 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ title }}</h1>
</div>
<div class="flex items-center gap-4">
<p data-reorder-status aria-live="polite" class="flex items-center gap-1.5 text-[13px] text-muted">
<template v-if="saving > 0">
<LoaderCircle :size="14" class="animate-spin" aria-hidden="true" />
{{ t('backend::lang.form.saving') }}
</template>
<template v-else-if="savedShown">{{ t('backend::lang.reorder.saved') }}</template>
</p>
<label v-if="partition" class="flex items-center gap-2 font-semibold">
{{ partition.label }}
<select
data-reorder-partition
:value="String(partition.value)"
class="h-input w-[240px] appearance-none rounded-control border border-border-strong bg-surface px-3.5 font-normal text-text"
@change="onPartition"
>
<option v-for="option in partition.options" :key="option.value" :value="String(option.value)">
{{ option.label }}
</option>
</select>
</label>
</div>
</header>
<p v-if="status === 'failed'" role="alert" class="flex items-center gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
{{ t('backend::lang.reorder.load_failed') }}
</p>
<p v-else-if="status === 'forbidden'" role="alert" class="flex items-center gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
{{ t('backend::lang.form.forbidden') }}
</p>
<div
v-else
data-reorder-card
class="overflow-hidden rounded-card border border-border bg-surface shadow-card"
:aria-busy="status === 'loading' ? 'true' : undefined"
>
<template v-if="status === 'loading'">
<div v-for="row in 5" :key="row" data-reorder-skeleton class="flex min-h-row items-center gap-3 border-t border-border px-4 first:border-t-0">
<span class="size-5 rounded-[6px] bg-skel" />
<span class="h-3 w-48 rounded-[6px] bg-skel" />
</div>
</template>
<div v-else-if="tree.length === 0" data-reorder-empty class="flex flex-col items-center gap-2 px-6 py-8 text-center">
<span class="flex size-10 items-center justify-center rounded-full bg-subtle text-muted">
<ArrowUpDown :size="20" aria-hidden="true" />
</span>
<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 />
</div>
<p class="sr-only" aria-live="polite" data-reorder-live>{{ announcement }}</p>
</section>
</template>

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { api } from '../api/client'
import type { SettingsEntry } from '../api/types'
import { parseControllerId } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import { iconFor } from '../app/icons'
import { settingsGroups, settingsPath } from '../state/useSettings'
@@ -7,6 +11,37 @@ import { settingsGroups, settingsPath } from '../state/useSettings'
// Settings index (D-21): every permitted settings page, grouped by category,
// with its icon, label and description. The server filters the list. A
// singleton entry opens its settings form; a link entry opens its controller.
// A link to a singleton controller (D-11) opens that controller's one record:
// GET …/singleton answers its id (a controller that is not a singleton
// answers 404 and the link opens its list).
const router = useRouter()
const opening = ref<string | null>(null)
async function open(event: MouseEvent, entry: SettingsEntry, navigate: (event: MouseEvent) => unknown): Promise<void> {
const params = entry.controller ? parseControllerId(entry.controller) : null
if (!params) {
void navigate(event)
return
}
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
// A new tab or window opens the link as it is.
return
}
event.preventDefault()
if (opening.value !== null) {
return
}
opening.value = entry.code
const target = settingsPath(entry)
try {
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/singleton', { params: { path: params } })
await router.push(data ? `${target}/${data.data.id}` : target)
} catch {
await router.push(target)
} finally {
opening.value = null
}
}
</script>
<template>
@@ -28,17 +63,25 @@ import { settingsGroups, settingsPath } from '../state/useSettings'
<RouterLink
v-for="entry in group.entries"
:key="entry.code"
v-slot="{ href, navigate }"
:to="settingsPath(entry)"
:data-settings="entry.code"
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
custom
>
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-col">
<span class="font-semibold">{{ entry.label }}</span>
<span v-if="entry.description" class="text-[13px] text-muted">{{ entry.description }}</span>
</span>
<a
:href="href"
:data-settings="entry.code"
:aria-busy="opening === entry.code ? 'true' : undefined"
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
@click="open($event, entry, navigate)"
>
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-col">
<span class="font-semibold">{{ entry.label }}</span>
<span v-if="entry.description" class="text-[13px] text-muted">{{ entry.description }}</span>
</span>
</a>
</RouterLink>
</div>
</section>