A move made while a save runs is queued and sent after it settles, so out-of-order completion can no longer leave the database on an older tree than the screen. A failure that a waiting save supersedes keeps the later moves, and a response tied to an earlier load or partition leaves the current tree and its last saved state alone.
506 lines
21 KiB
TypeScript
506 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ref } from 'vue'
|
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import ReorderBranch from '../../src/components/reorder/ReorderBranch.vue'
|
|
import { REORDER_PAGE, type ReorderPage } from '../../src/components/reorder/context'
|
|
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree'
|
|
import { setSettings } from '../../src/state/useSettings'
|
|
import ReorderView from '../../src/views/ReorderView.vue'
|
|
import { toasts } from '../../src/state/useToasts'
|
|
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
|
|
import { API, keydown, mountApp, queryOf, 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')
|
|
})
|
|
})
|
|
|
|
// Plan 06 coverage: announcements per keyboard move, children moving with
|
|
// their parent, loading and load failure, the partition switch flushing a
|
|
// pending save, and the tree rules (no drop into the own subtree).
|
|
describe('ReorderView moves', () => {
|
|
it('announces every keyboard move and moves children with their parent', async () => {
|
|
let body: { nodes: unknown[] } | null = null
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/companions/links/reorder',
|
|
{
|
|
[`GET ${REORDER}`]: tree(),
|
|
[`POST ${REORDER}`]: async (request) => {
|
|
body = (await request.json()) as { nodes: unknown[] }
|
|
return tree()
|
|
},
|
|
},
|
|
{ attach: true },
|
|
)
|
|
const live = () => wrapper.find('[data-reorder-live]').text()
|
|
|
|
keydown(handle(wrapper, 2).element, 'ArrowUp')
|
|
await flushPromises()
|
|
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
|
await vi.waitFor(() => expect(live()).toBe('Przeniesiono Blog na pozycję 1 z 3.'))
|
|
|
|
keydown(handle(wrapper, 1).element, 'ArrowRight')
|
|
await flushPromises()
|
|
expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)')
|
|
await vi.waitFor(() => expect(live()).toBe('Home jest teraz wewnątrz Blog.'))
|
|
|
|
keydown(handle(wrapper, 3).element, 'ArrowLeft')
|
|
await flushPromises()
|
|
expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
|
|
await vi.waitFor(() => expect(live()).toBe('Przeniesiono News na poziom 1.'))
|
|
expect(document.activeElement).toBe(handle(wrapper, 3).element)
|
|
|
|
// A move that cannot run changes and announces nothing.
|
|
const before = live()
|
|
keydown(handle(wrapper, 2).element, 'ArrowUp')
|
|
await flushPromises()
|
|
expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
|
|
expect(live()).toBe(before)
|
|
|
|
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1))
|
|
expect(body!.nodes).toEqual([
|
|
{ id: 2, parent_id: null, sort_order: 1 },
|
|
{ id: 1, parent_id: 2, sort_order: 2 },
|
|
{ id: 3, parent_id: null, sort_order: 3 },
|
|
{ id: 4, parent_id: null, sort_order: 4 },
|
|
])
|
|
})
|
|
|
|
it('shows the loading skeleton, then the load failure alert', async () => {
|
|
let release: (reply: Reply) => void = () => undefined
|
|
const pending = new Promise<Reply>((resolve) => {
|
|
release = resolve
|
|
})
|
|
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: () => pending })
|
|
expect(wrapper.find('[data-reorder-card]').attributes('aria-busy')).toBe('true')
|
|
expect(wrapper.findAll('[data-reorder-skeleton]')).toHaveLength(5)
|
|
release({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } })
|
|
await flushPromises()
|
|
expect(wrapper.find('[role="alert"]').text()).toContain('Nie udało się wczytać rekordów.')
|
|
expect(wrapper.find('[data-reorder-card]').exists()).toBe(false)
|
|
})
|
|
|
|
it('a partition switch sends the pending save first, then loads the chosen partition', async () => {
|
|
const order: string[] = []
|
|
const { wrapper, router, calls } = await mountApp(
|
|
'/acme/companions/links/reorder',
|
|
{
|
|
[`GET ${REORDER}`]: (request) => {
|
|
order.push(`GET ${queryOf(request).get('partition') ?? '-'}`)
|
|
return tree()
|
|
},
|
|
[`POST ${REORDER}`]: async (request) => {
|
|
const sent = (await request.json()) as { partition: number }
|
|
order.push(`POST ${sent.partition}`)
|
|
return tree()
|
|
},
|
|
},
|
|
{ attach: true },
|
|
)
|
|
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
|
await flushPromises()
|
|
await wrapper.find('[data-reorder-partition]').setValue('6')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.query.partition).toBe('6')
|
|
expect(order).toEqual(['GET -', 'POST 5', 'GET 6'])
|
|
await wait(500)
|
|
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// WR-02: one save in flight at a time, later moves sent after it settles,
|
|
// and a failure that a waiting save supersedes does not throw moves away.
|
|
describe('ReorderView save ordering', () => {
|
|
it('sends a move made during a save after that save, with the latest tree', async () => {
|
|
const bodies: { nodes: { id: number }[] }[] = []
|
|
const releases: ((reply: Reply) => void)[] = []
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/companions/links/reorder',
|
|
{
|
|
[`GET ${REORDER}`]: tree(),
|
|
[`POST ${REORDER}`]: async (request) => {
|
|
bodies.push((await request.json()) as { nodes: { id: number }[] })
|
|
return new Promise<Reply>((resolve) => releases.push(resolve))
|
|
},
|
|
},
|
|
{ attach: true },
|
|
)
|
|
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
|
await flushPromises()
|
|
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1))
|
|
|
|
// A second move while the first save is in flight waits for it.
|
|
keydown(handle(wrapper, 4).element, 'ArrowUp')
|
|
await flushPromises()
|
|
await wait(500)
|
|
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
|
|
|
// The first save fails: the later moves stay on screen, and the waiting
|
|
// save sends them.
|
|
releases[0]!({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } })
|
|
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(2))
|
|
expect(shape(wrapper)).toBe('Blog(News) Bez tytułu (#4) Home')
|
|
expect(toasts.value).toHaveLength(0)
|
|
expect(bodies[1]!.nodes.map((node) => node.id)).toEqual([2, 3, 4, 1])
|
|
releases[1]!(tree())
|
|
await vi.waitFor(() => {
|
|
expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność')
|
|
})
|
|
expect(shape(wrapper)).toBe('Blog(News) Bez tytułu (#4) Home')
|
|
await wait(500)
|
|
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(2)
|
|
})
|
|
|
|
it('ignores a save response that arrives after the partition changed', async () => {
|
|
let releaseFirst: (reply: Reply) => void = () => undefined
|
|
let posts = 0
|
|
const footer = tree()
|
|
;(footer.body as { data: { nodes: unknown[] } }).data.nodes = [
|
|
{ id: 7, name: 'Imprint', parent_id: null, sort_order: 1 },
|
|
{ id: 8, name: 'Privacy', parent_id: null, sort_order: 2 },
|
|
]
|
|
const { wrapper } = await mountApp(
|
|
'/acme/companions/links/reorder',
|
|
{
|
|
[`GET ${REORDER}`]: (request) => (queryOf(request).get('partition') === '6' ? footer : tree()),
|
|
[`POST ${REORDER}`]: () => {
|
|
posts++
|
|
if (posts === 1) {
|
|
return new Promise<Reply>((resolve) => {
|
|
releaseFirst = resolve
|
|
})
|
|
}
|
|
return { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
|
},
|
|
},
|
|
{ attach: true },
|
|
)
|
|
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
|
await flushPromises()
|
|
await wrapper.find('[data-reorder-partition]').setValue('6')
|
|
await flushPromises()
|
|
await vi.waitFor(() => expect(shape(wrapper)).toBe('Imprint Privacy'))
|
|
|
|
// The first partition's save succeeds late: the second partition's
|
|
// screen and its last saved tree are not touched.
|
|
releaseFirst(tree())
|
|
await flushPromises()
|
|
expect(shape(wrapper)).toBe('Imprint Privacy')
|
|
|
|
// A failed save on the second partition restores its own tree.
|
|
keydown(handle(wrapper, 7).element, 'ArrowDown')
|
|
await flushPromises()
|
|
expect(shape(wrapper)).toBe('Privacy Imprint')
|
|
await vi.waitFor(() => expect(toasts.value.length).toBeGreaterThan(0))
|
|
expect(shape(wrapper)).toBe('Imprint Privacy')
|
|
})
|
|
})
|
|
|
|
describe('reorder tree rules', () => {
|
|
// The OpenAPI document types parent_id as a number although the wire
|
|
// carries null at the top (17-02 deferred item 3), hence the cast.
|
|
type Wire = Parameters<typeof buildTree>[0]
|
|
const nodes = [
|
|
{ id: 1, name: 'Home', parent_id: null, sort_order: 2 },
|
|
{ id: 2, name: 'Blog', parent_id: null, sort_order: 1 },
|
|
{ id: 3, name: 'News', parent_id: 2, sort_order: 1 },
|
|
{ id: 4, name: 'Deep', parent_id: 3, sort_order: 1 },
|
|
{ id: 5, name: 'Orphan', parent_id: 99, sort_order: 3 },
|
|
] as unknown as Wire
|
|
const names = (list: TreeNode[]): string => list.map((node) => (node.children.length ? `${node.name}(${names(node.children)})` : node.name)).join(' ')
|
|
|
|
it('builds the tree by sort order and puts unknown parents and cycles at the top', () => {
|
|
expect(names(buildTree(nodes))).toBe('Blog(News(Deep)) Home Orphan')
|
|
const cycle = buildTree([
|
|
{ id: 1, name: 'A', parent_id: 2, sort_order: 1 },
|
|
{ id: 2, name: 'B', parent_id: 1, sort_order: 2 },
|
|
{ id: 3, name: 'C', parent_id: null, sort_order: 3 },
|
|
] as unknown as Wire)
|
|
expect(names(cycle).split(' ').sort()).toEqual(['A(B)', 'C'].sort())
|
|
})
|
|
|
|
it('refuses a drop into the node itself or its own subtree and leaves the tree as it was', () => {
|
|
const tree = buildTree(nodes)
|
|
for (const target of [2, 3, 4]) {
|
|
expect(canDrop(tree, 2, target)).toBe(false)
|
|
for (const zone of ['before', 'after', 'inside'] as const) {
|
|
expect(applyDrop(tree, 2, target, zone)).toBe(false)
|
|
}
|
|
}
|
|
expect(names(tree)).toBe('Blog(News(Deep)) Home Orphan')
|
|
expect(canDrop(tree, 4, 2)).toBe(true)
|
|
expect(applyDrop(tree, 4, 1, 'inside')).toBe(true)
|
|
expect(applyDrop(tree, 5, 2, 'before')).toBe(true)
|
|
expect(applyDrop(tree, 3, 1, 'after')).toBe(true)
|
|
expect(names(tree)).toBe('Orphan Blog Home(Deep) News')
|
|
})
|
|
|
|
it('moves only where a sibling or parent exists, and flattens pre-order from 1', () => {
|
|
const tree = buildTree(nodes)
|
|
expect(canMove(tree, 2, 'up')).toBe(false)
|
|
expect(canMove(tree, 2, 'indent')).toBe(false)
|
|
expect(canMove(tree, 2, 'outdent')).toBe(false)
|
|
expect(canMove(tree, 5, 'down')).toBe(false)
|
|
expect(canMove(tree, 99, 'down')).toBe(false)
|
|
expect(applyMove(tree, 4, 'outdent')).toBe(true)
|
|
expect(applyMove(tree, 1, 'indent')).toBe(true)
|
|
expect(names(tree)).toBe('Blog(News Deep Home) Orphan')
|
|
expect(flatten(tree)).toEqual([
|
|
{ id: 2, parent_id: null, sort_order: 1 },
|
|
{ id: 3, parent_id: 2, sort_order: 2 },
|
|
{ id: 4, parent_id: 2, sort_order: 3 },
|
|
{ id: 1, parent_id: 2, sort_order: 4 },
|
|
{ id: 5, parent_id: null, sort_order: 5 },
|
|
])
|
|
})
|
|
})
|
|
|
|
// The reorder modules imported by path (T-10-25): the page the route renders,
|
|
// its tree level and the injection key they share.
|
|
describe('reorder modules', () => {
|
|
it('the reorder route renders ReorderView with a ReorderBranch per tree level', async () => {
|
|
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree() })
|
|
expect(wrapper.findComponent(ReorderView).exists()).toBe(true)
|
|
// Top level and Blog's children.
|
|
expect(wrapper.findAllComponents(ReorderBranch).length).toBe(2)
|
|
})
|
|
|
|
it('ReorderBranch renders each node from the REORDER_PAGE context and moves through it', async () => {
|
|
const move = vi.fn()
|
|
const page: ReorderPage = {
|
|
dragging: ref(null),
|
|
dropTarget: ref(null),
|
|
blocked: ref(null),
|
|
nameOf: (node: TreeNode) => node.name,
|
|
canMove: () => true,
|
|
move,
|
|
onHandleKeydown: vi.fn(),
|
|
onDragStart: vi.fn(),
|
|
onDragOver: vi.fn(),
|
|
onDragLeave: vi.fn(),
|
|
onDrop: vi.fn(),
|
|
onDragEnd: vi.fn(),
|
|
}
|
|
const nodes: TreeNode[] = [
|
|
{ id: 7, name: 'First', children: [] },
|
|
{ id: 8, name: 'Second', children: [{ id: 9, name: 'Child', children: [] }] },
|
|
]
|
|
const wrapper = mount(ReorderBranch, { props: { nodes, depth: 0 }, global: { provide: { [REORDER_PAGE as symbol]: page } } })
|
|
const rows = wrapper.findAll('[data-reorder-node]')
|
|
expect(rows.map((row) => row.attributes('data-reorder-node'))).toEqual(['7', '8', '9'])
|
|
expect(rows.map((row) => row.find('span.min-w-0').text())).toEqual(['First', 'Second', 'Child'])
|
|
await wrapper.find('[data-reorder-node="8"] [data-action="reorder-down"]').trigger('click')
|
|
expect(move).toHaveBeenCalledWith(8, 'down')
|
|
})
|
|
})
|