Skip to content

Commit 5cb75b3

Browse files
os-zhuangclaudeos-zhuang
authored
fix(studio,timeline,list): 表单设计器解析对象翻译;timeline 认它自己配置的日期字段 (#3134, #3129) (#3175)
* wip: rescue in-progress reproduction scratch before restart loss (batch8) * wip: rescue reproduction scratch update (batch8) * fix(studio): the form-layout canvas speaks the project's language (#3134) ObjectFormDesigner read `entry.def.label` / `group.label` straight off the object draft, so a fully translated object still rendered its English source labels on the layout canvas while every other surface resolved the project's object translations. Route field cards (and the drag overlay) through `fieldLabel()` and section headers through `sectionLabel()` — the same resolver ObjectForm and RecordDetailView use — with the authored metadata label as the fallback. StudioDesignSurface passes the object's API name explicitly so the lookup root survives an unnamed draft body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(timeline,list): honour the `timeline.dateField` alias on the spec nesting (#3129) `dateField` is the pre-#2231 alias for `startDateField`. `@object-ui/types` declares it on the nested config and both ObjectView read-sites resolve it, but ObjectTimeline read it only off the flat prop and ListView only out of `options.timeline` — including in the capability gate. A view authored as `timeline: { dateField: … }` therefore fell through to the caller's default (`created_at` / `due_date`), which the projection does not request, so every record bucketed into "No date" with the configured date still in the row. Also drops the reproduction scratch in favour of two real regression suites. The pre-existing ObjectTimeline test stubs out `./renderer` and asserts only item titles, so the date binding had no behavioural coverage at all — which is how an all-"No date" timeline shipped green. The new plugin-timeline suite renders through the real renderer and reads the bucket headers; the plugin-list suite spies on the registry to pin the forwarded binding and the $select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * test(timeline): apply the untyped `data` passthrough without breaking type-check `ObjectTimelineProps` does not declare `data` (the component reads it off the rest args), so the regression suite has to apply it untyped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <support@objectstack.ai>
1 parent b67be19 commit 5cb75b3

9 files changed

Lines changed: 495 additions & 10 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
fix(studio): the form-layout canvas resolves the object's field and section translations (#3134)
6+
7+
`ObjectFormDesigner` bills itself as a preview of the end-user form, but it read
8+
labels straight off the object draft — `entry.def.label` for fields, `group.label`
9+
for section headers. Every other surface for the same object (`ObjectForm`,
10+
`RecordDetailView`, the data grid) resolves those through the project's object
11+
translations first, so a fully translated object rendered `Opportunity Name` /
12+
`Basic Information` on the layout canvas while the very same fields read
13+
`商机名称` / `基本信息` one click away.
14+
15+
The designer now goes through `useSafeFieldLabel()``fieldLabel()` for field
16+
cards (including the drag overlay) and `sectionLabel()` for section headers —
17+
which is the same resolver the runtime form uses, with the authored metadata
18+
label as fallback when no translation exists. The lookup root is the object's
19+
API name; `StudioDesignSurface` now passes it explicitly (`objectName`) so a
20+
draft body that has not been re-named still resolves, falling back to
21+
`draft.name`.
22+
23+
Observable rendering change (translated labels now appear where English source
24+
labels did), hence `minor`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@object-ui/plugin-timeline": minor
3+
"@object-ui/plugin-list": minor
4+
---
5+
6+
fix(timeline,list): the timeline honours `timeline.dateField`, not just `timeline.startDateField` (#3129)
7+
8+
`dateField` is the pre-#2231 alias for `startDateField`. `@object-ui/types`
9+
declares it on the nested config (`ListViewTimelineConfig`), and both
10+
`ObjectView` read-sites (app-shell and plugin-view) resolve it — but the two
11+
read-sites that actually drive the axis did not:
12+
13+
- `ObjectTimeline` consulted the alias only on the FLAT prop (`schema.dateField`),
14+
never on the nested `schema.timeline`.
15+
- `ListView` resolved it out of `options.timeline` but not out of the
16+
spec-canonical `schema.timeline` — including in the capability gate, so such a
17+
view could fail to offer the Timeline option at all.
18+
19+
So a view authored as `timeline: { dateField: 'start_date' }` — the spec nesting
20+
with the legacy key — fell through to the caller's default (`created_at` /
21+
`due_date`). That field is normally absent from the `$select` projection, so
22+
every record came back without it and the timeline rendered all of them under
23+
**No date** — while the configured date was sitting in the row untouched. That
24+
also explains why widening the view's projection changed nothing: the projection
25+
already carried the right field; the renderer was reading a different one.
26+
27+
Both read-sites now resolve the alias in the same precedence position they
28+
already use for `options.timeline.dateField`. The spec key still wins wherever
29+
both appear. Observable rendering change (records move out of "No date" into
30+
real date buckets), hence `minor`.

packages/app-shell/src/views/studio-design/ObjectFormDesigner.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { describe, it, expect, vi } from 'vitest';
99
import { render, screen, fireEvent } from '@testing-library/react';
1010
import React from 'react';
11+
import { I18nProvider, createI18n } from '@object-ui/i18n';
1112
import { ObjectFormDesigner } from './ObjectFormDesigner';
1213

1314
/** Build an array-shape `draft.fields` with `n` plain text fields. */
@@ -130,3 +131,112 @@ describe('ObjectFormDesigner — group selection', () => {
130131
expect(screen.queryByLabelText('Group settings')).toBeNull();
131132
});
132133
});
134+
135+
/**
136+
* Regression for objectui#3134.
137+
*
138+
* The layout canvas is a preview of the end-user form, so it has to resolve
139+
* labels the way `ObjectForm` / `RecordDetailView` do — through the project's
140+
* object translations. It used to read `entry.def.label` and `group.label`
141+
* straight off the draft metadata, so a fully translated object still rendered
142+
* its English source labels in the designer while every other surface in the
143+
* same locale showed the translation.
144+
*
145+
* The runtime bundle shape below is what `transformSpecTranslations` produces
146+
* from authored `objects.<obj>.fields.<f>.label` / `objects.<obj>._sections.
147+
* <key>.label` entries: field labels are flattened to `fields.<obj>.<field>`,
148+
* while `_sections` is preserved verbatim under the object scope.
149+
*/
150+
describe('ObjectFormDesigner — object translations (objectui#3134)', () => {
151+
const draft = {
152+
name: 'crm_opportunity',
153+
fields: [
154+
{ name: 'name', type: 'text', label: 'Opportunity Name', group: 'basic' },
155+
{ name: 'amount', type: 'number', label: 'Amount', group: 'basic' },
156+
],
157+
fieldGroups: [{ key: 'basic', label: 'Basic Information' }],
158+
};
159+
160+
const renderTranslated = (props: Record<string, unknown> = {}) => {
161+
const instance = createI18n({
162+
defaultLanguage: 'zh',
163+
detectBrowserLanguage: false,
164+
resources: {
165+
zh: {
166+
app: {
167+
objects: {
168+
crm_opportunity: {
169+
label: '商机',
170+
_sections: { basic: { label: '基本信息' } },
171+
},
172+
},
173+
fields: { crm_opportunity: { name: '商机名称' } },
174+
},
175+
},
176+
},
177+
});
178+
return render(
179+
<I18nProvider instance={instance}>
180+
<ObjectFormDesigner
181+
draft={draft}
182+
systemFieldNames={new Set()}
183+
onChange={noop}
184+
onSelectField={noop}
185+
{...props}
186+
/>
187+
</I18nProvider>,
188+
);
189+
};
190+
191+
it('renders the translated field label instead of the raw metadata label', () => {
192+
renderTranslated();
193+
expect(screen.getByText('商机名称')).toBeTruthy();
194+
expect(screen.queryByText('Opportunity Name')).toBeNull();
195+
});
196+
197+
it('renders the translated section label instead of the raw group label', () => {
198+
const { container } = renderTranslated();
199+
const heading = container.querySelector<HTMLInputElement>('input[type="text"], input:not([type])');
200+
expect(heading?.value ?? '').toBe('基本信息');
201+
expect(screen.queryByText('Basic Information')).toBeNull();
202+
});
203+
204+
it('falls back to the metadata label when the object has no translation for a field', () => {
205+
// `amount` is untranslated — the designer must show the authored label, not
206+
// a blank cell or the raw field name.
207+
renderTranslated();
208+
expect(screen.getByText('Amount')).toBeTruthy();
209+
});
210+
211+
it('prefers the explicit objectName prop over draft.name as the lookup root', () => {
212+
// A draft whose body has not been (re)named yet still resolves, because the
213+
// Studio surface passes the object it is editing.
214+
const { container } = render(
215+
<I18nProvider
216+
instance={createI18n({
217+
defaultLanguage: 'zh',
218+
detectBrowserLanguage: false,
219+
resources: {
220+
zh: {
221+
app: {
222+
objects: { crm_opportunity: { _sections: { basic: { label: '基本信息' } } } },
223+
fields: { crm_opportunity: { name: '商机名称' } },
224+
},
225+
},
226+
},
227+
})}
228+
>
229+
<ObjectFormDesigner
230+
draft={{ ...draft, name: undefined }}
231+
objectName="crm_opportunity"
232+
systemFieldNames={new Set()}
233+
onChange={noop}
234+
onSelectField={noop}
235+
/>
236+
</I18nProvider>,
237+
);
238+
expect(screen.getByText('商机名称')).toBeTruthy();
239+
const heading = container.querySelector<HTMLInputElement>('input[type="text"], input:not([type])');
240+
expect(heading?.value ?? '').toBe('基本信息');
241+
});
242+
});

packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
type FieldEntry,
5353
type FieldsView,
5454
} from '../metadata-admin/previews/object-fields-io';
55+
import { useSafeFieldLabel } from '@object-ui/i18n';
5556
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
5657

5758
const UNGROUPED = '__ungrouped__';
@@ -63,6 +64,13 @@ const unFid = (id: string) => id.slice(2);
6364
export interface ObjectFormDesignerProps {
6465
/** Object metadata draft (reads `fields` + `fieldGroups`). */
6566
draft: Record<string, unknown>;
67+
/**
68+
* API name of the object being designed — the lookup root for the field /
69+
* section translations the canvas renders. Falls back to `draft.name`, which
70+
* the object metadata body carries; pass it explicitly when the caller has a
71+
* more reliable handle (a freshly created draft may not have been named yet).
72+
*/
73+
objectName?: string;
6674
/** Field names to hide from the layout (system/audit) but preserve on write. */
6775
systemFieldNames: Set<string>;
6876
/** Persist a partial object-draft patch (fields / fieldGroups) + mark dirty. */
@@ -128,19 +136,21 @@ function FieldControlPreview({ type }: { type: string }): React.ReactElement {
128136
/** One draggable field card inside a section. */
129137
function SortableField({
130138
entry,
139+
label,
131140
columns,
132141
selected,
133142
onSelect,
134143
}: {
135144
entry: FieldEntry;
145+
/** Already resolved through the project's field translations. */
146+
label: string;
136147
columns: number;
137148
selected: boolean;
138149
onSelect: () => void;
139150
}): React.ReactElement {
140151
const locale = useMetadataLocale();
141152
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: fid(entry.name) });
142153
const type = String(entry.def.type ?? 'text');
143-
const label = String(entry.def.label ?? entry.name);
144154
const required = !!entry.def.required;
145155
// Mirror the real form: wide widgets (textarea/markdown/html/…) take the whole
146156
// row. `col-span-full` (grid-column: 1/-1) spans every column at ANY container
@@ -188,6 +198,7 @@ function Section({
188198
canMoveUp,
189199
canMoveDown,
190200
entryByName,
201+
fieldLabelOf,
191202
selectedField,
192203
onSelectField,
193204
selected = false,
@@ -205,6 +216,8 @@ function Section({
205216
canMoveUp: boolean;
206217
canMoveDown: boolean;
207218
entryByName: Map<string, FieldEntry>;
219+
/** Resolves a field entry to its translated display label. */
220+
fieldLabelOf: (entry: FieldEntry) => string;
208221
selectedField?: string | null;
209222
onSelectField: (name: string) => void;
210223
selected?: boolean;
@@ -306,6 +319,7 @@ function Section({
306319
<SortableField
307320
key={id}
308321
entry={entry}
322+
label={fieldLabelOf(entry)}
309323
columns={columns}
310324
selected={selectedField === name}
311325
onSelect={() => onSelectField(name)}
@@ -320,6 +334,7 @@ function Section({
320334

321335
export function ObjectFormDesigner({
322336
draft,
337+
objectName: objectNameProp,
323338
systemFieldNames,
324339
onChange,
325340
selectedField,
@@ -334,6 +349,26 @@ export function ObjectFormDesigner({
334349
const groups = React.useMemo(() => readGroups(draft.fieldGroups), [draft.fieldGroups]);
335350
const entryByName = React.useMemo(() => new Map(view.entries.map((e) => [e.name, e] as const)), [view]);
336351

352+
// The canvas is a preview of the END-USER form, so it must speak the same
353+
// language that form does (objectui#3134). `ObjectForm` / `RecordDetailView`
354+
// resolve every field and section through the project's object translations
355+
// (`objects.<object>.fields.<field>.label` /
356+
// `objects.<object>._sections.<key>.label`); the designer read the raw draft
357+
// metadata instead, so a fully translated object still rendered its English
358+
// source labels here while every other surface showed the translation.
359+
// `useSafeFieldLabel` is the provider-safe wrapper — the designer is also
360+
// mounted in tests/previews with no I18nProvider, where it degrades to the
361+
// identity fallback.
362+
const { fieldLabel, sectionLabel } = useSafeFieldLabel();
363+
const objectName = objectNameProp || (typeof draft.name === 'string' ? draft.name : '');
364+
const fieldLabelOf = React.useCallback(
365+
(entry: FieldEntry) => {
366+
const fallback = String(entry.def.label ?? entry.name);
367+
return objectName ? fieldLabel(objectName, entry.name, fallback) : fallback;
368+
},
369+
[objectName, fieldLabel],
370+
);
371+
337372
// Column count mirrors the real form (objectui#2578): derived ONCE from the
338373
// object's editable field count and applied to every section, so the layout
339374
// designer reads at the same density end users see. Each section's container
@@ -347,10 +382,13 @@ export function ObjectFormDesigner({
347382
const containerOrder = React.useMemo(() => [...groups.map((g) => cid(g.key)), cid(UNGROUPED)], [groups]);
348383
const labelOf = React.useMemo(() => {
349384
const m = new Map<string, string>();
350-
for (const g of groups) m.set(cid(g.key), g.label || g.key);
385+
for (const g of groups) {
386+
const fallback = g.label || g.key;
387+
m.set(cid(g.key), objectName ? sectionLabel(objectName, g.key, fallback) : fallback);
388+
}
351389
m.set(cid(UNGROUPED), t('engine.studio.designer.ungrouped', locale));
352390
return m;
353-
}, [groups, locale]);
391+
}, [groups, locale, objectName, sectionLabel]);
354392

355393
// Derive container → ordered field ids from the draft (editable fields only;
356394
// system/audit fields are preserved on write but never shown in the layout).
@@ -533,6 +571,7 @@ export function ObjectFormDesigner({
533571
canMoveUp={declaredIdx > 0}
534572
canMoveDown={declaredIdx >= 0 && declaredIdx < groups.length - 1}
535573
entryByName={entryByName}
574+
fieldLabelOf={fieldLabelOf}
536575
selectedField={selectedField}
537576
onSelectField={onSelectField}
538577
selected={!isUngrouped && selectedGroup === unCid(c)}
@@ -550,7 +589,7 @@ export function ObjectFormDesigner({
550589
{activeEntry ? (
551590
<div className="flex items-center gap-1.5 rounded-md border bg-background px-2 py-2 shadow-lg">
552591
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
553-
<span className="text-xs font-medium">{String(activeEntry.def.label ?? activeEntry.name)}</span>
592+
<span className="text-xs font-medium">{fieldLabelOf(activeEntry)}</span>
554593
</div>
555594
) : null}
556595
</DragOverlay>

packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2590,6 +2590,7 @@ export function DataPillar({
25902590
{formMode === 'layout' ? (
25912591
<ObjectFormDesigner
25922592
draft={objDraft}
2593+
objectName={current.name}
25932594
systemFieldNames={STUDIO_SYSTEM_FIELD_NAMES}
25942595
onChange={onPatch}
25952596
selectedField={fieldSel?.kind === 'field' ? fieldSel.id : null}

packages/plugin-list/src/ListView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,7 +1326,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
13261326
}
13271327

13281328
// Check for Timeline capabilities (spec config takes precedence)
1329-
if (schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
1329+
if (schema.timeline?.startDateField || (schema.timeline as any)?.dateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
13301330
resolvable.push('timeline');
13311331
}
13321332

@@ -1584,7 +1584,11 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
15841584
// Nested timeline config (spec-compliant, used by ObjectTimeline)
15851585
timeline: Object.keys(mergedTimeline).length > 0 ? mergedTimeline : undefined,
15861586
// Deprecated top-level props for backward compat
1587-
startDateField: schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
1587+
// `dateField` is the deprecated alias for `startDateField`. It was read
1588+
// from `options.timeline` but not from the spec-canonical
1589+
// `schema.timeline`, so the spec nesting + legacy key silently fell
1590+
// through to `created_at` (objectui#3129).
1591+
startDateField: schema.timeline?.startDateField || (schema.timeline as any)?.dateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
15881592
titleField: schema.timeline?.titleField || schema.options?.timeline?.titleField || 'name',
15891593
...(schema.timeline?.endDateField ? { endDateField: schema.timeline.endDateField } : {}),
15901594
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),

0 commit comments

Comments
 (0)