fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first plain text field only, so records named by an mltext field fell back to the generic update message. A shared recordDisplayName helper now takes the first text or ML field and reads its default locale, then the first non-empty one.
This commit is contained in:
@@ -217,6 +217,35 @@ export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[
|
|||||||
return { ...seeded, ...localeRecord(incoming) }
|
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. */
|
/** D-10 422 details (field -> messages) as a string-list map. */
|
||||||
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
||||||
const out: Record<string, string[]> = {}
|
const out: Record<string, string[]> = {}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
mergeMLValue,
|
mergeMLValue,
|
||||||
panelDomId,
|
panelDomId,
|
||||||
presetUpdates,
|
presetUpdates,
|
||||||
|
recordDisplayName,
|
||||||
schemaEnabledLocales,
|
schemaEnabledLocales,
|
||||||
snapshot,
|
snapshot,
|
||||||
tabDomId,
|
tabDomId,
|
||||||
@@ -139,12 +140,10 @@ const panelFields = computed(() => {
|
|||||||
return fields.value.filter((field) => tabOf(field) === key)
|
return fields.value.filter((field) => tabOf(field) === key)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The record's display name: the first text field's value, in schema order. */
|
/** The record's display name: the first text-like field's value, in schema order. */
|
||||||
const recordName = computed(() => {
|
const recordName = computed(() =>
|
||||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
|
||||||
const value = first ? values.value[first.name] : undefined
|
)
|
||||||
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (mode === 'update' && recordName.value !== '') {
|
if (mode === 'update' && recordName.value !== '') {
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ import FormTabs from '../components/form/FormTabs.vue'
|
|||||||
import PreviewField from '../components/form/PreviewField.vue'
|
import PreviewField from '../components/form/PreviewField.vue'
|
||||||
import RecordActions from '../components/form/RecordActions.vue'
|
import RecordActions from '../components/form/RecordActions.vue'
|
||||||
import { RELATION_MANAGER } from '../components/form/registry'
|
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 PartialHost from '../components/partial/PartialHost.vue'
|
||||||
import Button from '../components/ui/Button.vue'
|
import Button from '../components/ui/Button.vue'
|
||||||
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
||||||
@@ -96,12 +105,10 @@ const panelFields = computed(() => {
|
|||||||
return fields.value.filter((field) => tabOf(field) === key)
|
return fields.value.filter((field) => tabOf(field) === key)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The record's display name: the first text field's value, in schema order. */
|
/** The record's display name: the first text-like field's value, in schema order. */
|
||||||
const recordName = computed(() => {
|
const recordName = computed(() =>
|
||||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
|
||||||
const value = first ? values.value[first.name] : undefined
|
)
|
||||||
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value }))
|
const subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value }))
|
||||||
const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '')
|
const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '')
|
||||||
|
|||||||
@@ -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')
|
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 () => {
|
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') }))
|
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text') }))
|
||||||
await editor(wrapper, 'name').setValue('Hello World')
|
await editor(wrapper, 'name').setValue('Hello World')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
import { setBundle } from '../../src/app/i18n'
|
import { setBundle } from '../../src/app/i18n'
|
||||||
|
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
||||||
import { toasts } from '../../src/state/useToasts'
|
import { toasts } from '../../src/state/useToasts'
|
||||||
import PreviewView from '../../src/views/PreviewView.vue'
|
import PreviewView from '../../src/views/PreviewView.vue'
|
||||||
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
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)
|
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 () => {
|
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)) }))
|
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('—')
|
expect(wrapper.find('[data-preview-field="joined_ip"] [data-empty]').text()).toBe('—')
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
panelDomId,
|
panelDomId,
|
||||||
presetUpdates,
|
presetUpdates,
|
||||||
presetValue,
|
presetValue,
|
||||||
|
recordDisplayName,
|
||||||
snapshot,
|
snapshot,
|
||||||
tabDomId,
|
tabDomId,
|
||||||
tabOf,
|
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' })
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user