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:
@@ -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": {
|
||||
|
||||
26
admin/src/api/schema.d.ts
vendored
26
admin/src/api/schema.d.ts
vendored
@@ -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 */
|
||||
|
||||
@@ -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[<name>]. */
|
||||
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. */
|
||||
export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', '
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<tr class="h-row-head border-y border-border bg-subtle">
|
||||
<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>
|
||||
<tbody :aria-busy="loading ? 'true' : undefined">
|
||||
<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">
|
||||
<span class="block size-[18px] rounded-checkbox bg-skel" />
|
||||
</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
|
||||
v-for="(row, index) in rows"
|
||||
: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"
|
||||
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)"
|
||||
>
|
||||
<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
|
||||
v-for="(column, columnIndex) in columns"
|
||||
: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"
|
||||
>
|
||||
<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]" />
|
||||
</RouterLink>
|
||||
<CellValue v-else :column="column" :value="row[column.key]" />
|
||||
|
||||
328
admin/src/components/relation/RelationManager.vue
Normal file
328
admin/src/components/relation/RelationManager.vue
Normal 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>
|
||||
399
admin/src/components/relation/RelationPickerModal.vue
Normal file
399
admin/src/components/relation/RelationPickerModal.vue
Normal 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>
|
||||
@@ -10,6 +10,7 @@ import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import { needsRecord } from '../components/form/registry'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
@@ -62,7 +63,13 @@ const confirm = useConfirm()
|
||||
// delete, so the dirty guard does not ask.
|
||||
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 tabs = computed<TabItem[]>(() => {
|
||||
@@ -334,6 +341,7 @@ void load()
|
||||
:errors="errors"
|
||||
:labels="labels"
|
||||
:source="path"
|
||||
:record-id="recordId"
|
||||
:id-prefix="ID_PREFIX"
|
||||
@update="update"
|
||||
/>
|
||||
|
||||
6
admin/tests/fixtures/lang.json
vendored
6
admin/tests/fixtures/lang.json
vendored
@@ -309,6 +309,12 @@
|
||||
"backend::lang.relation.link": {
|
||||
"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": {
|
||||
"other": "Usuń"
|
||||
},
|
||||
|
||||
@@ -115,12 +115,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "parts",
|
||||
"name": "members",
|
||||
"type": "relation-manager",
|
||||
"relation": "parts",
|
||||
"relation": "members",
|
||||
"span": "full",
|
||||
"tab": "Parts",
|
||||
"context": "update"
|
||||
"tab": "Members",
|
||||
"comment": "People who may edit this widget"
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
|
||||
81
admin/tests/fixtures/widgets.relation-candidates.json
vendored
Normal file
81
admin/tests/fixtures/widgets.relation-candidates.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
51
admin/tests/fixtures/widgets.relation-linked.json
vendored
Normal file
51
admin/tests/fixtures/widgets.relation-linked.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
81
admin/tests/fixtures/widgets.relation-schema.json
vendored
Normal file
81
admin/tests/fixtures/widgets.relation-schema.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,8 @@ describe('tabs and context', () => {
|
||||
it('groups fields into tabs with the default tab first and filters by context', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
|
||||
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Parts'])
|
||||
const [general, details, parts] = wrapper.findAll('[role="tab"]')
|
||||
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Members'])
|
||||
const [general, details, members] = wrapper.findAll('[role="tab"]')
|
||||
expect(general!.attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.find('[role="tabpanel"]').attributes('aria-labelledby')).toBe(general!.attributes('id'))
|
||||
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-name').exists()).toBe(false)
|
||||
|
||||
// The relation manager is not registered yet: it renders the fallback.
|
||||
await parts!.trigger('click')
|
||||
expect(wrapper.find('[data-unsupported-field]').text()).toBe('Nieobsługiwany typ pola: relation-manager')
|
||||
// The relation manager has its own renderer (relation.smoke covers it).
|
||||
await members!.trigger('click')
|
||||
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 () => {
|
||||
|
||||
329
admin/tests/smoke/relation.smoke.test.ts
Normal file
329
admin/tests/smoke/relation.smoke.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user