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