Files
summercms/admin/src/components/form/fields/DropdownField.vue
Jakub Zych d3bf5d8f86 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
2026-10-12 16:08:23 +02:00

270 lines
9.4 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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'] | null] }>()
const EMPTY = 'empty'
const PLACEHOLDER = 'placeholder'
const INDENT = ' '.repeat(4)
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) {
return a === b || (a == null && b == null)
}
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(() => {
const index = options.value.findIndex((option) => same(option.value, props.modelValue))
if (index >= 0) {
return String(index)
}
if (props.field.emptyOption !== undefined && !hasValue()) {
return EMPTY
}
return 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
if (value === EMPTY) {
emit('update:modelValue', null)
return
}
const option = options.value[Number(value)]
if (option) {
emit('update:modelValue', option.value)
}
}
</script>
<template>
<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>