feat(12.2-04): edit related records in modals and defer relation work on new records

- RelationManager renders create/link/delete/unlink in declared order with
  one primary, opens the child modal (update or view form) or the pivot
  modal on row click, and deletes selected children behind a busy confirm
- RelationChildModal creates and edits children with its own session key
  (X-Child-Session-Key) so uploads and dates work inside it
- RelationPivotModal edits link details; the picker links one record with
  its pivot values when the relation has a pivot form
- deferrable managers render on the create screen with owner id 0, the
  form's X-Session-Key and the pending note, and mark the form dirty
- the registry resolves RelationManager lazily (child forms close an
  import cycle); DataTable gains openable rows and a trailing cell
- relation lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
Jakub Zych
2026-10-02 19:56:10 +02:00
parent a0c182745e
commit 69a37456f6
20 changed files with 1698 additions and 112 deletions

View File

@@ -150,3 +150,77 @@ describe('relation manager (D-05, design screen 5)', () => {
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')
})
})

View File

@@ -158,3 +158,68 @@ describe('relation picker modal', () => {
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
})
})
describe('pivot link step (Phase 12.2, D-14)', () => {
const pivotSchema = {
...relationSchemaFixture.data,
pivotForm: [{ name: 'role', label: 'Role', type: 'text' }],
}
async function openPivot(overrides: Record<string, Route> = {}) {
const calls = mockApi(routes(overrides))
const wrapper = mount(RelationPickerModal, {
props: { open: true, source, recordId: 1, relation: 'members', schema: pivotSchema },
attachTo: document.body,
})
await flushPromises()
await wait(0)
await flushPromises()
return { wrapper, calls }
}
const nextButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="next"]')!
it('selects one record, asks for its pivot values and links it with them', async () => {
const { wrapper, calls } = await openPivot({ [`POST ${LINK}`]: { body: { data: { linked: 1 }, meta: {} } } })
expect(dialog()!.querySelector('[role="listbox"]')!.getAttribute('aria-multiselectable')).toBe('false')
expect(nextButton().disabled).toBe(true)
await click(options()[0])
await click(options()[1])
expect(options().filter((option) => option.getAttribute('aria-selected') === 'true')).toHaveLength(1)
await click(nextButton())
expect(dialog()!.querySelector('[data-pivot-step]')).not.toBeNull()
const role = dialog()!.querySelector<HTMLInputElement>('#link-members-role')!
role.value = 'editor'
role.dispatchEvent(new Event('input'))
await flushPromises()
await click(confirmButton())
const [post] = requestsTo(calls, 'POST', LINK)
expect(await post!.clone().json()).toEqual({ ids: [candidatesFixture.page1.data[1]!.id], pivot: { role: 'editor' } })
expect(wrapper.emitted('linked')).toEqual([[1]])
})
it('goes back to the list with the selection kept', async () => {
await openPivot()
await click(options()[1])
await click(nextButton())
await click(dialog()!.querySelector('[data-action="back"]'))
expect(dialog()!.querySelector('[data-pivot-step]')).toBeNull()
expect(options()[1]!.getAttribute('aria-selected')).toBe('true')
})
it('keeps pivot errors in the dialog on a 422', async () => {
const { wrapper } = await openPivot({
[`POST ${LINK}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Invalid', details: { role: ['The role is invalid.'] } } },
},
})
await click(options()[0])
await click(nextButton())
await click(confirmButton())
expect(dialog()!.querySelector('[data-pivot-step]')!.textContent).toContain('The role is invalid.')
expect(wrapper.emitted('update:open')).toBeUndefined()
})
})