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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user