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:
@@ -38,6 +38,13 @@
|
||||
"mode": "crop"
|
||||
},
|
||||
"useCaption": true
|
||||
},
|
||||
{
|
||||
"name": "parts",
|
||||
"label": "Parts",
|
||||
"type": "relation-manager",
|
||||
"relation": "parts",
|
||||
"deferrable": true
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
// key so the server can attach the files. The upload XMLHttpRequest is
|
||||
// replaced by a fake the test answers by hand. The datepicker part runs in a
|
||||
// fixed non-UTC zone: a stored UTC instant shows as local time and goes back
|
||||
// unchanged, a date goes out as YYYY-MM-DD.
|
||||
// unchanged, a date goes out as YYYY-MM-DD. A deferrable hasMany manager
|
||||
// on the create screen works on owner id 0 with the same key, and its child
|
||||
// modal adds its own key for the child's save.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||
import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import type { RelationSchema } from '../../src/api/types'
|
||||
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import { clearUser } from '../../src/state/useAuth'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { clearToasts } from '../../src/state/useToasts'
|
||||
import lang from '../fixtures/lang.json'
|
||||
import { deferredFormSchemaFixture } from '../fixtures/typed'
|
||||
import { deferredFormSchemaFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/gadgets`
|
||||
@@ -81,6 +84,18 @@ function fileItem(id: number, name: string, pending: boolean) {
|
||||
|
||||
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
|
||||
|
||||
// A hasMany of parts: create and delete in the toolbar, a one-field manage form.
|
||||
const partsSchema: RelationSchema = {
|
||||
...relationSchemaFixture.data,
|
||||
name: 'parts',
|
||||
label: 'Parts',
|
||||
kind: 'hasMany',
|
||||
deferrable: true,
|
||||
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'delete'], showSearch: false },
|
||||
manageForm: [{ name: 'title', label: 'Title', type: 'text', required: true }],
|
||||
}
|
||||
const noParts = { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } }
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
|
||||
@@ -88,6 +103,10 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
[`POST ${BASE}`]: { status: 201, body: created },
|
||||
[`GET ${BASE}/5`]: { body: created },
|
||||
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
|
||||
[`GET ${BASE}/schema/relation/parts`]: { body: { data: partsSchema, meta: {} } },
|
||||
[`GET ${BASE}/0/relations/parts`]: { body: noParts },
|
||||
[`GET ${BASE}/5/relations/parts`]: { body: noParts },
|
||||
[`GET ${BASE}/7/relations/parts`]: { body: noParts },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -307,3 +326,77 @@ describe('datepicker values in a fixed time zone', () => {
|
||||
expect((await put!.clone().json()).starts_at).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('relation manager on a new record', () => {
|
||||
it('creates a child on owner 0 with the form key and its own key, turns the form dirty and saves with the key', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/demo/gadgets/create',
|
||||
routes({
|
||||
[`POST ${BASE}/0/relations/parts/records`]: {
|
||||
status: 201,
|
||||
body: { data: { id: 90, title: 'Bolt' }, meta: { labels: {} } },
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
|
||||
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', `${BASE}/0/relations/parts`)
|
||||
const key = list!.headers.get(SESSION_HEADER) ?? ''
|
||||
expect(key).toMatch(KEY_PATTERN)
|
||||
expect(new URL(list!.url).search).not.toContain(key)
|
||||
// create is the toolbar's only primary; delete waits for a selection.
|
||||
expect(manager.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await manager.find('[data-action="create"]').trigger('click')
|
||||
await flushPromises()
|
||||
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')
|
||||
expect(modal).not.toBeNull()
|
||||
expect(modal!.dataset.mode).toBe('create')
|
||||
const title = modal!.querySelector<HTMLInputElement>('#child-parts-title')!
|
||||
title.value = 'Bolt'
|
||||
title.dispatchEvent(new Event('input'))
|
||||
await flushPromises()
|
||||
;(modal!.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const [post] = requestsTo(calls, 'POST', `${BASE}/0/relations/parts/records`)
|
||||
expect(post).toBeDefined()
|
||||
expect(post!.headers.get(SESSION_HEADER)).toBe(key)
|
||||
const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? ''
|
||||
expect(childKey).toMatch(KEY_PATTERN)
|
||||
expect(childKey).not.toBe(key)
|
||||
expect(await post!.clone().json()).toEqual({ title: 'Bolt' })
|
||||
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
||||
// The list reloads with the same key.
|
||||
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(2)
|
||||
|
||||
// The deferred child alone makes the form dirty.
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
const confirm = document.body.querySelector('[data-confirm-dialog]')
|
||||
expect(confirm).not.toBeNull()
|
||||
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('#field-name').setValue('Box')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const [save] = requestsTo(calls, 'POST', BASE)
|
||||
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
|
||||
})
|
||||
|
||||
it('hides a non-deferrable manager on the create screen', async () => {
|
||||
const schema = structuredClone(deferredFormSchemaFixture)
|
||||
const parts = schema.data.fields.find((field) => field.name === 'parts')!
|
||||
parts.deferrable = false
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/demo/gadgets/create',
|
||||
routes({ [`GET ${BASE}/schema/form`]: { body: schema } }),
|
||||
)
|
||||
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
||||
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user