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:
@@ -72,6 +72,20 @@ export type FieldNotice = Schemas['cabana.FieldNotice']
|
||||
export type AdminDependentOptionsResult = Schemas['cabana.AdminDependentOptionsResult']
|
||||
/** One dependent option; depth indents a choice taken from a tree. */
|
||||
export type AdminDependentOption = Schemas['cabana.AdminDependentOption']
|
||||
/** The tree reorder page (D-09): title, partition and the scoped nodes. */
|
||||
export type AdminReorderTree = Schemas['cabana.AdminReorderTree']
|
||||
/** One node of a reorder tree; parent_id is null at the top level. */
|
||||
export type AdminReorderNode = Schemas['cabana.AdminReorderNode']
|
||||
/** One node of a reorder save. */
|
||||
export type AdminReorderNodeInput = Schemas['cabana.AdminReorderNodeInput']
|
||||
/** Body of POST …/reorder: the whole scoped node set and the partition. */
|
||||
export type AdminReorderRequest = Schemas['cabana.AdminReorderRequest']
|
||||
/** The partition of a reorder page: its label, the choices and the one shown. */
|
||||
export type AdminReorderPartition = Schemas['cabana.AdminReorderPartition']
|
||||
/** A list schema's tree reorder page (title, partition column). */
|
||||
export type ListReorder = Schemas['cabana.ListReorder']
|
||||
/** The id of a singleton controller's one record (D-11). */
|
||||
export type AdminSingletonResult = Schemas['cabana.AdminSingletonResult']
|
||||
/** A repeater's row form: the nested fields of one row (D-07). */
|
||||
export type RepeaterForm = Schemas['cabana.RepeaterForm']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import LoginView from '../views/LoginView.vue'
|
||||
import ListView from '../views/ListView.vue'
|
||||
import FormView from '../views/FormView.vue'
|
||||
import PreviewView from '../views/PreviewView.vue'
|
||||
import ReorderView from '../views/ReorderView.vue'
|
||||
import SettingsFormView from '../views/SettingsFormView.vue'
|
||||
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
||||
import AccountView from '../views/AccountView.vue'
|
||||
@@ -36,7 +37,7 @@ export function safeRedirect(value: unknown): string | null {
|
||||
return value
|
||||
}
|
||||
|
||||
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview'])
|
||||
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview', 'reorder'])
|
||||
|
||||
/**
|
||||
* The controller a route shows, or '' for every other screen (settings,
|
||||
@@ -72,6 +73,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
||||
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
||||
// The tree reorder page of a list with config_reorder.yaml (D-09).
|
||||
{ path: '/:vendor/:plugin/:controller/reorder', name: 'reorder', component: ReorderView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
||||
// The read-only record screen of a form with a preview block (D-11).
|
||||
{
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
390
admin/src/views/ReorderView.vue
Normal file
390
admin/src/views/ReorderView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user