From 22206c34fdee4844970b166e00709d18c1d03933 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:36:51 +0000 Subject: [PATCH 1/2] fix(spec): serve a field's translated help on its authored inlineHelpText The i18n extractor scaffolds objects.O.fields.F.help from help ?? inlineHelpText ?? description and the console draws inlineHelpText || description, but translateObject served the entry on description only, so a field authoring inlineHelpText kept showing its source text in every locale. The entry is now served on the authored, non-empty inlineHelpText too, under the same per-slot packaged-base divergence rule (ADR-0029 D9.2a); description keeps today's behaviour. Claude-Session: https://claude.ai/code/session_01ADHkmStM2XRLNZKJCmbBaA Co-authored-by: Claude --- .../spec/src/system/i18n-resolver.test.ts | 162 +++++++++++++++++- packages/spec/src/system/i18n-resolver.ts | 80 ++++++--- 2 files changed, 208 insertions(+), 34 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 8d2a470395c..a9121ee7b04 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -3923,6 +3923,7 @@ describe('translateObject — catalog vs explicit override', () => { // ──────────────────────────────────────────────────────────────────────────── // translateObject — a field's translated help is served on `description` (#21948) +// and on an authored `inlineHelpText` (#29153) // ──────────────────────────────────────────────────────────────────────────── import { FieldSchema } from '../data/field.zod'; @@ -3937,6 +3938,12 @@ import { FieldSchema } from '../data/field.zod'; * The entry is served on `description` now, under ADR-0029 D9.2a: the catalog * applies only while the served `description` still equals the packaged * field's (`valueOverridesPackagedBase`), and with no packaged base it applies. + * + * #29153 — the extractor scaffolds the entry from `help ?? inlineHelpText ?? + * description` and the console draws `inlineHelpText || description`, so a + * field authoring `inlineHelpText` showed its source text over a translation + * served on `description` alone. The entry is served on that authored + * `inlineHelpText` too, under the same per-slot divergence rule. */ describe('translateObject — a field\'s translated help is served on `description`', () => { const TWO_FACTOR_EN = @@ -3975,7 +3982,9 @@ describe('translateObject — a field\'s translated help is served on `descripti sys_user: { fields: { two_factor_enabled: { label: 'Two-Factor Enabled', help: TWO_FACTOR_EN }, - phone: { label: 'Phone', help: 'Contact number.' }, + // The extractor's source is `help ?? inlineHelpText ?? description`, + // so a field authoring `inlineHelpText` scaffolds THAT text here. + phone: { label: 'Phone', help: 'Include the country code.' }, }, }, }, @@ -3985,7 +3994,7 @@ describe('translateObject — a field\'s translated help is served on `descripti sys_user: { fields: { two_factor_enabled: { label: '已启用双因素认证', help: TWO_FACTOR_ZH }, - phone: { label: '电话', help: '联系电话。' }, + phone: { label: '电话', help: '请填写国家区号。' }, }, }, }, @@ -4016,10 +4025,10 @@ describe('translateObject — a field\'s translated help is served on `descripti expect('help' in field).toBe(false); }); - it('a field declaring both `description` and `inlineHelpText` gets the translation on `description`; `inlineHelpText` is untouched', () => { + it('a field declaring both `description` and `inlineHelpText` gets the translation on both, the slot the console draws first included', () => { const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).phone; - expect(field.description).toBe('联系电话。'); - expect(field.inlineHelpText).toBe('Include the country code.'); + expect(field.inlineHelpText).toBe('请填写国家区号。'); + expect(field.description).toBe('请填写国家区号。'); expect('help' in field).toBe(false); expect(unrecognizedKeys(field)).toEqual([]); }); @@ -4036,7 +4045,7 @@ describe('translateObject — a field\'s translated help is served on `descripti } // Judged per field: the undiverged sibling is still translated… const zh = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })); - expect(zh.phone.description).toBe('联系电话。'); + expect(zh.phone.description).toBe('请填写国家区号。'); // …and the field `label` stays a flat `catalog ?? document`, as before. expect(zh.two_factor_enabled.label).toBe('已启用双因素认证'); }); @@ -4079,7 +4088,7 @@ describe('translateObject — a field\'s translated help is served on `descripti const out = translateObject(asArray(served) as any, BUNDLE, { locale: 'zh-CN', packagedBase: asArray(PACKAGED) }); const byName = Object.fromEntries((out.fields as any[]).map((f) => [f.name, f])); expect(byName.two_factor_enabled.description).toBe('Edited by the tenant.'); - expect(byName.phone.description).toBe('联系电话。'); + expect(byName.phone.description).toBe('请填写国家区号。'); expect((out.fields as any[]).some((f) => 'help' in f)).toBe(false); }); @@ -4090,6 +4099,145 @@ describe('translateObject — a field\'s translated help is served on `descripti }); }); +/** + * #29153 — one derivation in three places: the extractor scaffolds + * `fields.F.help` from `help ?? inlineHelpText ?? description`, the console + * draws `inlineHelpText || description`, and the resolver serves the entry on + * the slot the console draws first. The matrix below is that agreement, by + * authored slot. + */ +describe('translateObject — a field\'s translated help reaches the slot the console draws first', () => { + /** The console's reader (`fieldHelpText` in `@object-ui/fields`), restated. */ + const consoleHelpLine = (field: Record) => + (field.inlineHelpText as string | undefined) || (field.description as string | undefined) || undefined; + + /** The showcase's declaration (`examples/app-showcase`, `showcase_contact.lead_score`). */ + const SHOWCASE = { + name: 'showcase_contact', + label: 'Contact', + fields: { + lead_score: { + name: 'lead_score', + type: 'number', + label: 'Lead score', + readonly: true, + inlineHelpText: 'Computed by scoring rules — not user-editable.', + }, + email: { name: 'email', type: 'email', label: 'Email', description: 'Primary address.' }, + nickname: { name: 'nickname', type: 'text', label: 'Nickname', inlineHelpText: 'What friends call them.' }, + blank_inline: { name: 'blank_inline', type: 'text', label: 'Blank', inlineHelpText: '', description: 'Shown instead.' }, + }, + }; + const LEAD_SCORE_ZH = '由评分规则计算,不可手动编辑。'; + const BUNDLE: TranslationBundle = { + en: { + objects: { + showcase_contact: { + fields: { + lead_score: { label: 'Lead score', help: 'Computed by scoring rules — not user-editable.' }, + email: { label: 'Email', help: 'Primary address.' }, + blank_inline: { label: 'Blank', help: 'Shown instead.' }, + }, + }, + }, + } as any, + 'zh-CN': { + objects: { + showcase_contact: { + fields: { + lead_score: { label: '线索评分', help: LEAD_SCORE_ZH }, + email: { label: '邮箱', help: '主要地址。' }, + // `nickname` has a label entry and NO help entry. + nickname: { label: '昵称' }, + blank_inline: { label: '空白', help: '改为显示此项。' }, + }, + }, + }, + } as any, + }; + + const clone = (value: V): V => JSON.parse(JSON.stringify(value)); + const fieldsOf = (out: unknown) => (out as { fields: Record> }).fields; + const serve = (doc: unknown, locale: string, packagedBase: unknown = SHOWCASE) => + fieldsOf(translateObject(clone(doc) as any, BUNDLE, { locale, packagedBase })); + + it('serves the showcase `lead_score` translation on its authored `inlineHelpText`, which the console draws', () => { + const field = serve(SHOWCASE, 'zh-CN').lead_score; + expect(field.inlineHelpText).toBe(LEAD_SCORE_ZH); + expect(field.description).toBe(LEAD_SCORE_ZH); + expect(consoleHelpLine(field)).toBe(LEAD_SCORE_ZH); + expect('help' in field).toBe(false); + const parsed = FieldSchema.safeParse(field); + expect(parsed.success ? [] : parsed.error.issues.filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + + it('CONTROL: the source locale serves the authored `inlineHelpText` untouched', () => { + const field = serve(SHOWCASE, 'en').lead_score; + expect(field.inlineHelpText).toBe(SHOWCASE.fields.lead_score.inlineHelpText); + expect(consoleHelpLine(field)).toBe(SHOWCASE.fields.lead_score.inlineHelpText); + }); + + it('CONTROL: a locale with no `help` entry for the field serves its authored `inlineHelpText` untouched', () => { + const field = serve(SHOWCASE, 'zh-CN').nickname; + expect(field.label).toBe('昵称'); + expect(field.inlineHelpText).toBe('What friends call them.'); + expect('description' in field).toBe(false); + }); + + it('never takes another field\'s `help` entry', () => { + // `nickname` sits beside two fields that carry zh-CN help entries. + const fields = serve(SHOWCASE, 'zh-CN'); + expect(fields.nickname.inlineHelpText).toBe('What friends call them.'); + expect(fields.email.inlineHelpText).toBeUndefined(); + }); + + it('a field with no `inlineHelpText` gains none: the translation stays on `description`, which the console draws', () => { + const field = serve(SHOWCASE, 'zh-CN').email; + expect('inlineHelpText' in field).toBe(false); + expect(field.description).toBe('主要地址。'); + expect(consoleHelpLine(field)).toBe('主要地址。'); + }); + + it('an empty `inlineHelpText` reads as absent, as in the console: it stays empty and `description` carries the translation', () => { + const field = serve(SHOWCASE, 'zh-CN').blank_inline; + expect(field.inlineHelpText).toBe(''); + expect(field.description).toBe('改为显示此项。'); + expect(consoleHelpLine(field)).toBe('改为显示此项。'); + }); + + it('keeps an `inlineHelpText` that diverged from the packaged field, in every locale', () => { + const served = clone(SHOWCASE); + served.fields.lead_score.inlineHelpText = 'Edited by the tenant.'; + for (const locale of ['zh-CN', 'en']) { + const field = fieldsOf( + translateMetadataDocument('object', clone(served), BUNDLE, { locale, packagedBase: SHOWCASE }), + ).lead_score; + expect(field.inlineHelpText).toBe('Edited by the tenant.'); + expect(consoleHelpLine(field)).toBe('Edited by the tenant.'); + } + }); + + it('an `inlineHelpText` on a field the packaged base does not declare counts as diverged', () => { + const served = clone(SHOWCASE) as any; + served.fields.extra = { name: 'extra', type: 'text', label: 'Extra', inlineHelpText: 'Added by an extension.' }; + const bundle = clone(BUNDLE) as any; + bundle['zh-CN'].objects.showcase_contact.fields.extra = { help: '扩展添加。' }; + const field = fieldsOf(translateObject(served, bundle, { locale: 'zh-CN', packagedBase: SHOWCASE })).extra; + expect(field.inlineHelpText).toBe('Added by an extension.'); + }); + + it('with no packaged base, the catalog applies to `inlineHelpText` too', () => { + const field = fieldsOf(translateObject(clone(SHOWCASE), BUNDLE, { locale: 'zh-CN' })).lead_score; + expect(field.inlineHelpText).toBe(LEAD_SCORE_ZH); + }); + + it('does not mutate the input document', () => { + const doc = clone(SHOWCASE); + translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: SHOWCASE }); + expect(doc).toEqual(SHOWCASE); + }); +}); + // ════════════════════════════════════════════════════════════════════════════ // Screen-flow copy resolvers (#7646 / #11287) // ════════════════════════════════════════════════════════════════════════════ diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a1b5d316242..0241a7c5ab2 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3599,7 +3599,8 @@ export interface ObjectLike { * Minimal field shape consumed by `translateObject`. It names only keys * `FieldSchema` declares: there is no `help` here, because a field spells its * help text `description` / `inlineHelpText`. The bundle's field `help` entry - * is served on `description` (see {@link translateObject}). + * is served on `description`, and on `inlineHelpText` when the field authors + * one (see {@link translateObject}). */ export interface ObjectFieldLike { name?: string; @@ -4324,8 +4325,9 @@ function packagedRulesByName(packagedBase: unknown): Map.fields..help` - * (`FieldTranslationSchema.help`), and the i18n extractor writes that entry - * from the field's `description` (`packages/cli/src/utils/i18n-extract.ts`; - * its other source, a field `help`, is a key `FieldSchema` refuses). So the - * entry is the translation of `description`, and it is served THERE. It is - * ⛔ never served on a `help` key, which `FieldSchema` does not declare: the - * served field def then carried a key the contract refuses, every consumer - * that reads only declared keys rendered the source-language `description`, - * and the console's ingestion step warned once per such field (#21948). It is - * ⛔ not served on `inlineHelpText` either, which the extractor never reads. - * - * `description` is a key the catalog had never touched, so it follows the - * rule above, as every string this translator added since does: the catalog - * applies only while the served field's `description` still equals the same - * field's in {@link TranslateDocumentOptions.packagedBase}, judged by + * (`FieldTranslationSchema.help`). One derivation decides which text that + * entry translates, and three places hold it: + * + * - the i18n extractor writes the entry from `help ?? inlineHelpText ?? + * description` (`packages/cli/src/utils/i18n-extract.ts`; `help` is a key + * `FieldSchema` refuses and points at `inlineHelpText`, so no stored field + * carries it); + * - the console draws a field's help line from `inlineHelpText || + * description` (`fieldHelpText` in `@object-ui/fields`); + * - so this translator serves the entry on BOTH slots the console can draw: + * on `description`, and on `inlineHelpText` when the field authors a + * non-empty one. Serving it on `description` alone left the translation + * masked: the console read the source-language `inlineHelpText` first. + * + * A field that authors no `inlineHelpText` (absent or `''`, which the console + * reads as absent) gains no `inlineHelpText` key: the console already draws + * `description` for it. The entry is ⛔ never served on a `help` key, which + * `FieldSchema` does not declare: the served field def then carried a key the + * contract refuses, every consumer that reads only declared keys rendered the + * source-language text, and the console's ingestion step warned once per such + * field (#21948). + * + * `description` and `inlineHelpText` are keys the catalog had never touched, + * so each follows the rule above, as every string this translator added since + * does, judged per key: the catalog applies to a slot only while the served + * field's value there still equals the same field's in + * {@link TranslateDocumentOptions.packagedBase}, judged by * {@link valueOverridesPackagedBase} (⛔ never a second comparison). A field - * the base does not declare was authored after the fact, and its description - * counts as diverged; no base supplied means nothing is inferred and the + * the base does not declare was authored after the fact, and both its slots + * count as diverged; no base supplied means nothing is inferred and the * catalog applies. * * ## A field's `inlineTitle` — the inline child grid's heading @@ -4444,18 +4459,29 @@ export function translateObject( lookupObjectFieldAttr(bundle, objectName, name, 'label', opts) ?? builtinSystemFieldLabel(name, def.label, opts); if (translatedLabel) next.label = translatedLabel; - // The bundle's field `help` entry translates `description`, and is served - // there — never on an undeclared `help` — unless the served description - // diverged from the packaged field's (ADR-0029 D9.2a; see the docblock). - if (!valueOverridesPackagedBase(packagedObjectField(packagedObject, name), 'description', def.description)) { - const translatedDescription = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); - if (translatedDescription) next.description = translatedDescription; + // The bundle's field `help` entry is served on `description`, and on the + // field's own `inlineHelpText` when it authors one — the slot the console + // draws first — never on an undeclared `help`. Each slot keeps a value + // that diverged from the packaged field's (ADR-0029 D9.2a; see the docblock). + const packagedField = packagedObjectField(packagedObject, name); + const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); + if (translatedHelp) { + if (!valueOverridesPackagedBase(packagedField, 'description', def.description)) { + next.description = translatedHelp; + } + if ( + typeof def.inlineHelpText === 'string' && + def.inlineHelpText.length > 0 && + !valueOverridesPackagedBase(packagedField, 'inlineHelpText', def.inlineHelpText) + ) { + next.inlineHelpText = translatedHelp; + } } // The inline child grid's heading, under the same divergence rule. The // console's inline-subform pass copies the served value onto the parent's // form, so this is the one place it is localized. An untranslated heading // keeps its authored text — never the child's plural label in its place. - if (!valueOverridesPackagedBase(packagedObjectField(packagedObject, name), 'inlineTitle', def.inlineTitle)) { + if (!valueOverridesPackagedBase(packagedField, 'inlineTitle', def.inlineTitle)) { const translatedInlineTitle = lookupObjectFieldAttr(bundle, objectName, name, 'inlineTitle', opts); if (translatedInlineTitle) next.inlineTitle = translatedInlineTitle; } From e1451dc02f544a7024084fc622ac218a6ba8407a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:37:48 +0000 Subject: [PATCH 2/2] chore(changeset): spec patch for the translated field help on inlineHelpText Claude-Session: https://claude.ai/code/session_01ADHkmStM2XRLNZKJCmbBaA Co-authored-by: Claude --- .changeset/29153-field-help-on-inline-help-text.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/29153-field-help-on-inline-help-text.md diff --git a/.changeset/29153-field-help-on-inline-help-text.md b/.changeset/29153-field-help-on-inline-help-text.md new file mode 100644 index 00000000000..45da9b39262 --- /dev/null +++ b/.changeset/29153-field-help-on-inline-help-text.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): a field's translated help reaches its authored `inlineHelpText`, the line the console draws + +Clause-②: no +Metadata-protocol: none + +Nothing changes in what a metadata author can write. What changes is the served, translated read of an object: `translateObject` (the resolver behind `GET /api/v1/meta/object/:name` and every other localized object read) now serves a field's `objects.OBJECT.fields.FIELD.help` translation on the field's own `inlineHelpText` when the field authors one. + +- **Before.** The translation was served on `description` only. The i18n extractor scaffolds that entry from `help ?? inlineHelpText ?? description`, and the console draws a field's help line from `inlineHelpText || description`, so a field that authored `inlineHelpText` showed its source-language text in every locale and its translation never appeared. +- **Now.** A field that authors a non-empty `inlineHelpText` is served the translation there, and on `description` as before. A field without one (absent, or an empty string, which the console reads as absent) gains no `inlineHelpText` key; its translation stays on `description`, which the console already draws. +- **Unchanged.** A locale with no `help` entry for the field, and the source locale, serve the authored text. Each slot keeps a value that diverged from the packaged declaration (a tenant's own edit), judged per slot by the same rule that already guards `description`. Nothing is ever served on a `help` key.