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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user