diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts index f2c33ec..813e3ec 100644 --- a/admin/src/components/form/formState.ts +++ b/admin/src/components/form/formState.ts @@ -217,6 +217,35 @@ export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[ return { ...seeded, ...localeRecord(incoming) } } +/** The record's display name: the value of the first text-like field (plain + * `text` or an ML type) in schema order. An ML value reads the default (first + * enabled) locale, then the first non-empty locale; '' when nothing is set. */ +export function recordDisplayName( + fields: readonly FormField[] | undefined, + values: AdminRecord, + enabledLocales: readonly string[], +): string { + const first = fields?.find((field) => field.type === 'text' || isMLFieldType(field.type)) + const value = first ? values[first.name] : undefined + if (typeof value === 'string' || typeof value === 'number') { + return String(value).trim() + } + if (!first || !isMLFieldType(first.type)) { + return '' + } + const texts = localeRecord(value) + const preferred = enabledLocales[0] !== undefined ? (texts[enabledLocales[0]] ?? '').trim() : '' + if (preferred !== '') { + return preferred + } + for (const text of Object.values(texts)) { + if (text.trim() !== '') { + return text.trim() + } + } + return '' +} + /** D-10 422 details (field -> messages) as a string-list map. */ export function fieldErrors(details: ErrorBody['details'] | undefined): Record { const out: Record = {} diff --git a/admin/src/views/FormView.vue b/admin/src/views/FormView.vue index f638722..adb4741 100644 --- a/admin/src/views/FormView.vue +++ b/admin/src/views/FormView.vue @@ -28,6 +28,7 @@ import { mergeMLValue, panelDomId, presetUpdates, + recordDisplayName, schemaEnabledLocales, snapshot, tabDomId, @@ -139,12 +140,10 @@ const panelFields = computed(() => { return fields.value.filter((field) => tabOf(field) === key) }) -/** The record's display name: the first text field's value, in schema order. */ -const recordName = computed(() => { - const first = schema.value?.fields.find((field) => field.type === 'text') - const value = first ? values.value[first.name] : undefined - return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' -}) +/** The record's display name: the first text-like field's value, in schema order. */ +const recordName = computed(() => + recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)), +) const title = computed(() => { if (mode === 'update' && recordName.value !== '') { diff --git a/admin/src/views/PreviewView.vue b/admin/src/views/PreviewView.vue index d5d7860..df31194 100644 --- a/admin/src/views/PreviewView.vue +++ b/admin/src/views/PreviewView.vue @@ -14,7 +14,16 @@ import FormTabs from '../components/form/FormTabs.vue' import PreviewField from '../components/form/PreviewField.vue' import RecordActions from '../components/form/RecordActions.vue' import { RELATION_MANAGER } from '../components/form/registry' -import { DEFAULT_TAB, contextAllows, panelDomId, tabDomId, tabOf, type TabItem } from '../components/form/formState' +import { + DEFAULT_TAB, + contextAllows, + panelDomId, + recordDisplayName, + schemaEnabledLocales, + tabDomId, + tabOf, + type TabItem, +} from '../components/form/formState' import PartialHost from '../components/partial/PartialHost.vue' import Button from '../components/ui/Button.vue' import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs' @@ -96,12 +105,10 @@ const panelFields = computed(() => { return fields.value.filter((field) => tabOf(field) === key) }) -/** The record's display name: the first text field's value, in schema order. */ -const recordName = computed(() => { - const first = schema.value?.fields.find((field) => field.type === 'text') - const value = first ? values.value[first.name] : undefined - return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' -}) +/** The record's display name: the first text-like field's value, in schema order. */ +const recordName = computed(() => + recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)), +) const subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value })) const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '') diff --git a/admin/tests/form/FormView.test.ts b/admin/tests/form/FormView.test.ts index a4c99b1..5c135f2 100644 --- a/admin/tests/form/FormView.test.ts +++ b/admin/tests/form/FormView.test.ts @@ -611,6 +611,14 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => { expect(editor(wrapper, 'slug').element.value).toBe('ada-lovelace') }) + it('titles a record by the default locale of an mltext first field, and publishes it as the crumb', async () => { + const record = clone(rosterRecordFixture) + record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' } + const { wrapper } = await mountApp('/acme/roster/people/1', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text'), [`GET ${PERSON}`]: { body: record } })) + expect(wrapper.find('h1').text()).toBe('Ada Lovelace') + expect(recordCrumb.value).toBe('Ada Lovelace') + }) + it('drives a text slug from the active locale of an mltext name, until edited', async () => { const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text') })) await editor(wrapper, 'name').setValue('Hello World') diff --git a/admin/tests/form/PreviewView.test.ts b/admin/tests/form/PreviewView.test.ts index 07dc850..be3c98c 100644 --- a/admin/tests/form/PreviewView.test.ts +++ b/admin/tests/form/PreviewView.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' +import { recordCrumb } from '../../src/state/useBreadcrumbs' import { toasts } from '../../src/state/useToasts' import PreviewView from '../../src/views/PreviewView.vue' import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed' @@ -121,6 +122,23 @@ describe('preview view (UI-SPEC S3, D-11)', () => { expect(editor.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true) }) + it('heads the record by the default locale of an mltext first field, and publishes it as the crumb', async () => { + const { wrapper } = await mountApp( + PREVIEW, + routes({ + [`GET ${LIST}/schema/form`]: schemaWith((schema) => { + schema.meta.enabledLocales = ['en', 'pl'] + schema.fields.find((field) => field.name === 'name')!.type = 'mltext' + }), + [`GET ${RECORD}`]: recordWith((record) => { + record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' } + }), + }), + ) + expect(wrapper.find('h1').text()).toBe('Ada Lovelace') + expect(recordCrumb.value).toBe('Ada Lovelace') + }) + it('shows the muted dash for an empty value and the label of a relation, never its id', async () => { const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.data.joined_ip = null)) })) expect(wrapper.find('[data-preview-field="joined_ip"] [data-empty]').text()).toBe('—') diff --git a/admin/tests/form/formState.test.ts b/admin/tests/form/formState.test.ts index 1b363db..1c1e83f 100644 --- a/admin/tests/form/formState.test.ts +++ b/admin/tests/form/formState.test.ts @@ -11,6 +11,7 @@ import { panelDomId, presetUpdates, presetValue, + recordDisplayName, snapshot, tabDomId, tabOf, @@ -271,3 +272,33 @@ describe('ML create seed and adopt merge', () => { expect(mergeMLValue({ en: 'Hello', pl: 'Witaj' }, ['en', 'pl'])).toEqual({ en: 'Hello', pl: 'Witaj' }) }) }) + +describe('recordDisplayName', () => { + const field = (name: string, type: string): FormField => ({ ...formField('name'), name, type }) + const text = field('name', 'text') + const title = field('title', 'mltext') + const locales = ['en', 'pl'] + + it('reads a plain text value, trimmed', () => { + expect(recordDisplayName([text], { name: ' Anna ' }, locales)).toBe('Anna') + expect(recordDisplayName([text], { name: 42 }, locales)).toBe('42') + }) + + it('reads the default locale of an ML value', () => { + expect(recordDisplayName([title], { title: { en: 'Hello', pl: 'Cześć' } }, locales)).toBe('Hello') + }) + + it('falls back to the first non-empty locale when the default is empty', () => { + expect(recordDisplayName([title], { title: { en: ' ', pl: 'Cześć' } }, locales)).toBe('Cześć') + }) + + it('is empty when every locale is empty', () => { + expect(recordDisplayName([title], { title: { en: '', pl: '' } }, locales)).toBe('') + expect(recordDisplayName([title], {}, locales)).toBe('') + }) + + it('skips fields that are not text-like, in favour of a later text field', () => { + const fields = [field('notify', 'checkbox'), field('tags', 'relation'), title, text] + expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post') + }) +})