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

@@ -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)
})
})