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>
|
||||
|
||||
54
admin/tests/fixtures/lang.json
vendored
54
admin/tests/fixtures/lang.json
vendored
@@ -288,6 +288,9 @@
|
||||
"backend::lang.form.error_title": {
|
||||
"other": "Nie udało się zapisać."
|
||||
},
|
||||
"backend::lang.form.forbidden": {
|
||||
"other": "Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane."
|
||||
},
|
||||
"backend::lang.form.load_failed": {
|
||||
"other": "Nie udało się wczytać formularza."
|
||||
},
|
||||
@@ -468,6 +471,9 @@
|
||||
"many": ":count rekordów",
|
||||
"other": ":count rekordu"
|
||||
},
|
||||
"backend::lang.messages.list.reorder": {
|
||||
"other": "Zmień kolejność rekordów"
|
||||
},
|
||||
"backend::lang.messages.list.selected": {
|
||||
"other": "Zaznaczono :count"
|
||||
},
|
||||
@@ -630,6 +636,54 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.reorder.empty": {
|
||||
"other": "Nie ma jeszcze czego porządkować"
|
||||
},
|
||||
"backend::lang.reorder.empty_hint": {
|
||||
"other": "Najpierw dodaj rekordy, a potem wróć tutaj, aby ustalić ich kolejność."
|
||||
},
|
||||
"backend::lang.reorder.failed": {
|
||||
"other": "Nie udało się zapisać nowej kolejności. Przywrócono poprzednią."
|
||||
},
|
||||
"backend::lang.reorder.indent": {
|
||||
"other": "Przenieś do poprzedniej pozycji: :name"
|
||||
},
|
||||
"backend::lang.reorder.load_failed": {
|
||||
"other": "Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie."
|
||||
},
|
||||
"backend::lang.reorder.move": {
|
||||
"other": "Przenieś: :name"
|
||||
},
|
||||
"backend::lang.reorder.move_down": {
|
||||
"other": "Przenieś niżej: :name"
|
||||
},
|
||||
"backend::lang.reorder.move_up": {
|
||||
"other": "Przenieś wyżej: :name"
|
||||
},
|
||||
"backend::lang.reorder.moved": {
|
||||
"other": "Przeniesiono :name na pozycję :position z :total."
|
||||
},
|
||||
"backend::lang.reorder.nested": {
|
||||
"other": ":name jest teraz wewnątrz :parent."
|
||||
},
|
||||
"backend::lang.reorder.outdent": {
|
||||
"other": "Przenieś poziom wyżej: :name"
|
||||
},
|
||||
"backend::lang.reorder.saved": {
|
||||
"other": "Zapisano kolejność"
|
||||
},
|
||||
"backend::lang.reorder.stale": {
|
||||
"other": "Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie."
|
||||
},
|
||||
"backend::lang.reorder.tree": {
|
||||
"other": "Kolejność rekordów"
|
||||
},
|
||||
"backend::lang.reorder.unnested": {
|
||||
"other": "Przeniesiono :name na poziom :level."
|
||||
},
|
||||
"backend::lang.reorder.untitled": {
|
||||
"other": "Bez tytułu (#:id)"
|
||||
},
|
||||
"backend::lang.repeater.add": {
|
||||
"other": "Dodaj pozycję"
|
||||
},
|
||||
|
||||
221
admin/tests/views/ReorderView.test.ts
Normal file
221
admin/tests/views/ReorderView.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's
|
||||
// Reorder button and the singleton form (D-11). Plan 06 completes them.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const REORDER = `${BASE}/reorder`
|
||||
|
||||
function tree(): Reply {
|
||||
return {
|
||||
body: {
|
||||
data: {
|
||||
title: 'Reorder links',
|
||||
partition: {
|
||||
label: 'Group',
|
||||
value: 5,
|
||||
options: [
|
||||
{ value: 5, label: 'Main' },
|
||||
{ value: 6, label: 'Footer' },
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{ id: 1, name: 'Home', parent_id: null, sort_order: 1 },
|
||||
{ id: 2, name: 'Blog', parent_id: null, sort_order: 2 },
|
||||
{ id: 3, name: 'News', parent_id: 2, sort_order: 3 },
|
||||
{ id: 4, name: '', parent_id: null, sort_order: 4 },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The tree as "name(children)" strings, in display order. */
|
||||
function shape(wrapper: VueWrapper): string {
|
||||
const read = (list: Element): string =>
|
||||
Array.from(list.children)
|
||||
.map((item) => {
|
||||
const name = item.querySelector(':scope > [data-reorder-node] > span.min-w-0')?.textContent?.trim() ?? '?'
|
||||
const children = item.querySelector(':scope > ul')
|
||||
return children ? `${name}(${read(children)})` : name
|
||||
})
|
||||
.join(' ')
|
||||
return read(wrapper.find('[data-reorder-tree]').element)
|
||||
}
|
||||
|
||||
const handle = (wrapper: VueWrapper, id: number) => wrapper.find(`[data-reorder-handle="${id}"]`)
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ReorderView', () => {
|
||||
it('ReorderView tracer: a keyboard move sends one debounced POST with the full node set', async () => {
|
||||
let body: unknown = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: tree(),
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
body = await request.json()
|
||||
return tree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
expect(wrapper.find('h1').text()).toBe('Reorder links')
|
||||
expect(wrapper.find('[data-reorder-tree]').attributes('aria-label')).toBe('Kolejność rekordów')
|
||||
expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)')
|
||||
expect(wrapper.find<HTMLSelectElement>('[data-reorder-partition]').element.value).toBe('5')
|
||||
// First row: up, indent and outdent are impossible.
|
||||
const first = wrapper.find('[data-reorder-node="1"]')
|
||||
expect(first.find('[data-action="reorder-up"]').attributes('disabled')).toBeDefined()
|
||||
expect(first.find('[data-action="reorder-indent"]').attributes('disabled')).toBeDefined()
|
||||
expect(first.find('[data-action="reorder-outdent"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
// Three quick moves by keyboard on the handles.
|
||||
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
||||
expect(document.activeElement).toBe(handle(wrapper, 1).element)
|
||||
keydown(handle(wrapper, 1).element, 'ArrowRight')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)')
|
||||
keydown(handle(wrapper, 4).element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Bez tytułu (#4) Blog(News Home)')
|
||||
expect(document.activeElement).toBe(handle(wrapper, 4).element)
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(0)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
expect(body).toEqual({
|
||||
partition: 5,
|
||||
nodes: [
|
||||
{ id: 4, parent_id: null, sort_order: 1 },
|
||||
{ id: 2, parent_id: null, sort_order: 2 },
|
||||
{ id: 3, parent_id: 2, sort_order: 3 },
|
||||
{ id: 1, parent_id: 2, sort_order: 4 },
|
||||
],
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność')
|
||||
})
|
||||
expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Bez tytułu (#4) na pozycję 1 z 2.')
|
||||
await wait(500)
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('restores the saved tree when a save fails and reloads on a stale set', async () => {
|
||||
let status = 500
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: tree(),
|
||||
[`POST ${REORDER}`]: () => ({ status, body: { error: { code: status === 409 ? 'reorder_stale' : 'x', message: 'x', details: {} } } }),
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-reorder-node="1"] [data-action="reorder-down"]').trigger('click')
|
||||
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
||||
await vi.waitFor(() => {
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.', tone: 'danger' })
|
||||
})
|
||||
expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)')
|
||||
|
||||
status = 409
|
||||
await wrapper.find('[data-reorder-node="2"] [data-action="reorder-outdent"]').trigger('click')
|
||||
await wrapper.find('[data-reorder-node="3"] [data-action="reorder-outdent"]').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.', tone: 'danger' })
|
||||
})
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', REORDER)).toHaveLength(2)
|
||||
expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)')
|
||||
})
|
||||
|
||||
it('shows the empty state and the 403 alert', async () => {
|
||||
const empty = tree()
|
||||
;(empty.body as { data: { nodes: unknown[] } }).data.nodes = []
|
||||
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: empty })
|
||||
expect(wrapper.find('[data-reorder-empty]').text()).toContain('Nie ma jeszcze czego porządkować')
|
||||
wrapper.unmount()
|
||||
|
||||
const forbidden = await mountApp('/acme/companions/links/reorder', {
|
||||
[`GET ${REORDER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } },
|
||||
})
|
||||
expect(forbidden.wrapper.find('[role="alert"]').text()).toContain('Nie masz uprawnień')
|
||||
})
|
||||
})
|
||||
|
||||
describe('list Reorder button', () => {
|
||||
it('links to the reorder page with the filtered partition', async () => {
|
||||
const schema = clone(listSchemaFixture)
|
||||
schema.data.toolbarButtons = ['create', 'reorder']
|
||||
schema.data.reorder = { title: 'Reorder links', partition: { column: 'group_id', label: 'Group' } }
|
||||
schema.data.filters = [...schema.data.filters, { ...schema.data.filters[0]!, name: 'group', label: 'Group', column: 'group_id' }]
|
||||
const routes: Record<string, Route> = {
|
||||
[`GET ${BASE}/schema/list`]: { body: schema },
|
||||
[`GET ${BASE}`]: { body: listFixture },
|
||||
}
|
||||
const { wrapper } = await mountApp('/acme/companions/links?filter[group]=6', routes)
|
||||
const buttons = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action'))
|
||||
expect(buttons).toEqual(['reorder', 'create'])
|
||||
const reorder = wrapper.find('[data-action="reorder"]')
|
||||
expect(reorder.text()).toBe('Zmień kolejność rekordów')
|
||||
expect(reorder.attributes('href')).toBe('/acme/companions/links/reorder?partition=6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton form', () => {
|
||||
it('opens the one record from settings, without Delete, and returns to the settings index', async () => {
|
||||
setSettings([
|
||||
{
|
||||
code: 'profile',
|
||||
label: 'Site profile',
|
||||
description: '',
|
||||
icon: 'settings',
|
||||
category: 'CMS',
|
||||
keywords: [],
|
||||
model: '',
|
||||
controller: 'acme.companions.profiles',
|
||||
order: 1,
|
||||
},
|
||||
])
|
||||
const schema = clone(companionsFormSchemaFixture)
|
||||
schema.data.singleton = true
|
||||
schema.data.fields = schema.data.fields.filter((field) => field.name === 'title')
|
||||
const PROFILES = `${API}/acme/companions/profiles`
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/settings',
|
||||
{
|
||||
[`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } },
|
||||
[`GET ${PROFILES}/schema/form`]: { body: schema },
|
||||
[`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } },
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9')
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
|
||||
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
})
|
||||
@@ -455,6 +455,8 @@ A controller that edits exactly one record, such as a site-wide definition, impl
|
||||
- The controller needs a `config_form.yaml` and no `config_list.yaml`. Its list, create, bulk and delete routes answer 404 `not_found`, and the singleton route answers 404 on any other controller.
|
||||
- A settings entry whose `Controller` names the singleton controller links to it; see [Settings](settings.md).
|
||||
|
||||
In the admin, clicking such a settings entry asks the singleton route for the id and opens the record's form directly. The form has no Delete button, and its back button, Cancel and Save and close return to the settings index. A settings link to a controller that is not a singleton gets a 404 from that route and opens the controller's list as before.
|
||||
|
||||
## Record actions
|
||||
|
||||
A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through `pact.HasAdminRecordActions` (`PeopleController` above registers two), and `recordActions` in `config_form.yaml` lists the ones the form offers, in display order:
|
||||
|
||||
@@ -205,6 +205,8 @@ A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../re
|
||||
|
||||
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).
|
||||
|
||||
In the admin, a list whose schema carries `reorder` shows a "Reorder records" button before "New record". It opens the reorder page, and when the list is filtered on the partition column it opens that partition. The page shows the tree with a drag handle and move up, move down, outdent and indent buttons on every row. A row can also be dragged before, after or into another row (never into its own subtree), and the arrow keys on a focused handle move it the same way as the buttons. There is no Save button: 400 ms after the last move the page posts the whole set, shows "Order saved", and sends a pending save at once when the administrator leaves. A refused save puts the last saved order back; a 409 reloads the tree. A partitioned page has a selector for the partition.
|
||||
|
||||
## Remembered list state
|
||||
|
||||
The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query.
|
||||
|
||||
@@ -181,6 +181,7 @@ messages:
|
||||
one: ":count record"
|
||||
other: ":count records"
|
||||
create: New record
|
||||
reorder: Reorder records
|
||||
empty_search: Nothing found
|
||||
empty_search_hint: No record matches “:term”. Try another search.
|
||||
selected: ":count selected"
|
||||
@@ -381,3 +382,17 @@ repeater:
|
||||
reorder:
|
||||
stale: The records changed in the meantime. The list has been reloaded.
|
||||
load_failed: The records could not be loaded. Refresh the page to try again.
|
||||
failed: The new order could not be saved. The previous order is back.
|
||||
saved: Order saved
|
||||
empty: There is nothing to reorder yet
|
||||
empty_hint: Add records first, then come back to set their order.
|
||||
untitled: "Untitled (#:id)"
|
||||
tree: Order of records
|
||||
move: "Move: :name"
|
||||
move_up: "Move up: :name"
|
||||
move_down: "Move down: :name"
|
||||
indent: "Move inside the previous item: :name"
|
||||
outdent: "Move out one level: :name"
|
||||
moved: ":name moved to position :position of :total."
|
||||
nested: ":name is now inside :parent."
|
||||
unnested: ":name moved out to level :level."
|
||||
|
||||
@@ -195,6 +195,7 @@ messages:
|
||||
many: ":count rekordów"
|
||||
other: ":count rekordu"
|
||||
create: Nowy rekord
|
||||
reorder: Zmień kolejność rekordów
|
||||
empty_search: Nic nie znaleziono
|
||||
empty_search_hint: Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy.
|
||||
selected: "Zaznaczono :count"
|
||||
@@ -409,3 +410,17 @@ repeater:
|
||||
reorder:
|
||||
stale: Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.
|
||||
load_failed: Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie.
|
||||
failed: Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.
|
||||
saved: Zapisano kolejność
|
||||
empty: Nie ma jeszcze czego porządkować
|
||||
empty_hint: Najpierw dodaj rekordy, a potem wróć tutaj, aby ustalić ich kolejność.
|
||||
untitled: "Bez tytułu (#:id)"
|
||||
tree: Kolejność rekordów
|
||||
move: "Przenieś: :name"
|
||||
move_up: "Przenieś wyżej: :name"
|
||||
move_down: "Przenieś niżej: :name"
|
||||
indent: "Przenieś do poprzedniej pozycji: :name"
|
||||
outdent: "Przenieś poziom wyżej: :name"
|
||||
moved: "Przeniesiono :name na pozycję :position z :total."
|
||||
nested: ":name jest teraz wewnątrz :parent."
|
||||
unnested: "Przeniesiono :name na poziom :level."
|
||||
|
||||
Reference in New Issue
Block a user