feat(17-02): repeater field and dependent dropdown states
- RepeaterField: rows with drag handle, index, titleFrom title, error chip, move up/down (buttons, ArrowUp/ArrowDown on the handle, native drag and drop), expand/collapse, remove (asks only for a row holding a value), add with nested defaults, maxItems counter, empty state, collapse all; every change edits form state only - 422 row paths (content.3.answer) open the row, mark it and focus the nested control; the banner no longer repeats row messages - DropdownField dependsOn: waiting, loading, not used, empty, loaded with depth indent, failed with retry; cached per sibling value, last request wins, a stale value is cleared after a sibling change and announced - FormView provides form feedback (errors and notices by row path, the polite live region, its confirm dialog) and the options cache; FormGrid provides the sibling scope for repeater rows - repeater.* and form.dependent_* phrase keys (en, pl), smoke specs, docs
This commit is contained in:
@@ -68,6 +68,12 @@ export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
|
||||
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
|
||||
/** One save notice of a plugin field type: field, content locale and message. */
|
||||
export type FieldNotice = Schemas['cabana.FieldNotice']
|
||||
/** A dependent dropdown's options for one sibling value (D-08): applies false takes no value. */
|
||||
export type AdminDependentOptionsResult = Schemas['cabana.AdminDependentOptionsResult']
|
||||
/** One dependent option; depth indents a choice taken from a tree. */
|
||||
export type AdminDependentOption = Schemas['cabana.AdminDependentOption']
|
||||
/** A repeater's row form: the nested fields of one row (D-07). */
|
||||
export type RepeaterForm = Schemas['cabana.RepeaterForm']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { t, tc } from '../../app/i18n'
|
||||
import { keyField } from './formState'
|
||||
|
||||
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
|
||||
// Messages of keys that are not form fields are listed here, so no server
|
||||
@@ -14,9 +15,11 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const count = computed(() => Object.keys(props.errors).length)
|
||||
// A repeater row path (`content.3.answer`) belongs to its field: it renders
|
||||
// under the nested control, not here.
|
||||
const orphans = computed(() =>
|
||||
Object.entries(props.errors)
|
||||
.filter(([name]) => !props.fieldNames.includes(name))
|
||||
.filter(([name]) => !props.fieldNames.includes(name) && !props.fieldNames.includes(keyField(name)))
|
||||
.flatMap(([, messages]) => messages),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
|
||||
import FormField from './FormField.vue'
|
||||
import { FIELD_SCOPE } from './formContext'
|
||||
import { RELATION_MANAGER } from './registry'
|
||||
|
||||
// Two-column form grid (design screen 4): span left -> column 1, right ->
|
||||
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
|
||||
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
|
||||
defineProps<{
|
||||
// The grid is the scope its fields read siblings from (a dependent dropdown,
|
||||
// D-08); a repeater row's grid passes `scopePath`, the repeater's name, so a
|
||||
// nested field's routes address it as `<repeater>.<field>`.
|
||||
const props = defineProps<{
|
||||
fields: FormFieldSchema[]
|
||||
values: AdminRecord
|
||||
errors: Record<string, string[]>
|
||||
@@ -16,9 +21,17 @@ defineProps<{
|
||||
source?: ControllerParams | null
|
||||
recordId?: number | null
|
||||
idPrefix?: string
|
||||
/** The repeater whose row this grid renders. */
|
||||
scopePath?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
||||
|
||||
provide(FIELD_SCOPE, {
|
||||
fields: computed(() => props.fields),
|
||||
values: computed(() => props.values),
|
||||
path: (name: string) => (props.scopePath ? `${props.scopePath}.${name}` : name),
|
||||
})
|
||||
|
||||
function spanClass(field: FormFieldSchema): string {
|
||||
// The relation manager always takes the whole row (design screen 5).
|
||||
if (field.type === RELATION_MANAGER) {
|
||||
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
/** Types rendered by their own control in its read-only mode. */
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor'])
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater'])
|
||||
|
||||
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
||||
const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { FormOption } from '../../../api/types'
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ChevronDown, CircleAlert, RotateCcw } from '@lucide/vue'
|
||||
import { api } from '../../../api/client'
|
||||
import type { AdminDependentOptionsResult, FormOption } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { controlClass, type FieldControlProps } from '../control'
|
||||
import { DEPENDENT_CACHE, FIELD_SCOPE, FORM_FEEDBACK } from '../formContext'
|
||||
|
||||
// type: dropdown. With `dependsOn` (D-08, UI-SPEC "Dependent dropdown") the
|
||||
// options come from GET …/fields/{field}/dependent-options for the sibling's
|
||||
// current value (same form, or same repeater row through the row's grid
|
||||
// scope), and the control shows the waiting, loading, not-used, empty,
|
||||
// loaded and failed states. Answers are cached per sibling value for the
|
||||
// form view and a late answer for an old sibling value is dropped. After the
|
||||
// sibling changes, a value the new options no longer offer is cleared to
|
||||
// null (the form becomes dirty) and announced in the form's live region; on
|
||||
// load a stored value no option matches only shows the placeholder.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value'] | null] }>()
|
||||
|
||||
const EMPTY = 'empty'
|
||||
const PLACEHOLDER = 'placeholder'
|
||||
const INDENT = ' '.repeat(4)
|
||||
|
||||
const options = computed(() => props.field.options ?? [])
|
||||
type Dependent = 'waiting' | 'loading' | 'unused' | 'empty' | 'ready' | 'failed'
|
||||
|
||||
const scope = inject(FIELD_SCOPE, null)
|
||||
const feedback = inject(FORM_FEEDBACK, null)
|
||||
const cache = inject(DEPENDENT_CACHE, null) ?? new Map<string, AdminDependentOptionsResult>()
|
||||
|
||||
const sibling = computed(() => props.field.dependsOn ?? '')
|
||||
const dependent = computed(() => sibling.value !== '')
|
||||
const state = ref<Dependent>('waiting')
|
||||
const loaded = ref<FormOption[]>([])
|
||||
|
||||
/** The sibling's value as the route takes it: a string, or null when empty. */
|
||||
const siblingValue = computed<string | null>(() => {
|
||||
const value = scope?.values.value[sibling.value]
|
||||
if (value === null || value === undefined || value === '' || typeof value === 'object') {
|
||||
return null
|
||||
}
|
||||
return String(value)
|
||||
})
|
||||
const siblingField = computed(() => scope?.fields.value.find((field) => field.name === sibling.value))
|
||||
const siblingLabel = computed(() => siblingField.value?.label || sibling.value)
|
||||
const siblingOptionLabel = computed(() => {
|
||||
const option = (siblingField.value?.options ?? []).find((item) => same(item.value, siblingValue.value))
|
||||
return option?.label ?? siblingValue.value ?? ''
|
||||
})
|
||||
|
||||
const options = computed<FormOption[]>(() => (dependent.value ? loaded.value : (props.field.options ?? [])))
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (a === null || a === undefined || b === null || b === undefined) {
|
||||
@@ -20,6 +59,112 @@ function same(a: unknown, b: unknown): boolean {
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
function hasValue(): boolean {
|
||||
return props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== ''
|
||||
}
|
||||
|
||||
function clearValue(): void {
|
||||
if (!hasValue()) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', null)
|
||||
feedback?.announce(t('backend::lang.form.dependent_cleared', { field: props.field.label || props.field.name }))
|
||||
}
|
||||
|
||||
function adopt(result: AdminDependentOptionsResult, changed: boolean): void {
|
||||
if (!result.applies) {
|
||||
loaded.value = []
|
||||
state.value = 'unused'
|
||||
if (changed) {
|
||||
clearValue()
|
||||
}
|
||||
return
|
||||
}
|
||||
loaded.value = result.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: INDENT.repeat(Math.max(0, option.depth)) + option.label,
|
||||
}))
|
||||
state.value = loaded.value.length > 0 ? 'ready' : 'empty'
|
||||
if (changed && hasValue() && !loaded.value.some((option) => same(option.value, props.modelValue))) {
|
||||
clearValue()
|
||||
}
|
||||
}
|
||||
|
||||
let sequence = 0
|
||||
// Whether the request that failed followed a sibling edit: Retry keeps it.
|
||||
let failedAfterChange = false
|
||||
|
||||
/** Loads the options for the sibling's current value; `changed` after a sibling edit. */
|
||||
async function load(changed: boolean): Promise<void> {
|
||||
const current = ++sequence
|
||||
const value = siblingValue.value
|
||||
if (value === null) {
|
||||
loaded.value = []
|
||||
state.value = 'waiting'
|
||||
if (changed) {
|
||||
clearValue()
|
||||
}
|
||||
return
|
||||
}
|
||||
const path = scope?.path(props.field.name) ?? props.field.name
|
||||
const key = `${path}\u0000${value}`
|
||||
const cached = cache.get(key)
|
||||
if (cached) {
|
||||
adopt(cached, changed)
|
||||
return
|
||||
}
|
||||
if (!props.source) {
|
||||
failedAfterChange = changed
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
state.value = 'loading'
|
||||
try {
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options', {
|
||||
params: {
|
||||
path: { ...props.source, field: path },
|
||||
// values[<sibling>]=<value>: a dynamic key the OpenAPI document does not list.
|
||||
query: { [`values[${sibling.value}]`]: value } as never,
|
||||
},
|
||||
})
|
||||
if (current !== sequence) {
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
failedAfterChange = changed
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
cache.set(key, data.data)
|
||||
adopt(data.data, changed)
|
||||
} catch {
|
||||
if (current === sequence) {
|
||||
failedAfterChange = changed
|
||||
state.value = 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry(): void {
|
||||
void load(failedAfterChange)
|
||||
}
|
||||
|
||||
watch(siblingValue, (next, previous) => {
|
||||
if (dependent.value && next !== previous) {
|
||||
void load(true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (dependent.value) {
|
||||
void load(false)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sequence++
|
||||
})
|
||||
|
||||
// The <select> works on indexes so option values keep their JSON type
|
||||
// (string, number or boolean) when they are sent back.
|
||||
const selected = computed(() => {
|
||||
@@ -27,13 +172,37 @@ const selected = computed(() => {
|
||||
if (index >= 0) {
|
||||
return String(index)
|
||||
}
|
||||
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
|
||||
if (props.field.emptyOption !== undefined && !hasValue()) {
|
||||
return EMPTY
|
||||
}
|
||||
return PLACEHOLDER
|
||||
})
|
||||
|
||||
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
|
||||
/** The single disabled option text of a dependent state other than ready. */
|
||||
const stateText = computed(() => {
|
||||
switch (state.value) {
|
||||
case 'waiting':
|
||||
return t('backend::lang.form.dependent_waiting', { field: siblingLabel.value })
|
||||
case 'loading':
|
||||
return t('backend::lang.form.loading_options')
|
||||
case 'unused':
|
||||
return t('backend::lang.form.dependent_not_used')
|
||||
case 'empty':
|
||||
return t('backend::lang.form.dependent_empty', { type: siblingOptionLabel.value })
|
||||
case 'failed':
|
||||
return t('backend::lang.form.dependent_failed')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
const blocked = computed(() => dependent.value && state.value !== 'ready')
|
||||
const muted = computed(() => blocked.value || selected.value === EMPTY || selected.value === PLACEHOLDER)
|
||||
const hintId = computed(() => `${props.controlId}-dependent`)
|
||||
const describedBy = computed(() =>
|
||||
[props.describedBy ?? '', dependent.value && (state.value === 'empty' || state.value === 'failed') ? hintId.value : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
@@ -49,33 +218,52 @@ function onChange(event: Event): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<select
|
||||
:id="controlId"
|
||||
:name="field.name"
|
||||
:value="selected"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
|
||||
class="h-input appearance-none pr-10"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
|
||||
<!-- A stored value no option matches shows the placeholder, never a
|
||||
wrong choice (also next to an emptyOption). -->
|
||||
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
|
||||
{{ t('backend::lang.form.select_placeholder') }}
|
||||
</option>
|
||||
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5" :data-dependent="dependent ? state : undefined">
|
||||
<div class="relative">
|
||||
<select
|
||||
:id="controlId"
|
||||
:name="field.name"
|
||||
:value="blocked ? 'state' : selected"
|
||||
:disabled="blocked || undefined"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-busy="dependent && state === 'loading' ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
|
||||
class="h-input appearance-none pr-10 disabled:cursor-not-allowed"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="blocked" value="state" class="text-muted">{{ stateText }}</option>
|
||||
<template v-else>
|
||||
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
|
||||
<!-- A stored value no option matches shows the placeholder, never a
|
||||
wrong choice (also next to an emptyOption). -->
|
||||
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
|
||||
{{ t('backend::lang.form.select_placeholder') }}
|
||||
</option>
|
||||
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="dependent && state === 'empty'" :id="hintId" data-dependent-hint class="text-[13px] text-muted">
|
||||
{{ stateText }}
|
||||
</p>
|
||||
<div v-else-if="dependent && state === 'failed'" class="flex flex-wrap items-center gap-2">
|
||||
<p :id="hintId" role="alert" class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.form.dependent_failed') }}</span>
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" :icon="RotateCcw" data-action="dependent-retry" @click="retry">
|
||||
{{ t('backend::lang.form.dependent_retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
465
admin/src/components/form/fields/RepeaterField.vue
Normal file
465
admin/src/components/form/fields/RepeaterField.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue'
|
||||
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from '@lucide/vue'
|
||||
import type { AdminRecord, FormField } from '../../../api/types'
|
||||
import { t, tc } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { type FieldControlProps } from '../control'
|
||||
import { FORM_FEEDBACK } from '../formContext'
|
||||
import FormGrid from '../FormGrid.vue'
|
||||
import PreviewField from '../PreviewField.vue'
|
||||
import { inertFragment } from '../richeditor/lossy'
|
||||
|
||||
// type: repeater (D-07, UI-SPEC RepeaterField): a list of rows, each a
|
||||
// nested FormGrid of the repeater's form.fields. Rows add, remove (asking
|
||||
// first when the row holds a value), reorder by drag, by ArrowUp/ArrowDown on
|
||||
// the handle and by the move buttons, and collapse; every change only edits
|
||||
// the form value, which the record's Save sends. Row errors and notices come
|
||||
// from the form's feedback under `<field>.<index>.<nested>`. A collapsed row
|
||||
// unmounts its body (its richeditor is not running); its values stay in the
|
||||
// form value. Read-only shows every row expanded through PreviewField.
|
||||
// The expand button is a plain aria-expanded/aria-controls disclosure and
|
||||
// the body a v-if: it mounts in the same tick, so a 422 that opens a row can
|
||||
// focus the nested control at once.
|
||||
//
|
||||
// Only the registry imports this module, so the registry always evaluates
|
||||
// first and this module's FormGrid -> registry import cycle is safe.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: AdminRecord[]] }>()
|
||||
|
||||
const feedback = inject(FORM_FEEDBACK, null)
|
||||
|
||||
const nested = computed<FormField[]>(() => props.field.form?.fields ?? [])
|
||||
const readOnly = computed(() => !!props.field.readOnly)
|
||||
const rows = computed<AdminRecord[]>(() =>
|
||||
Array.isArray(props.modelValue)
|
||||
? props.modelValue.map((row) => (row !== null && typeof row === 'object' && !Array.isArray(row) ? (row as AdminRecord) : {}))
|
||||
: [],
|
||||
)
|
||||
const maxItems = computed(() => (typeof props.field.maxItems === 'number' ? props.field.maxItems : null))
|
||||
const minItems = computed(() => (typeof props.field.minItems === 'number' ? props.field.minItems : 0))
|
||||
const addLabel = computed(() => props.field.prompt || t('backend::lang.repeater.add'))
|
||||
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||
|
||||
// Stable keys per row: v-for identity, element ids and the expanded state.
|
||||
let counter = 0
|
||||
const keys = ref<string[]>([])
|
||||
const collapsed = ref<Set<string>>(new Set())
|
||||
let lastEmitted: unknown = undefined
|
||||
|
||||
function newKey(): string {
|
||||
counter += 1
|
||||
return `r${counter}`
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value === lastEmitted && keys.value.length === rows.value.length) {
|
||||
return
|
||||
}
|
||||
// A value from outside (load, the save's answer): keep the keys when the
|
||||
// row count is unchanged, so ids and expanded rows stay put.
|
||||
if (keys.value.length !== rows.value.length) {
|
||||
keys.value = rows.value.map(() => newKey())
|
||||
collapsed.value = new Set()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function commit(next: AdminRecord[], nextKeys: string[]): void {
|
||||
keys.value = nextKeys
|
||||
lastEmitted = next
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === '' || value === false || (Array.isArray(value) && value.length === 0)
|
||||
}
|
||||
|
||||
function defaults(): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of nested.value) {
|
||||
if (field.default !== undefined) {
|
||||
out[field.name] = field.default
|
||||
} else if (field.type === 'checkbox' || field.type === 'switch') {
|
||||
out[field.name] = false
|
||||
} else {
|
||||
out[field.name] = null
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether removing the row loses something: a value other than empty or the default. */
|
||||
function holdsValue(row: AdminRecord): boolean {
|
||||
return nested.value.some((field) => {
|
||||
const value = row[field.name]
|
||||
if (isEmptyValue(value)) {
|
||||
return false
|
||||
}
|
||||
return field.default === undefined || String(field.default) !== String(value)
|
||||
})
|
||||
}
|
||||
|
||||
function plainText(html: string): string {
|
||||
return (inertFragment(html).textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function title(index: number): string {
|
||||
const row = rows.value[index] ?? {}
|
||||
const source = nested.value.find((field) => field.name === props.field.titleFrom)
|
||||
const value = source ? row[source.name] : undefined
|
||||
let text = ''
|
||||
if (source && !isEmptyValue(value) && typeof value !== 'object') {
|
||||
if (source.type === 'dropdown') {
|
||||
text = (source.options ?? []).find((option) => String(option.value) === String(value))?.label ?? String(value)
|
||||
} else if (source.type === 'richeditor') {
|
||||
text = plainText(String(value))
|
||||
} else {
|
||||
text = String(value).trim()
|
||||
}
|
||||
}
|
||||
return text !== '' ? text : t('backend::lang.repeater.untitled', { n: index + 1 })
|
||||
}
|
||||
|
||||
function rowMap(map: Record<string, string[]> | undefined, index: number): Record<string, string[]> {
|
||||
const prefix = `${props.field.name}.${index}.`
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [key, lines] of Object.entries(map ?? {})) {
|
||||
if (key.startsWith(prefix) && key.length > prefix.length) {
|
||||
out[key.slice(prefix.length)] = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const rowErrors = computed(() => rows.value.map((_, index) => rowMap(feedback?.errors.value, index)))
|
||||
const rowNotices = computed(() => rows.value.map((_, index) => rowMap(feedback?.notices.value, index)))
|
||||
const errorCount = (index: number) => Object.keys(rowErrors.value[index] ?? {}).length
|
||||
|
||||
// A 422 opens every row with errors, so focusError reaches the control.
|
||||
watch(rowErrors, (all) => {
|
||||
const open = all.flatMap((errors, index) => (Object.keys(errors).length > 0 ? [keys.value[index]!] : []))
|
||||
if (open.length > 0) {
|
||||
const next = new Set(collapsed.value)
|
||||
for (const key of open) {
|
||||
next.delete(key)
|
||||
}
|
||||
collapsed.value = next
|
||||
}
|
||||
})
|
||||
|
||||
function isOpen(key: string): boolean {
|
||||
return readOnly.value || !collapsed.value.has(key)
|
||||
}
|
||||
|
||||
function setOpen(key: string, open: boolean): void {
|
||||
const next = new Set(collapsed.value)
|
||||
if (open) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
collapsed.value = next
|
||||
}
|
||||
|
||||
const anyOpen = computed(() => keys.value.some((key) => !collapsed.value.has(key)))
|
||||
|
||||
function toggleAll(): void {
|
||||
collapsed.value = anyOpen.value ? new Set(keys.value) : new Set()
|
||||
}
|
||||
|
||||
// Elements by row key: handles keep focus after a move or a removal.
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const addButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
function handleOf(key: string): HTMLElement | null {
|
||||
return root.value?.querySelector<HTMLElement>(`[data-repeater-handle="${key}"]`) ?? null
|
||||
}
|
||||
|
||||
function onNested(index: number, name: string, value: unknown): void {
|
||||
const next = rows.value.map((row, at) => (at === index ? { ...row, [name]: value } : row))
|
||||
commit(next, [...keys.value])
|
||||
feedback?.clearError(`${props.field.name}.${index}.${name}`)
|
||||
}
|
||||
|
||||
async function add(): Promise<void> {
|
||||
if (readOnly.value || (maxItems.value !== null && rows.value.length >= maxItems.value)) {
|
||||
return
|
||||
}
|
||||
const key = newKey()
|
||||
commit([...rows.value, defaults()], [...keys.value, key])
|
||||
await nextTick()
|
||||
const row = root.value?.querySelector(`[data-repeater-key="${key}"]`)
|
||||
row?.querySelector<HTMLElement>('[data-repeater-body] input, [data-repeater-body] select, [data-repeater-body] textarea, [data-repeater-body] [contenteditable="true"]')?.focus()
|
||||
}
|
||||
|
||||
async function remove(index: number): Promise<void> {
|
||||
const row = rows.value[index]
|
||||
if (!row || readOnly.value || rows.value.length <= minItems.value) {
|
||||
return
|
||||
}
|
||||
if (holdsValue(row) && feedback) {
|
||||
const name = title(index)
|
||||
const ok = await feedback.confirm({
|
||||
message: t('backend::lang.repeater.remove_confirm', { title: name }),
|
||||
confirmLabel: t('backend::lang.repeater.remove'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const nextKeys = keys.value.filter((_, at) => at !== index)
|
||||
commit(
|
||||
rows.value.filter((_, at) => at !== index),
|
||||
nextKeys,
|
||||
)
|
||||
await nextTick()
|
||||
const focusKey = nextKeys[index] ?? nextKeys[index - 1]
|
||||
const handle = focusKey ? handleOf(focusKey) : null
|
||||
if (handle) {
|
||||
handle.focus()
|
||||
} else {
|
||||
addButton.value?.$el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function move(from: number, to: number): Promise<void> {
|
||||
const total = rows.value.length
|
||||
if (readOnly.value || from === to || from < 0 || to < 0 || from >= total || to >= total) {
|
||||
return
|
||||
}
|
||||
const nextRows = [...rows.value]
|
||||
const nextKeys = [...keys.value]
|
||||
const [row] = nextRows.splice(from, 1)
|
||||
const [key] = nextKeys.splice(from, 1)
|
||||
nextRows.splice(to, 0, row!)
|
||||
nextKeys.splice(to, 0, key!)
|
||||
commit(nextRows, nextKeys)
|
||||
await nextTick()
|
||||
handleOf(key!)?.focus()
|
||||
feedback?.announce(t('backend::lang.repeater.moved', { title: title(to), position: to + 1, total }))
|
||||
}
|
||||
|
||||
function onHandleKeydown(event: KeyboardEvent, index: number): void {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
void move(index, event.key === 'ArrowUp' ? index - 1 : index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Native drag and drop by the handle (as FileuploadField): the dragged row
|
||||
// fades and a dashed slot marks where it lands.
|
||||
const dragging = ref<number | null>(null)
|
||||
const slot = ref<number | null>(null)
|
||||
|
||||
function onDragStart(event: DragEvent, index: number): void {
|
||||
dragging.value = index
|
||||
slot.value = null
|
||||
event.dataTransfer?.setData('text/plain', keys.value[index] ?? '')
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, index: number): void {
|
||||
if (dragging.value === null) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const box = target.getBoundingClientRect()
|
||||
slot.value = event.clientY > box.top + box.height / 2 ? index + 1 : index
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
if (dragging.value === null || slot.value === null) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const from = dragging.value
|
||||
const to = slot.value > from ? slot.value - 1 : slot.value
|
||||
onDragEnd()
|
||||
void move(from, to)
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
dragging.value = null
|
||||
slot.value = null
|
||||
}
|
||||
|
||||
const iconButton =
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
role="group"
|
||||
:aria-labelledby="labelledBy"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:data-repeater="field.name"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
v-if="rows.length === 0"
|
||||
data-repeater-empty
|
||||
class="flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center"
|
||||
>
|
||||
<p class="font-semibold">{{ t('backend::lang.repeater.empty') }}</p>
|
||||
<p v-if="!readOnly" class="max-w-[420px] text-[13px] text-muted">
|
||||
{{ t('backend::lang.repeater.empty_hint', { prompt: addLabel }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-for="(row, index) in rows" :key="keys[index]">
|
||||
<div
|
||||
v-if="dragging !== null && slot === index && slot !== dragging && slot !== dragging + 1"
|
||||
data-repeater-slot
|
||||
class="h-12 rounded-inner border-2 border-dashed border-primary bg-sel"
|
||||
/>
|
||||
<div
|
||||
:data-repeater-row="index"
|
||||
:data-repeater-key="keys[index]"
|
||||
:class="[errorCount(index) > 0 ? 'border-danger' : 'border-border', dragging === index ? 'opacity-50' : '']"
|
||||
class="rounded-inner border bg-surface"
|
||||
@dragover="onDragOver($event, index)"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div
|
||||
:class="isOpen(keys[index]!) ? 'rounded-t-inner border-b border-border' : 'rounded-inner'"
|
||||
class="flex min-h-12 items-center gap-2 bg-subtle px-2"
|
||||
>
|
||||
<button
|
||||
v-if="!readOnly"
|
||||
type="button"
|
||||
draggable="true"
|
||||
:data-repeater-handle="keys[index]"
|
||||
:aria-label="t('backend::lang.repeater.move', { title: title(index) })"
|
||||
:class="iconButton"
|
||||
class="cursor-grab"
|
||||
@keydown="onHandleKeydown($event, index)"
|
||||
@dragstart="onDragStart($event, index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<GripVertical :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="text-[13px] text-muted tabular-nums">{{ index + 1 }}.</span>
|
||||
<span data-repeater-title class="min-w-0 flex-1 truncate font-semibold" :title="title(index)">{{ title(index) }}</span>
|
||||
<span
|
||||
v-if="errorCount(index) > 0"
|
||||
data-repeater-errors
|
||||
class="inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold text-danger"
|
||||
>
|
||||
{{ tc('backend::lang.form.tab_errors', errorCount(index)) }}
|
||||
</span>
|
||||
<div v-if="!readOnly" class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-up"
|
||||
:aria-label="t('backend::lang.repeater.move_up', { title: title(index) })"
|
||||
:disabled="index === 0"
|
||||
:class="iconButton"
|
||||
@click="move(index, index - 1)"
|
||||
>
|
||||
<ChevronUp :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-down"
|
||||
:aria-label="t('backend::lang.repeater.move_down', { title: title(index) })"
|
||||
:disabled="index === rows.length - 1"
|
||||
:class="iconButton"
|
||||
@click="move(index, index + 1)"
|
||||
>
|
||||
<ChevronDown :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-toggle"
|
||||
:aria-label="t(isOpen(keys[index]!) ? 'backend::lang.repeater.collapse' : 'backend::lang.repeater.expand', { title: title(index) })"
|
||||
:aria-expanded="isOpen(keys[index]!) ? 'true' : 'false'"
|
||||
:aria-controls="`${controlId}-${keys[index]}-body`"
|
||||
:class="iconButton"
|
||||
@click="setOpen(keys[index]!, !isOpen(keys[index]!))"
|
||||
>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
:class="isOpen(keys[index]!) ? 'rotate-180' : ''"
|
||||
class="transition-transform duration-150"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-remove"
|
||||
:aria-label="t('backend::lang.repeater.remove_named', { title: title(index) })"
|
||||
:disabled="rows.length <= minItems"
|
||||
:class="iconButton"
|
||||
class="hover:bg-danger-soft hover:text-danger"
|
||||
@click="remove(index)"
|
||||
>
|
||||
<Trash2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isOpen(keys[index]!)" :id="`${controlId}-${keys[index]}-body`" data-repeater-body class="p-4">
|
||||
<dl v-if="readOnly" class="m-0 grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
|
||||
<PreviewField
|
||||
v-for="item in nested"
|
||||
:key="item.name"
|
||||
:class="item.span === 'left' ? 'min-[600px]:col-start-1' : item.span === 'right' ? 'min-[600px]:col-start-2' : 'col-span-full'"
|
||||
:field="{ ...item, readOnly: true }"
|
||||
:value="row[item.name]"
|
||||
:source="source"
|
||||
:record-id="recordId"
|
||||
:id-prefix="`${controlId}-${keys[index]}`"
|
||||
/>
|
||||
</dl>
|
||||
<FormGrid
|
||||
v-else
|
||||
:fields="nested"
|
||||
:values="row"
|
||||
:errors="rowErrors[index] ?? {}"
|
||||
:notices="rowNotices[index]"
|
||||
:source="source"
|
||||
:record-id="recordId"
|
||||
:id-prefix="`${controlId}-${keys[index]}`"
|
||||
:scope-path="field.name"
|
||||
@update="(name: string, value: unknown) => onNested(index, name, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="dragging !== null && slot === rows.length && slot !== dragging + 1"
|
||||
data-repeater-slot
|
||||
class="h-12 rounded-inner border-2 border-dashed border-primary bg-sel"
|
||||
@dragover.prevent
|
||||
@drop="onDrop"
|
||||
/>
|
||||
|
||||
<div v-if="!readOnly" class="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
ref="addButton"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="Plus"
|
||||
data-action="repeater-add"
|
||||
:disabled="maxItems !== null && rows.length >= maxItems"
|
||||
@click="add"
|
||||
>
|
||||
{{ addLabel }}
|
||||
</Button>
|
||||
<span v-if="maxItems !== null" data-repeater-count class="text-[13px] text-muted tabular-nums">
|
||||
{{ t('backend::lang.repeater.count', { current: rows.length, max: maxItems }) }}
|
||||
</span>
|
||||
<Button v-if="rows.length > 1" variant="ghost" size="sm" class="ml-auto" data-action="repeater-toggle-all" @click="toggleAll">
|
||||
{{ t(anyOpen ? 'backend::lang.repeater.collapse_all' : 'backend::lang.repeater.expand_all') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@
|
||||
// provider (settings pages) gets the defaults of the injecting control.
|
||||
import { ref, type InjectionKey, type Ref } from 'vue'
|
||||
import type { FileRoutes } from '../../api/files'
|
||||
import type { AdminRecord } from '../../api/types'
|
||||
import type { AdminDependentOptionsResult, AdminRecord, FormField } from '../../api/types'
|
||||
|
||||
/** Read-only view of the form's current values. */
|
||||
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
|
||||
@@ -113,3 +113,39 @@ export const FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'
|
||||
|
||||
/** How long a widget waits for its custom element to be defined. */
|
||||
export const WIDGET_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* The record form's feedback (Phase 17, D-07, D-08): errors and save notices
|
||||
* keyed by field name or row path (`<repeater>.<index>.<nested>`), the form's
|
||||
* single polite live region, and its confirm dialog. FormView provides it; a
|
||||
* control without it (settings pages) announces nothing and never asks.
|
||||
*/
|
||||
export interface FormFeedback {
|
||||
errors: Readonly<Ref<Record<string, string[]>>>
|
||||
notices: Readonly<Ref<Record<string, string[]>>>
|
||||
/** Drops the error lines of one key: the administrator edited that value. */
|
||||
clearError(key: string): void
|
||||
/** Announces text in the form's polite live region. */
|
||||
announce(text: string): void
|
||||
/** Asks through the form's ConfirmDialog; resolves true on confirm. */
|
||||
confirm(request: { message: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>
|
||||
}
|
||||
|
||||
export const FORM_FEEDBACK: InjectionKey<FormFeedback> = Symbol('summer.form.feedback')
|
||||
|
||||
/**
|
||||
* The fields a FormGrid renders and their values (D-08): a dependent
|
||||
* dropdown reads its sibling here. A repeater row's grid provides the row's
|
||||
* fields and values, and `path` names a field for its routes
|
||||
* (`<repeater>.<field>` in a row).
|
||||
*/
|
||||
export interface FieldScope {
|
||||
fields: Readonly<Ref<readonly FormField[]>>
|
||||
values: Readonly<Ref<AdminRecord>>
|
||||
path(name: string): string
|
||||
}
|
||||
|
||||
export const FIELD_SCOPE: InjectionKey<FieldScope> = Symbol('summer.form.fieldScope')
|
||||
|
||||
/** Dependent options per field path and sibling value, for the life of a form view. */
|
||||
export const DEPENDENT_CACHE: InjectionKey<Map<string, AdminDependentOptionsResult>> = Symbol('summer.form.dependentCache')
|
||||
|
||||
@@ -296,3 +296,65 @@ export function focusField(prefix: string, name: string): boolean {
|
||||
element.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
/** The top-level field of an error or notice key: `content` for `content.3.answer`. */
|
||||
export function keyField(key: string): string {
|
||||
const dot = key.indexOf('.')
|
||||
return dot < 0 ? key : key.slice(0, dot)
|
||||
}
|
||||
|
||||
/** Whether a field has errors on itself or, for a repeater, on any row path. */
|
||||
export function fieldHasErrors(errors: Record<string, string[]>, name: string): boolean {
|
||||
return Object.keys(errors).some((key) => key === name || key.startsWith(`${name}.`))
|
||||
}
|
||||
|
||||
/** The entries of one repeater row (`<field>.<index>.<nested>`) keyed by nested name. */
|
||||
export function rowEntries(map: Record<string, string[]>, field: string, index: number): Record<string, string[]> {
|
||||
const prefix = `${field}.${index}.`
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [key, lines] of Object.entries(map)) {
|
||||
if (key.startsWith(prefix) && key.length > prefix.length) {
|
||||
out[key.slice(prefix.length)] = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The first error key of a field in the server's order: itself, else its first row path. */
|
||||
export function firstErrorKey(errors: Record<string, string[]>, name: string): string | undefined {
|
||||
if (errors[name]) {
|
||||
return name
|
||||
}
|
||||
return Object.keys(errors).find((key) => key.startsWith(`${name}.`))
|
||||
}
|
||||
|
||||
const NAME = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* Focuses the control of an error key. A row path (`content.3.answer`)
|
||||
* focuses the nested control of that repeater row, found through the row's
|
||||
* data attributes and the nested label's `for`; a plain name is focusField.
|
||||
*/
|
||||
export function focusError(prefix: string, key: string): boolean {
|
||||
const parts = key.split('.')
|
||||
if (parts.length < 3) {
|
||||
return focusField(prefix, parts[0] ?? key)
|
||||
}
|
||||
const [field, index, nested] = parts as [string, string, string]
|
||||
if (!NAME.test(field) || !/^\d+$/.test(index) || !NAME.test(nested)) {
|
||||
return focusField(prefix, field)
|
||||
}
|
||||
const cell = document.querySelector(`[data-repeater="${field}"] [data-repeater-row="${index}"] [data-field="${nested}"]`)
|
||||
const target = cell?.querySelector('label[for]')?.getAttribute('for')
|
||||
const control =
|
||||
(target ? document.getElementById(target) : null) ??
|
||||
cell?.querySelector<HTMLElement>('input, select, textarea, [contenteditable="true"], button')
|
||||
if (!control) {
|
||||
return focusField(prefix, field)
|
||||
}
|
||||
if (!control.matches('input, select, textarea, button, [tabindex], [contenteditable="true"]')) {
|
||||
control.setAttribute('tabindex', '-1')
|
||||
}
|
||||
control.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
// isRegisteredField, which know the field, not only its type.
|
||||
// Phase 17 adds the richeditor control (D-10): an HTML string value edited
|
||||
// with a lazily loaded TipTap editor, sent unchanged until the administrator
|
||||
// edits it.
|
||||
// edits it. The repeater control (D-07) holds a list of row objects, each
|
||||
// a nested FormGrid, labelled as a group. It renders FormGrid, so it sits on
|
||||
// an import cycle with this module; only this module imports it, so this
|
||||
// module always evaluates first and the binding is complete here.
|
||||
import type { Component } from 'vue'
|
||||
import type { FormField } from '../../api/types'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
@@ -37,6 +40,7 @@ import PasswordField from './fields/PasswordField.vue'
|
||||
import MLPluginField from './fields/MLPluginField.vue'
|
||||
import PluginField from './fields/PluginField.vue'
|
||||
import PermissionEditorField from './fields/PermissionEditorField.vue'
|
||||
import RepeaterField from './fields/RepeaterField.vue'
|
||||
import RichEditorField from './fields/RichEditorField.vue'
|
||||
import RelationManager from '../relation/RelationManager.vue'
|
||||
import RelationField from './fields/RelationField.vue'
|
||||
@@ -84,6 +88,7 @@ const renderers = new Map<string, Component>([
|
||||
['mltextarea', MLTextareaField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
['richeditor', RichEditorField],
|
||||
['repeater', RepeaterField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
@@ -103,7 +108,16 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
||||
* Types whose control is a group rather than one focusable element: the
|
||||
* visible label is a span the group points at, not a label for an input.
|
||||
*/
|
||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown'])
|
||||
const groupLabelledTypes = new Set<string>([
|
||||
'widget',
|
||||
'partial',
|
||||
'fileupload',
|
||||
'permissioneditor',
|
||||
'mltext',
|
||||
'mltextarea',
|
||||
'mlmarkdown',
|
||||
'repeater',
|
||||
])
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||
|
||||
@@ -13,7 +13,18 @@ import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import { FORM_ASSETS, FORM_ENABLED_LOCALES, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
|
||||
import {
|
||||
DEPENDENT_CACHE,
|
||||
FORM_ASSETS,
|
||||
FORM_ENABLED_LOCALES,
|
||||
FORM_FEEDBACK,
|
||||
FORM_LOCALE,
|
||||
FORM_PATCH,
|
||||
FORM_SESSION,
|
||||
FORM_VALUES,
|
||||
createUploadGate,
|
||||
} from '../components/form/formContext'
|
||||
import type { AdminDependentOptionsResult } from '../api/types'
|
||||
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
@@ -22,7 +33,9 @@ import {
|
||||
contextAllows,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
fieldHasErrors,
|
||||
firstErrorKey,
|
||||
focusError,
|
||||
initialValues,
|
||||
isMLField,
|
||||
mergeMLValue,
|
||||
@@ -124,7 +137,7 @@ const tabs = computed<TabItem[]>(() => {
|
||||
out.push(tab)
|
||||
}
|
||||
}
|
||||
if (errors.value[field.name]) {
|
||||
if (fieldHasErrors(errors.value, field.name)) {
|
||||
tab.errors += 1
|
||||
}
|
||||
}
|
||||
@@ -281,6 +294,36 @@ provide(FORM_PATCH, update)
|
||||
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
||||
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
|
||||
provide(FORM_ASSETS, () => assetsReady)
|
||||
// The form's single polite live region (dependent dropdown clears, repeater
|
||||
// moves) and the feedback nested controls read: errors and notices by row
|
||||
// path, the confirm dialog (Phase 17, D-07, D-08).
|
||||
const announcement = ref('')
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function announce(text: string): void {
|
||||
// Emptied first, so the same text is announced again.
|
||||
announcement.value = ''
|
||||
if (announceTimer !== null) {
|
||||
clearTimeout(announceTimer)
|
||||
}
|
||||
announceTimer = setTimeout(() => {
|
||||
announceTimer = null
|
||||
announcement.value = text
|
||||
}, 50)
|
||||
}
|
||||
provide(FORM_FEEDBACK, {
|
||||
errors: computed(() => errors.value),
|
||||
notices: computed(() => notices.value),
|
||||
clearError: (key: string) => {
|
||||
if (errors.value[key]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[key]
|
||||
errors.value = next
|
||||
}
|
||||
},
|
||||
announce,
|
||||
confirm: (request) => confirm.ask(request),
|
||||
})
|
||||
provide(DEPENDENT_CACHE, new Map<string, AdminDependentOptionsResult>())
|
||||
provide(FORM_SESSION, {
|
||||
key: sessionKey,
|
||||
recordId: recordId ?? 0,
|
||||
@@ -297,7 +340,8 @@ provide(FORM_SESSION, {
|
||||
/** 422: messages under fields, the first invalid field (schema order) focused. */
|
||||
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
||||
errors.value = fieldErrors(error?.details)
|
||||
const invalid = fields.value.filter((field) => errors.value[field.name])
|
||||
// A repeater row path (content.3.answer) counts for its field.
|
||||
const invalid = fields.value.filter((field) => fieldHasErrors(errors.value, field.name))
|
||||
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
|
||||
if (!first) {
|
||||
return
|
||||
@@ -306,7 +350,12 @@ async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<vo
|
||||
activeTab.value = tabOf(first)
|
||||
}
|
||||
await nextTick()
|
||||
focusField(ID_PREFIX, first.name)
|
||||
const key = firstErrorKey(errors.value, first.name) ?? first.name
|
||||
if (!focusError(ID_PREFIX, key)) {
|
||||
// A repeater row the errors opened mounts its body one tick later.
|
||||
await nextTick()
|
||||
focusError(ID_PREFIX, key)
|
||||
}
|
||||
}
|
||||
|
||||
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
|
||||
@@ -467,6 +516,9 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
window.removeEventListener(ML_LOCALE_CHANGE, onMLLocale)
|
||||
if (announceTimer !== null) {
|
||||
clearTimeout(announceTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// The header's last breadcrumb is this record's title (design: Top header).
|
||||
@@ -547,6 +599,8 @@ void load()
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<p class="sr-only" aria-live="polite" data-form-live>{{ announcement }}</p>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
|
||||
142
admin/tests/fixtures/companions.form-schema.json
vendored
142
admin/tests/fixtures/companions.form-schema.json
vendored
@@ -3,25 +3,139 @@
|
||||
"name": "Link",
|
||||
"modelClass": "Link",
|
||||
"fields": [
|
||||
{ "name": "title", "type": "text", "label": "Title", "span": "left" },
|
||||
{ "name": "description", "type": "richeditor", "label": "Description", "comment": "Shown under the title." }
|
||||
{
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"label": "Title",
|
||||
"span": "left"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"type": "dropdown",
|
||||
"label": "Type",
|
||||
"span": "right",
|
||||
"options": [
|
||||
{
|
||||
"value": "url",
|
||||
"label": "Link"
|
||||
},
|
||||
{
|
||||
"value": "post",
|
||||
"label": "Post"
|
||||
},
|
||||
{
|
||||
"value": "category",
|
||||
"label": "Category"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"type": "dropdown",
|
||||
"label": "Reference",
|
||||
"span": "left",
|
||||
"dependsOn": "type"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "richeditor",
|
||||
"label": "Description",
|
||||
"comment": "Shown under the title."
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"type": "repeater",
|
||||
"label": "Questions",
|
||||
"span": "full",
|
||||
"prompt": "Add question",
|
||||
"titleFrom": "question",
|
||||
"maxItems": 3,
|
||||
"form": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "question",
|
||||
"type": "text",
|
||||
"label": "Question",
|
||||
"span": "left",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"type": "dropdown",
|
||||
"label": "Kind",
|
||||
"span": "right",
|
||||
"options": [
|
||||
{
|
||||
"value": "post",
|
||||
"label": "Post"
|
||||
},
|
||||
{
|
||||
"value": "url",
|
||||
"label": "Link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"type": "dropdown",
|
||||
"label": "Target",
|
||||
"span": "left",
|
||||
"dependsOn": "kind"
|
||||
},
|
||||
{
|
||||
"name": "visible",
|
||||
"type": "checkbox",
|
||||
"label": "Visible",
|
||||
"span": "right",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": { "other": "New link" },
|
||||
"update": { "other": "Edit link" },
|
||||
"saved": { "other": "Saved" },
|
||||
"deleteConfirm": { "other": "Delete this record?" },
|
||||
"deleted": { "other": "Record deleted" },
|
||||
"preview": { "other": "Link details" },
|
||||
"edit": { "other": "Edit link" }
|
||||
"create": {
|
||||
"other": "New link"
|
||||
},
|
||||
"update": {
|
||||
"other": "Edit link"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this record?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Record deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Link details"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit link"
|
||||
}
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/companions/links",
|
||||
"create": { "redirect": "acme/companions/links/:id", "redirectClose": "acme/companions/links" },
|
||||
"update": { "redirect": "acme/companions/links", "redirectClose": "acme/companions/links" }
|
||||
"create": {
|
||||
"redirect": "acme/companions/links/:id",
|
||||
"redirectClose": "acme/companions/links"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/companions/links",
|
||||
"redirectClose": "acme/companions/links"
|
||||
}
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"assets": { "scripts": [], "styles": [] }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": []
|
||||
}
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
|
||||
75
admin/tests/fixtures/lang.json
vendored
75
admin/tests/fixtures/lang.json
vendored
@@ -252,6 +252,27 @@
|
||||
"backend::lang.form.deleting": {
|
||||
"other": "Usuwanie…"
|
||||
},
|
||||
"backend::lang.form.dependent_cleared": {
|
||||
"other": "Wyczyszczono pole „:field”, bo zmieniły się jego opcje."
|
||||
},
|
||||
"backend::lang.form.dependent_empty": {
|
||||
"other": "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ."
|
||||
},
|
||||
"backend::lang.form.dependent_failed": {
|
||||
"other": "Nie udało się wczytać opcji."
|
||||
},
|
||||
"backend::lang.form.dependent_invalid": {
|
||||
"other": "Wybierz jedną z dostępnych opcji."
|
||||
},
|
||||
"backend::lang.form.dependent_not_used": {
|
||||
"other": "Nie dotyczy tego wyboru"
|
||||
},
|
||||
"backend::lang.form.dependent_retry": {
|
||||
"other": "Ponów wczytywanie opcji"
|
||||
},
|
||||
"backend::lang.form.dependent_waiting": {
|
||||
"other": "Najpierw wybierz pole „:field”"
|
||||
},
|
||||
"backend::lang.form.discard": {
|
||||
"other": "Porzuć zmiany"
|
||||
},
|
||||
@@ -609,6 +630,60 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.repeater.add": {
|
||||
"other": "Dodaj pozycję"
|
||||
},
|
||||
"backend::lang.repeater.collapse": {
|
||||
"other": "Zwiń: :title"
|
||||
},
|
||||
"backend::lang.repeater.collapse_all": {
|
||||
"other": "Zwiń wszystkie"
|
||||
},
|
||||
"backend::lang.repeater.count": {
|
||||
"other": "Pozycje: :current z :max"
|
||||
},
|
||||
"backend::lang.repeater.empty": {
|
||||
"other": "Brak pozycji"
|
||||
},
|
||||
"backend::lang.repeater.empty_hint": {
|
||||
"other": "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą."
|
||||
},
|
||||
"backend::lang.repeater.expand": {
|
||||
"other": "Rozwiń: :title"
|
||||
},
|
||||
"backend::lang.repeater.expand_all": {
|
||||
"other": "Rozwiń wszystkie"
|
||||
},
|
||||
"backend::lang.repeater.max_items": {
|
||||
"other": "Usuń pozycje, aby zostało ich najwyżej :max."
|
||||
},
|
||||
"backend::lang.repeater.min_items": {
|
||||
"other": "Dodaj pozycje, aby było ich co najmniej :min."
|
||||
},
|
||||
"backend::lang.repeater.move": {
|
||||
"other": "Przenieś: :title"
|
||||
},
|
||||
"backend::lang.repeater.move_down": {
|
||||
"other": "Przenieś niżej: :title"
|
||||
},
|
||||
"backend::lang.repeater.move_up": {
|
||||
"other": "Przenieś wyżej: :title"
|
||||
},
|
||||
"backend::lang.repeater.moved": {
|
||||
"other": "Przeniesiono :title na pozycję :position z :total."
|
||||
},
|
||||
"backend::lang.repeater.remove": {
|
||||
"other": "Usuń pozycję"
|
||||
},
|
||||
"backend::lang.repeater.remove_confirm": {
|
||||
"other": "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem."
|
||||
},
|
||||
"backend::lang.repeater.remove_named": {
|
||||
"other": "Usuń: :title"
|
||||
},
|
||||
"backend::lang.repeater.untitled": {
|
||||
"other": "Pozycja :n"
|
||||
},
|
||||
"backend::lang.richeditor.bold": {
|
||||
"other": "Pogrubienie"
|
||||
},
|
||||
|
||||
128
admin/tests/form/DependentDropdown.test.ts
Normal file
128
admin/tests/form/DependentDropdown.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the dependent dropdown (D-08). Plan 06
|
||||
// completes the coverage.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
const REFERENCES = `${BASE}/fields/reference/dependent-options`
|
||||
|
||||
const answers: Record<string, Reply> = {
|
||||
post: {
|
||||
body: {
|
||||
data: {
|
||||
applies: true,
|
||||
options: [
|
||||
{ value: '7', label: 'Hello world', depth: 0 },
|
||||
{ value: '8', label: 'Nested post', depth: 1 },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
},
|
||||
category: { body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } },
|
||||
url: { body: { data: { applies: false, options: [] }, meta: {} } },
|
||||
}
|
||||
|
||||
function references(request: Request): Reply {
|
||||
return answers[queryOf(request).get('values[type]') ?? ''] ?? { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
||||
}
|
||||
|
||||
function routes(type: string, reference: string | null, overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', type, reference, rows: [] }, meta: {} } },
|
||||
[`GET ${REFERENCES}`]: references,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('DependentDropdown', () => {
|
||||
it('DependentDropdown loads options for the sibling value and clears a stale value', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('post', '7', {
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: { data: { id: 1, ...body }, meta: {} } }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
// textContent: text() trims the U+00A0 depth indent.
|
||||
const optionTexts = () => select().findAll('option').map((option) => (option.element.textContent ?? '').replace(/^ +| +$/g, ''))
|
||||
|
||||
// Loaded for the stored sibling value, depth indented, stored value kept.
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(1)
|
||||
expect(queryOf(requestsTo(calls, 'GET', REFERENCES)[0]!).get('values[type]')).toBe('post')
|
||||
expect(select().attributes('disabled')).toBeUndefined()
|
||||
expect(optionTexts()).toEqual(['Hello world', ' Nested post'])
|
||||
expect(select().element.selectedOptions[0]!.text).toBe('Hello world')
|
||||
|
||||
// The sibling changes: new options, the stale value is cleared and announced.
|
||||
await wrapper.find('#field-type').setValue('2')
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', REFERENCES)[1]!).get('values[type]')).toBe('category')
|
||||
expect(optionTexts()).toEqual(['Wybierz…', 'News'])
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-form-live]').text()).toBe('Wyczyszczono pole „Reference”, bo zmieniły się jego opcje.')
|
||||
})
|
||||
|
||||
// A type without references: disabled, not used.
|
||||
await wrapper.find('#field-type').setValue('0')
|
||||
await flushPromises()
|
||||
expect(select().attributes('disabled')).toBeDefined()
|
||||
expect(optionTexts()).toEqual(['Nie dotyczy tego wyboru'])
|
||||
|
||||
// Back to a type seen before: answered from the cache, no new request.
|
||||
await wrapper.find('#field-type').setValue('1')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(3)
|
||||
expect(optionTexts()).toEqual(['Wybierz…', 'Hello world', ' Nested post'])
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(body).toMatchObject({ type: 'post', reference: null })
|
||||
})
|
||||
|
||||
it('waits for the sibling, and offers a retry when loading fails', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('', null, {
|
||||
[`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', type: null, reference: null, rows: [] }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
expect(select.attributes('disabled')).toBeDefined()
|
||||
expect(select.text()).toBe('Najpierw wybierz pole „Type”')
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(0)
|
||||
|
||||
answers.post = { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
||||
try {
|
||||
await wrapper.find('#field-type').setValue('1')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-field="reference"] [role="alert"]').text()).toBe('Nie udało się wczytać opcji.')
|
||||
} finally {
|
||||
answers.post = { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }] }, meta: {} } }
|
||||
}
|
||||
await wrapper.find('[data-action="dependent-retry"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find<HTMLSelectElement>('#field-reference').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('#field-reference').text()).toContain('Hello world')
|
||||
})
|
||||
})
|
||||
166
admin/tests/form/RepeaterField.test.ts
Normal file
166
admin/tests/form/RepeaterField.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the repeater field (D-07). Plan 06
|
||||
// completes the coverage.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
const TARGETS = `${BASE}/fields/rows.target/dependent-options`
|
||||
|
||||
function record() {
|
||||
return {
|
||||
data: {
|
||||
id: 1,
|
||||
title: 'Docs',
|
||||
description: '',
|
||||
rows: [
|
||||
{ question: 'First', kind: 'url', target: null, visible: true },
|
||||
{ question: 'Second', kind: 'url', target: null, visible: false },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record() },
|
||||
[`GET ${TARGETS}`]: { body: { data: { applies: false, options: [] }, meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const titles = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater="rows"] [data-repeater-title]').map((node) => node.text())
|
||||
const rowAt = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater="rows"] [data-repeater-row="${index}"]`)
|
||||
|
||||
function confirmDialog(): HTMLElement | null {
|
||||
return document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('RepeaterField', () => {
|
||||
it('RepeaterField tracer: add, edit nested, reorder and remove change form state only', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: { data: { id: 1, ...body }, meta: {} } }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const group = wrapper.find('[data-repeater="rows"]')
|
||||
expect(group.attributes('role')).toBe('group')
|
||||
expect(group.attributes('aria-labelledby')).toBe('field-rows-label')
|
||||
expect(wrapper.find('#field-rows-label').text()).toBe('Questions')
|
||||
expect(titles(wrapper)).toEqual(['First', 'Second'])
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 2 z 3')
|
||||
|
||||
// Add: a third row with nested defaults; the counter reaches maxItems.
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['First', 'Second', 'Pozycja 3'])
|
||||
expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3')
|
||||
|
||||
// Edit a nested field: the row title follows titleFrom.
|
||||
const third = rowAt(wrapper, 2)
|
||||
const question = third.find<HTMLInputElement>('[data-field="question"] input')
|
||||
expect(question.attributes('id')).toMatch(/^field-rows-r\d+-question$/)
|
||||
await question.setValue('Third')
|
||||
expect(titles(wrapper)).toEqual(['First', 'Second', 'Third'])
|
||||
|
||||
// Reorder by button and by keyboard on the handle; the move is announced.
|
||||
await rowAt(wrapper, 0).find('[data-action="repeater-down"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['Second', 'First', 'Third'])
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-form-live]').text()).toBe('Przeniesiono First na pozycję 2 z 3.')
|
||||
})
|
||||
const handle = rowAt(wrapper, 2).find('[data-repeater-handle]')
|
||||
keydown(handle.element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['Second', 'Third', 'First'])
|
||||
expect(document.activeElement).toBe(rowAt(wrapper, 1).find('[data-repeater-handle]').element)
|
||||
|
||||
// Remove a row that holds a value: it asks first.
|
||||
await rowAt(wrapper, 0).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmDialog()).not.toBeNull()
|
||||
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Usunąć „Second”?')
|
||||
confirmDialog()!.click()
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['Third', 'First'])
|
||||
|
||||
// An added row holding only its defaults is removed at once.
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await flushPromises()
|
||||
await rowAt(wrapper, 2).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmDialog()).toBeNull()
|
||||
expect(titles(wrapper)).toEqual(['Third', 'First'])
|
||||
|
||||
// Nothing was sent before Save; Save sends the rows as edited.
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
expect(calls.every((request) => request.method === 'GET')).toBe(true)
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(body!.rows).toEqual([
|
||||
{ question: 'Third', kind: null, target: null, visible: true },
|
||||
{ question: 'First', kind: 'url', target: null, visible: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('expands rows with path errors, shows the chip and focuses the first invalid nested control', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { 'rows.1.question': ['Question is required.'] } } },
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
// Collapse the second row first: the error opens it again.
|
||||
await rowAt(wrapper, 1).find('[data-action="repeater-toggle"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowAt(wrapper, 1).find('[data-field="question"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const row = rowAt(wrapper, 1)
|
||||
expect(row.classes()).toContain('border-danger')
|
||||
expect(row.find('[data-repeater-errors]').text()).toBe('1 błąd')
|
||||
expect(row.find('[data-field="question"]').text()).toContain('Question is required.')
|
||||
expect(document.activeElement).toBe(row.find('[data-field="question"] input').element)
|
||||
// The banner counts it but does not repeat the row message.
|
||||
expect(wrapper.find('[data-error-banner]').text()).not.toContain('Question is required.')
|
||||
})
|
||||
|
||||
it('shows the empty state with the prompt', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({ [`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', rows: [] }, meta: {} } } }),
|
||||
)
|
||||
expect(wrapper.find('[data-repeater-empty]').text()).toContain('Brak pozycji')
|
||||
expect(wrapper.find('[data-repeater-empty]').text()).toContain('Użyj przycisku „Add question” poniżej, aby dodać pierwszą.')
|
||||
expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question')
|
||||
})
|
||||
})
|
||||
@@ -150,6 +150,8 @@ fields:
|
||||
|
||||
The admin SPA fetches the options from `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value>` whenever the sibling changes; for a dropdown inside a repeater row, `{field}` is `<repeater>.<field>` and the provider receives that path as the field name. The route is guarded like the form schema (a backend session with the controller's permissions) and answers `{applies, options: [{value, label, depth}]}` with labels in the request locale.
|
||||
|
||||
In the admin, the dropdown stays disabled with a hint until the sibling has a value, while its options load, and when the sibling's value takes no choice. A failed load offers a retry. Answers are cached per sibling value while the form is open. When the administrator changes the sibling and the current choice is not among the new options, the dropdown clears it, the form counts as changed and a screen reader hears that the field was cleared. A stored value that is missing from the options on load is never replaced by another choice: the dropdown shows its placeholder and the save answers with the validation message below.
|
||||
|
||||
The server checks the value again on every save, so a client cannot store a reference the options do not offer. When the dropdown or its sibling is written, the provider is asked with the sibling's submitted value (or the stored one when only the dropdown is sent), and in every row of a written repeater with the row's own sibling value. A value that does not apply is stored as null; a value that is not among the options is a 422 with `form.dependent_invalid` ("Choose one of the listed options.") on the field, or under `<repeater>.<index>.<field>` in a row. Null passes this check; `required` still applies.
|
||||
|
||||
Menu item types are the common source of such options. A menu or sitemap controller answers with `signpost.ReferenceOptions`, which turns the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references:
|
||||
@@ -425,6 +427,8 @@ fields:
|
||||
|
||||
A save body sends the list of rows. Null is stored as an empty list; anything that is not a list of objects is a 422 on the field. Each row keeps only the nested field names, so extra keys are dropped, and its values are coerced per nested type: a number to a JSON number, a checkbox or switch to a boolean, everything else to a string or null. A required nested value missing from a row is a 422 under `<field>.<index>.<nested>`, such as `content.3.answer`. Too many or too few rows is a 422 on the field (`repeater.max_items`, `repeater.min_items`), and a required repeater needs at least one row. Every nested `richeditor` value is sanitized like a top-level one, with its notices under the row path, and every nested dependent dropdown is rechecked against its row's sibling.
|
||||
|
||||
In the admin, every row has a drag handle, its number, a title (the `titleFrom` value, or "Item N"), buttons to move it up or down, collapse it and remove it, and the nested fields below. The handle also moves the row with the arrow keys. Removing a row that holds a value asks first; an empty row goes at once. The add button appends a row with the nested defaults, and a counter shows the number of rows when `maxItems` is set. Every change only edits the form: nothing is sent until the record is saved, and Cancel discards it. A validation error under a row path opens that row, marks it and focuses the first invalid field.
|
||||
|
||||
The repeater is the one field type whose value may be a nested list: `cabana.ProjectWritableFields` lets a repeater's list through and still drops nested values on every other field. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up.
|
||||
|
||||
## Rich text
|
||||
|
||||
@@ -103,6 +103,12 @@ form:
|
||||
action_stale: This action no longer applies to this record. The page has been refreshed.
|
||||
forbidden: You do not have permission to make this change. Nothing was saved.
|
||||
dependent_invalid: Choose one of the listed options.
|
||||
dependent_waiting: "Choose “:field” first"
|
||||
dependent_not_used: Not used for this choice
|
||||
dependent_empty: "No “:type” records yet. Add one first, or choose another type."
|
||||
dependent_cleared: "“:field” was cleared because its options changed."
|
||||
dependent_failed: The options could not be loaded.
|
||||
dependent_retry: Retry loading options
|
||||
relation:
|
||||
add: Add
|
||||
link: Link
|
||||
@@ -356,6 +362,22 @@ richeditor:
|
||||
repeater:
|
||||
max_items: Remove items until there are at most :max.
|
||||
min_items: Add items until there are at least :min.
|
||||
add: Add item
|
||||
empty: No items yet
|
||||
empty_hint: "Use “:prompt” below to add the first one."
|
||||
untitled: "Item :n"
|
||||
count: "Items: :current of :max"
|
||||
move: "Move: :title"
|
||||
move_up: "Move up: :title"
|
||||
move_down: "Move down: :title"
|
||||
collapse: "Collapse: :title"
|
||||
expand: "Expand: :title"
|
||||
collapse_all: Collapse all
|
||||
expand_all: Expand all
|
||||
moved: ":title moved to position :position of :total."
|
||||
remove: Remove item
|
||||
remove_named: "Remove: :title"
|
||||
remove_confirm: "Remove “:title”? The change is saved together with the record."
|
||||
reorder:
|
||||
stale: The records changed in the meantime. The list has been reloaded.
|
||||
load_failed: The records could not be loaded. Refresh the page to try again.
|
||||
|
||||
@@ -113,6 +113,12 @@ form:
|
||||
action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona.
|
||||
forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane.
|
||||
dependent_invalid: Wybierz jedną z dostępnych opcji.
|
||||
dependent_waiting: "Najpierw wybierz pole „:field”"
|
||||
dependent_not_used: Nie dotyczy tego wyboru
|
||||
dependent_empty: "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ."
|
||||
dependent_cleared: "Wyczyszczono pole „:field”, bo zmieniły się jego opcje."
|
||||
dependent_failed: Nie udało się wczytać opcji.
|
||||
dependent_retry: Ponów wczytywanie opcji
|
||||
relation:
|
||||
add: Dodaj
|
||||
link: Dołącz
|
||||
@@ -384,6 +390,22 @@ richeditor:
|
||||
repeater:
|
||||
max_items: Usuń pozycje, aby zostało ich najwyżej :max.
|
||||
min_items: Dodaj pozycje, aby było ich co najmniej :min.
|
||||
add: Dodaj pozycję
|
||||
empty: Brak pozycji
|
||||
empty_hint: "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą."
|
||||
untitled: "Pozycja :n"
|
||||
count: "Pozycje: :current z :max"
|
||||
move: "Przenieś: :title"
|
||||
move_up: "Przenieś wyżej: :title"
|
||||
move_down: "Przenieś niżej: :title"
|
||||
collapse: "Zwiń: :title"
|
||||
expand: "Rozwiń: :title"
|
||||
collapse_all: Zwiń wszystkie
|
||||
expand_all: Rozwiń wszystkie
|
||||
moved: "Przeniesiono :title na pozycję :position z :total."
|
||||
remove: Usuń pozycję
|
||||
remove_named: "Usuń: :title"
|
||||
remove_confirm: "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem."
|
||||
reorder:
|
||||
stale: Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.
|
||||
load_failed: Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie.
|
||||
|
||||
Reference in New Issue
Block a user