feat(12.2-04): edit related records in modals and defer relation work on new records
- RelationManager renders create/link/delete/unlink in declared order with one primary, opens the child modal (update or view form) or the pivot modal on row click, and deletes selected children behind a busy confirm - RelationChildModal creates and edits children with its own session key (X-Child-Session-Key) so uploads and dates work inside it - RelationPivotModal edits link details; the picker links one record with its pivot values when the relation has a pivot form - deferrable managers render on the create screen with owner id 0, the form's X-Session-Key and the pending note, and mark the form dirty - the registry resolves RelationManager lazily (child forms close an import cycle); DataTable gains openable rows and a trailing cell - relation lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
@@ -34,6 +34,12 @@ export type { FieldControlProps } from './control'
|
|||||||
/** The relation manager's field type (design screen 5). */
|
/** The relation manager's field type (design screen 5). */
|
||||||
export const RELATION_MANAGER = 'relation-manager'
|
export const RELATION_MANAGER = 'relation-manager'
|
||||||
|
|
||||||
|
// The relation manager renders child forms (FormGrid -> FormField -> this
|
||||||
|
// registry), so it sits on an import cycle with this module. It is looked up
|
||||||
|
// when a field renders, never while this module evaluates: whichever module
|
||||||
|
// of the cycle loads first, the binding is complete by then.
|
||||||
|
const lazyRenderers = new Map<string, () => Component>([[RELATION_MANAGER, () => RelationManager]])
|
||||||
|
|
||||||
const renderers = new Map<string, Component>([
|
const renderers = new Map<string, Component>([
|
||||||
['text', TextField],
|
['text', TextField],
|
||||||
['textarea', TextareaField],
|
['textarea', TextareaField],
|
||||||
@@ -42,7 +48,6 @@ const renderers = new Map<string, Component>([
|
|||||||
['switch', SwitchField],
|
['switch', SwitchField],
|
||||||
['checkbox', CheckboxField],
|
['checkbox', CheckboxField],
|
||||||
['relation', RelationField],
|
['relation', RelationField],
|
||||||
[RELATION_MANAGER, RelationManager],
|
|
||||||
['widget', WidgetField],
|
['widget', WidgetField],
|
||||||
['partial', PartialField],
|
['partial', PartialField],
|
||||||
['fileupload', FileuploadField],
|
['fileupload', FileuploadField],
|
||||||
@@ -54,7 +59,8 @@ const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Types that need a saved record: never rendered on create (D-05, design
|
* Types that need a saved record: never rendered on create (D-05, design
|
||||||
* screen 5).
|
* screen 5), except a relation manager whose field is deferrable (Phase
|
||||||
|
* 12.2, D-03): FormView keeps it and it works on owner id 0.
|
||||||
*/
|
*/
|
||||||
const recordBound = new Set<string>([RELATION_MANAGER])
|
const recordBound = new Set<string>([RELATION_MANAGER])
|
||||||
|
|
||||||
@@ -68,12 +74,12 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
|||||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload'])
|
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload'])
|
||||||
|
|
||||||
export function rendererFor(type: string): Component {
|
export function rendererFor(type: string): Component {
|
||||||
return renderers.get(type) ?? UnsupportedField
|
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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) && !valueless.has(type)
|
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a field type renders only on an existing record (relation manager). */
|
/** Whether a field type renders only on an existing record (relation manager). */
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import CellValue from './CellValue.vue'
|
|||||||
// (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
|
// The relation variant (design screen 5) has 56px rows, an initials avatar
|
||||||
// before the first column's text and the remaining columns muted.
|
// before the first column's text and the remaining columns muted. Phase
|
||||||
|
// 12.2: with `openable` its rows open (the first cell's text is a button and
|
||||||
|
// a row click outside other controls does the same), and `trailing` adds a
|
||||||
|
// 56px last cell filled by the `trailing` slot.
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -26,6 +29,10 @@ const props = withDefaults(
|
|||||||
sortable?: boolean
|
sortable?: boolean
|
||||||
sort?: { column: string; dir: 'asc' | 'desc' } | null
|
sort?: { column: string; dir: 'asc' | 'desc' } | null
|
||||||
variant?: 'list' | 'relation'
|
variant?: 'list' | 'relation'
|
||||||
|
/** Rows open on click (relation variant): emits `open`. */
|
||||||
|
openable?: boolean
|
||||||
|
/** Adds a trailing 56px cell per row, filled by the `trailing` slot. */
|
||||||
|
trailing?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -35,9 +42,15 @@ const props = withDefaults(
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
sort: null,
|
sort: null,
|
||||||
variant: 'list',
|
variant: 'list',
|
||||||
|
openable: false,
|
||||||
|
trailing: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
|
const emit = defineEmits<{
|
||||||
|
'update:selected': [ids: RowId[]]
|
||||||
|
sort: [column: string]
|
||||||
|
open: [row: AdminRecord, trigger: HTMLElement | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -60,6 +73,11 @@ function openRow(event: MouseEvent, row: AdminRecord): void {
|
|||||||
if (target?.closest('a, button, input, label, select')) {
|
if (target?.closest('a, button, input, label, select')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (props.openable) {
|
||||||
|
const opener = (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[data-row-open]') ?? null
|
||||||
|
emit('open', row, opener)
|
||||||
|
return
|
||||||
|
}
|
||||||
const to = linkOf(row)
|
const to = linkOf(row)
|
||||||
if (to) {
|
if (to) {
|
||||||
void router.push(to)
|
void router.push(to)
|
||||||
@@ -135,7 +153,13 @@ function cellClass(column: ListColumn, columnIndex: number): string {
|
|||||||
return relation.value || column.relation ? 'text-muted' : ''
|
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) + (props.trailing ? 1 : 0), 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
function openFrom(event: MouseEvent, row: AdminRecord): void {
|
||||||
|
emit('open', row, event.currentTarget as HTMLElement | null)
|
||||||
|
}
|
||||||
|
|
||||||
// Varied skeleton bar widths, as in the design.
|
// Varied skeleton bar widths, as in the design.
|
||||||
const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', 'w-2/5']
|
const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', 'w-2/5']
|
||||||
@@ -182,6 +206,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
|||||||
</button>
|
</button>
|
||||||
<template v-else>{{ column.label }}</template>
|
<template v-else>{{ column.label }}</template>
|
||||||
</th>
|
</th>
|
||||||
|
<td v-if="trailing" class="w-[56px] pr-5" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody :aria-busy="loading ? 'true' : undefined">
|
<tbody :aria-busy="loading ? 'true' : undefined">
|
||||||
@@ -193,6 +218,7 @@ 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 in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
||||||
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
|
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
|
||||||
</td>
|
</td>
|
||||||
|
<td v-if="trailing" class="pr-5" />
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="rows.length === 0">
|
<tr v-else-if="rows.length === 0">
|
||||||
@@ -208,8 +234,8 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
|||||||
:key="rowKey(row, index)"
|
:key="rowKey(row, index)"
|
||||||
:class="[
|
:class="[
|
||||||
rowHeight,
|
rowHeight,
|
||||||
isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '',
|
isSelected(row) ? 'bg-sel' : linkOf(row) || openable ? 'hover:bg-hover' : '',
|
||||||
linkOf(row) ? 'cursor-pointer' : '',
|
linkOf(row) || openable ? 'cursor-pointer' : '',
|
||||||
]"
|
]"
|
||||||
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
|
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
|
||||||
class="border-b border-border transition-colors duration-150 ease-out"
|
class="border-b border-border transition-colors duration-150 ease-out"
|
||||||
@@ -241,13 +267,25 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>{{ initials(row[column.key]) }}</span
|
>{{ initials(row[column.key]) }}</span
|
||||||
>
|
>
|
||||||
<CellValue :column="column" :value="row[column.key]" />
|
<button
|
||||||
|
v-if="openable"
|
||||||
|
type="button"
|
||||||
|
data-row-open
|
||||||
|
class="min-w-0 truncate text-left font-semibold text-text"
|
||||||
|
@click="openFrom($event, row)"
|
||||||
|
>
|
||||||
|
<CellValue :column="column" :value="row[column.key]" />
|
||||||
|
</button>
|
||||||
|
<CellValue v-else :column="column" :value="row[column.key]" />
|
||||||
</span>
|
</span>
|
||||||
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
|
<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]" />
|
||||||
</td>
|
</td>
|
||||||
|
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
|
||||||
|
<slot name="trailing" :row="row" />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
529
admin/src/components/relation/RelationChildModal.vue
Normal file
529
admin/src/components/relation/RelationChildModal.vue
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, provide, readonly, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { Trash2, X } from '@lucide/vue'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
import { childFileRoutes } from '../../api/files'
|
||||||
|
import type { AdminRecord, ControllerParams, FormField, RecordMeta, RelationSchema } from '../../api/types'
|
||||||
|
import { message, t } from '../../app/i18n'
|
||||||
|
import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
|
||||||
|
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||||
|
import FormGrid from '../form/FormGrid.vue'
|
||||||
|
import FormTabs from '../form/FormTabs.vue'
|
||||||
|
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, type FormSession } from '../form/formContext'
|
||||||
|
import {
|
||||||
|
DEFAULT_TAB,
|
||||||
|
contextAllows,
|
||||||
|
editablePayload,
|
||||||
|
fieldErrors,
|
||||||
|
focusField,
|
||||||
|
initialValues,
|
||||||
|
panelDomId,
|
||||||
|
snapshot,
|
||||||
|
tabDomId,
|
||||||
|
tabOf,
|
||||||
|
type TabItem,
|
||||||
|
} from '../form/formState'
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../ui/confirm'
|
||||||
|
import { showToast } from '../../state/useToasts'
|
||||||
|
|
||||||
|
// Related record modal (Phase 12.2, D-11, D-17; UI-SPEC section 4). Creates
|
||||||
|
// a child through POST .../relations/{name}/records, edits one through
|
||||||
|
// GET/PUT .../records/{child}, or shows the view form read-only. The form is
|
||||||
|
// the relation's manageForm (viewForm for a preview) rendered with the
|
||||||
|
// record form's own FormGrid, tabs and error banner. The modal makes its own
|
||||||
|
// session key when it opens: uploads inside it are held against that key
|
||||||
|
// (X-Child-Session-Key) and committed by the child's save, which also sends
|
||||||
|
// the record form's key so an unsaved parent is found (owner id 0).
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
source: ControllerParams
|
||||||
|
/** Owner record id; 0 for the record being created in the form's session. */
|
||||||
|
ownerId: number
|
||||||
|
relation: string
|
||||||
|
schema: RelationSchema
|
||||||
|
mode: 'create' | 'update' | 'preview'
|
||||||
|
/** Child id in update and preview mode. */
|
||||||
|
childId: number | null
|
||||||
|
/** The record form's session key (X-Session-Key). */
|
||||||
|
sessionKey: string
|
||||||
|
/** Whether the footer offers Delete (the view declares the delete button). */
|
||||||
|
canDelete: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [open: boolean]
|
||||||
|
/** A child was created, updated or deleted. */
|
||||||
|
changed: [kind: 'created' | 'updated' | 'deleted']
|
||||||
|
/** The child no longer exists (404): the list must reload. */
|
||||||
|
gone: []
|
||||||
|
closed: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const values = ref<AdminRecord>({})
|
||||||
|
const labels = ref<RecordMeta['labels']>({})
|
||||||
|
const errors = ref<Record<string, string[]>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const failed = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const saved = ref('')
|
||||||
|
const activeTab = ref(DEFAULT_TAB)
|
||||||
|
const childKey = ref(newSessionKey())
|
||||||
|
const pendingChanges = ref(0)
|
||||||
|
const revision = ref(0)
|
||||||
|
const body = ref<HTMLElement | null>(null)
|
||||||
|
const confirm = useConfirm()
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
const idPrefix = computed(() => `child-${props.relation}`)
|
||||||
|
const preview = computed(() => props.mode === 'preview')
|
||||||
|
const formMode = computed(() => (props.mode === 'create' ? 'create' : 'update'))
|
||||||
|
|
||||||
|
/** The form fields: manageForm, or viewForm read-only, in the form's context. */
|
||||||
|
const fields = computed<FormField[]>(() => {
|
||||||
|
const source = (preview.value ? props.schema.viewForm : props.schema.manageForm) ?? []
|
||||||
|
const visible = source.filter((field) => contextAllows(field, formMode.value))
|
||||||
|
if (!preview.value) {
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
return visible.map((field) => ({
|
||||||
|
...field,
|
||||||
|
readOnly: true,
|
||||||
|
attributes: { ...(field.attributes ?? {}), readonly: true },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||||
|
|
||||||
|
const tabs = computed<TabItem[]>(() => {
|
||||||
|
if (!fields.value.some((field) => field.tab)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const out: TabItem[] = []
|
||||||
|
for (const field of fields.value) {
|
||||||
|
const key = tabOf(field)
|
||||||
|
let tab = out.find((item) => item.key === key)
|
||||||
|
if (!tab) {
|
||||||
|
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
|
||||||
|
if (key === DEFAULT_TAB) {
|
||||||
|
out.unshift(tab)
|
||||||
|
} else {
|
||||||
|
out.push(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.value[field.name]) {
|
||||||
|
tab.errors += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
const activeIndex = computed(() => Math.max(tabs.value.findIndex((tab) => tab.key === activeTab.value), 0))
|
||||||
|
const panelFields = computed(() => {
|
||||||
|
if (tabs.value.length === 0) {
|
||||||
|
return fields.value
|
||||||
|
}
|
||||||
|
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
|
||||||
|
return fields.value.filter((field) => tabOf(field) === key)
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const messages = props.schema.messages
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
return message(messages.createTitle)
|
||||||
|
}
|
||||||
|
return message(preview.value ? messages.previewTitle : messages.updateTitle)
|
||||||
|
})
|
||||||
|
const submitLabel = computed(() =>
|
||||||
|
message(props.mode === 'create' ? props.schema.messages.createSubmit : props.schema.messages.updateSubmit),
|
||||||
|
)
|
||||||
|
const dirty = computed(
|
||||||
|
() =>
|
||||||
|
!preview.value &&
|
||||||
|
!loading.value &&
|
||||||
|
!failed.value &&
|
||||||
|
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { [SESSION_HEADER]: props.sessionKey, [CHILD_SESSION_HEADER]: childKey.value } as {
|
||||||
|
'X-Session-Key': string
|
||||||
|
'X-Child-Session-Key': string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathParams() {
|
||||||
|
return { ...props.source, id: props.ownerId, name: props.relation }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields inside the modal (fileupload, datepicker) work against the child:
|
||||||
|
// file routes of child id 0 on create, both keys on every call.
|
||||||
|
const session: FormSession = {
|
||||||
|
get key() {
|
||||||
|
return props.sessionKey
|
||||||
|
},
|
||||||
|
get childKey() {
|
||||||
|
return childKey.value
|
||||||
|
},
|
||||||
|
get recordId() {
|
||||||
|
return props.childId ?? 0
|
||||||
|
},
|
||||||
|
routes: (field: string) =>
|
||||||
|
childFileRoutes(
|
||||||
|
props.source,
|
||||||
|
props.ownerId,
|
||||||
|
props.relation,
|
||||||
|
props.childId ?? 0,
|
||||||
|
field,
|
||||||
|
props.sessionKey,
|
||||||
|
childKey.value,
|
||||||
|
),
|
||||||
|
markDirty: () => {
|
||||||
|
pendingChanges.value++
|
||||||
|
},
|
||||||
|
pendingChanges: readonly(pendingChanges),
|
||||||
|
revision: readonly(revision),
|
||||||
|
}
|
||||||
|
provide(FORM_SESSION, session)
|
||||||
|
provide(FORM_VALUES, computed(() => values.value))
|
||||||
|
provide(FORM_PATCH, update)
|
||||||
|
|
||||||
|
function update(name: string, value: unknown): void {
|
||||||
|
values.value = { ...values.value, [name]: value }
|
||||||
|
if (errors.value[name]) {
|
||||||
|
const next = { ...errors.value }
|
||||||
|
delete next[name]
|
||||||
|
errors.value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(record: AdminRecord, meta?: RecordMeta): void {
|
||||||
|
values.value = { ...record }
|
||||||
|
labels.value = meta?.labels ?? {}
|
||||||
|
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first focusable control of the body (a field, or the alert's Cancel). */
|
||||||
|
async function focusFirst(): Promise<void> {
|
||||||
|
await nextTick()
|
||||||
|
const target = body.value?.querySelector<HTMLElement>(
|
||||||
|
'input:not([type="hidden"]):not([tabindex="-1"]), select, textarea, button, [contenteditable="true"], [tabindex="0"]',
|
||||||
|
)
|
||||||
|
target?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function gone(): void {
|
||||||
|
emit('update:open', false)
|
||||||
|
showToast(t('backend::lang.relation.child_gone'), 'danger')
|
||||||
|
emit('gone')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const current = ++generation
|
||||||
|
errors.value = {}
|
||||||
|
failed.value = false
|
||||||
|
busy.value = false
|
||||||
|
pendingChanges.value = 0
|
||||||
|
childKey.value = newSessionKey()
|
||||||
|
activeTab.value = DEFAULT_TAB
|
||||||
|
if (props.mode === 'create' || props.childId === null) {
|
||||||
|
loading.value = false
|
||||||
|
adopt(initialValues(fields.value))
|
||||||
|
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||||
|
await focusFirst()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}', {
|
||||||
|
params: { path: { ...pathParams(), child: props.childId }, header: headers() },
|
||||||
|
})
|
||||||
|
if (current !== generation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.data) {
|
||||||
|
adopt(result.data.data, result.data.meta)
|
||||||
|
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||||
|
} else if (result.response.status === 404) {
|
||||||
|
gone()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (current === generation) {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === generation) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current === generation) {
|
||||||
|
await focusFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
void load()
|
||||||
|
} else {
|
||||||
|
generation++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function setOpen(open: boolean): Promise<void> {
|
||||||
|
if (open) {
|
||||||
|
emit('update:open', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (busy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dirty.value) {
|
||||||
|
const ok = await confirm.ask({
|
||||||
|
message: t('backend::lang.form.unsaved_confirm'),
|
||||||
|
confirmLabel: t('backend::lang.form.discard'),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 422: messages under fields, the first invalid field focused (tab switched). */
|
||||||
|
async function showErrors(details: Record<string, unknown> | undefined): Promise<void> {
|
||||||
|
errors.value = fieldErrors(details)
|
||||||
|
const invalid = fields.value.filter((field) => errors.value[field.name])
|
||||||
|
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
|
||||||
|
if (!first) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tabs.value.length > 0) {
|
||||||
|
activeTab.value = tabOf(first)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
focusField(idPrefix.value, first.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
if (busy.value || preview.value || loading.value || failed.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const payload = editablePayload(fields.value, values.value)
|
||||||
|
const result =
|
||||||
|
props.mode === 'create' || props.childId === null
|
||||||
|
? await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records', {
|
||||||
|
params: { path: pathParams(), header: headers() },
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}', {
|
||||||
|
params: { path: { ...pathParams(), child: props.childId }, header: headers() },
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
if (result.data) {
|
||||||
|
const kind = props.mode === 'create' ? 'created' : 'updated'
|
||||||
|
errors.value = {}
|
||||||
|
pendingChanges.value = 0
|
||||||
|
emit('update:open', false)
|
||||||
|
showToast(message(kind === 'created' ? props.schema.messages.created : props.schema.messages.updated))
|
||||||
|
emit('changed', kind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const status = result.response.status
|
||||||
|
if (status === 422) {
|
||||||
|
await showErrors(result.error?.error.details)
|
||||||
|
} else if (status === 404) {
|
||||||
|
gone()
|
||||||
|
} else {
|
||||||
|
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 onDelete(): Promise<void> {
|
||||||
|
if (busy.value || props.childId === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const childId = props.childId
|
||||||
|
const ok = await confirm.ask({
|
||||||
|
message: message(props.schema.messages.deleteOneConfirm),
|
||||||
|
confirmLabel: t('backend::lang.form.delete'),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete', {
|
||||||
|
params: { path: pathParams(), header: { [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string } },
|
||||||
|
body: { ids: [childId] },
|
||||||
|
})
|
||||||
|
if (result.data) {
|
||||||
|
pendingChanges.value = 0
|
||||||
|
emit('update:open', false)
|
||||||
|
showToast(message(props.schema.messages.deleted, result.data.data.deleted || 1))
|
||||||
|
emit('changed', 'deleted')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.response.status === 404) {
|
||||||
|
gone()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus goes to the first field once it is there (see load). */
|
||||||
|
function onOpenAutoFocus(event: Event): void {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus goes back to the opener, which the relation manager owns. */
|
||||||
|
function onCloseAutoFocus(event: Event): void {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('closed')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot :open="open" @update:open="setOpen">
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||||
|
<DialogContent
|
||||||
|
data-relation-child-modal
|
||||||
|
data-dialog-content
|
||||||
|
:data-mode="mode"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-describedby="undefined"
|
||||||
|
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[720px] -translate-x-1/2 -translate-y-1/2 flex-col rounded-modal bg-surface text-text shadow-pop"
|
||||||
|
@open-auto-focus="onOpenAutoFocus"
|
||||||
|
@close-auto-focus="onCloseAutoFocus"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3 p-6 pb-4">
|
||||||
|
<DialogTitle class="min-w-0 flex-1 text-[20px] font-bold tracking-[-0.01em]">{{ title }}</DialogTitle>
|
||||||
|
<DialogClose
|
||||||
|
data-action="close"
|
||||||
|
:aria-label="t('backend::lang.form.close')"
|
||||||
|
:disabled="busy"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div ref="body" data-child-body class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 pb-6">
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
data-child-loading
|
||||||
|
aria-hidden="true"
|
||||||
|
class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div v-for="n in 6" :key="n" class="flex flex-col gap-1.5">
|
||||||
|
<span class="block h-3 w-24 rounded-[6px] bg-skel" />
|
||||||
|
<span class="block h-input rounded-control bg-skel" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="failed"
|
||||||
|
role="alert"
|
||||||
|
data-child-load-failed
|
||||||
|
class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.relation.child_load_failed') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||||
|
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="idPrefix" class="self-start" />
|
||||||
|
<form
|
||||||
|
:id="tabs.length > 0 ? panelDomId(idPrefix, activeIndex) : undefined"
|
||||||
|
:role="tabs.length > 0 ? 'tabpanel' : undefined"
|
||||||
|
:aria-labelledby="tabs.length > 0 ? tabDomId(idPrefix, activeIndex) : undefined"
|
||||||
|
novalidate
|
||||||
|
@submit.prevent="onSubmit"
|
||||||
|
>
|
||||||
|
<fieldset :disabled="busy || preview" class="m-0 min-w-0 border-0 p-0">
|
||||||
|
<FormGrid
|
||||||
|
:fields="panelFields"
|
||||||
|
:values="values"
|
||||||
|
:errors="errors"
|
||||||
|
:labels="labels"
|
||||||
|
:source="source"
|
||||||
|
:record-id="childId"
|
||||||
|
:id-prefix="idPrefix"
|
||||||
|
@update="update"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2.5 border-t border-border px-6 py-4">
|
||||||
|
<Button
|
||||||
|
v-if="mode === 'update' && canDelete && !loading && !failed"
|
||||||
|
variant="danger"
|
||||||
|
data-action="delete-child"
|
||||||
|
:icon="Trash2"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.form.delete') }}
|
||||||
|
</Button>
|
||||||
|
<div class="ml-auto flex gap-2.5">
|
||||||
|
<Button v-if="preview" variant="outline" data-action="cancel" @click="setOpen(false)">
|
||||||
|
{{ t('backend::lang.form.close') }}
|
||||||
|
</Button>
|
||||||
|
<template v-else>
|
||||||
|
<Button variant="ghost" data-action="cancel" :disabled="busy" @click="setOpen(false)">
|
||||||
|
{{ t('backend::lang.form.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!failed"
|
||||||
|
variant="primary"
|
||||||
|
data-action="save-child"
|
||||||
|
:disabled="busy || loading"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
{{ busy ? t('backend::lang.form.saving') : submitLabel }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, inject, onBeforeUnmount, ref } from 'vue'
|
||||||
import { Search, UserMinus, UserPlus } from '@lucide/vue'
|
import { Info, Plus, Search, SlidersHorizontal, Trash2, UserMinus, UserPlus } from '@lucide/vue'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import type { AdminRecord, ListMeta, RelationQuery, RelationSchema } from '../../api/types'
|
import type { AdminRecord, ListMeta, RelationQuery, RelationSchema } from '../../api/types'
|
||||||
import { message, t } from '../../app/i18n'
|
import { message, t } from '../../app/i18n'
|
||||||
import type { RowId } from '../../app/listQuery'
|
import type { RowId } from '../../app/listQuery'
|
||||||
|
import { SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
|
||||||
import { showToast } from '../../state/useToasts'
|
import { showToast } from '../../state/useToasts'
|
||||||
import DataTable from '../list/DataTable.vue'
|
import DataTable from '../list/DataTable.vue'
|
||||||
import Pagination from '../list/Pagination.vue'
|
import Pagination from '../list/Pagination.vue'
|
||||||
@@ -12,22 +13,46 @@ import Button from '../ui/Button.vue'
|
|||||||
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '../ui/confirm'
|
import { useConfirm } from '../ui/confirm'
|
||||||
import type { FieldControlProps } from '../form/control'
|
import type { FieldControlProps } from '../form/control'
|
||||||
|
import { FORM_SESSION } from '../form/formContext'
|
||||||
|
import RelationChildModal from './RelationChildModal.vue'
|
||||||
import RelationPickerModal from './RelationPickerModal.vue'
|
import RelationPickerModal from './RelationPickerModal.vue'
|
||||||
|
import RelationPivotModal from './RelationPivotModal.vue'
|
||||||
|
|
||||||
// Relation manager field (D-05, design screen 5). Renders only for an
|
// Relation manager field (D-05, design screen 5). It lists the linked rows
|
||||||
// existing record: it lists the linked rows from GET {id}/relations/{name}
|
// from GET {id}/relations/{name} with the relation schema's view columns, a
|
||||||
// with the relation schema's view columns, a debounced search and row
|
// debounced search and row selection, and runs the view's toolbar buttons
|
||||||
// selection, and runs the view's toolbar buttons in declared order: `link`
|
// in declared order: `create` opens the child modal, `link` the candidate
|
||||||
// opens the candidate picker, `unlink` detaches the selected rows after a
|
// picker, `delete` deletes and `unlink` detaches the selected rows after a
|
||||||
// confirmation. It holds no form value; every change goes to the relation
|
// confirmation. It holds no form value; every change goes to the relation
|
||||||
// endpoints directly. Candidate scoping (owner, inactive users) is the
|
// endpoints directly. Candidate scoping (owner, inactive users) is the
|
||||||
// server's: the SPA never filters rows itself.
|
// server's: the SPA never filters rows itself.
|
||||||
|
//
|
||||||
|
// Phase 12.2 (D-03, D-11, D-12, D-14): a row opens the child modal (update,
|
||||||
|
// or the view form read-only) or the pivot modal; a belongsToMany with a
|
||||||
|
// pivot form and update gets a trailing pivot button. On the create screen
|
||||||
|
// a deferrable manager works on owner id 0 with the form's session key: the
|
||||||
|
// server holds every change until the record's first save, and each change
|
||||||
|
// marks the form dirty. On a saved record changes are immediate.
|
||||||
const props = defineProps<FieldControlProps>()
|
const props = defineProps<FieldControlProps>()
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE = 300
|
const SEARCH_DEBOUNCE = 300
|
||||||
|
|
||||||
|
const session = inject(FORM_SESSION, null)
|
||||||
const relationName = computed(() => props.field.relation || props.field.name)
|
const relationName = computed(() => props.field.relation || props.field.name)
|
||||||
const ready = computed(() => props.source != null && typeof props.recordId === 'number')
|
/**
|
||||||
|
* The owner id the routes use: the saved record's id, or 0 for the record
|
||||||
|
* being created when the field is deferrable and the form has a session.
|
||||||
|
*/
|
||||||
|
const ownerId = computed<number | null>(() => {
|
||||||
|
if (typeof props.recordId === 'number') {
|
||||||
|
return props.recordId
|
||||||
|
}
|
||||||
|
return session && session.recordId === 0 && props.field.deferrable ? 0 : null
|
||||||
|
})
|
||||||
|
const deferred = computed(() => ownerId.value === 0)
|
||||||
|
const ready = computed(() => props.source != null && ownerId.value !== null)
|
||||||
|
// The record form's key; a stray manager without a form still gets one.
|
||||||
|
const sessionKey = session?.key ?? newSessionKey()
|
||||||
|
|
||||||
const schema = ref<RelationSchema | null>(null)
|
const schema = ref<RelationSchema | null>(null)
|
||||||
const rows = ref<AdminRecord[]>([])
|
const rows = ref<AdminRecord[]>([])
|
||||||
@@ -40,8 +65,17 @@ const sort = ref<{ column: string; dir: 'asc' | 'desc' } | null>(null)
|
|||||||
const selected = ref<RowId[]>([])
|
const selected = ref<RowId[]>([])
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const pickerOpen = ref(false)
|
const pickerOpen = ref(false)
|
||||||
// A function ref: a template ref inside v-for would collect an array.
|
const childOpen = ref(false)
|
||||||
|
const childMode = ref<'create' | 'update' | 'preview'>('create')
|
||||||
|
const childId = ref<number | null>(null)
|
||||||
|
const pivotOpen = ref(false)
|
||||||
|
const pivotChild = ref<number | null>(null)
|
||||||
|
const pivotName = ref('')
|
||||||
|
// The element that opened a dialog gets focus back when it closes. Function
|
||||||
|
// refs: a template ref inside v-for would collect an array.
|
||||||
|
let opener: HTMLElement | null = null
|
||||||
let linkButton: HTMLElement | null = null
|
let linkButton: HTMLElement | null = null
|
||||||
|
let createButton: HTMLElement | null = null
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -52,8 +86,41 @@ const messages = computed(() => schema.value?.messages)
|
|||||||
const headingId = computed(() => `${props.controlId}-heading`)
|
const headingId = computed(() => `${props.controlId}-heading`)
|
||||||
const commentId = computed(() => `${props.controlId}-comment`)
|
const commentId = computed(() => `${props.controlId}-comment`)
|
||||||
|
|
||||||
|
const canCreate = computed(() => buttons.value.includes('create') && (schema.value?.manageForm?.length ?? 0) > 0)
|
||||||
|
const canUpdate = computed(() => buttons.value.includes('update') && (schema.value?.manageForm?.length ?? 0) > 0)
|
||||||
|
const hasPivot = computed(
|
||||||
|
() =>
|
||||||
|
schema.value?.kind === 'belongsToMany' &&
|
||||||
|
(schema.value?.pivotForm?.length ?? 0) > 0 &&
|
||||||
|
(buttons.value.includes('link') || buttons.value.includes('update')),
|
||||||
|
)
|
||||||
|
const canPreview = computed(() => (schema.value?.viewForm?.length ?? 0) > 0)
|
||||||
|
/** What a row click opens, in the UI-SPEC order. */
|
||||||
|
const rowAction = computed<'update' | 'pivot' | 'preview' | null>(() => {
|
||||||
|
if (canUpdate.value) {
|
||||||
|
return 'update'
|
||||||
|
}
|
||||||
|
if (hasPivot.value) {
|
||||||
|
return 'pivot'
|
||||||
|
}
|
||||||
|
return canPreview.value ? 'preview' : null
|
||||||
|
})
|
||||||
|
const pivotButton = computed(() => canUpdate.value && hasPivot.value)
|
||||||
|
const selectable = computed(() => buttons.value.includes('unlink') || buttons.value.includes('delete'))
|
||||||
|
|
||||||
|
function header(): { 'X-Session-Key': string } | undefined {
|
||||||
|
return deferred.value ? ({ [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string }) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function pathParams() {
|
function pathParams() {
|
||||||
return { ...props.source!, id: props.recordId as number, name: relationName.value }
|
return { ...props.source!, id: ownerId.value as number, name: relationName.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On the create screen every change waits for the save: the form is dirty. */
|
||||||
|
function changed(): void {
|
||||||
|
if (deferred.value) {
|
||||||
|
session?.markDirty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSchema(): Promise<void> {
|
async function loadSchema(): Promise<void> {
|
||||||
@@ -78,7 +145,7 @@ async function loadRows(): Promise<void> {
|
|||||||
query.dir = sort.value.dir
|
query.dir = sort.value.dir
|
||||||
}
|
}
|
||||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}', {
|
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}', {
|
||||||
params: { path: pathParams(), query },
|
params: { path: pathParams(), query, header: header() },
|
||||||
})
|
})
|
||||||
if (current !== generation) {
|
if (current !== generation) {
|
||||||
return
|
return
|
||||||
@@ -154,6 +221,23 @@ function selectedIds(): number[] {
|
|||||||
return selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
return selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 nameOf(row: AdminRecord): string {
|
||||||
|
const key = columns.value[0]?.key
|
||||||
|
const value = key ? row[key] : undefined
|
||||||
|
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
|
||||||
|
}
|
||||||
|
|
||||||
async function onUnlink(): Promise<void> {
|
async function onUnlink(): Promise<void> {
|
||||||
const ids = selectedIds()
|
const ids = selectedIds()
|
||||||
if (busy.value || ids.length === 0 || !schema.value) {
|
if (busy.value || ids.length === 0 || !schema.value) {
|
||||||
@@ -170,11 +254,12 @@ async function onUnlink(): Promise<void> {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink', {
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink', {
|
||||||
params: { path: pathParams() },
|
params: { path: pathParams(), header: header() },
|
||||||
body: { ids },
|
body: { ids },
|
||||||
})
|
})
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
selected.value = []
|
selected.value = []
|
||||||
|
changed()
|
||||||
showToast(message(messages.value?.unlinked, result.data.data.removed ?? ids.length))
|
showToast(message(messages.value?.unlinked, result.data.data.removed ?? ids.length))
|
||||||
await loadRows()
|
await loadRows()
|
||||||
return
|
return
|
||||||
@@ -187,20 +272,125 @@ async function onUnlink(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs the confirmed delete; the confirm button stays busy meanwhile. */
|
||||||
|
async function deleteSelected(ids: number[]): Promise<void> {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete', {
|
||||||
|
params: { path: pathParams(), header: header() },
|
||||||
|
body: { ids },
|
||||||
|
})
|
||||||
|
if (result.data) {
|
||||||
|
selected.value = []
|
||||||
|
changed()
|
||||||
|
showToast(message(messages.value?.deleted, result.data.data.deleted || ids.length))
|
||||||
|
await loadRows()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The selection is kept so the admin can try again.
|
||||||
|
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 onDelete(): Promise<void> {
|
||||||
|
const ids = selectedIds()
|
||||||
|
if (busy.value || ids.length === 0 || !schema.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await confirm.ask(
|
||||||
|
{
|
||||||
|
message: message(messages.value?.deleteConfirm, ids.length),
|
||||||
|
confirmLabel: message(messages.value?.deleteSelected),
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
() => deleteSelected(ids),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function onLinked(count: number): Promise<void> {
|
async function onLinked(count: number): Promise<void> {
|
||||||
|
changed()
|
||||||
showToast(message(messages.value?.linked, count))
|
showToast(message(messages.value?.linked, count))
|
||||||
selected.value = []
|
selected.value = []
|
||||||
await loadRows()
|
await loadRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Focus returns to the button that opened the picker. */
|
function onCreate(event: MouseEvent): void {
|
||||||
|
opener = (event.currentTarget as HTMLElement | null) ?? createButton
|
||||||
|
childMode.value = 'create'
|
||||||
|
childId.value = null
|
||||||
|
childOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPivot(row: AdminRecord, trigger: HTMLElement | null): void {
|
||||||
|
const id = idOf(row)
|
||||||
|
if (id === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opener = trigger
|
||||||
|
pivotChild.value = id
|
||||||
|
pivotName.value = nameOf(row)
|
||||||
|
pivotOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row click: update, else pivot, else read-only preview (UI-SPEC order). */
|
||||||
|
function onRowOpen(row: AdminRecord, trigger: HTMLElement | null): void {
|
||||||
|
const action = rowAction.value
|
||||||
|
const id = idOf(row)
|
||||||
|
if (action === null || id === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'pivot') {
|
||||||
|
openPivot(row, trigger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opener = trigger
|
||||||
|
childMode.value = action
|
||||||
|
childId.value = id
|
||||||
|
childOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChildChanged(): Promise<void> {
|
||||||
|
changed()
|
||||||
|
selected.value = []
|
||||||
|
await loadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPivotSaved(): Promise<void> {
|
||||||
|
changed()
|
||||||
|
await loadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGone(): Promise<void> {
|
||||||
|
selected.value = []
|
||||||
|
await loadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus returns to the element that opened the dialog. */
|
||||||
function returnFocus(): void {
|
function returnFocus(): void {
|
||||||
|
const target = opener && opener.isConnected ? opener : null
|
||||||
|
opener = null
|
||||||
|
target?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToLink(): void {
|
||||||
linkButton?.focus()
|
linkButton?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLinkButton(instance: unknown): void {
|
function elementOf(instance: unknown): HTMLElement | null {
|
||||||
const element = (instance as { $el?: unknown } | null)?.$el
|
const element = (instance as { $el?: unknown } | null)?.$el
|
||||||
linkButton = element instanceof HTMLElement ? element : null
|
return element instanceof HTMLElement ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLinkButton(instance: unknown): void {
|
||||||
|
linkButton = elementOf(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCreateButton(instance: unknown): void {
|
||||||
|
createButton = elementOf(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -218,62 +408,93 @@ void load()
|
|||||||
:id="controlId"
|
:id="controlId"
|
||||||
data-relation-manager
|
data-relation-manager
|
||||||
:data-relation-name="relationName"
|
:data-relation-name="relationName"
|
||||||
|
:data-deferred="deferred ? 'true' : undefined"
|
||||||
:aria-labelledby="headingId"
|
:aria-labelledby="headingId"
|
||||||
:aria-describedby="field.comment ? commentId : undefined"
|
:aria-describedby="field.comment ? commentId : undefined"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="flex flex-col gap-4"
|
class="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<header class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<header class="flex flex-wrap items-center gap-3">
|
||||||
<h2 :id="headingId" class="text-[17px] font-bold tracking-[-0.01em]">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
{{ schema?.label || field.label || relationName }}
|
<h2 :id="headingId" class="text-[17px] font-bold tracking-[-0.01em]">
|
||||||
</h2>
|
{{ schema?.label || field.label || relationName }}
|
||||||
<p v-if="field.comment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
|
</h2>
|
||||||
</div>
|
<p v-if="field.comment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
|
||||||
<label v-if="schema?.view.showSearch" class="relative block w-[220px] max-w-full">
|
</div>
|
||||||
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
|
<label v-if="schema?.view.showSearch" class="relative block w-[220px] max-w-full">
|
||||||
<Search
|
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
|
||||||
:size="16"
|
<Search
|
||||||
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted"
|
:size="16"
|
||||||
aria-hidden="true"
|
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted"
|
||||||
/>
|
aria-hidden="true"
|
||||||
<input
|
/>
|
||||||
type="search"
|
<input
|
||||||
data-relation-search
|
type="search"
|
||||||
:value="search"
|
data-relation-search
|
||||||
:placeholder="t('backend::lang.list.search_prompt')"
|
:value="search"
|
||||||
class="h-[38px] w-full rounded-control border border-transparent bg-subtle pr-3 pl-9"
|
:placeholder="t('backend::lang.list.search_prompt')"
|
||||||
@input="onSearch"
|
class="h-[38px] w-full rounded-control border border-transparent bg-subtle pr-3 pl-9"
|
||||||
@keydown.enter.prevent
|
@input="onSearch"
|
||||||
/>
|
@keydown.enter.prevent
|
||||||
</label>
|
/>
|
||||||
<template v-for="button in buttons" :key="button">
|
</label>
|
||||||
<Button
|
<template v-for="button in buttons" :key="button">
|
||||||
v-if="button === 'unlink'"
|
<Button
|
||||||
data-action="unlink"
|
v-if="button === 'create' && canCreate"
|
||||||
size="sm"
|
:ref="setCreateButton"
|
||||||
variant="danger"
|
data-action="create"
|
||||||
:icon="UserMinus"
|
size="sm"
|
||||||
:disabled="busy || selected.length === 0"
|
variant="primary"
|
||||||
@click="onUnlink"
|
:icon="Plus"
|
||||||
>
|
:disabled="busy || !schema"
|
||||||
{{ message(messages?.unlinkSelected) }}
|
aria-haspopup="dialog"
|
||||||
</Button>
|
@click="onCreate"
|
||||||
<Button
|
>
|
||||||
v-else-if="button === 'link'"
|
{{ message(messages?.create) }}
|
||||||
:ref="setLinkButton"
|
</Button>
|
||||||
data-action="link"
|
<Button
|
||||||
size="sm"
|
v-else-if="button === 'link'"
|
||||||
variant="primary"
|
:ref="setLinkButton"
|
||||||
:icon="UserPlus"
|
data-action="link"
|
||||||
:disabled="busy || !schema"
|
size="sm"
|
||||||
aria-haspopup="dialog"
|
:variant="canCreate ? 'outline' : 'primary'"
|
||||||
@click="pickerOpen = true"
|
:icon="UserPlus"
|
||||||
>
|
:disabled="busy || !schema"
|
||||||
{{ message(messages?.link) }}
|
aria-haspopup="dialog"
|
||||||
</Button>
|
@click="pickerOpen = true"
|
||||||
</template>
|
>
|
||||||
</header>
|
{{ message(messages?.link) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="button === 'delete'"
|
||||||
|
data-action="delete"
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
:icon="Trash2"
|
||||||
|
:disabled="busy || selected.length === 0"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
{{ message(messages?.deleteSelected) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="button === 'unlink'"
|
||||||
|
data-action="unlink"
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
:icon="UserMinus"
|
||||||
|
:disabled="busy || selected.length === 0"
|
||||||
|
@click="onUnlink"
|
||||||
|
>
|
||||||
|
{{ message(messages?.unlinkSelected) }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</header>
|
||||||
|
<p v-if="deferred" data-relation-pending class="flex items-center gap-2 text-[13px] text-muted">
|
||||||
|
<Info :size="14" class="shrink-0" aria-hidden="true" />
|
||||||
|
<span>{{ t('backend::lang.relation.pending_note') }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
<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') }}
|
{{ t('backend::lang.relation.load_failed') }}
|
||||||
@@ -286,14 +507,28 @@ void load()
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:selectable="buttons.includes('unlink')"
|
:selectable="selectable"
|
||||||
:sortable="true"
|
:sortable="true"
|
||||||
:sort="sort"
|
:sort="sort"
|
||||||
|
:openable="rowAction !== null"
|
||||||
|
:trailing="pivotButton"
|
||||||
@sort="onSort"
|
@sort="onSort"
|
||||||
|
@open="onRowOpen"
|
||||||
>
|
>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<span data-relation-empty class="text-muted">{{ message(messages?.empty) }}</span>
|
<span data-relation-empty class="text-muted">{{ message(messages?.empty) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #trailing="{ row }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="edit-pivot"
|
||||||
|
:aria-label="message(messages?.editPivot, undefined, { name: nameOf(row) })"
|
||||||
|
class="flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
|
||||||
|
@click="openPivot(row, $event.currentTarget as HTMLElement)"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal :size="16" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="meta && meta.last_page > 1"
|
v-if="meta && meta.last_page > 1"
|
||||||
@@ -309,10 +544,42 @@ void load()
|
|||||||
v-if="schema && buttons.includes('link')"
|
v-if="schema && buttons.includes('link')"
|
||||||
v-model:open="pickerOpen"
|
v-model:open="pickerOpen"
|
||||||
:source="source!"
|
:source="source!"
|
||||||
:record-id="recordId as number"
|
:record-id="ownerId as number"
|
||||||
:relation="relationName"
|
:relation="relationName"
|
||||||
:schema="schema"
|
:schema="schema"
|
||||||
|
:session-key="deferred ? sessionKey : null"
|
||||||
@linked="onLinked"
|
@linked="onLinked"
|
||||||
|
@closed="returnToLink"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RelationChildModal
|
||||||
|
v-if="schema && (canCreate || rowAction === 'update' || rowAction === 'preview')"
|
||||||
|
v-model:open="childOpen"
|
||||||
|
:source="source!"
|
||||||
|
:owner-id="ownerId as number"
|
||||||
|
:relation="relationName"
|
||||||
|
:schema="schema"
|
||||||
|
:mode="childMode"
|
||||||
|
:child-id="childId"
|
||||||
|
:session-key="sessionKey"
|
||||||
|
:can-delete="buttons.includes('delete')"
|
||||||
|
@changed="onChildChanged"
|
||||||
|
@gone="onGone"
|
||||||
|
@closed="returnFocus"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RelationPivotModal
|
||||||
|
v-if="schema && hasPivot"
|
||||||
|
v-model:open="pivotOpen"
|
||||||
|
:source="source!"
|
||||||
|
:owner-id="ownerId as number"
|
||||||
|
:relation="relationName"
|
||||||
|
:schema="schema"
|
||||||
|
:child-id="pivotChild"
|
||||||
|
:child-name="pivotName"
|
||||||
|
:session-key="sessionKey"
|
||||||
|
@saved="onPivotSaved"
|
||||||
|
@gone="onGone"
|
||||||
@closed="returnFocus"
|
@closed="returnFocus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -321,6 +588,7 @@ void load()
|
|||||||
:message="confirm.request.value?.message ?? ''"
|
:message="confirm.request.value?.message ?? ''"
|
||||||
:confirm-label="confirm.request.value?.confirmLabel"
|
:confirm-label="confirm.request.value?.confirmLabel"
|
||||||
:danger="confirm.request.value?.danger"
|
:danger="confirm.request.value?.danger"
|
||||||
|
:busy="confirm.busy.value"
|
||||||
@confirm="confirm.confirm"
|
@confirm="confirm.confirm"
|
||||||
@cancel="confirm.cancel"
|
@cancel="confirm.cancel"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -13,7 +13,11 @@ import { Check, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue'
|
|||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
|
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
|
||||||
import { message, t } from '../../app/i18n'
|
import { message, t } from '../../app/i18n'
|
||||||
|
import { SESSION_HEADER } from '../../app/sessionKey'
|
||||||
import { showToast } from '../../state/useToasts'
|
import { showToast } from '../../state/useToasts'
|
||||||
|
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||||
|
import FormGrid from '../form/FormGrid.vue'
|
||||||
|
import { editablePayload, fieldErrors, focusField, initialValues } from '../form/formState'
|
||||||
|
|
||||||
// Candidate picker (design screen 5, "Dodaj redaktora"): a Reka Dialog
|
// Candidate picker (design screen 5, "Dodaj redaktora"): a Reka Dialog
|
||||||
// (role dialog, aria-modal, focus trapped, Esc closes) over GET
|
// (role dialog, aria-modal, focus trapped, Esc closes) over GET
|
||||||
@@ -21,12 +25,19 @@ import { showToast } from '../../state/useToasts'
|
|||||||
// ids kept across pages and search changes and cleared on close. "Dodaj (N)"
|
// 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
|
// POSTs link with the selected ids. Candidates are shown exactly as the
|
||||||
// server returns them: owner exclusion and scoping are server-side.
|
// server returns them: owner exclusion and scoping are server-side.
|
||||||
|
// Phase 12.2 (D-14): when the relation has a pivot form the picker selects
|
||||||
|
// one record, Next shows the pivot form in the same dialog, and Add link
|
||||||
|
// posts `{ids: [id], pivot: {...}}`; Back returns to the list with its
|
||||||
|
// search, page and selection. On an unsaved record (id 0) every call sends
|
||||||
|
// the form's session key.
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
source: ControllerParams
|
source: ControllerParams
|
||||||
recordId: number
|
recordId: number
|
||||||
relation: string
|
relation: string
|
||||||
schema: RelationSchema
|
schema: RelationSchema
|
||||||
|
/** The record form's session key; sent when the owner is unsaved. */
|
||||||
|
sessionKey?: string | null
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:open': [open: boolean]
|
'update:open': [open: boolean]
|
||||||
@@ -45,6 +56,12 @@ const loading = ref(false)
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const chosen = ref<Set<number>>(new Set())
|
const chosen = ref<Set<number>>(new Set())
|
||||||
const searchInput = ref<HTMLInputElement | null>(null)
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
|
// Pivot link flow: step 1 picks one record, step 2 fills the pivot form.
|
||||||
|
const step = ref<1 | 2>(1)
|
||||||
|
const chosenRow = ref<AdminRecord | null>(null)
|
||||||
|
const pivotValues = ref<AdminRecord>({})
|
||||||
|
const pivotErrors = ref<Record<string, string[]>>({})
|
||||||
|
const stepTwo = ref<HTMLElement | null>(null)
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -53,6 +70,15 @@ const primary = computed(() => columns.value[0]?.key ?? '')
|
|||||||
const secondary = computed(() => columns.value[1]?.key ?? '')
|
const secondary = computed(() => columns.value[1]?.key ?? '')
|
||||||
const count = computed(() => chosen.value.size)
|
const count = computed(() => chosen.value.size)
|
||||||
const listId = `relation-${props.relation}-candidates`
|
const listId = `relation-${props.relation}-candidates`
|
||||||
|
const pivotFields = computed(() => props.schema.pivotForm ?? [])
|
||||||
|
const pivotFieldNames = computed(() => pivotFields.value.map((field) => field.name))
|
||||||
|
/** With a pivot form one record is linked at a time, with its pivot values. */
|
||||||
|
const single = computed(() => pivotFields.value.length > 0)
|
||||||
|
const pivotPrefix = computed(() => `link-${props.relation}`)
|
||||||
|
|
||||||
|
function header(): { 'X-Session-Key': string } | undefined {
|
||||||
|
return props.sessionKey ? ({ [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string }) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const range = computed(() => {
|
const range = computed(() => {
|
||||||
const m = meta.value
|
const m = meta.value
|
||||||
@@ -100,6 +126,7 @@ async function fetchPage(): Promise<void> {
|
|||||||
params: {
|
params: {
|
||||||
path: { ...props.source, id: props.recordId, name: props.relation },
|
path: { ...props.source, id: props.recordId, name: props.relation },
|
||||||
query: { search: term === '' ? undefined : term, page: page.value, per_page: PER_PAGE },
|
query: { search: term === '' ? undefined : term, page: page.value, per_page: PER_PAGE },
|
||||||
|
header: header(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (current !== generation) {
|
if (current !== generation) {
|
||||||
@@ -131,6 +158,10 @@ function reset(): void {
|
|||||||
meta.value = null
|
meta.value = null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
chosen.value = new Set()
|
chosen.value = new Set()
|
||||||
|
step.value = 1
|
||||||
|
chosenRow.value = null
|
||||||
|
pivotValues.value = {}
|
||||||
|
pivotErrors.value = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -176,6 +207,12 @@ function toggle(row: AdminRecord): void {
|
|||||||
if (id === null) {
|
if (id === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (single.value) {
|
||||||
|
// Radio behaviour: the chosen record is replaced, never added to.
|
||||||
|
chosen.value = new Set([id])
|
||||||
|
chosenRow.value = row
|
||||||
|
return
|
||||||
|
}
|
||||||
const next = new Set(chosen.value)
|
const next = new Set(chosen.value)
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
next.delete(id)
|
next.delete(id)
|
||||||
@@ -185,12 +222,51 @@ function toggle(row: AdminRecord): void {
|
|||||||
chosen.value = next
|
chosen.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Step 2 of the pivot flow: the chosen record and its pivot form. */
|
||||||
|
async function goNext(): Promise<void> {
|
||||||
|
if (!single.value || chosen.value.size === 0 || !chosenRow.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step.value = 2
|
||||||
|
pivotValues.value = initialValues(pivotFields.value)
|
||||||
|
pivotErrors.value = {}
|
||||||
|
await nextTick()
|
||||||
|
stepTwo.value?.querySelector<HTMLElement>('input, select, textarea, button, [contenteditable="true"]')?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to the list, keeping its search, page and selection. */
|
||||||
|
async function goBack(): Promise<void> {
|
||||||
|
if (busy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step.value = 1
|
||||||
|
pivotErrors.value = {}
|
||||||
|
await nextTick()
|
||||||
|
const chosenOption = document.querySelector<HTMLElement>(`#${listId} [aria-selected="true"]`)
|
||||||
|
;(chosenOption ?? searchInput.value)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePivot(name: string, value: unknown): void {
|
||||||
|
pivotValues.value = { ...pivotValues.value, [name]: value }
|
||||||
|
if (pivotErrors.value[name]) {
|
||||||
|
const next = { ...pivotErrors.value }
|
||||||
|
delete next[name]
|
||||||
|
pivotErrors.value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onOptionKeydown(event: KeyboardEvent, row: AdminRecord, index: number): void {
|
function onOptionKeydown(event: KeyboardEvent, row: AdminRecord, index: number): void {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
|
event.preventDefault()
|
||||||
|
toggle(row)
|
||||||
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
toggle(row)
|
toggle(row)
|
||||||
|
if (single.value) {
|
||||||
|
void goNext()
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
case 'ArrowUp': {
|
case 'ArrowUp': {
|
||||||
@@ -212,17 +288,42 @@ async function onConfirm(): Promise<void> {
|
|||||||
if (busy.value || ids.length === 0) {
|
if (busy.value || ids.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const pivot = single.value
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link', {
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link', {
|
||||||
params: { path: { ...props.source, id: props.recordId, name: props.relation } },
|
params: { path: { ...props.source, id: props.recordId, name: props.relation }, header: header() },
|
||||||
body: { ids },
|
body: pivot ? { ids: ids.slice(0, 1), pivot: editablePayload(pivotFields.value, pivotValues.value) } : { ids },
|
||||||
})
|
})
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
emit('linked', result.data.data.linked ?? ids.length)
|
emit('linked', result.data.data.linked ?? ids.length)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const status = result.response.status
|
||||||
|
if (pivot && status === 422) {
|
||||||
|
// Pivot errors stay in the dialog, under their fields.
|
||||||
|
pivotErrors.value = fieldErrors(result.error?.error.details)
|
||||||
|
const first = pivotFields.value.find((field) => pivotErrors.value[field.name])
|
||||||
|
if (first) {
|
||||||
|
await nextTick()
|
||||||
|
focusField(pivotPrefix.value, first.name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pivot && status === 404) {
|
||||||
|
// The chosen record is gone: back to the list, reloaded.
|
||||||
|
showToast(t('backend::lang.relation.child_gone'), 'danger')
|
||||||
|
chosen.value = new Set()
|
||||||
|
chosenRow.value = null
|
||||||
|
step.value = 1
|
||||||
|
void fetchPage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pivot) {
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
return
|
||||||
|
}
|
||||||
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
@@ -267,8 +368,16 @@ const pagerButton =
|
|||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
<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>
|
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{
|
||||||
<DialogDescription class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
|
step === 2 ? message(schema.messages.pivotTitle) : message(schema.messages.link)
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription v-if="step === 1" class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
|
||||||
|
<DialogDescription v-else class="flex min-w-0 flex-col" data-chosen-record>
|
||||||
|
<span class="truncate font-semibold text-text" :title="text(chosenRow ?? {}, primary)">{{
|
||||||
|
text(chosenRow ?? {}, primary)
|
||||||
|
}}</span>
|
||||||
|
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(chosenRow ?? {}, secondary) }}</span>
|
||||||
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<DialogClose
|
<DialogClose
|
||||||
data-action="close"
|
data-action="close"
|
||||||
@@ -279,7 +388,24 @@ const pagerButton =
|
|||||||
</DialogClose>
|
</DialogClose>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="relative block">
|
<div v-if="step === 2" ref="stepTwo" data-pivot-step class="flex min-h-0 flex-col gap-4 overflow-y-auto">
|
||||||
|
<FormErrorBanner :errors="pivotErrors" :field-names="pivotFieldNames" />
|
||||||
|
<form novalidate @submit.prevent="onConfirm">
|
||||||
|
<fieldset :disabled="busy" class="m-0 min-w-0 border-0 p-0">
|
||||||
|
<FormGrid
|
||||||
|
:fields="pivotFields"
|
||||||
|
:values="pivotValues"
|
||||||
|
:errors="pivotErrors"
|
||||||
|
:source="source"
|
||||||
|
:record-id="null"
|
||||||
|
:id-prefix="pivotPrefix"
|
||||||
|
@update="updatePivot"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-show="step === 1" class="relative block">
|
||||||
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
|
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
|
||||||
<Search
|
<Search
|
||||||
:size="18"
|
:size="18"
|
||||||
@@ -299,9 +425,10 @@ const pagerButton =
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ul
|
<ul
|
||||||
|
v-show="step === 1"
|
||||||
:id="listId"
|
:id="listId"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-multiselectable="true"
|
:aria-multiselectable="single ? 'false' : 'true'"
|
||||||
:aria-label="message(schema.messages.link)"
|
:aria-label="message(schema.messages.link)"
|
||||||
:aria-busy="loading ? 'true' : undefined"
|
:aria-busy="loading ? 'true' : undefined"
|
||||||
class="flex min-h-0 flex-col gap-2 overflow-y-auto"
|
class="flex min-h-0 flex-col gap-2 overflow-y-auto"
|
||||||
@@ -328,6 +455,16 @@ const pagerButton =
|
|||||||
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(row, secondary) }}</span>
|
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(row, secondary) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
v-if="single"
|
||||||
|
data-radio
|
||||||
|
:class="isChosen(row) ? 'border-primary' : 'border-border-strong bg-surface'"
|
||||||
|
class="flex size-[18px] shrink-0 items-center justify-center rounded-full border"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span v-if="isChosen(row)" class="block size-2.5 rounded-full bg-primary" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
:class="isChosen(row) ? 'border-primary bg-primary text-on-primary' : 'border-border-strong bg-surface'"
|
: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"
|
class="flex size-[18px] shrink-0 items-center justify-center rounded-checkbox border"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -349,7 +486,7 @@ const pagerButton =
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
v-if="meta && meta.total > 0"
|
v-if="step === 1 && meta && meta.total > 0"
|
||||||
:aria-label="t('backend::lang.list.pagination')"
|
:aria-label="t('backend::lang.list.pagination')"
|
||||||
class="flex items-center gap-2 text-[13px] text-muted"
|
class="flex items-center gap-2 text-[13px] text-muted"
|
||||||
>
|
>
|
||||||
@@ -377,20 +514,44 @@ const pagerButton =
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex gap-2.5">
|
<div class="flex gap-2.5">
|
||||||
|
<button
|
||||||
|
v-if="step === 2"
|
||||||
|
type="button"
|
||||||
|
data-action="back"
|
||||||
|
:disabled="busy"
|
||||||
|
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.relation.back') }}
|
||||||
|
</button>
|
||||||
<DialogClose
|
<DialogClose
|
||||||
|
v-else
|
||||||
data-action="cancel"
|
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"
|
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') }}
|
{{ t('backend::lang.form.cancel') }}
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<button
|
<button
|
||||||
|
v-if="single && step === 1"
|
||||||
|
type="button"
|
||||||
|
data-action="next"
|
||||||
|
:disabled="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="goNext"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.relation.next') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
data-action="confirm-link"
|
data-action="confirm-link"
|
||||||
:disabled="busy || count === 0"
|
:disabled="busy || count === 0"
|
||||||
|
:aria-busy="busy ? 'true' : undefined"
|
||||||
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"
|
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"
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
{{ t('backend::lang.relation.add_count', { count }) }}
|
<template v-if="single">{{ busy ? t('backend::lang.form.saving') : message(schema.messages.linkSubmit) }}</template>
|
||||||
|
<template v-else>{{ t('backend::lang.relation.add_count', { count }) }}</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
288
admin/src/components/relation/RelationPivotModal.vue
Normal file
288
admin/src/components/relation/RelationPivotModal.vue
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { X } from '@lucide/vue'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
import type { AdminRecord, ControllerParams, RelationSchema } from '../../api/types'
|
||||||
|
import { message, t } from '../../app/i18n'
|
||||||
|
import { SESSION_HEADER } from '../../app/sessionKey'
|
||||||
|
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||||
|
import FormGrid from '../form/FormGrid.vue'
|
||||||
|
import { editablePayload, fieldErrors, focusField, snapshot } from '../form/formState'
|
||||||
|
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../ui/confirm'
|
||||||
|
import { showToast } from '../../state/useToasts'
|
||||||
|
|
||||||
|
// Link details (Phase 12.2, D-14; UI-SPEC section 4, pivot modal). Edits
|
||||||
|
// the pivot form (pivot.form) values of one belongsToMany link through
|
||||||
|
// GET/PUT .../relations/{name}/pivot/{child}. Only the pivot form's fields
|
||||||
|
// are sent; server-owned pivot columns never appear. A 422 stays in the
|
||||||
|
// dialog, a 404 closes it and reloads the list, any other failure keeps the
|
||||||
|
// input with a danger toast. Closing with changes asks first.
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
source: ControllerParams
|
||||||
|
/** Owner record id; 0 for the record being created in the form's session. */
|
||||||
|
ownerId: number
|
||||||
|
relation: string
|
||||||
|
schema: RelationSchema
|
||||||
|
childId: number | null
|
||||||
|
/** The related record's display name. */
|
||||||
|
childName: string
|
||||||
|
/** The record form's session key (X-Session-Key). */
|
||||||
|
sessionKey: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [open: boolean]
|
||||||
|
saved: []
|
||||||
|
gone: []
|
||||||
|
closed: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const values = ref<AdminRecord>({})
|
||||||
|
const errors = ref<Record<string, string[]>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const failed = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const saved = ref('')
|
||||||
|
const confirm = useConfirm()
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
const idPrefix = computed(() => `pivot-${props.relation}`)
|
||||||
|
const fields = computed(() => props.schema.pivotForm ?? [])
|
||||||
|
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||||
|
const dirty = computed(
|
||||||
|
() => !loading.value && !failed.value && snapshot(editablePayload(fields.value, values.value)) !== saved.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function header() {
|
||||||
|
return { [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function path(child: number) {
|
||||||
|
return { ...props.source, id: props.ownerId, name: props.relation, child }
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(name: string, value: unknown): void {
|
||||||
|
values.value = { ...values.value, [name]: value }
|
||||||
|
if (errors.value[name]) {
|
||||||
|
const next = { ...errors.value }
|
||||||
|
delete next[name]
|
||||||
|
errors.value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gone(): void {
|
||||||
|
emit('update:open', false)
|
||||||
|
showToast(t('backend::lang.relation.child_gone'), 'danger')
|
||||||
|
emit('gone')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const current = ++generation
|
||||||
|
errors.value = {}
|
||||||
|
failed.value = false
|
||||||
|
busy.value = false
|
||||||
|
values.value = {}
|
||||||
|
saved.value = snapshot({})
|
||||||
|
if (props.childId === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}', {
|
||||||
|
params: { path: path(props.childId), header: header() },
|
||||||
|
})
|
||||||
|
if (current !== generation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.data) {
|
||||||
|
values.value = { ...result.data.data }
|
||||||
|
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||||
|
} else if (result.response.status === 404) {
|
||||||
|
gone()
|
||||||
|
} else {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (current === generation) {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === generation) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
void load()
|
||||||
|
} else {
|
||||||
|
generation++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function setOpen(open: boolean): Promise<void> {
|
||||||
|
if (open) {
|
||||||
|
emit('update:open', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (busy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dirty.value) {
|
||||||
|
const ok = await confirm.ask({
|
||||||
|
message: t('backend::lang.form.unsaved_confirm'),
|
||||||
|
confirmLabel: t('backend::lang.form.discard'),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
if (busy.value || loading.value || failed.value || props.childId === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}', {
|
||||||
|
params: { path: path(props.childId), header: header() },
|
||||||
|
body: editablePayload(fields.value, values.value),
|
||||||
|
})
|
||||||
|
if (result.data) {
|
||||||
|
errors.value = {}
|
||||||
|
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||||
|
emit('update:open', false)
|
||||||
|
showToast(message(props.schema.messages.pivotSaved))
|
||||||
|
emit('saved')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const status = result.response.status
|
||||||
|
if (status === 422) {
|
||||||
|
errors.value = fieldErrors(result.error?.error.details)
|
||||||
|
const first = fields.value.find((field) => errors.value[field.name])
|
||||||
|
if (first) {
|
||||||
|
await nextTick()
|
||||||
|
focusField(idPrefix.value, first.name)
|
||||||
|
}
|
||||||
|
} else if (status === 404) {
|
||||||
|
gone()
|
||||||
|
} else {
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseAutoFocus(event: Event): void {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('closed')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot :open="open" @update:open="setOpen">
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||||
|
<DialogContent
|
||||||
|
data-relation-pivot-modal
|
||||||
|
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 overflow-y-auto rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||||
|
@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.pivotTitle) }}</DialogTitle>
|
||||||
|
<DialogDescription class="truncate text-muted" :title="childName">{{ childName }}</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<DialogClose
|
||||||
|
data-action="close"
|
||||||
|
:aria-label="t('backend::lang.form.close')"
|
||||||
|
:disabled="busy"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
data-pivot-loading
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex flex-col gap-1.5"
|
||||||
|
>
|
||||||
|
<span class="block h-3 w-24 rounded-[6px] bg-skel" />
|
||||||
|
<span class="block h-input rounded-control bg-skel" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="failed" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||||
|
{{ t('backend::lang.relation.child_load_failed') }}
|
||||||
|
</p>
|
||||||
|
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="onSubmit">
|
||||||
|
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||||
|
<fieldset :disabled="busy" class="m-0 min-w-0 border-0 p-0">
|
||||||
|
<FormGrid
|
||||||
|
:fields="fields"
|
||||||
|
:values="values"
|
||||||
|
:errors="errors"
|
||||||
|
:source="source"
|
||||||
|
:record-id="null"
|
||||||
|
:id-prefix="idPrefix"
|
||||||
|
@update="update"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="cancel"
|
||||||
|
:disabled="busy"
|
||||||
|
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
@click="setOpen(false)"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.form.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="save-pivot"
|
||||||
|
:disabled="busy || loading || failed"
|
||||||
|
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="onSubmit"
|
||||||
|
>
|
||||||
|
{{ busy ? t('backend::lang.form.saving') : message(schema.messages.pivotSubmit) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
AlertDialogRoot,
|
AlertDialogRoot,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
|
import { LoaderCircle } from '@lucide/vue'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
|
|
||||||
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
|
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
|
||||||
// cancels, focus returns to the trigger). Text only: messages come from the
|
// cancels, focus returns to the trigger). Text only: messages come from the
|
||||||
// server and are never rendered as HTML.
|
// server and are never rendered as HTML. While `busy` (an action started by
|
||||||
withDefaults(
|
// the confirm is running) both buttons are disabled and the dialog stays.
|
||||||
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
message: string
|
message: string
|
||||||
@@ -21,13 +23,17 @@ withDefaults(
|
|||||||
confirmLabel?: string
|
confirmLabel?: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
danger?: boolean
|
danger?: boolean
|
||||||
|
busy?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false },
|
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false, busy: false },
|
||||||
)
|
)
|
||||||
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
|
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
|
||||||
|
|
||||||
// Closing any other way than the confirm button (Esc, Anuluj) cancels.
|
// Closing any other way than the confirm button (Esc, Anuluj) cancels.
|
||||||
function onOpenChange(open: boolean): void {
|
function onOpenChange(open: boolean): void {
|
||||||
|
if (!open && props.busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('update:open', open)
|
emit('update:open', open)
|
||||||
if (!open) {
|
if (!open) {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
@@ -35,6 +41,9 @@ function onOpenChange(open: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onConfirm(): void {
|
function onConfirm(): void {
|
||||||
|
if (props.busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
}
|
}
|
||||||
@@ -55,17 +64,21 @@ function onConfirm(): void {
|
|||||||
<div class="flex gap-2.5">
|
<div class="flex gap-2.5">
|
||||||
<AlertDialogCancel
|
<AlertDialogCancel
|
||||||
data-action="cancel"
|
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"
|
:disabled="busy"
|
||||||
|
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
|
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-action="confirm"
|
data-action="confirm"
|
||||||
|
:disabled="busy"
|
||||||
|
:aria-busy="busy ? 'true' : undefined"
|
||||||
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
|
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
|
||||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control font-semibold hover:opacity-90"
|
class="inline-flex h-button flex-1 items-center justify-center gap-2 rounded-control font-semibold hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
|
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
|
||||||
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
|
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Promise-based confirmation for ConfirmDialog: `await ask(text)` resolves
|
// Promise-based confirmation for ConfirmDialog: `await ask(text)` resolves
|
||||||
// true on confirm and false on Anuluj, Esc or a second request.
|
// true on confirm and false on Anuluj, Esc or a second request. With an
|
||||||
|
// action, the dialog stays open and busy while the action runs after the
|
||||||
|
// confirm, and closes when it settles (a toolbar delete shows its progress
|
||||||
|
// on the confirm button).
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
export interface ConfirmRequest {
|
export interface ConfirmRequest {
|
||||||
@@ -10,22 +13,49 @@ export interface ConfirmRequest {
|
|||||||
|
|
||||||
export function useConfirm() {
|
export function useConfirm() {
|
||||||
const request = ref<ConfirmRequest | null>(null)
|
const request = ref<ConfirmRequest | null>(null)
|
||||||
|
const busy = ref(false)
|
||||||
let resolver: ((ok: boolean) => void) | null = null
|
let resolver: ((ok: boolean) => void) | null = null
|
||||||
|
let action: (() => Promise<unknown>) | null = null
|
||||||
|
|
||||||
function settle(ok: boolean): void {
|
function settle(ok: boolean): void {
|
||||||
const resolve = resolver
|
const resolve = resolver
|
||||||
resolver = null
|
resolver = null
|
||||||
|
action = null
|
||||||
request.value = null
|
request.value = null
|
||||||
resolve?.(ok)
|
resolve?.(ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ask(next: ConfirmRequest): Promise<boolean> {
|
function ask(next: ConfirmRequest, run?: () => Promise<unknown>): Promise<boolean> {
|
||||||
settle(false)
|
settle(false)
|
||||||
request.value = next
|
request.value = next
|
||||||
|
action = run ?? null
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
resolver = resolve
|
resolver = resolve
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { request, ask, confirm: () => settle(true), cancel: () => settle(false) }
|
async function confirm(): Promise<void> {
|
||||||
|
if (busy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const run = action
|
||||||
|
if (run) {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await run()
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settle(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
if (busy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settle(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request, busy, ask, confirm, cancel }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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 { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext'
|
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext'
|
||||||
import { needsRecord } from '../components/form/registry'
|
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
||||||
import {
|
import {
|
||||||
DEFAULT_TAB,
|
DEFAULT_TAB,
|
||||||
contextAllows,
|
contextAllows,
|
||||||
@@ -82,9 +82,13 @@ const revision = ref(0)
|
|||||||
|
|
||||||
// A relation manager needs a saved record: on create it is dropped with its
|
// 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).
|
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
|
||||||
|
// A deferrable one (Phase 12.2, D-03) stays: it works on owner id 0 with the
|
||||||
|
// form's session key and the save commits its changes.
|
||||||
const fields = computed(() =>
|
const fields = computed(() =>
|
||||||
(schema.value?.fields ?? []).filter(
|
(schema.value?.fields ?? []).filter(
|
||||||
(field) => contextAllows(field, mode) && (mode === 'update' || !needsRecord(field.type)),
|
(field) =>
|
||||||
|
contextAllows(field, mode) &&
|
||||||
|
(mode === 'update' || !needsRecord(field.type) || (field.type === RELATION_MANAGER && !!field.deferrable)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||||
|
|||||||
@@ -38,6 +38,13 @@
|
|||||||
"mode": "crop"
|
"mode": "crop"
|
||||||
},
|
},
|
||||||
"useCaption": true
|
"useCaption": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parts",
|
||||||
|
"label": "Parts",
|
||||||
|
"type": "relation-manager",
|
||||||
|
"relation": "parts",
|
||||||
|
"deferrable": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"messages": {
|
"messages": {
|
||||||
|
|||||||
@@ -150,3 +150,77 @@ describe('relation manager (D-05, design screen 5)', () => {
|
|||||||
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
|
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => {
|
||||||
|
const editable = {
|
||||||
|
...relationSchemaFixture,
|
||||||
|
data: {
|
||||||
|
...relationSchemaFixture.data,
|
||||||
|
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'update', 'delete', 'link'] },
|
||||||
|
manageForm: [{ name: 'name', label: 'Name', type: 'text' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const RECORDS = `${LINKED}/records`
|
||||||
|
const DELETE = `${LINKED}/delete`
|
||||||
|
|
||||||
|
it('renders the toolbar in order with create as the only primary button', async () => {
|
||||||
|
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable } })
|
||||||
|
const actions = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action'))
|
||||||
|
expect(actions).toEqual(['create', 'delete', 'link'])
|
||||||
|
expect(wrapper.find('[data-action="link"]').classes()).not.toContain('bg-primary')
|
||||||
|
expect(wrapper.find('[data-action="create"]').classes()).toContain('bg-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the child modal in update mode from the row button and saves the child', async () => {
|
||||||
|
const first = linkedFixture.all.data[0]!
|
||||||
|
const { calls } = await mountManager({
|
||||||
|
[`GET ${SCHEMA}`]: { body: editable },
|
||||||
|
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
|
||||||
|
[`PUT ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
|
||||||
|
})
|
||||||
|
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
|
||||||
|
expect(modal.dataset.mode).toBe('update')
|
||||||
|
const input = modal.querySelector<HTMLInputElement>('#child-members-name')!
|
||||||
|
expect(input.value).toBe(String(first.name))
|
||||||
|
input.value = 'Renamed'
|
||||||
|
input.dispatchEvent(new Event('input'))
|
||||||
|
await flushPromises()
|
||||||
|
;(modal.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
const [put] = requestsTo(calls, 'PUT', `${RECORDS}/${first.id}`)
|
||||||
|
expect(await put!.clone().json()).toEqual({ name: 'Renamed' })
|
||||||
|
expect(put!.headers.get('X-Child-Session-Key')).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||||
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes, toasts and reloads when the child is gone', async () => {
|
||||||
|
const first = linkedFixture.all.data[0]!
|
||||||
|
const { calls } = await mountManager({
|
||||||
|
[`GET ${SCHEMA}`]: { body: editable },
|
||||||
|
[`GET ${RECORDS}/${first.id}`]: failure(404, 'Not found'),
|
||||||
|
})
|
||||||
|
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
||||||
|
expect(toasts.value[0]).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' })
|
||||||
|
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes the selected children after a confirmation and keeps them on failure', async () => {
|
||||||
|
const { wrapper, calls } = await mountManager({
|
||||||
|
[`GET ${SCHEMA}`]: { body: editable },
|
||||||
|
[`POST ${DELETE}`]: failure(422, 'Refused'),
|
||||||
|
})
|
||||||
|
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
||||||
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(requestsTo(calls, 'POST', DELETE)).toHaveLength(1)
|
||||||
|
expect(toasts.value[0]).toMatchObject({ text: 'Refused', tone: 'danger' })
|
||||||
|
expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -158,3 +158,68 @@ describe('relation picker modal', () => {
|
|||||||
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
|
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('pivot link step (Phase 12.2, D-14)', () => {
|
||||||
|
const pivotSchema = {
|
||||||
|
...relationSchemaFixture.data,
|
||||||
|
pivotForm: [{ name: 'role', label: 'Role', type: 'text' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPivot(overrides: Record<string, Route> = {}) {
|
||||||
|
const calls = mockApi(routes(overrides))
|
||||||
|
const wrapper = mount(RelationPickerModal, {
|
||||||
|
props: { open: true, source, recordId: 1, relation: 'members', schema: pivotSchema },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await wait(0)
|
||||||
|
await flushPromises()
|
||||||
|
return { wrapper, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="next"]')!
|
||||||
|
|
||||||
|
it('selects one record, asks for its pivot values and links it with them', async () => {
|
||||||
|
const { wrapper, calls } = await openPivot({ [`POST ${LINK}`]: { body: { data: { linked: 1 }, meta: {} } } })
|
||||||
|
expect(dialog()!.querySelector('[role="listbox"]')!.getAttribute('aria-multiselectable')).toBe('false')
|
||||||
|
expect(nextButton().disabled).toBe(true)
|
||||||
|
await click(options()[0])
|
||||||
|
await click(options()[1])
|
||||||
|
expect(options().filter((option) => option.getAttribute('aria-selected') === 'true')).toHaveLength(1)
|
||||||
|
|
||||||
|
await click(nextButton())
|
||||||
|
expect(dialog()!.querySelector('[data-pivot-step]')).not.toBeNull()
|
||||||
|
const role = dialog()!.querySelector<HTMLInputElement>('#link-members-role')!
|
||||||
|
role.value = 'editor'
|
||||||
|
role.dispatchEvent(new Event('input'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await click(confirmButton())
|
||||||
|
const [post] = requestsTo(calls, 'POST', LINK)
|
||||||
|
expect(await post!.clone().json()).toEqual({ ids: [candidatesFixture.page1.data[1]!.id], pivot: { role: 'editor' } })
|
||||||
|
expect(wrapper.emitted('linked')).toEqual([[1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goes back to the list with the selection kept', async () => {
|
||||||
|
await openPivot()
|
||||||
|
await click(options()[1])
|
||||||
|
await click(nextButton())
|
||||||
|
await click(dialog()!.querySelector('[data-action="back"]'))
|
||||||
|
expect(dialog()!.querySelector('[data-pivot-step]')).toBeNull()
|
||||||
|
expect(options()[1]!.getAttribute('aria-selected')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps pivot errors in the dialog on a 422', async () => {
|
||||||
|
const { wrapper } = await openPivot({
|
||||||
|
[`POST ${LINK}`]: {
|
||||||
|
status: 422,
|
||||||
|
body: { error: { code: 'validation_failed', message: 'Invalid', details: { role: ['The role is invalid.'] } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await click(options()[0])
|
||||||
|
await click(nextButton())
|
||||||
|
await click(confirmButton())
|
||||||
|
expect(dialog()!.querySelector('[data-pivot-step]')!.textContent).toContain('The role is invalid.')
|
||||||
|
expect(wrapper.emitted('update:open')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,17 +4,20 @@
|
|||||||
// key so the server can attach the files. The upload XMLHttpRequest is
|
// key so the server can attach the files. The upload XMLHttpRequest is
|
||||||
// replaced by a fake the test answers by hand. The datepicker part runs in a
|
// replaced by a fake the test answers by hand. The datepicker part runs in a
|
||||||
// fixed non-UTC zone: a stored UTC instant shows as local time and goes back
|
// fixed non-UTC zone: a stored UTC instant shows as local time and goes back
|
||||||
// unchanged, a date goes out as YYYY-MM-DD.
|
// unchanged, a date goes out as YYYY-MM-DD. A deferrable hasMany manager
|
||||||
|
// on the create screen works on owner id 0 with the same key, and its child
|
||||||
|
// modal adds its own key for the child's save.
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||||
import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date'
|
import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date'
|
||||||
import { setBundle } from '../../src/app/i18n'
|
import { setBundle } from '../../src/app/i18n'
|
||||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
import type { RelationSchema } from '../../src/api/types'
|
||||||
|
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey'
|
||||||
import { clearUser } from '../../src/state/useAuth'
|
import { clearUser } from '../../src/state/useAuth'
|
||||||
import { setNavigation } from '../../src/state/useNavigation'
|
import { setNavigation } from '../../src/state/useNavigation'
|
||||||
import { clearToasts } from '../../src/state/useToasts'
|
import { clearToasts } from '../../src/state/useToasts'
|
||||||
import lang from '../fixtures/lang.json'
|
import lang from '../fixtures/lang.json'
|
||||||
import { deferredFormSchemaFixture } from '../fixtures/typed'
|
import { deferredFormSchemaFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||||
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
||||||
|
|
||||||
const BASE = `${API}/acme/demo/gadgets`
|
const BASE = `${API}/acme/demo/gadgets`
|
||||||
@@ -81,6 +84,18 @@ function fileItem(id: number, name: string, pending: boolean) {
|
|||||||
|
|
||||||
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
|
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
|
||||||
|
|
||||||
|
// A hasMany of parts: create and delete in the toolbar, a one-field manage form.
|
||||||
|
const partsSchema: RelationSchema = {
|
||||||
|
...relationSchemaFixture.data,
|
||||||
|
name: 'parts',
|
||||||
|
label: 'Parts',
|
||||||
|
kind: 'hasMany',
|
||||||
|
deferrable: true,
|
||||||
|
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'delete'], showSearch: false },
|
||||||
|
manageForm: [{ name: 'title', label: 'Title', type: 'text', required: true }],
|
||||||
|
}
|
||||||
|
const noParts = { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } }
|
||||||
|
|
||||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||||
return {
|
return {
|
||||||
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
|
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
|
||||||
@@ -88,6 +103,10 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|||||||
[`POST ${BASE}`]: { status: 201, body: created },
|
[`POST ${BASE}`]: { status: 201, body: created },
|
||||||
[`GET ${BASE}/5`]: { body: created },
|
[`GET ${BASE}/5`]: { body: created },
|
||||||
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
|
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
|
||||||
|
[`GET ${BASE}/schema/relation/parts`]: { body: { data: partsSchema, meta: {} } },
|
||||||
|
[`GET ${BASE}/0/relations/parts`]: { body: noParts },
|
||||||
|
[`GET ${BASE}/5/relations/parts`]: { body: noParts },
|
||||||
|
[`GET ${BASE}/7/relations/parts`]: { body: noParts },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,3 +326,77 @@ describe('datepicker values in a fixed time zone', () => {
|
|||||||
expect((await put!.clone().json()).starts_at).toBeNull()
|
expect((await put!.clone().json()).starts_at).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('relation manager on a new record', () => {
|
||||||
|
it('creates a child on owner 0 with the form key and its own key, turns the form dirty and saves with the key', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/demo/gadgets/create',
|
||||||
|
routes({
|
||||||
|
[`POST ${BASE}/0/relations/parts/records`]: {
|
||||||
|
status: 201,
|
||||||
|
body: { data: { id: 90, title: 'Bolt' }, meta: { labels: {} } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const manager = wrapper.find('[data-relation-manager]')
|
||||||
|
expect(manager.attributes('data-deferred')).toBe('true')
|
||||||
|
expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note')
|
||||||
|
const [list] = requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)
|
||||||
|
const key = list!.headers.get(SESSION_HEADER) ?? ''
|
||||||
|
expect(key).toMatch(KEY_PATTERN)
|
||||||
|
expect(new URL(list!.url).search).not.toContain(key)
|
||||||
|
// create is the toolbar's only primary; delete waits for a selection.
|
||||||
|
expect(manager.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
|
await manager.find('[data-action="create"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')
|
||||||
|
expect(modal).not.toBeNull()
|
||||||
|
expect(modal!.dataset.mode).toBe('create')
|
||||||
|
const title = modal!.querySelector<HTMLInputElement>('#child-parts-title')!
|
||||||
|
title.value = 'Bolt'
|
||||||
|
title.dispatchEvent(new Event('input'))
|
||||||
|
await flushPromises()
|
||||||
|
;(modal!.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const [post] = requestsTo(calls, 'POST', `${BASE}/0/relations/parts/records`)
|
||||||
|
expect(post).toBeDefined()
|
||||||
|
expect(post!.headers.get(SESSION_HEADER)).toBe(key)
|
||||||
|
const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? ''
|
||||||
|
expect(childKey).toMatch(KEY_PATTERN)
|
||||||
|
expect(childKey).not.toBe(key)
|
||||||
|
expect(await post!.clone().json()).toEqual({ title: 'Bolt' })
|
||||||
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
||||||
|
// The list reloads with the same key.
|
||||||
|
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(2)
|
||||||
|
|
||||||
|
// The deferred child alone makes the form dirty.
|
||||||
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const confirm = document.body.querySelector('[data-confirm-dialog]')
|
||||||
|
expect(confirm).not.toBeNull()
|
||||||
|
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('#field-name').setValue('Box')
|
||||||
|
await wrapper.find('[data-action="save"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const [save] = requestsTo(calls, 'POST', BASE)
|
||||||
|
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides a non-deferrable manager on the create screen', async () => {
|
||||||
|
const schema = structuredClone(deferredFormSchemaFixture)
|
||||||
|
const parts = schema.data.fields.find((field) => field.name === 'parts')!
|
||||||
|
parts.deferrable = false
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/demo/gadgets/create',
|
||||||
|
routes({ [`GET ${BASE}/schema/form`]: { body: schema } }),
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
||||||
|
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
1
modules/boardwalk/dist/assets/index-B-HOzWeq.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-B-HOzWeq.css
vendored
Normal file
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
9
modules/boardwalk/dist/assets/index-ccm5iY7G.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-ccm5iY7G.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -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-DcD36JxU.js"></script>
|
<script type="module" crossorigin src="./assets/index-ccm5iY7G.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-DdPWaFzL.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-B-HOzWeq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ relation:
|
|||||||
candidates_range: ":from–:to of :total"
|
candidates_range: ":from–:to of :total"
|
||||||
no_candidates: There are no records to add.
|
no_candidates: There are no records to add.
|
||||||
load_failed: The linked records could not be loaded.
|
load_failed: The linked records could not be loaded.
|
||||||
|
child_load_failed: The record could not be loaded. Close this window and try again.
|
||||||
|
child_gone: This record no longer exists. The list has been refreshed.
|
||||||
|
next: Next
|
||||||
|
back: Back
|
||||||
|
pending_note: Changes in this section are saved together with the record.
|
||||||
settings:
|
settings:
|
||||||
title: Settings
|
title: Settings
|
||||||
empty: No settings are available.
|
empty: No settings are available.
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ relation:
|
|||||||
candidates_range: ":from–:to z :total"
|
candidates_range: ":from–:to z :total"
|
||||||
no_candidates: Brak rekordów do dodania.
|
no_candidates: Brak rekordów do dodania.
|
||||||
load_failed: Nie udało się wczytać powiązanych rekordów.
|
load_failed: Nie udało się wczytać powiązanych rekordów.
|
||||||
|
child_load_failed: Nie udało się wczytać rekordu. Zamknij to okno i spróbuj ponownie.
|
||||||
|
child_gone: Ten rekord już nie istnieje. Lista została odświeżona.
|
||||||
|
next: Dalej
|
||||||
|
back: Wstecz
|
||||||
|
pending_note: Zmiany w tej sekcji zostaną zapisane razem z rekordem.
|
||||||
settings:
|
settings:
|
||||||
title: Ustawienia
|
title: Ustawienia
|
||||||
empty: Brak dostępnych ustawień.
|
empty: Brak dostępnych ustawień.
|
||||||
|
|||||||
Reference in New Issue
Block a user