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:
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user