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:
2026-10-12 16:08:23 +02:00
parent 4a5e896429
commit d3bf5d8f86
17 changed files with 1432 additions and 60 deletions

View File

@@ -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>