diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 806f11d..d21ebfd 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -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. */ diff --git a/admin/src/components/form/FormErrorBanner.vue b/admin/src/components/form/FormErrorBanner.vue index 75d49c8..efa7b49 100644 --- a/admin/src/components/form/FormErrorBanner.vue +++ b/admin/src/components/form/FormErrorBanner.vue @@ -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), ) diff --git a/admin/src/components/form/FormGrid.vue b/admin/src/components/form/FormGrid.vue index 46591cb..a950025 100644 --- a/admin/src/components/form/FormGrid.vue +++ b/admin/src/components/form/FormGrid.vue @@ -1,12 +1,17 @@ diff --git a/admin/src/components/form/fields/RepeaterField.vue b/admin/src/components/form/fields/RepeaterField.vue new file mode 100644 index 0000000..3aa5345 --- /dev/null +++ b/admin/src/components/form/fields/RepeaterField.vue @@ -0,0 +1,465 @@ + + + diff --git a/admin/src/components/form/formContext.ts b/admin/src/components/form/formContext.ts index 382d595..c3e141e 100644 --- a/admin/src/components/form/formContext.ts +++ b/admin/src/components/form/formContext.ts @@ -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>> = 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 (`..`), 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>> + notices: Readonly>> + /** 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 +} + +export const FORM_FEEDBACK: InjectionKey = 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 + * (`.` in a row). + */ +export interface FieldScope { + fields: Readonly> + values: Readonly> + path(name: string): string +} + +export const FIELD_SCOPE: InjectionKey = Symbol('summer.form.fieldScope') + +/** Dependent options per field path and sibling value, for the life of a form view. */ +export const DEPENDENT_CACHE: InjectionKey> = Symbol('summer.form.dependentCache') diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts index c19a01a..5865577 100644 --- a/admin/src/components/form/formState.ts +++ b/admin/src/components/form/formState.ts @@ -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, name: string): boolean { + return Object.keys(errors).some((key) => key === name || key.startsWith(`${name}.`)) +} + +/** The entries of one repeater row (`..`) keyed by nested name. */ +export function rowEntries(map: Record, field: string, index: number): Record { + const prefix = `${field}.${index}.` + const out: Record = {} + 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, 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('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 +} diff --git a/admin/src/components/form/registry.ts b/admin/src/components/form/registry.ts index bebc8cd..1817460 100644 --- a/admin/src/components/form/registry.ts +++ b/admin/src/components/form/registry.ts @@ -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([ ['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([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(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown']) +const groupLabelledTypes = new Set([ + 'widget', + 'partial', + 'fileupload', + 'permissioneditor', + 'mltext', + 'mltextarea', + 'mlmarkdown', + 'repeater', +]) export function rendererFor(type: string): Component { return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField diff --git a/admin/src/views/FormView.vue b/admin/src/views/FormView.vue index a8339f0..6305ab6 100644 --- a/admin/src/views/FormView.vue +++ b/admin/src/views/FormView.vue @@ -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(() => { 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 | 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()) 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 { 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 { 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() +

{{ announcement }}

+ = { + 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 = {}): Record { + 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 | null = null + const { wrapper, calls } = await mountApp( + '/acme/companions/links/1', + routes('post', '7', { + [`PUT ${RECORD}`]: async (request) => { + body = (await request.json()) as Record + return { body: { data: { id: 1, ...body }, meta: {} } } + }, + }), + { attach: true }, + ) + const select = () => wrapper.find('#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('#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('#field-reference').attributes('disabled')).toBeUndefined() + expect(wrapper.find('#field-reference').text()).toContain('Hello world') + }) +}) diff --git a/admin/tests/form/RepeaterField.test.ts b/admin/tests/form/RepeaterField.test.ts new file mode 100644 index 0000000..19f284d --- /dev/null +++ b/admin/tests/form/RepeaterField.test.ts @@ -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 = {}): Record { + 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 | null = null + const { wrapper, calls } = await mountApp( + '/acme/companions/links/1', + routes({ + [`PUT ${RECORD}`]: async (request) => { + body = (await request.json()) as Record + 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('[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') + }) +}) diff --git a/docs/backend/forms.md b/docs/backend/forms.md index fe9cd85..af8973e 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -150,6 +150,8 @@ fields: The admin SPA fetches the options from `GET /api/v1/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[]=` whenever the sibling changes; for a dropdown inside a repeater row, `{field}` is `.` and the provider receives that path as the field name. The route is guarded like the form schema (a backend session with the controller's permissions) and answers `{applies, options: [{value, label, depth}]}` with labels in the request locale. +In the admin, the dropdown stays disabled with a hint until the sibling has a value, while its options load, and when the sibling's value takes no choice. A failed load offers a retry. Answers are cached per sibling value while the form is open. When the administrator changes the sibling and the current choice is not among the new options, the dropdown clears it, the form counts as changed and a screen reader hears that the field was cleared. A stored value that is missing from the options on load is never replaced by another choice: the dropdown shows its placeholder and the save answers with the validation message below. + The server checks the value again on every save, so a client cannot store a reference the options do not offer. When the dropdown or its sibling is written, the provider is asked with the sibling's submitted value (or the stored one when only the dropdown is sent), and in every row of a written repeater with the row's own sibling value. A value that does not apply is stored as null; a value that is not among the options is a 422 with `form.dependent_invalid` ("Choose one of the listed options.") on the field, or under `..` in a row. Null passes this check; `required` still applies. Menu item types are the common source of such options. A menu or sitemap controller answers with `signpost.ReferenceOptions`, which turns the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references: @@ -425,6 +427,8 @@ fields: A save body sends the list of rows. Null is stored as an empty list; anything that is not a list of objects is a 422 on the field. Each row keeps only the nested field names, so extra keys are dropped, and its values are coerced per nested type: a number to a JSON number, a checkbox or switch to a boolean, everything else to a string or null. A required nested value missing from a row is a 422 under `..`, such as `content.3.answer`. Too many or too few rows is a 422 on the field (`repeater.max_items`, `repeater.min_items`), and a required repeater needs at least one row. Every nested `richeditor` value is sanitized like a top-level one, with its notices under the row path, and every nested dependent dropdown is rechecked against its row's sibling. +In the admin, every row has a drag handle, its number, a title (the `titleFrom` value, or "Item N"), buttons to move it up or down, collapse it and remove it, and the nested fields below. The handle also moves the row with the arrow keys. Removing a row that holds a value asks first; an empty row goes at once. The add button appends a row with the nested defaults, and a counter shows the number of rows when `maxItems` is set. Every change only edits the form: nothing is sent until the record is saved, and Cancel discards it. A validation error under a row path opens that row, marks it and focuses the first invalid field. + The repeater is the one field type whose value may be a nested list: `cabana.ProjectWritableFields` lets a repeater's list through and still drops nested values on every other field. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up. ## Rich text diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index fd1ea06..8bb65c7 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -103,6 +103,12 @@ form: action_stale: This action no longer applies to this record. The page has been refreshed. forbidden: You do not have permission to make this change. Nothing was saved. dependent_invalid: Choose one of the listed options. + dependent_waiting: "Choose “:field” first" + dependent_not_used: Not used for this choice + dependent_empty: "No “:type” records yet. Add one first, or choose another type." + dependent_cleared: "“:field” was cleared because its options changed." + dependent_failed: The options could not be loaded. + dependent_retry: Retry loading options relation: add: Add link: Link @@ -356,6 +362,22 @@ richeditor: repeater: max_items: Remove items until there are at most :max. min_items: Add items until there are at least :min. + add: Add item + empty: No items yet + empty_hint: "Use “:prompt” below to add the first one." + untitled: "Item :n" + count: "Items: :current of :max" + move: "Move: :title" + move_up: "Move up: :title" + move_down: "Move down: :title" + collapse: "Collapse: :title" + expand: "Expand: :title" + collapse_all: Collapse all + expand_all: Expand all + moved: ":title moved to position :position of :total." + remove: Remove item + remove_named: "Remove: :title" + remove_confirm: "Remove “:title”? The change is saved together with the record." reorder: stale: The records changed in the meantime. The list has been reloaded. load_failed: The records could not be loaded. Refresh the page to try again. diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 1e2b471..09d3055 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -113,6 +113,12 @@ form: action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona. forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane. dependent_invalid: Wybierz jedną z dostępnych opcji. + dependent_waiting: "Najpierw wybierz pole „:field”" + dependent_not_used: Nie dotyczy tego wyboru + dependent_empty: "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ." + dependent_cleared: "Wyczyszczono pole „:field”, bo zmieniły się jego opcje." + dependent_failed: Nie udało się wczytać opcji. + dependent_retry: Ponów wczytywanie opcji relation: add: Dodaj link: Dołącz @@ -384,6 +390,22 @@ richeditor: repeater: max_items: Usuń pozycje, aby zostało ich najwyżej :max. min_items: Dodaj pozycje, aby było ich co najmniej :min. + add: Dodaj pozycję + empty: Brak pozycji + empty_hint: "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą." + untitled: "Pozycja :n" + count: "Pozycje: :current z :max" + move: "Przenieś: :title" + move_up: "Przenieś wyżej: :title" + move_down: "Przenieś niżej: :title" + collapse: "Zwiń: :title" + expand: "Rozwiń: :title" + collapse_all: Zwiń wszystkie + expand_all: Rozwiń wszystkie + moved: "Przeniesiono :title na pozycję :position z :total." + remove: Usuń pozycję + remove_named: "Usuń: :title" + remove_confirm: "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem." reorder: stale: Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie. load_failed: Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie.