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:
2026-10-12 16:18:31 +02:00
parent 204149ccd2
commit 872fd6535f
15 changed files with 1092 additions and 19 deletions

View File

@@ -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>