feat(10-04): search, link and unlink related records through the relation manager

- relation-manager registered in the field registry; renders only on an
  existing record, never on create, and is never part of the save body
- RelationManager: relation schema label and comment, debounced search,
  selectable linked list (DataTable relation variant), toolbar buttons in
  declared order, confirmed unlink with plural messages and toasts
- RelationPickerModal: Reka Dialog (aria-modal, focus trap, Esc) over the
  candidates endpoint five per page, selection kept across pages, Dodaj (N)
  POSTs link, focus returns to the opener
- admin OpenAPI documents search, sort, dir, page and per_page on the linked
  and candidate relation routes so the SPA sends them typed
- neutral acme.demo.widgets members fixtures and relation smoke tests
This commit is contained in:
Jakub Zych
2026-09-27 17:18:14 +02:00
parent 6e1b6dd5bf
commit f4e97cccad
26 changed files with 1509 additions and 33 deletions

View File

@@ -20,6 +20,7 @@ const control = computed(() => rendererFor(props.field.type))
:described-by="describedBy"
:labels="labels"
:source="source"
:record-id="recordId"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
</template>

View File

@@ -13,6 +13,7 @@ const props = defineProps<{
errors?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
recordId?: number | null
/** Prefix for element ids, so two forms on a page never collide. */
idPrefix?: string
}>()
@@ -46,6 +47,7 @@ const describedBy = computed(() =>
:described-by="describedBy"
:labels="labels"
:source="source"
:record-id="recordId"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>

View File

@@ -11,12 +11,17 @@ defineProps<{
errors: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
recordId?: number | null
idPrefix?: string
}>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(span: string | undefined): string {
switch (span) {
function spanClass(field: FormFieldSchema): string {
// The relation manager always takes the whole row (design screen 5).
if (field.type === 'relation-manager') {
return 'col-span-full'
}
switch (field.span) {
case 'left':
return 'min-[600px]:col-start-1'
case 'right':
@@ -35,12 +40,13 @@ function spanClass(span: string | undefined): string {
<FormField
v-for="field in fields"
:key="field.name"
:class="spanClass(field.span)"
:class="spanClass(field)"
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:labels="labels?.[field.name]"
:source="source"
:record-id="recordId"
:id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)"
/>

View File

@@ -1,13 +1,15 @@
// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. The relation manager
// arrives with its own component in a later plan. Phase 10.1 turns this seam
// into the plugin extension point.
// is registered like any control but holds no form value: it edits its
// relation through its own endpoints and renders only on an existing record.
// Phase 10.1 turns this seam into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue'
@@ -27,6 +29,8 @@ export interface FieldControlProps {
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
/** Id of the record being edited; null on create and on settings pages. */
recordId?: number | null
}
const renderers = new Map<string, Component>([
@@ -37,10 +41,17 @@ const renderers = new Map<string, Component>([
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
['relation-manager', RelationManager],
])
/** Types whose control shows the label itself (toggle cards). */
const selfLabelled = new Set<string>(['switch', 'checkbox'])
/** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox', 'relation-manager'])
/**
* Types that need a saved record and hold no form value: never rendered on
* create, never part of the save body (D-05, design screen 5).
*/
const recordBound = new Set<string>(['relation-manager'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
@@ -48,7 +59,12 @@ export function rendererFor(type: string): Component {
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type)
return renderers.has(type) && !recordBound.has(type)
}
/** Whether a field type renders only on an existing record (relation manager). */
export function needsRecord(type: string): boolean {
return recordBound.has(type)
}
export function ownsLabel(type: string): boolean {