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