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

@@ -3021,6 +3021,46 @@
"schema": { "schema": {
"type": "string" "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": { "responses": {
@@ -3133,6 +3173,46 @@
"schema": { "schema": {
"type": "string" "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": { "responses": {

View File

@@ -1454,7 +1454,18 @@ export interface paths {
/** List linked relation records */ /** List linked relation records */
get: { get: {
parameters: { 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; header?: never;
path: { path: {
/** @description Vendor */ /** @description Vendor */
@@ -1537,7 +1548,18 @@ export interface paths {
/** List relation candidates */ /** List relation candidates */
get: { get: {
parameters: { 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; header?: never;
path: { path: {
/** @description Vendor */ /** @description Vendor */

View File

@@ -5,6 +5,7 @@ import type { components, paths } from './schema'
type Schemas = components['schemas'] type Schemas = components['schemas']
type ControllerPath = paths['/{vendor}/{plugin}/{controller}'] type ControllerPath = paths['/{vendor}/{plugin}/{controller}']
type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options'] 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 AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest'] export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
@@ -27,6 +28,8 @@ export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages'] export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult'] export type RelationMutationResult = Schemas['cabana.RelationMutationResult']
export type RelationOption = Schemas['cabana.RelationOption'] 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 FilterOption = Schemas['cabana.FilterOption']
export type RecordMeta = Schemas['cabana.RecordMeta'] export type RecordMeta = Schemas['cabana.RecordMeta']
export type RecordEnvelope = Schemas['cabana.RecordEnvelope'] export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
@@ -46,5 +49,7 @@ export type IDsRequest = Schemas['cabana.AdminIDsRequest']
export type ControllerParams = ControllerPath['get']['parameters']['path'] export type ControllerParams = ControllerPath['get']['parameters']['path']
/** The list query: search, sort, dir, page, per_page and filter[<name>]. */ /** The list query: search, sort, dir, page, per_page and filter[<name>]. */
export type ListQuery = NonNullable<ControllerPath['get']['parameters']['query']> export type ListQuery = NonNullable<ControllerPath['get']['parameters']['query']>
/** Linked and candidate relation query: search, sort, dir, page, per_page. */
export type RelationQuery = NonNullable<RelationLinkedPath['get']['parameters']['query']>
/** Relation field options query: search, page, per_page. */ /** Relation field options query: search, page, per_page. */
export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']> export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']>

View File

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

View File

@@ -13,6 +13,7 @@ const props = defineProps<{
errors?: string[] errors?: string[]
labels?: RelationOption[] labels?: RelationOption[]
source?: ControllerParams | null source?: ControllerParams | null
recordId?: number | null
/** Prefix for element ids, so two forms on a page never collide. */ /** Prefix for element ids, so two forms on a page never collide. */
idPrefix?: string idPrefix?: string
}>() }>()
@@ -46,6 +47,7 @@ const describedBy = computed(() =>
:described-by="describedBy" :described-by="describedBy"
:labels="labels" :labels="labels"
:source="source" :source="source"
:record-id="recordId"
@update:model-value="(value: unknown) => emit('update:modelValue', value)" @update:model-value="(value: unknown) => emit('update:modelValue', value)"
/> />
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p> <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[]> errors: Record<string, string[]>
labels?: RecordMeta['labels'] labels?: RecordMeta['labels']
source?: ControllerParams | null source?: ControllerParams | null
recordId?: number | null
idPrefix?: string idPrefix?: string
}>() }>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>() const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(span: string | undefined): string { function spanClass(field: FormFieldSchema): string {
switch (span) { // 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': case 'left':
return 'min-[600px]:col-start-1' return 'min-[600px]:col-start-1'
case 'right': case 'right':
@@ -35,12 +40,13 @@ function spanClass(span: string | undefined): string {
<FormField <FormField
v-for="field in fields" v-for="field in fields"
:key="field.name" :key="field.name"
:class="spanClass(field.span)" :class="spanClass(field)"
:field="field" :field="field"
:model-value="values[field.name]" :model-value="values[field.name]"
:errors="errors[field.name]" :errors="errors[field.name]"
:labels="labels?.[field.name]" :labels="labels?.[field.name]"
:source="source" :source="source"
:record-id="recordId"
:id-prefix="idPrefix" :id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)" @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 // Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's // component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. The relation manager // 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 // is registered like any control but holds no form value: it edits its
// into the plugin extension point. // 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 { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types' import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue' import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue' import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue' import NumberField from './fields/NumberField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue' import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue' import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue' import TextField from './fields/TextField.vue'
@@ -27,6 +29,8 @@ export interface FieldControlProps {
labels?: RelationOption[] labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */ /** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null 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>([ const renderers = new Map<string, Component>([
@@ -37,10 +41,17 @@ const renderers = new Map<string, Component>([
['switch', SwitchField], ['switch', SwitchField],
['checkbox', CheckboxField], ['checkbox', CheckboxField],
['relation', RelationField], ['relation', RelationField],
['relation-manager', RelationManager],
]) ])
/** Types whose control shows the label itself (toggle cards). */ /** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox']) 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 { export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField 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. */ /** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean { 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 { export function ownsLabel(type: string): boolean {

View File

@@ -12,6 +12,8 @@ import CellValue from './CellValue.vue'
// column with a tri-state header over the current page, sortable headers // 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 // (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. // 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( const props = withDefaults(
defineProps<{ defineProps<{
@@ -23,8 +25,17 @@ const props = withDefaults(
selected?: RowId[] selected?: RowId[]
sortable?: boolean sortable?: boolean
sort?: { column: string; dir: 'asc' | 'desc' } | null 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] }>() const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
@@ -102,6 +113,28 @@ function canSort(column: ListColumn): boolean {
return props.sortable && column.sortable 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)) const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1))
// Varied skeleton bar widths, as in the design. // 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', '
<template> <template>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-left"> <table :class="relation ? 'min-w-[480px]' : 'min-w-[640px]'" class="w-full border-collapse text-left">
<thead class="sticky top-0 z-10"> <thead class="sticky top-0 z-10">
<tr class="h-row-head border-y border-border bg-subtle"> <tr class="h-row-head border-y border-border bg-subtle">
<th v-if="selectable" scope="col" data-select class="w-[52px] pl-5"> <th v-if="selectable" scope="col" data-select class="w-[52px] pl-5">
@@ -153,7 +186,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</thead> </thead>
<tbody :aria-busy="loading ? 'true' : undefined"> <tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading"> <template v-if="loading">
<tr v-for="n in 8" :key="`skeleton-${n}`" data-skeleton class="h-row border-b border-border"> <tr v-for="n in skeletonRows" :key="`skeleton-${n}`" data-skeleton :class="rowHeight" class="border-b border-border">
<td v-if="selectable" class="pl-5"> <td v-if="selectable" class="pl-5">
<span class="block size-[18px] rounded-checkbox bg-skel" /> <span class="block size-[18px] rounded-checkbox bg-skel" />
</td> </td>
@@ -173,9 +206,13 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<tr <tr
v-for="(row, index) in rows" v-for="(row, index) in rows"
:key="rowKey(row, index)" :key="rowKey(row, index)"
:class="[isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '', linkOf(row) ? 'cursor-pointer' : '']" :class="[
rowHeight,
isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '',
linkOf(row) ? 'cursor-pointer' : '',
]"
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined" :aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
class="h-row border-b border-border transition-colors duration-150 ease-out" class="border-b border-border transition-colors duration-150 ease-out"
@click="openRow($event, row)" @click="openRow($event, row)"
> >
<td v-if="selectable" data-select class="pl-5"> <td v-if="selectable" data-select class="pl-5">
@@ -194,10 +231,19 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<td <td
v-for="(column, columnIndex) in columns" v-for="(column, columnIndex) in columns"
:key="column.key" :key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : column.relation ? 'text-muted' : ''" :class="cellClass(column, columnIndex)"
class="px-3.5 first:pl-5 last:pr-5" class="px-3.5 first:pl-5 last:pr-5"
> >
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline"> <span v-if="relation && columnIndex === 0" class="flex items-center gap-3">
<span
data-avatar
class="flex size-[30px] shrink-0 items-center justify-center rounded-full bg-subtle text-[12px] font-bold text-text"
aria-hidden="true"
>{{ initials(row[column.key]) }}</span
>
<CellValue :column="column" :value="row[column.key]" />
</span>
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
<CellValue :column="column" :value="row[column.key]" /> <CellValue :column="column" :value="row[column.key]" />
</RouterLink> </RouterLink>
<CellValue v-else :column="column" :value="row[column.key]" /> <CellValue v-else :column="column" :value="row[column.key]" />

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Search, UserMinus, UserPlus } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminRecord, ListMeta, RelationQuery, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import type { RowId } from '../../app/listQuery'
import { showToast } from '../../state/useToasts'
import DataTable from '../list/DataTable.vue'
import Pagination from '../list/Pagination.vue'
import Button from '../ui/Button.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm'
import type { FieldControlProps } from '../form/registry'
import RelationPickerModal from './RelationPickerModal.vue'
// Relation manager field (D-05, design screen 5). Renders only for an
// existing record: it lists the linked rows from GET {id}/relations/{name}
// with the relation schema's view columns, a debounced search and row
// selection, and runs the view's toolbar buttons in declared order: `link`
// opens the candidate picker, `unlink` detaches the selected rows after a
// confirmation. It holds no form value; every change goes to the relation
// endpoints directly. Candidate scoping (owner, inactive users) is the
// server's: the SPA never filters rows itself.
const props = defineProps<FieldControlProps>()
const SEARCH_DEBOUNCE = 300
const relationName = computed(() => props.field.relation || props.field.name)
const ready = computed(() => props.source != null && typeof props.recordId === 'number')
const schema = ref<RelationSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
const search = ref('')
const page = ref(1)
const sort = ref<{ column: string; dir: 'asc' | 'desc' } | null>(null)
const selected = ref<RowId[]>([])
const busy = ref(false)
const pickerOpen = ref(false)
// A function ref: a template ref inside v-for would collect an array.
let linkButton: HTMLElement | null = null
const confirm = useConfirm()
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed(() => schema.value?.view.list.columns ?? [])
const buttons = computed(() => schema.value?.view.toolbarButtons ?? [])
const messages = computed(() => schema.value?.messages)
const headingId = computed(() => `${props.controlId}-heading`)
const commentId = computed(() => `${props.controlId}-comment`)
function pathParams() {
return { ...props.source!, id: props.recordId as number, name: relationName.value }
}
async function loadSchema(): Promise<void> {
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/schema/relation/{name}', {
params: { path: { ...props.source!, name: relationName.value } },
})
schema.value = data?.data ?? null
}
async function loadRows(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
try {
const term = search.value.trim()
const query: RelationQuery = { page: page.value }
if (term !== '') {
query.search = term
}
if (sort.value) {
query.sort = sort.value.column
query.dir = sort.value.dir
}
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}', {
params: { path: pathParams(), query },
})
if (current !== generation) {
return
}
rows.value = data?.data ?? []
meta.value = data?.meta ?? null
failed.value = !data
} catch {
if (current === generation) {
rows.value = []
meta.value = null
failed.value = true
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
async function load(): Promise<void> {
if (!ready.value) {
return
}
try {
await loadSchema()
} catch {
schema.value = null
}
if (!schema.value) {
loading.value = false
failed.value = true
return
}
await loadRows()
}
function onSearch(event: Event): void {
search.value = (event.target as HTMLInputElement).value
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
page.value = 1
selected.value = []
void loadRows()
}, SEARCH_DEBOUNCE)
}
function onPage(next: number): void {
page.value = next
selected.value = []
void loadRows()
}
/** asc -> desc -> none, like the main list. */
function onSort(column: string): void {
const current = sort.value
if (current?.column !== column) {
sort.value = { column, dir: 'asc' }
} else if (current.dir === 'asc') {
sort.value = { column, dir: 'desc' }
} else {
sort.value = null
}
page.value = 1
selected.value = []
void loadRows()
}
function selectedIds(): number[] {
return selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
}
async function onUnlink(): Promise<void> {
const ids = selectedIds()
if (busy.value || ids.length === 0 || !schema.value) {
return
}
const ok = await confirm.ask({
message: message(messages.value?.unlinkConfirm, ids.length),
confirmLabel: message(messages.value?.unlinkSelected),
danger: true,
})
if (!ok) {
return
}
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink', {
params: { path: pathParams() },
body: { ids },
})
if (result.data) {
selected.value = []
showToast(message(messages.value?.unlinked, result.data.data.removed ?? ids.length))
await loadRows()
return
}
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
async function onLinked(count: number): Promise<void> {
showToast(message(messages.value?.linked, count))
selected.value = []
await loadRows()
}
/** Focus returns to the button that opened the picker. */
function returnFocus(): void {
linkButton?.focus()
}
function setLinkButton(instance: unknown): void {
const element = (instance as { $el?: unknown } | null)?.$el
linkButton = element instanceof HTMLElement ? element : null
}
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
void load()
</script>
<template>
<section
v-if="ready"
:id="controlId"
data-relation-manager
:data-relation-name="relationName"
:aria-labelledby="headingId"
:aria-describedby="field.comment ? commentId : undefined"
tabindex="-1"
class="flex flex-col gap-4"
>
<header class="flex flex-wrap items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col">
<h2 :id="headingId" class="text-[17px] font-bold tracking-[-0.01em]">
{{ schema?.label || field.label || relationName }}
</h2>
<p v-if="field.comment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
</div>
<label v-if="schema?.view.showSearch" class="relative block w-[220px] max-w-full">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search
:size="16"
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
type="search"
data-relation-search
:value="search"
:placeholder="t('backend::lang.list.search_prompt')"
class="h-[38px] w-full rounded-control border border-transparent bg-subtle pr-3 pl-9"
@input="onSearch"
@keydown.enter.prevent
/>
</label>
<template v-for="button in buttons" :key="button">
<Button
v-if="button === 'unlink'"
data-action="unlink"
size="sm"
variant="danger"
:icon="UserMinus"
:disabled="busy || selected.length === 0"
@click="onUnlink"
>
{{ message(messages?.unlinkSelected) }}
</Button>
<Button
v-else-if="button === 'link'"
:ref="setLinkButton"
data-action="link"
size="sm"
variant="primary"
:icon="UserPlus"
:disabled="busy || !schema"
aria-haspopup="dialog"
@click="pickerOpen = true"
>
{{ message(messages?.link) }}
</Button>
</template>
</header>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
{{ t('backend::lang.relation.load_failed') }}
</p>
<div v-else class="overflow-hidden rounded-inner border border-border">
<DataTable
v-model:selected="selected"
variant="relation"
:columns="columns"
:rows="rows"
:loading="loading"
:selectable="buttons.includes('unlink')"
:sortable="true"
:sort="sort"
@sort="onSort"
>
<template #empty>
<span data-relation-empty class="text-muted">{{ message(messages?.empty) }}</span>
</template>
</DataTable>
<Pagination
v-if="meta && meta.last_page > 1"
:meta="meta"
:loading="loading"
:per-page-options="[]"
:per-page="meta.per_page"
@page="onPage"
/>
</div>
<RelationPickerModal
v-if="schema && buttons.includes('link')"
v-model:open="pickerOpen"
:source="source!"
:record-id="recordId as number"
:relation="relationName"
:schema="schema"
@linked="onLinked"
@closed="returnFocus"
/>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>
</section>
</template>

View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { Check, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { showToast } from '../../state/useToasts'
// Candidate picker (design screen 5, "Dodaj redaktora"): a Reka Dialog
// (role dialog, aria-modal, focus trapped, Esc closes) over GET
// {id}/relations/{name}/candidates, five per page. The selection is a set of
// ids kept across pages and search changes and cleared on close. "Dodaj (N)"
// POSTs link with the selected ids. Candidates are shown exactly as the
// server returns them: owner exclusion and scoping are server-side.
const props = defineProps<{
open: boolean
source: ControllerParams
recordId: number
relation: string
schema: RelationSchema
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
linked: [count: number]
closed: []
}>()
const PER_PAGE = 5
const SEARCH_DEBOUNCE = 300
const search = ref('')
const page = ref(1)
const candidates = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(false)
const busy = ref(false)
const chosen = ref<Set<number>>(new Set())
const searchInput = ref<HTMLInputElement | null>(null)
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed(() => props.schema.manage.list.columns)
const primary = computed(() => columns.value[0]?.key ?? '')
const secondary = computed(() => columns.value[1]?.key ?? '')
const count = computed(() => chosen.value.size)
const listId = `relation-${props.relation}-candidates`
const range = computed(() => {
const m = meta.value
if (!m || m.total === 0) {
return ''
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.relation.candidates_range', { from, to, total: m.total })
})
const lastPage = computed(() => Math.max(meta.value?.last_page ?? 1, 1))
function idOf(row: AdminRecord): number | null {
const id = row.id
if (typeof id === 'number' && Number.isInteger(id)) {
return id
}
if (typeof id === 'string' && /^\d+$/.test(id)) {
return Number(id)
}
return null
}
function text(row: AdminRecord, key: string): string {
const value = key === '' ? undefined : row[key]
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
function initials(label: string): string {
return label
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
async function fetchPage(): Promise<void> {
const current = ++generation
loading.value = true
try {
const term = search.value.trim()
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates', {
params: {
path: { ...props.source, id: props.recordId, name: props.relation },
query: { search: term === '' ? undefined : term, page: page.value, per_page: PER_PAGE },
},
})
if (current !== generation) {
return
}
candidates.value = data?.data ?? []
meta.value = data?.meta ?? null
} catch {
if (current === generation) {
candidates.value = []
meta.value = null
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
function reset(): void {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
generation++
search.value = ''
page.value = 1
candidates.value = []
meta.value = null
loading.value = false
chosen.value = new Set()
}
watch(
() => props.open,
(open) => {
if (open) {
reset()
void fetchPage()
} else {
reset()
}
},
{ immediate: true },
)
function onSearch(event: Event): void {
search.value = (event.target as HTMLInputElement).value
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
page.value = 1
void fetchPage()
}, SEARCH_DEBOUNCE)
}
function goTo(next: number): void {
if (next < 1 || next > lastPage.value || next === page.value) {
return
}
page.value = next
void fetchPage()
}
function isChosen(row: AdminRecord): boolean {
const id = idOf(row)
return id !== null && chosen.value.has(id)
}
function toggle(row: AdminRecord): void {
const id = idOf(row)
if (id === null) {
return
}
const next = new Set(chosen.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
chosen.value = next
}
function onOptionKeydown(event: KeyboardEvent, row: AdminRecord, index: number): void {
switch (event.key) {
case ' ':
case 'Enter':
event.preventDefault()
toggle(row)
break
case 'ArrowDown':
case 'ArrowUp': {
event.preventDefault()
const options = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="option"]')
const step = event.key === 'ArrowDown' ? 1 : -1
options?.[Math.min(Math.max(index + step, 0), options.length - 1)]?.focus()
break
}
}
}
function setOpen(open: boolean): void {
emit('update:open', open)
}
async function onConfirm(): Promise<void> {
const ids = [...chosen.value]
if (busy.value || ids.length === 0) {
return
}
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link', {
params: { path: { ...props.source, id: props.recordId, name: props.relation } },
body: { ids },
})
if (result.data) {
setOpen(false)
emit('linked', result.data.data.linked ?? ids.length)
return
}
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
/** The search input takes focus when the dialog opens. */
function onOpenAutoFocus(event: Event): void {
event.preventDefault()
searchInput.value?.focus()
}
/** Focus goes back to the opener, which the parent owns. */
function onCloseAutoFocus(event: Event): void {
event.preventDefault()
emit('closed')
}
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
const pagerButton =
'inline-flex size-[34px] items-center justify-center rounded-tab border border-border text-text hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60'
</script>
<template>
<DialogRoot :open="open" @update:open="setOpen">
<DialogPortal>
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
<DialogContent
data-relation-picker
data-dialog-content
aria-modal="true"
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{ message(schema.messages.link) }}</DialogTitle>
<DialogDescription class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
</div>
<DialogClose
data-action="close"
:aria-label="t('backend::lang.form.close')"
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text"
>
<X :size="16" aria-hidden="true" />
</DialogClose>
</div>
<label class="relative block">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search
:size="18"
class="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
ref="searchInput"
type="search"
data-candidate-search
:value="search"
:placeholder="message(schema.messages.candidateSearch)"
:aria-controls="listId"
class="h-input w-full rounded-control border border-border-strong bg-surface pr-3.5 pl-10"
@input="onSearch"
/>
</label>
<ul
:id="listId"
role="listbox"
aria-multiselectable="true"
:aria-label="message(schema.messages.link)"
:aria-busy="loading ? 'true' : undefined"
class="flex min-h-0 flex-col gap-2 overflow-y-auto"
>
<li
v-for="(row, index) in candidates"
:key="idOf(row) ?? `row-${index}`"
role="option"
tabindex="0"
:data-candidate="idOf(row) ?? undefined"
:aria-selected="isChosen(row) ? 'true' : 'false'"
:class="isChosen(row) ? 'border-[#e0b020] bg-sel' : 'border-border hover:bg-hover'"
class="flex h-[54px] cursor-pointer items-center gap-3 rounded-inner border px-3 transition-colors duration-150 ease-out"
@click="toggle(row)"
@keydown="onOptionKeydown($event, row, index)"
>
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-subtle text-[12px] font-bold"
aria-hidden="true"
>{{ initials(text(row, primary)) }}</span
>
<span class="flex min-w-0 flex-1 flex-col leading-tight">
<span class="truncate font-semibold">{{ text(row, primary) }}</span>
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(row, secondary) }}</span>
</span>
<span
:class="isChosen(row) ? 'border-primary bg-primary text-on-primary' : 'border-border-strong bg-surface'"
class="flex size-[18px] shrink-0 items-center justify-center rounded-checkbox border"
aria-hidden="true"
>
<Check v-if="isChosen(row)" :size="14" />
</span>
</li>
<li v-if="loading && candidates.length === 0" role="presentation" class="px-1 py-3 text-[13px] text-muted">
{{ t('backend::lang.list.loading') }}
</li>
<li
v-else-if="!loading && candidates.length === 0"
role="presentation"
data-candidates-empty
class="px-1 py-3 text-[13px] text-muted"
>
{{ t('backend::lang.relation.no_candidates') }}
</li>
</ul>
<nav
v-if="meta && meta.total > 0"
:aria-label="t('backend::lang.list.pagination')"
class="flex items-center gap-2 text-[13px] text-muted"
>
<span data-candidates-range class="flex-1">{{ range }}</span>
<button
type="button"
data-page="prev"
:aria-label="t('backend::lang.list.prev_page')"
:class="pagerButton"
:disabled="loading || page <= 1"
@click="goTo(page - 1)"
>
<ChevronLeft :size="16" aria-hidden="true" />
</button>
<button
type="button"
data-page="next"
:aria-label="t('backend::lang.list.next_page')"
:class="pagerButton"
:disabled="loading || page >= lastPage"
@click="goTo(page + 1)"
>
<ChevronRight :size="16" aria-hidden="true" />
</button>
</nav>
<div class="flex gap-2.5">
<DialogClose
data-action="cancel"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover"
>
{{ t('backend::lang.form.cancel') }}
</DialogClose>
<button
type="button"
data-action="confirm-link"
:disabled="busy || count === 0"
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
@click="onConfirm"
>
{{ t('backend::lang.relation.add_count', { count }) }}
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -10,6 +10,7 @@ import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue' import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue' import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue' import FormTabs from '../components/form/FormTabs.vue'
import { needsRecord } from '../components/form/registry'
import { import {
DEFAULT_TAB, DEFAULT_TAB,
contextAllows, contextAllows,
@@ -62,7 +63,13 @@ const confirm = useConfirm()
// delete, so the dirty guard does not ask. // delete, so the dirty guard does not ask.
let leaving = false let leaving = false
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode))) // A relation manager needs a saved record: on create it is dropped with its
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
const fields = computed(() =>
(schema.value?.fields ?? []).filter(
(field) => contextAllows(field, mode) && (mode === 'update' || !needsRecord(field.type)),
),
)
const fieldNames = computed(() => fields.value.map((field) => field.name)) const fieldNames = computed(() => fields.value.map((field) => field.name))
const tabs = computed<TabItem[]>(() => { const tabs = computed<TabItem[]>(() => {
@@ -334,6 +341,7 @@ void load()
:errors="errors" :errors="errors"
:labels="labels" :labels="labels"
:source="path" :source="path"
:record-id="recordId"
:id-prefix="ID_PREFIX" :id-prefix="ID_PREFIX"
@update="update" @update="update"
/> />

View File

@@ -309,6 +309,12 @@
"backend::lang.relation.link": { "backend::lang.relation.link": {
"other": "Dołącz" "other": "Dołącz"
}, },
"backend::lang.relation.load_failed": {
"other": "Nie udało się wczytać powiązanych rekordów."
},
"backend::lang.relation.no_candidates": {
"other": "Brak rekordów do dodania."
},
"backend::lang.relation.remove": { "backend::lang.relation.remove": {
"other": "Usuń" "other": "Usuń"
}, },

View File

@@ -115,12 +115,12 @@
] ]
}, },
{ {
"name": "parts", "name": "members",
"type": "relation-manager", "type": "relation-manager",
"relation": "parts", "relation": "members",
"span": "full", "span": "full",
"tab": "Parts", "tab": "Members",
"context": "update" "comment": "People who may edit this widget"
} }
], ],
"messages": { "messages": {

View File

@@ -0,0 +1,81 @@
{
"page1": {
"data": [
{
"id": 11,
"name": "Alan Turing",
"email": "alan.turing@example.test"
},
{
"id": 12,
"name": "Grace Hopper",
"email": "grace.hopper@example.test"
},
{
"id": 13,
"name": "Emmy Noether",
"email": "emmy.noether@example.test"
},
{
"id": 14,
"name": "Niels Bohr",
"email": "niels.bohr@example.test"
},
{
"id": 15,
"name": "Marie Curie",
"email": "marie.curie@example.test"
}
],
"meta": {
"page": 1,
"per_page": 5,
"total": 7,
"last_page": 2
}
},
"page2": {
"data": [
{
"id": 16,
"name": "Carl Gauss",
"email": "carl.gauss@example.test"
},
{
"id": 17,
"name": "Rosalind Franklin",
"email": "rosalind.franklin@example.test"
}
],
"meta": {
"page": 2,
"per_page": 5,
"total": 7,
"last_page": 2
}
},
"search": {
"data": [
{
"id": 13,
"name": "Emmy Noether",
"email": "emmy.noether@example.test"
}
],
"meta": {
"page": 1,
"per_page": 5,
"total": 1,
"last_page": 1
}
},
"empty": {
"data": [],
"meta": {
"page": 1,
"per_page": 5,
"total": 0,
"last_page": 0
}
}
}

View File

@@ -0,0 +1,51 @@
{
"all": {
"data": [
{
"id": 21,
"name": "Ada Byron",
"email": "ada.byron@example.test"
},
{
"id": 22,
"name": "Max Planck",
"email": "max.planck@example.test"
},
{
"id": 23,
"name": "Lise Meitner",
"email": "lise.meitner@example.test"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 3,
"last_page": 1
}
},
"search": {
"data": [
{
"id": 22,
"name": "Max Planck",
"email": "max.planck@example.test"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
},
"empty": {
"data": [],
"meta": {
"page": 1,
"per_page": 20,
"total": 0,
"last_page": 0
}
}
}

View File

@@ -0,0 +1,81 @@
{
"data": {
"name": "members",
"label": "Members",
"view": {
"list": {
"columns": [
{
"key": "name",
"label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "email",
"label": "E-mail",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [
"link",
"unlink"
],
"showSearch": true
},
"manage": {
"list": {
"columns": [
{
"key": "name",
"label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "email",
"label": "E-mail",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [],
"showSearch": true
},
"messages": {
"link": {
"other": "Add member"
},
"linkHint": {
"other": "Pick people who are not members yet. The owner is not listed."
},
"candidateSearch": {
"other": "Search by name or email…"
},
"linked": {
"one": "Added :count member",
"other": "Added :count members"
},
"unlinkSelected": {
"other": "Remove selected"
},
"unlinkConfirm": {
"one": "Remove :count member?",
"other": "Remove :count members?"
},
"unlinked": {
"one": "Removed :count member",
"other": "Removed :count members"
},
"empty": {
"other": "This widget has no members."
}
}
},
"meta": {
"locale": "en"
}
}

View File

@@ -75,8 +75,8 @@ describe('tabs and context', () => {
it('groups fields into tabs with the default tab first and filters by context', async () => { it('groups fields into tabs with the default tab first and filters by context', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes()) const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Parts']) expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Members'])
const [general, details, parts] = wrapper.findAll('[role="tab"]') const [general, details, members] = wrapper.findAll('[role="tab"]')
expect(general!.attributes('aria-selected')).toBe('true') expect(general!.attributes('aria-selected')).toBe('true')
expect(wrapper.find('[role="tabpanel"]').attributes('aria-labelledby')).toBe(general!.attributes('id')) expect(wrapper.find('[role="tabpanel"]').attributes('aria-labelledby')).toBe(general!.attributes('id'))
expect(wrapper.find('#field-name').exists()).toBe(true) expect(wrapper.find('#field-name').exists()).toBe(true)
@@ -88,9 +88,10 @@ describe('tabs and context', () => {
expect(wrapper.find('#field-batch').exists()).toBe(false) expect(wrapper.find('#field-batch').exists()).toBe(false)
expect(wrapper.find('#field-name').exists()).toBe(false) expect(wrapper.find('#field-name').exists()).toBe(false)
// The relation manager is not registered yet: it renders the fallback. // The relation manager has its own renderer (relation.smoke covers it).
await parts!.trigger('click') await members!.trigger('click')
expect(wrapper.find('[data-unsupported-field]').text()).toBe('Nieobsługiwany typ pola: relation-manager') expect(wrapper.find('[data-relation-manager]').exists()).toBe(true)
expect(wrapper.find('[data-unsupported-field]').exists()).toBe(false)
}) })
it('shows create-context fields on create and hides update-only tabs', async () => { it('shows create-context fields on create and hides update-only tabs', async () => {

View File

@@ -0,0 +1,329 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import relationSchema from '../fixtures/widgets.relation-schema.json'
import linked from '../fixtures/widgets.relation-linked.json'
import candidates from '../fixtures/widgets.relation-candidates.json'
import { API, mountApp, pathOf, queryOf, requestsTo, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const SCHEMA = `${BASE}/schema/relation/members`
const LINKED = `${RECORD}/relations/members`
const CANDIDATES = `${LINKED}/candidates`
const LINK = `${LINKED}/link`
const UNLINK = `${LINKED}/unlink`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: formSchema },
[`GET ${RECORD}`]: { body: record },
[`GET ${SCHEMA}`]: { body: relationSchema },
[`GET ${LINKED}`]: (request) => ({
body: queryOf(request).get('search') === 'max' ? linked.search : linked.all,
}),
[`GET ${CANDIDATES}`]: (request) => {
const query = queryOf(request)
if (query.get('search') === 'noether') {
return { body: candidates.search }
}
return { body: query.get('page') === '2' ? candidates.page2 : candidates.page1 }
},
[`POST ${LINK}`]: async (request) => ({
body: { data: { linked: ((await request.json()) as { ids: number[] }).ids.length }, meta: {} },
}),
[`POST ${UNLINK}`]: async (request) => ({
body: { data: { removed: ((await request.json()) as { ids: number[] }).ids.length }, meta: {} },
}),
...overrides,
}
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
async function openMembers(wrapper: VueWrapper): Promise<void> {
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === 'Members')
expect(tab).toBeDefined()
await tab!.trigger('click')
await flushPromises()
}
function manager(wrapper: VueWrapper) {
return wrapper.find('[data-relation-manager]')
}
function rowNames(wrapper: VueWrapper): string[] {
return manager(wrapper)
.findAll('tbody tr')
.map((tr) => {
const cell = tr.findAll('td:not([data-select])')[0]
const avatar = cell?.find('[data-avatar]')
const initials = avatar?.exists() ? avatar.text() : ''
return `${initials} ${(cell?.text() ?? '').slice(initials.length)}`.trim()
})
}
function picker(): HTMLElement | null {
return document.body.querySelector('[data-relation-picker]')
}
function options(): HTMLElement[] {
return [...(picker()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])]
}
function optionNames(): string[] {
return options().map((option) => option.querySelector('.font-semibold')?.textContent?.trim() ?? '')
}
function confirmButton(): HTMLButtonElement {
return picker()!.querySelector('[data-action="confirm-link"]') as HTMLButtonElement
}
async function click(element: Element | null | undefined): Promise<void> {
;(element as HTMLElement).click()
await flushPromises()
}
/** Opens the picker the way a keyboard user does: focus, then activate. */
async function openPicker(wrapper: VueWrapper): Promise<HTMLElement> {
const button = manager(wrapper).find('[data-action="link"]').element as HTMLElement
button.focus()
await click(button)
await wait(0)
await flushPromises()
return button
}
function confirmDialog(): HTMLElement | null {
return document.body.querySelector('[data-confirm-dialog]')
}
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('relation manager field', () => {
it('is absent on create, even without context: update, and never requests relation data', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', routes())
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['Podstawowe', 'Details'])
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
expect(calls.some((request) => pathOf(request).includes('/relations/') || pathOf(request).includes('/schema/relation/'))).toBe(false)
})
it('lists the linked rows with the view columns, the label, comment and toolbar in declared order', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
await openMembers(wrapper)
const section = manager(wrapper)
expect(section.find('h2').text()).toBe('Members')
expect(section.text()).toContain('People who may edit this widget')
expect(section.findAll('th:not([data-select])').map((th) => th.text())).toEqual(['Name', 'E-mail'])
expect(rowNames(wrapper)).toEqual(['AB Ada Byron', 'MP Max Planck', 'LM Lise Meitner'])
expect(section.find('tbody tr [data-avatar]').text()).toBe('AB')
expect(section.findAll('tbody tr')[0]!.classes()).toContain('h-[56px]')
expect(section.findAll('[data-action]').map((button) => button.attributes('data-action'))).toEqual(['link', 'unlink'])
expect(section.find('[data-action="link"]').text()).toBe('Add member')
expect(section.find('[data-action="unlink"]').text()).toBe('Remove selected')
expect(section.find('[data-action="unlink"]').attributes('disabled')).toBeDefined()
expect(requestsTo(calls, 'GET', SCHEMA)).toHaveLength(1)
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1)
// The save body never carries the relation manager.
expect(wrapper.find('[data-unsupported-field]').exists()).toBe(false)
})
it('searches the linked rows after a 300 ms debounce', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
await openMembers(wrapper)
await manager(wrapper).find('[data-relation-search]').setValue('max')
await wait(100)
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1)
await wait(300)
await flushPromises()
const searches = requestsTo(calls, 'GET', LINKED)
expect(searches).toHaveLength(2)
expect(queryOf(searches[1]!).get('search')).toBe('max')
expect(queryOf(searches[1]!).get('page')).toBe('1')
expect(rowNames(wrapper)).toEqual(['MP Max Planck'])
})
it('shows the relation empty message when nothing is linked', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${LINKED}`]: { body: linked.empty } }))
await openMembers(wrapper)
expect(manager(wrapper).find('[data-relation-empty]').text()).toBe('This widget has no members.')
})
it('confirms with the plural unlinkConfirm, POSTs unlink with the ids, reloads and toasts', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
const rows = manager(wrapper).findAll('tbody tr')
await rows[0]!.find('[role="checkbox"]').trigger('click')
await rows[2]!.find('[role="checkbox"]').trigger('click')
const unlink = manager(wrapper).find('[data-action="unlink"]')
expect(unlink.attributes('disabled')).toBeUndefined()
await unlink.trigger('click')
await flushPromises()
expect(confirmDialog()!.textContent).toContain('Remove 2 members?')
await click(confirmDialog()!.querySelector('[data-action="confirm"]'))
const [post] = requestsTo(calls, 'POST', UNLINK)
expect(post).toBeDefined()
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await post!.json()).toEqual({ ids: [21, 23] })
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2)
expect(wrapper.find('[role="status"]').text()).toContain('Removed 2 members')
expect(manager(wrapper).find('[data-action="unlink"]').attributes('disabled')).toBeDefined()
})
it('sends nothing when the unlink confirmation is cancelled', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
await manager(wrapper).findAll('tbody tr')[1]!.find('[role="checkbox"]').trigger('click')
await manager(wrapper).find('[data-action="unlink"]').trigger('click')
await flushPromises()
expect(confirmDialog()!.textContent).toContain('Remove 1 member?')
await click(confirmDialog()!.querySelector('[data-action="cancel"]'))
expect(requestsTo(calls, 'POST', UNLINK)).toHaveLength(0)
})
})
describe('relation picker modal', () => {
it('opens an aria-modal dialog with the search focused and five candidates per page', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
await openPicker(wrapper)
const dialog = picker()!
expect(dialog.getAttribute('role')).toBe('dialog')
expect(dialog.getAttribute('aria-modal')).toBe('true')
expect(dialog.textContent).toContain('Add member')
expect(dialog.textContent).toContain('Pick people who are not members yet. The owner is not listed.')
const search = dialog.querySelector<HTMLInputElement>('[data-candidate-search]')!
expect(search.placeholder).toBe('Search by name or email…')
expect(document.activeElement).toBe(search)
const listbox = dialog.querySelector('[role="listbox"]')!
expect(listbox.getAttribute('aria-multiselectable')).toBe('true')
const [first] = requestsTo(calls, 'GET', CANDIDATES)
expect(queryOf(first!).get('per_page')).toBe('5')
expect(queryOf(first!).get('page')).toBe('1')
expect(optionNames()).toEqual(['Alan Turing', 'Grace Hopper', 'Emmy Noether', 'Niels Bohr', 'Marie Curie'])
// Rendered exactly as served: the SPA does not filter candidates.
expect(options()).toHaveLength(candidates.page1.data.length)
expect(dialog.querySelector('[data-candidates-range]')!.textContent).toBe('1–5 z 7')
expect(confirmButton().disabled).toBe(true)
expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)')
})
it('keeps the selection across pages and POSTs link with every selected id', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
const opener = await openPicker(wrapper)
await click(options()[0])
await click(options()[1])
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
expect(options()[2]!.getAttribute('aria-selected')).toBe('false')
expect(confirmButton().textContent?.trim()).toBe('Dodaj (2)')
await click(picker()!.querySelector('[data-page="next"]'))
const pages = requestsTo(calls, 'GET', CANDIDATES)
expect(queryOf(pages[pages.length - 1]!).get('page')).toBe('2')
expect(optionNames()).toEqual(['Carl Gauss', 'Rosalind Franklin'])
expect(picker()!.querySelector('[data-candidates-range]')!.textContent).toBe('6–7 z 7')
// Keyboard selection with Space.
options()[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
await flushPromises()
expect(confirmButton().textContent?.trim()).toBe('Dodaj (3)')
await click(picker()!.querySelector('[data-page="prev"]'))
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
expect(options()[1]!.getAttribute('aria-selected')).toBe('true')
await click(confirmButton())
await wait(0)
await flushPromises()
const [post] = requestsTo(calls, 'POST', LINK)
expect(await post!.json()).toEqual({ ids: [11, 12, 17] })
expect(picker()).toBeNull()
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2)
expect(wrapper.find('[role="status"]').text()).toContain('Added 3 members')
expect(document.activeElement).toBe(opener)
})
it('searches candidates after the debounce and resets to page 1', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
await openPicker(wrapper)
await click(picker()!.querySelector('[data-page="next"]'))
const search = picker()!.querySelector<HTMLInputElement>('[data-candidate-search]')!
search.value = 'noether'
search.dispatchEvent(new Event('input', { bubbles: true }))
await wait(350)
await flushPromises()
const pages = requestsTo(calls, 'GET', CANDIDATES)
const last = pages[pages.length - 1]!
expect(queryOf(last).get('search')).toBe('noether')
expect(queryOf(last).get('page')).toBe('1')
expect(optionNames()).toEqual(['Emmy Noether'])
})
it('closes on Esc without linking, returns focus to the opener and clears the selection', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
const opener = await openPicker(wrapper)
await click(options()[0])
expect(confirmButton().textContent?.trim()).toBe('Dodaj (1)')
document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
await wait(0)
await flushPromises()
expect(picker()).toBeNull()
expect(document.activeElement).toBe(opener)
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(0)
await openPicker(wrapper)
expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)')
expect(options().every((option) => option.getAttribute('aria-selected') === 'false')).toBe(true)
})
it('closes on Anuluj and returns focus to the opener', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true })
await openMembers(wrapper)
const opener = await openPicker(wrapper)
await click(picker()!.querySelector('[data-action="cancel"]'))
await wait(0)
await flushPromises()
expect(picker()).toBeNull()
expect(document.activeElement).toBe(opener)
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-DvYDRBo9.js"></script> <script type="module" crossorigin src="./assets/index-BC0qEqbI.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CWPavuit.css"> <link rel="stylesheet" crossorigin href="./assets/index-wkQq4mI5.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -453,6 +453,11 @@ func AdminDelete() {}
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param id path integer true "Owner id" // @Param id path integer true "Owner id"
// @Param name path string true "Relation name" // @Param name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns"
// @Param sort query string false "Sort column (a sortable panel column)"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page (1-100, default 20)"
// @Success 200 {object} ListEnvelope[[]AdminRecord] // @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -472,6 +477,11 @@ func AdminRelationLinked() {}
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param id path integer true "Owner id" // @Param id path integer true "Owner id"
// @Param name path string true "Relation name" // @Param name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns"
// @Param sort query string false "Sort column (a sortable panel column)"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page (1-100, default 20)"
// @Success 200 {object} ListEnvelope[[]AdminRecord] // @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope

View File

@@ -92,6 +92,8 @@ relation:
search: Search… search: Search…
add_count: "Add (:count)" add_count: "Add (:count)"
candidates_range: ":from–:to of :total" candidates_range: ":from–:to of :total"
no_candidates: There are no records to add.
load_failed: The linked records could not be loaded.
settings: settings:
title: Settings title: Settings
empty: No settings are available. empty: No settings are available.

View File

@@ -100,6 +100,8 @@ relation:
search: Szukaj… search: Szukaj…
add_count: "Dodaj (:count)" add_count: "Dodaj (:count)"
candidates_range: ":from–:to z :total" candidates_range: ":from–:to z :total"
no_candidates: Brak rekordów do dodania.
load_failed: Nie udało się wczytać powiązanych rekordów.
settings: settings:
title: Ustawienia title: Ustawienia
empty: Brak dostępnych ustawień. empty: Brak dostępnych ustawień.