Compare commits

..

5 Commits

Author SHA1 Message Date
Jakub Zych
40a2a6584c docs(quick-261007-qgx): record heading from translatable fields 2026-10-07 19:07:34 +02:00
Jakub Zych
b6b194e5eb fix(boardwalk): rebuild embedded admin shell 2026-10-07 19:07:25 +02:00
Jakub Zych
a714bb4629 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.
2026-10-07 19:05:47 +02:00
Jakub Zych
7554241e9d docs(planning): log to-many list column fix 2026-10-07 18:43:18 +02:00
Jakub Zych
fa25b4e000 fix(cabana): show to-many relation list columns joined with commas 2026-10-07 18:43:17 +02:00
14 changed files with 240 additions and 25 deletions

View File

@@ -5,10 +5,10 @@ current_phase: 15
current_phase_name: Journal plugin
status: executing
stopped_at: Completed 15-04-PLAN.md
last_updated: "2026-10-06T21:58:51.425Z"
last_activity: 2026-10-06
last_updated: "2026-10-07T16:43:18.298Z"
last_activity: 2026-10-07
last_activity_desc: Completed quick task 261006-s0v (remove ML copy-from locale UI)
state_head: 4a94380b5fe947b657dadd250e1960865ca3285e
state_head: fa25b4e000fac864b949be6daa4f800a4511a458
progress:
total_phases: 26
completed_phases: 12
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 15 (Journal plugin) — EXECUTING
Plan: 4 of 4
Status: Ready for phase verification
Last activity: 2026-10-07 — Completed quick task 261007-p4y: toggle sizing and settings controller links
Last activity: 2026-10-07 — Completed quick task 261007-qgx: record heading from translatable (mltext) fields
Progress: [██████████] 100%
@@ -652,6 +652,8 @@ Recent decisions affecting current work:
| 15 | Commit accumulated planning documents properly | 2026-10-06 | c05ad09 | — |
| 16 | Default icon shouldn't be a square but a sun. | 2026-10-06 | 4a94380 | — |
| 261007-p4y | Switch toggle pixel sizing; settings entries linking to admin controllers; translate Locales moved to Settings | 2026-10-07 | 0b09acd | [261007-p4y-toggle-proportions-and-settings-controll](./quick/261007-p4y-toggle-proportions-and-settings-controll/) |
| 18 | cabana list: join to-many relation column values with ", " | 2026-10-07 | fa25b4e | — |
| 261007-qgx | Admin record heading, crumb and title from the first text or mltext field (default locale) | 2026-10-07 | a714bb4 | [261007-qgx-admin-record-heading-uses-translatable-m](./quick/261007-qgx-admin-record-heading-uses-translatable-m/) |
## Deferred Items

View File

@@ -0,0 +1,33 @@
---
quick_id: 261007-qgx
mode: quick
---
# Quick 261007-qgx: admin record heading uses translatable (mltext) fields
The admin record heading, breadcrumb record crumb and document title read the
first `text` field only, so records whose name field is `mltext` (journal Post
title, Category name) fall back to "Edit record".
## Task 1: shared recordDisplayName helper
- files: admin/src/components/form/formState.ts, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue
- action: add `recordDisplayName(fields, values, enabledLocales)`: first field
of type `text` or an ML type (`isMLFieldType`) in schema order; plain values
as today (string/number, trimmed); ML values use the default (first enabled)
locale, else the first non-empty locale, else ''. Both views use it with
`schemaEnabledLocales(schema.meta)`.
- done: plain `text` behaviour unchanged; an mltext first field names the record.
## Task 2: tests
- files: admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts
- action: helper cases (plain, default locale filled, default empty + other
filled, all empty, non-text first field skipped); FormView and PreviewView
show the default-locale value as heading and record crumb for an mltext field.
- verify: `npm test`, `vue-tsc --noEmit`.
## Task 3: ship
- rebuild the embedded admin shell (`npm run build` → modules/boardwalk/dist),
`go vet ./... && go test ./...`, tag and push v0.1.12.

View File

@@ -0,0 +1,18 @@
---
quick_id: 261007-qgx
status: complete
commits: [a714bb4, b6b194e]
---
# Quick 261007-qgx: admin record heading uses translatable (mltext) fields
- `recordDisplayName(fields, values, enabledLocales)` in
`admin/src/components/form/formState.ts`: the first `text` or ML field in
schema order names the record; an ML value reads the default (first enabled)
locale, then the first non-empty locale, else '' (the generic message stays).
- FormView and PreviewView both use it with `schemaEnabledLocales(schema.meta)`;
plain `text` behaviour is unchanged.
- Tests: five helper cases in `formState.test.ts`; FormView and PreviewView
cases for an mltext first field (heading and record crumb). Both view tests
fail without the fix. Admin suite 1090/1090, `go vet` and `go test ./...` green.
- Embedded admin shell rebuilt (`modules/boardwalk/dist`), shipped as v0.1.12.

View File

@@ -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<string, string[]> {
const out: Record<string, string[]> = {}

View File

@@ -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 !== '') {

View File

@@ -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 ?? '')

View File

@@ -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')

View File

@@ -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('—')

View File

@@ -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')
})
})

View File

@@ -236,4 +236,4 @@ A relation's `messages` block overrides the relation manager's copy, each key a
## Relations in lists
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). On a to-many relation the column joins the selected value of every related record with `, `, as WinterCMS does, and stays empty when there are none. A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BK2Ktgov.js"></script>
<script type="module" crossorigin src="./assets/index-BFvYYWYJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
</head>
<body>

View File

@@ -84,3 +84,41 @@ func TestFieldByColumnTagBeatsGoName(t *testing.T) {
t.Fatalf("a tagged field matched by its Go name: %v", got)
}
}
type listTag struct {
ID uint `gorm:"primarykey"`
Name *string `gorm:"column:name"`
}
type listTagged struct {
ID uint `gorm:"primarykey"`
Tags []listTag `gorm:"many2many:tagged_tags"`
Pointers []*listTag `gorm:"many2many:tagged_pointer_tags"`
}
// TestProjectRowJoinsToManyRelation pins a to-many relation column (relation:
// tags, select: name): the list shows every related record's selected value
// joined with ", " like Winter, and leaves the key unset when there are none.
func TestProjectRowJoinsToManyRelation(t *testing.T) {
news, guides := "News", "Guides"
row := &listTagged{
ID: 3,
Tags: []listTag{{ID: 1, Name: &news}, {ID: 2, Name: &guides}, {ID: 4}},
Pointers: []*listTag{nil, {ID: 2, Name: &guides}},
}
cols := []ListColumn{
{Key: "tags", Relation: "tags", Select: "name"},
{Key: "pointers", Relation: "pointers", Select: "name"},
}
projected := projectRow(row, nil, cols)
if projected["tags"] != "News, Guides" {
t.Fatalf("tags = %#v, want %q", projected["tags"], "News, Guides")
}
if projected["pointers"] != "Guides" {
t.Fatalf("pointers = %#v, want %q", projected["pointers"], "Guides")
}
empty := projectRow(&listTagged{ID: 5}, nil, cols)
if _, ok := empty["tags"]; ok {
t.Fatalf("empty relation projected %#v, want the key unset", empty)
}
}

View File

@@ -580,16 +580,48 @@ func relatedSelect(row reflect.Value, controller pact.AdminController, col ListC
}
field = field.Elem()
}
column := listRelationColumn(controller, col.Relation, col.Select)
if field.Kind() == reflect.Slice {
return relatedSelectMany(field, column)
}
if field.Kind() != reflect.Struct {
return nil, false
}
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
selected := fieldByColumn(field, column)
if !selected.IsValid() || !selected.CanInterface() {
return nil, false
}
return selected.Interface(), true
}
// relatedSelectMany joins the selected column of every related record with
// ", ", as Winter's list does for a to-many relation column. No related
// records leave the key unset, so the list shows its empty placeholder.
func relatedSelectMany(records reflect.Value, column string) (any, bool) {
values := make([]string, 0, records.Len())
for i := 0; i < records.Len(); i++ {
record := records.Index(i)
for record.Kind() == reflect.Pointer && !record.IsNil() {
record = record.Elem()
}
if record.Kind() != reflect.Struct {
continue
}
selected := fieldByColumn(record, column)
for selected.IsValid() && selected.Kind() == reflect.Pointer && !selected.IsNil() {
selected = selected.Elem()
}
if !selected.IsValid() || !selected.CanInterface() || selected.Kind() == reflect.Pointer {
continue
}
values = append(values, fmt.Sprint(selected.Interface()))
}
if len(values) == 0 {
return nil, false
}
return strings.Join(values, ", "), true
}
func listRelationColumn(controller pact.AdminController, relation, column string) string {
mapper, ok := controller.(pact.ListRelationColumnMapper)
if !ok || mapper == nil {