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

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