From f4e97cccada2d4f13db300a739e8de620b44966a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 27 Sep 2026 17:18:14 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 80 ++++ admin/src/api/schema.d.ts | 26 +- admin/src/api/types.ts | 5 + admin/src/components/form/FieldRenderer.vue | 1 + admin/src/components/form/FormField.vue | 2 + admin/src/components/form/FormGrid.vue | 12 +- admin/src/components/form/registry.ts | 26 +- admin/src/components/list/DataTable.vue | 60 ++- .../components/relation/RelationManager.vue | 328 ++++++++++++++ .../relation/RelationPickerModal.vue | 399 ++++++++++++++++++ admin/src/views/FormView.vue | 10 +- admin/tests/fixtures/lang.json | 6 + admin/tests/fixtures/widgets.form-schema.json | 8 +- .../fixtures/widgets.relation-candidates.json | 81 ++++ .../fixtures/widgets.relation-linked.json | 51 +++ .../fixtures/widgets.relation-schema.json | 81 ++++ admin/tests/smoke/form.smoke.test.ts | 11 +- admin/tests/smoke/relation.smoke.test.ts | 329 +++++++++++++++ boardwalk/dist/assets/index-BC0qEqbI.js | 3 + boardwalk/dist/assets/index-CWPavuit.css | 1 - boardwalk/dist/assets/index-DvYDRBo9.js | 3 - boardwalk/dist/assets/index-wkQq4mI5.css | 1 + boardwalk/dist/index.html | 4 +- cabana/admin_openapi.go | 10 + phrasebook/backend/lang/en/lang.yaml | 2 + phrasebook/backend/lang/pl/lang.yaml | 2 + 26 files changed, 1509 insertions(+), 33 deletions(-) create mode 100644 admin/src/components/relation/RelationManager.vue create mode 100644 admin/src/components/relation/RelationPickerModal.vue create mode 100644 admin/tests/fixtures/widgets.relation-candidates.json create mode 100644 admin/tests/fixtures/widgets.relation-linked.json create mode 100644 admin/tests/fixtures/widgets.relation-schema.json create mode 100644 admin/tests/smoke/relation.smoke.test.ts create mode 100644 boardwalk/dist/assets/index-BC0qEqbI.js delete mode 100644 boardwalk/dist/assets/index-CWPavuit.css delete mode 100644 boardwalk/dist/assets/index-DvYDRBo9.js create mode 100644 boardwalk/dist/assets/index-wkQq4mI5.css diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index b85fb19..73dac7b 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -3021,6 +3021,46 @@ "schema": { "type": "string" } + }, + { + "description": "Search term over the panel's searchable columns", + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "description": "Sort column (a sortable panel column)", + "in": "query", + "name": "sort", + "schema": { + "type": "string" + } + }, + { + "description": "Sort direction (asc or desc)", + "in": "query", + "name": "dir", + "schema": { + "type": "string" + } + }, + { + "description": "Page", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Records per page (1-100, default 20)", + "in": "query", + "name": "per_page", + "schema": { + "type": "integer" + } } ], "responses": { @@ -3133,6 +3173,46 @@ "schema": { "type": "string" } + }, + { + "description": "Search term over the panel's searchable columns", + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "description": "Sort column (a sortable panel column)", + "in": "query", + "name": "sort", + "schema": { + "type": "string" + } + }, + { + "description": "Sort direction (asc or desc)", + "in": "query", + "name": "dir", + "schema": { + "type": "string" + } + }, + { + "description": "Page", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Records per page (1-100, default 20)", + "in": "query", + "name": "per_page", + "schema": { + "type": "integer" + } } ], "responses": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index dae0e51..243b32a 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1454,7 +1454,18 @@ export interface paths { /** List linked relation records */ get: { parameters: { - query?: never; + query?: { + /** @description Search term over the panel's searchable columns */ + search?: string; + /** @description Sort column (a sortable panel column) */ + sort?: string; + /** @description Sort direction (asc or desc) */ + dir?: string; + /** @description Page */ + page?: number; + /** @description Records per page (1-100, default 20) */ + per_page?: number; + }; header?: never; path: { /** @description Vendor */ @@ -1537,7 +1548,18 @@ export interface paths { /** List relation candidates */ get: { parameters: { - query?: never; + query?: { + /** @description Search term over the panel's searchable columns */ + search?: string; + /** @description Sort column (a sortable panel column) */ + sort?: string; + /** @description Sort direction (asc or desc) */ + dir?: string; + /** @description Page */ + page?: number; + /** @description Records per page (1-100, default 20) */ + per_page?: number; + }; header?: never; path: { /** @description Vendor */ diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 26458f1..454d7aa 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -5,6 +5,7 @@ import type { components, paths } from './schema' type Schemas = components['schemas'] type ControllerPath = paths['/{vendor}/{plugin}/{controller}'] type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options'] +type RelationLinkedPath = paths['/{vendor}/{plugin}/{controller}/{id}/relations/{name}'] export type AdminLoginData = Schemas['cabana.AdminLoginData'] export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest'] @@ -27,6 +28,8 @@ export type RelationSchema = Schemas['cabana.RelationSchema'] export type RelationMessages = Schemas['cabana.RelationMessages'] export type RelationMutationResult = Schemas['cabana.RelationMutationResult'] export type RelationOption = Schemas['cabana.RelationOption'] +export type RelationPanel = Schemas['cabana.RelationPanel'] +export type RelationColumn = Schemas['cabana.RelationColumn'] export type FilterOption = Schemas['cabana.FilterOption'] export type RecordMeta = Schemas['cabana.RecordMeta'] export type RecordEnvelope = Schemas['cabana.RecordEnvelope'] @@ -46,5 +49,7 @@ export type IDsRequest = Schemas['cabana.AdminIDsRequest'] export type ControllerParams = ControllerPath['get']['parameters']['path'] /** The list query: search, sort, dir, page, per_page and filter[]. */ export type ListQuery = NonNullable +/** Linked and candidate relation query: search, sort, dir, page, per_page. */ +export type RelationQuery = NonNullable /** Relation field options query: search, page, per_page. */ export type FieldOptionsQuery = NonNullable diff --git a/admin/src/components/form/FieldRenderer.vue b/admin/src/components/form/FieldRenderer.vue index 1ba86c2..dd380cc 100644 --- a/admin/src/components/form/FieldRenderer.vue +++ b/admin/src/components/form/FieldRenderer.vue @@ -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)" /> diff --git a/admin/src/components/form/FormField.vue b/admin/src/components/form/FormField.vue index 447c6a4..b9ad7f0 100644 --- a/admin/src/components/form/FormField.vue +++ b/admin/src/components/form/FormField.vue @@ -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)" />

{{ field.comment }}

diff --git a/admin/src/components/form/FormGrid.vue b/admin/src/components/form/FormGrid.vue index 112fdfb..d889ccc 100644 --- a/admin/src/components/form/FormGrid.vue +++ b/admin/src/components/form/FormGrid.vue @@ -11,12 +11,17 @@ defineProps<{ errors: Record 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 { diff --git a/admin/src/components/form/registry.ts b/admin/src/components/form/registry.ts index 3513ece..5a005ce 100644 --- a/admin/src/components/form/registry.ts +++ b/admin/src/components/form/registry.ts @@ -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([ @@ -37,10 +41,17 @@ const renderers = new Map([ ['switch', SwitchField], ['checkbox', CheckboxField], ['relation', RelationField], + ['relation-manager', RelationManager], ]) -/** Types whose control shows the label itself (toggle cards). */ -const selfLabelled = new Set(['switch', 'checkbox']) +/** Types whose control shows the label itself (toggle cards, relation manager). */ +const selfLabelled = new Set(['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(['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 { diff --git a/admin/src/components/list/DataTable.vue b/admin/src/components/list/DataTable.vue index 008ffb7..517447e 100644 --- a/admin/src/components/list/DataTable.vue +++ b/admin/src/components/list/DataTable.vue @@ -12,6 +12,8 @@ import CellValue from './CellValue.vue' // column with a tri-state header over the current page, sortable headers // (asc -> desc -> none) with aria-sort, row links to the record route, eight // skeleton rows while loading and an `empty` slot for the empty states. +// The relation variant (design screen 5) has 56px rows, an initials avatar +// before the first column's text and the remaining columns muted. const props = withDefaults( defineProps<{ @@ -23,8 +25,17 @@ const props = withDefaults( selected?: RowId[] sortable?: boolean sort?: { column: string; dir: 'asc' | 'desc' } | null + variant?: 'list' | 'relation' }>(), - { loading: false, rowLink: undefined, selectable: false, selected: () => [], sortable: false, sort: null }, + { + loading: false, + rowLink: undefined, + selectable: false, + selected: () => [], + sortable: false, + sort: null, + variant: 'list', + }, ) const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>() @@ -102,6 +113,28 @@ function canSort(column: ListColumn): boolean { return props.sortable && column.sortable } +const relation = computed(() => props.variant === 'relation') +const rowHeight = computed(() => (relation.value ? 'h-[56px]' : 'h-row')) +const skeletonRows = computed(() => (relation.value ? 3 : 8)) + +/** Initials of a cell value for the relation variant's avatar. */ +function initials(value: unknown): string { + const text = typeof value === 'string' || typeof value === 'number' ? String(value) : '' + return text + .split(/[\s@._-]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join('') +} + +function cellClass(column: ListColumn, columnIndex: number): string { + if (columnIndex === 0) { + return 'font-semibold' + } + return relation.value || column.relation ? 'text-muted' : '' +} + const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1)) // Varied skeleton bar widths, as in the design. @@ -110,7 +143,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '