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 @@ + + + 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()