Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a2a6584c | ||
|
|
b6b194e5eb | ||
|
|
a714bb4629 | ||
|
|
7554241e9d | ||
|
|
fa25b4e000 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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[]> = {}
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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('—')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user