- ReorderView reads flat from the reorder data; ReorderBranch takes a flat prop and shows only move up and move down - ArrowRight and ArrowLeft do nothing, an inside drop lands after the row (tree.ts flatZone, flatMove) and flatten sends parent_id null - vitest flat cases; docs describe the flat page; rebuilt dist
463 lines
14 KiB
Vue
463 lines
14 KiB
Vue
<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,
|
|
flatMove,
|
|
flatZone,
|
|
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. Only one save is
|
|
// in flight at a time: a save asked for meanwhile waits for it and then sends
|
|
// the tree as it was when asked, so the server never ends on an older tree
|
|
// than the screen. A failed save restores the last saved tree unless a later
|
|
// save (which carries the whole tree) is waiting; a 409 (the records changed
|
|
// meanwhile) reloads it. A response that arrives after the page reloaded or
|
|
// switched partition leaves the screen alone. A flat reorder (D-17, the
|
|
// data's `flat`) is a plain ordered list: no indent, outdent or inside drop,
|
|
// ArrowRight and ArrowLeft do nothing, and every node is sent with
|
|
// parent_id null.
|
|
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 flat = ref(false)
|
|
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('')
|
|
|
|
// One save: the tree it sends, its partition and the load generation it
|
|
// was taken from.
|
|
interface SaveJob {
|
|
sent: TreeNode[]
|
|
partition: AdminReorderPartition['value'] | null
|
|
generation: number
|
|
}
|
|
|
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
let inFlight = false
|
|
let queued: SaveJob | 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
|
|
flat.value = data.data.flat === true
|
|
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)
|
|
}
|
|
|
|
/** Saves the tree as it is now, or queues it behind the save in flight. */
|
|
function save(): void {
|
|
saveTimer = null
|
|
const job: SaveJob = { sent: cloneTree(tree.value), partition: partition.value?.value ?? null, generation }
|
|
if (inFlight) {
|
|
queued = job
|
|
return
|
|
}
|
|
void send(job)
|
|
}
|
|
|
|
/** Whether a later move of the same page is waiting to be saved. */
|
|
function superseded(job: SaveJob): boolean {
|
|
return saveTimer !== null || (queued !== null && queued.generation === job.generation)
|
|
}
|
|
|
|
function failed(job: SaveJob): void {
|
|
if (job.generation === generation) {
|
|
if (superseded(job)) {
|
|
// The waiting save carries the whole tree, later moves included.
|
|
return
|
|
}
|
|
tree.value = cloneTree(savedTree)
|
|
}
|
|
showToast(t('backend::lang.reorder.failed'), 'danger')
|
|
}
|
|
|
|
async function send(job: SaveJob): Promise<void> {
|
|
inFlight = true
|
|
const body = { nodes: flatten(job.sent, flat.value), partition: job.partition } as unknown as AdminReorderRequest
|
|
savedShown.value = false
|
|
saving.value++
|
|
try {
|
|
const { data, response } = await api.POST('/{vendor}/{plugin}/{controller}/reorder', { params: { path }, body })
|
|
const current = job.generation === generation
|
|
if (data) {
|
|
if (current) {
|
|
savedTree = job.sent
|
|
if (!superseded(job)) {
|
|
showSaved()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if (response.status === 409) {
|
|
if (current) {
|
|
// A waiting save of the same set would be refused too.
|
|
if (queued !== null && queued.generation === job.generation) {
|
|
queued = null
|
|
}
|
|
showToast(t('backend::lang.reorder.stale'), 'danger')
|
|
await load()
|
|
}
|
|
return
|
|
}
|
|
failed(job)
|
|
} catch {
|
|
failed(job)
|
|
} finally {
|
|
saving.value--
|
|
inFlight = false
|
|
const next = queued
|
|
queued = null
|
|
if (next) {
|
|
void send(next)
|
|
}
|
|
}
|
|
}
|
|
|
|
function scheduleSave(): void {
|
|
if (saveTimer !== null) {
|
|
clearTimeout(saveTimer)
|
|
}
|
|
saveTimer = setTimeout(save, SAVE_DEBOUNCE_MS)
|
|
}
|
|
|
|
/** Sends a pending save at once (leaving the page, changing the partition). */
|
|
function flush(): void {
|
|
if (saveTimer !== null) {
|
|
clearTimeout(saveTimer)
|
|
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 {
|
|
if (!flatMove(kind, flat.value)) {
|
|
return
|
|
}
|
|
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 && flatMove(kind, flat.value)) {
|
|
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: flatZone(zone, flat.value) }
|
|
}
|
|
|
|
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) => flatMove(kind, flat.value) && 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"
|
|
:flat="flat"
|
|
: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>
|