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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user