diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts
index d21ebfd..426f5df 100644
--- a/admin/src/api/types.ts
+++ b/admin/src/api/types.ts
@@ -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. */
diff --git a/admin/src/app/router.ts b/admin/src/app/router.ts
index 3ed0031..96b3a83 100644
--- a/admin/src/app/router.ts
+++ b/admin/src/app/router.ts
@@ -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).
{
diff --git a/admin/src/components/reorder/ReorderBranch.vue b/admin/src/components/reorder/ReorderBranch.vue
new file mode 100644
index 0000000..bf74b28
--- /dev/null
+++ b/admin/src/components/reorder/ReorderBranch.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+ {{ page.nameOf(node) }}
+
+
+
+
+
+
+
+
+
diff --git a/admin/src/components/reorder/context.ts b/admin/src/components/reorder/context.ts
new file mode 100644
index 0000000..1a981da
--- /dev/null
+++ b/admin/src/components/reorder/context.ts
@@ -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>
+ /** The row and zone a drop would land in, or null. */
+ dropTarget: Readonly>
+ /** The row under the pointer that refuses the drop (the dragged node's own subtree). */
+ blocked: Readonly>
+ 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 = Symbol('summer.reorder.page')
diff --git a/admin/src/components/reorder/tree.ts b/admin/src/components/reorder/tree.ts
new file mode 100644
index 0000000..89d1063
--- /dev/null
+++ b/admin/src/components/reorder/tree.ts
@@ -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 & { 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()
+ 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()
+ 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
+}
diff --git a/admin/src/views/FormView.vue b/admin/src/views/FormView.vue
index 6305ab6..ada2352 100644
--- a/admin/src/views/FormView.vue
+++ b/admin/src/views/FormView.vue
@@ -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 {
async function onSaveAndClose(): Promise {
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()