Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them. Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
17 KiB
TypeScript
370 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { ref } from 'vue'
|
|
import { createMemoryHistory } from 'vue-router'
|
|
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
|
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
|
|
import { createAdminRouter } from '../../src/app/router'
|
|
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
|
import { toasts } from '../../src/state/useToasts'
|
|
import { clone, deferredRelationSchemaFixture, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
|
|
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
|
|
|
|
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
|
const SCHEMA = `${API}/acme/demo/widgets/schema/relation/members`
|
|
const LINKED = `${API}/acme/demo/widgets/1/relations/members`
|
|
const UNLINK = `${LINKED}/unlink`
|
|
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${SCHEMA}`]: { body: relationSchemaFixture },
|
|
[`GET ${LINKED}`]: { body: linkedFixture.all },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function mountManager(
|
|
overrides: Record<string, Route> = {},
|
|
props: Record<string, unknown> = {},
|
|
session: FormSession | null = null,
|
|
) {
|
|
const calls = mockApi(routes(overrides))
|
|
const wrapper = mount(RelationManager, {
|
|
props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props },
|
|
// DataTable injects the router for its row links.
|
|
global: {
|
|
plugins: [createAdminRouter(createMemoryHistory())],
|
|
provide: session ? { [FORM_SESSION as symbol]: session } : {},
|
|
},
|
|
attachTo: document.body,
|
|
})
|
|
await flushPromises()
|
|
return { wrapper, calls }
|
|
}
|
|
|
|
async function selectFirstAndUnlink(wrapper: VueWrapper) {
|
|
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
|
await wrapper.find('[data-action="unlink"]').trigger('click')
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('relation manager (D-05, design screen 5)', () => {
|
|
it('renders nothing and requests nothing without a saved record or a source', async () => {
|
|
for (const props of [{ recordId: null }, { source: null }]) {
|
|
const { wrapper, calls } = await mountManager({}, props)
|
|
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
|
expect(calls).toHaveLength(0)
|
|
wrapper.unmount()
|
|
}
|
|
})
|
|
|
|
it('is a region labelled by its heading and described by its comment', async () => {
|
|
const { wrapper } = await mountManager()
|
|
const region = wrapper.find('[data-relation-manager]')
|
|
expect(region.element.tagName).toBe('SECTION')
|
|
expect(region.attributes('id')).toBe('field-members')
|
|
expect(region.attributes('tabindex')).toBe('-1')
|
|
expect(region.attributes('aria-labelledby')).toBe('field-members-heading')
|
|
expect(wrapper.find('#field-members-heading').text()).toBe('Members')
|
|
expect(region.attributes('aria-describedby')).toBe('field-members-comment')
|
|
expect(wrapper.find('#field-members-comment').text()).toBe('People who may edit this widget')
|
|
expect(wrapper.find('[data-action="link"]').attributes('aria-haspopup')).toBe('dialog')
|
|
})
|
|
|
|
it('uses the relation key of the field, not its name, for the endpoints', async () => {
|
|
const field = { ...formField('members'), name: 'team', relation: 'members' }
|
|
const { wrapper, calls } = await mountManager({}, { field })
|
|
expect(wrapper.find('[data-relation-manager]').attributes('data-relation-name')).toBe('members')
|
|
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1)
|
|
})
|
|
|
|
it('announces a failed schema or list load as an alert', async () => {
|
|
const schemaDown = await mountManager({ [`GET ${SCHEMA}`]: failure(500, 'Boom') })
|
|
expect(schemaDown.wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać powiązanych rekordów.')
|
|
expect(requestsTo(schemaDown.calls, 'GET', LINKED)).toHaveLength(0)
|
|
schemaDown.wrapper.unmount()
|
|
|
|
const listDown = await mountManager({ [`GET ${LINKED}`]: failure(403, 'Forbidden') })
|
|
expect(listDown.wrapper.find('[role="alert"]').exists()).toBe(true)
|
|
expect(listDown.wrapper.find('table').exists()).toBe(false)
|
|
})
|
|
|
|
it('cycles sorting asc, desc, none through the linked query and resets the page', async () => {
|
|
const { wrapper, calls } = await mountManager()
|
|
const header = () => wrapper.find('[data-sort="email"]')
|
|
await header().trigger('click')
|
|
await flushPromises()
|
|
await header().trigger('click')
|
|
await flushPromises()
|
|
await header().trigger('click')
|
|
await flushPromises()
|
|
const queries = requestsTo(calls, 'GET', LINKED).map((request) => queryOf(request))
|
|
expect(queries.map((query) => [query.get('sort'), query.get('dir'), query.get('page')])).toEqual([
|
|
[null, null, '1'],
|
|
['email', 'asc', '1'],
|
|
['email', 'desc', '1'],
|
|
[null, null, '1'],
|
|
])
|
|
expect(wrapper.find('th[aria-sort]').attributes('aria-sort')).toBe('none')
|
|
})
|
|
|
|
it('pages the linked rows only when there is more than one page', async () => {
|
|
expect((await mountManager()).wrapper.find('[data-page="next"]').exists()).toBe(false)
|
|
|
|
const paged = clone(linkedFixture.all)
|
|
paged.meta = { page: 1, per_page: 3, total: 5, last_page: 2 }
|
|
const { wrapper, calls } = await mountManager({ [`GET ${LINKED}`]: { body: paged } })
|
|
await wrapper.find('[data-page="next"]').trigger('click')
|
|
await flushPromises()
|
|
expect(queryOf(requestsTo(calls, 'GET', LINKED).at(-1)!).get('page')).toBe('2')
|
|
})
|
|
|
|
it('shows no unlink column or button when the view does not declare unlink', async () => {
|
|
const schema = clone(relationSchemaFixture)
|
|
schema.data.view.toolbarButtons = ['link']
|
|
schema.data.view.showSearch = false
|
|
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: schema } })
|
|
expect(wrapper.find('[data-action="unlink"]').exists()).toBe(false)
|
|
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-relation-search]').exists()).toBe(false)
|
|
})
|
|
|
|
it('toasts the server message when an unlink is refused and keeps the selection', async () => {
|
|
const { wrapper } = await mountManager({ [`POST ${UNLINK}`]: failure(409, 'Owner cannot be removed') })
|
|
await selectFirstAndUnlink(wrapper)
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Owner cannot be removed', tone: 'danger' })
|
|
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBe('true')
|
|
})
|
|
|
|
it('toasts the generic error when the unlink cannot reach the server', async () => {
|
|
const { wrapper } = await mountManager({
|
|
[`POST ${UNLINK}`]: () => {
|
|
throw new TypeError('offline')
|
|
},
|
|
})
|
|
await selectFirstAndUnlink(wrapper)
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
|
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => {
|
|
const editable = {
|
|
...relationSchemaFixture,
|
|
data: {
|
|
...relationSchemaFixture.data,
|
|
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'update', 'delete', 'link'] },
|
|
manageForm: [{ name: 'name', label: 'Name', type: 'text' }],
|
|
},
|
|
}
|
|
const RECORDS = `${LINKED}/records`
|
|
const DELETE = `${LINKED}/delete`
|
|
|
|
it('renders the toolbar in order with create as the only primary button', async () => {
|
|
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable } })
|
|
const actions = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action'))
|
|
expect(actions).toEqual(['create', 'delete', 'link'])
|
|
expect(wrapper.find('[data-action="link"]').classes()).not.toContain('bg-primary')
|
|
expect(wrapper.find('[data-action="create"]').classes()).toContain('bg-primary')
|
|
})
|
|
|
|
it('opens the child modal in update mode from the row button and saves the child', async () => {
|
|
const first = linkedFixture.all.data[0]!
|
|
const { calls } = await mountManager({
|
|
[`GET ${SCHEMA}`]: { body: editable },
|
|
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
|
|
[`PUT ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
|
|
})
|
|
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
|
|
expect(modal.dataset.mode).toBe('update')
|
|
const input = modal.querySelector<HTMLInputElement>('#child-members-name')!
|
|
expect(input.value).toBe(String(first.name))
|
|
input.value = 'Renamed'
|
|
input.dispatchEvent(new Event('input'))
|
|
await flushPromises()
|
|
;(modal.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
const [put] = requestsTo(calls, 'PUT', `${RECORDS}/${first.id}`)
|
|
expect(await put!.clone().json()).toEqual({ name: 'Renamed' })
|
|
expect(put!.headers.get('X-Child-Session-Key')).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
|
})
|
|
|
|
it('closes, toasts and reloads when the child is gone', async () => {
|
|
const first = linkedFixture.all.data[0]!
|
|
const { calls } = await mountManager({
|
|
[`GET ${SCHEMA}`]: { body: editable },
|
|
[`GET ${RECORDS}/${first.id}`]: failure(404, 'Not found'),
|
|
})
|
|
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
|
expect(toasts.value[0]).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' })
|
|
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2)
|
|
})
|
|
|
|
it('deletes the selected children after a confirmation and keeps them on failure', async () => {
|
|
const { wrapper, calls } = await mountManager({
|
|
[`GET ${SCHEMA}`]: { body: editable },
|
|
[`POST ${DELETE}`]: failure(422, 'Refused'),
|
|
})
|
|
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
|
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'POST', DELETE)).toHaveLength(1)
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Refused', tone: 'danger' })
|
|
expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true')
|
|
})
|
|
})
|
|
|
|
describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-14)', () => {
|
|
const members = deferredRelationSchemaFixture.members
|
|
const RECORDS = `${LINKED}/records`
|
|
const PIVOT = `${LINKED}/pivot`
|
|
const DELETE = `${LINKED}/delete`
|
|
const first = linkedFixture.all.data[0]!
|
|
|
|
function withButtons(buttons: string[], extra: Record<string, unknown> = {}) {
|
|
const schema = clone(members)
|
|
schema.data.view.toolbarButtons = buttons
|
|
Object.assign(schema.data, extra)
|
|
return { body: schema }
|
|
}
|
|
|
|
function session(recordId: number): FormSession & { markDirty: ReturnType<typeof vi.fn> } {
|
|
return {
|
|
key: 'f'.repeat(43),
|
|
recordId,
|
|
routes: () => {
|
|
throw new Error('no file routes in this test')
|
|
},
|
|
markDirty: vi.fn(),
|
|
pendingChanges: ref(0),
|
|
revision: ref(0),
|
|
beginUpload: () => () => {},
|
|
activeUploads: ref(0),
|
|
}
|
|
}
|
|
|
|
it('opens the pivot modal on a row click when update is not declared', async () => {
|
|
const { calls } = await mountManager({
|
|
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
|
|
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
|
|
})
|
|
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
|
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
|
expect(requestsTo(calls, 'GET', `${PIVOT}/${first.id}`)).toHaveLength(1)
|
|
})
|
|
|
|
it('opens the view form read-only without update and without a pivot form', async () => {
|
|
const { calls } = await mountManager({
|
|
[`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [] }),
|
|
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
|
|
})
|
|
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
|
|
expect(modal.dataset.mode).toBe('preview')
|
|
expect(requestsTo(calls, 'GET', `${RECORDS}/${first.id}`)).toHaveLength(1)
|
|
})
|
|
|
|
it('offers no row action without update, a pivot form or a view form', async () => {
|
|
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [], viewForm: [] }) })
|
|
expect(wrapper.find('[data-row-open]').exists()).toBe(false)
|
|
})
|
|
|
|
it('adds a trailing pivot button next to update on a belongsToMany with a pivot form', async () => {
|
|
const { wrapper } = await mountManager({
|
|
[`GET ${SCHEMA}`]: withButtons(['update', 'link'], { manageForm: [{ name: 'email', label: 'E-mail', type: 'text' }] }),
|
|
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
|
|
})
|
|
const buttons = wrapper.findAll('[data-action="edit-pivot"]')
|
|
expect(buttons).toHaveLength(linkedFixture.all.data.length)
|
|
expect(buttons[0]!.attributes('aria-label')).toBe(`Edit link details: ${first.email}`)
|
|
await buttons[0]!.trigger('click')
|
|
await flushPromises()
|
|
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
|
|
})
|
|
|
|
it('confirms a delete with the plural count and toasts the plural result', async () => {
|
|
const { wrapper } = await mountManager({
|
|
[`GET ${SCHEMA}`]: withButtons(['delete']),
|
|
[`POST ${DELETE}`]: { body: { data: { deleted: 2 }, meta: {} } },
|
|
})
|
|
const boxes = wrapper.findAll('tbody [role="checkbox"]')
|
|
await boxes[0]!.trigger('click')
|
|
await boxes[1]!.trigger('click')
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete the selected (2)? This cannot be undone.')
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 2 records', tone: 'success' })
|
|
})
|
|
|
|
it('works on owner 0 with the form key on the create screen and marks the form dirty', async () => {
|
|
const field = { ...formField('members'), deferrable: true }
|
|
const create = session(0)
|
|
const ZERO = `${API}/acme/demo/widgets/0/relations/members`
|
|
const { wrapper, calls } = await mountManager(
|
|
{
|
|
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
|
|
[`GET ${ZERO}`]: { body: linkedFixture.all },
|
|
[`POST ${ZERO}/unlink`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } },
|
|
},
|
|
{ field, recordId: null },
|
|
create,
|
|
)
|
|
const manager = wrapper.find('[data-relation-manager]')
|
|
expect(manager.attributes('data-deferred')).toBe('true')
|
|
expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note')
|
|
const [list] = requestsTo(calls, 'GET', ZERO)
|
|
expect(list!.headers.get(SESSION_HEADER)).toBe(create.key)
|
|
await selectFirstAndUnlink(wrapper)
|
|
const [unlink] = requestsTo(calls, 'POST', `${ZERO}/unlink`)
|
|
expect(unlink!.headers.get(SESSION_HEADER)).toBe(create.key)
|
|
expect(create.markDirty).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('stays hidden on the create screen when the field is not deferrable', async () => {
|
|
const field = { ...formField('members'), deferrable: false }
|
|
const { wrapper, calls } = await mountManager({}, { field, recordId: null }, session(0))
|
|
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
it('sends no session key and leaves the form clean on a saved record', async () => {
|
|
const update = session(1)
|
|
const { wrapper, calls } = await mountManager(
|
|
{ [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), [`POST ${UNLINK}`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } } },
|
|
{ field: { ...formField('members'), deferrable: true } },
|
|
update,
|
|
)
|
|
expect(wrapper.find('[data-relation-pending]').exists()).toBe(false)
|
|
expect(requestsTo(calls, 'GET', LINKED)[0]!.headers.get(SESSION_HEADER)).toBeNull()
|
|
await selectFirstAndUnlink(wrapper)
|
|
expect(requestsTo(calls, 'POST', UNLINK)[0]!.headers.get(SESSION_HEADER)).toBeNull()
|
|
expect(update.markDirty).not.toHaveBeenCalled()
|
|
})
|
|
})
|