diff --git a/.changeset/29153-field-help-on-inline-help-text.md b/.changeset/29153-field-help-on-inline-help-text.md index 45da9b39262..587d49377b1 100644 --- a/.changeset/29153-field-help-on-inline-help-text.md +++ b/.changeset/29153-field-help-on-inline-help-text.md @@ -10,5 +10,5 @@ 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. +- **Now.** A field that authors a non-empty `inlineHelpText` is served the translation there, the text the entry was scaffolded from, and keeps its source-language `description`. 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. diff --git a/.changeset/29286-field-help-description-slot.md b/.changeset/29286-field-help-description-slot.md new file mode 100644 index 00000000000..434ac62b9da --- /dev/null +++ b/.changeset/29286-field-help-description-slot.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): a field that authors both `inlineHelpText` and a different `description` keeps its source-language `description` + +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` serves a field's `objects.OBJECT.fields.FIELD.help` translation on ONE slot, the one the i18n extractor scaffolded the entry from. + +- **Before.** The translation was served on `description`. The extractor scaffolds the entry from `help ?? inlineHelpText ?? description`, so on a field that authors both a non-empty `inlineHelpText` and a different `description`, the entry translates the `inlineHelpText`, and every reader of `description` (Studio, the designer) showed that other sentence's translation. +- **Now.** A field that authors a non-empty `inlineHelpText` is served the translation there and keeps its source-language `description` (and gains none when it has none). `description` takes the translation only when neither the served field nor its packaged declaration authors a non-empty `inlineHelpText`. An empty `inlineHelpText` reads as absent, as it does in the extractor and the console. +- **Unchanged.** A field that authors only `description` is served its translation there. Each slot keeps a value that diverged from the packaged declaration (a tenant's own edit). Nothing is ever served on a `help` key. diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index a9121ee7b04..6ee5ef93cb3 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -3944,6 +3944,10 @@ import { FieldSchema } from '../data/field.zod'; * 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. + * + * #29286 — and on that `inlineHelpText` ONLY: the entry translates the text it + * was seeded from, so a field authoring both texts keeps its source-language + * `description` rather than show another sentence's translation there. */ describe('translateObject — a field\'s translated help is served on `description`', () => { const TWO_FACTOR_EN = @@ -4025,10 +4029,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 both, the slot the console draws first included', () => { + it('a field declaring both `description` and `inlineHelpText` gets the translation on `inlineHelpText` only, the slot the entry was seeded from', () => { const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).phone; expect(field.inlineHelpText).toBe('请填写国家区号。'); - expect(field.description).toBe('请填写国家区号。'); + expect(field.description).toBe('Contact number.'); expect('help' in field).toBe(false); expect(unrecognizedKeys(field)).toEqual([]); }); @@ -4045,7 +4049,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.inlineHelpText).toBe('请填写国家区号。'); // …and the field `label` stays a flat `catalog ?? document`, as before. expect(zh.two_factor_enabled.label).toBe('已启用双因素认证'); }); @@ -4088,7 +4092,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.inlineHelpText).toBe('请填写国家区号。'); expect((out.fields as any[]).some((f) => 'help' in f)).toBe(false); }); @@ -4126,6 +4130,15 @@ describe('translateObject — a field\'s translated help reaches the slot the co 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.' }, + // Authors BOTH texts, and they differ: the extractor seeds `help` from the + // `inlineHelpText`, so the entry is no translation of the `description`. + vat_number: { + name: 'vat_number', + type: 'text', + label: 'VAT number', + inlineHelpText: 'Leave blank for private customers.', + description: 'Tax id used on invoices.', + }, }, }; const LEAD_SCORE_ZH = '由评分规则计算,不可手动编辑。'; @@ -4134,7 +4147,10 @@ describe('translateObject — a field\'s translated help reaches the slot the co objects: { showcase_contact: { fields: { - lead_score: { label: 'Lead score', help: 'Computed by scoring rules — not user-editable.' }, + // No `help` for `lead_score` here: the source-locale CONTROL below + // would pass on an entry that repeats the authored text whether or + // not the translator served it. + lead_score: { label: 'Lead score' }, email: { label: 'Email', help: 'Primary address.' }, blank_inline: { label: 'Blank', help: 'Shown instead.' }, }, @@ -4150,6 +4166,7 @@ describe('translateObject — a field\'s translated help reaches the slot the co // `nickname` has a label entry and NO help entry. nickname: { label: '昵称' }, blank_inline: { label: '空白', help: '改为显示此项。' }, + vat_number: { label: '增值税号', help: '个人客户请留空。' }, }, }, }, @@ -4164,17 +4181,19 @@ describe('translateObject — a field\'s translated help reaches the slot the co 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('description' in field).toBe(false); 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', () => { + it('CONTROL: the source locale, which carries no `help` entry for the field, serves the authored `inlineHelpText` untouched, never another locale\'s', () => { const field = serve(SHOWCASE, 'en').lead_score; + expect(field.label).toBe('Lead score'); expect(field.inlineHelpText).toBe(SHOWCASE.fields.lead_score.inlineHelpText); expect(consoleHelpLine(field)).toBe(SHOWCASE.fields.lead_score.inlineHelpText); + expect('description' in field).toBe(false); }); it('CONTROL: a locale with no `help` entry for the field serves its authored `inlineHelpText` untouched', () => { @@ -4205,6 +4224,32 @@ describe('translateObject — a field\'s translated help reaches the slot the co expect(consoleHelpLine(field)).toBe('改为显示此项。'); }); + it('a field authoring both texts gets the translation on `inlineHelpText` and keeps its source-language `description`', () => { + // The entry translates the `inlineHelpText` it was seeded from; on + // `description` it would show another sentence's translation. + const field = serve(SHOWCASE, 'zh-CN').vat_number; + expect(field.inlineHelpText).toBe('个人客户请留空。'); + expect(field.description).toBe('Tax id used on invoices.'); + expect(consoleHelpLine(field)).toBe('个人客户请留空。'); + expect('help' in field).toBe(false); + }); + + it('a served `inlineHelpText` blanked over a packaged one still keeps `description` in its source language', () => { + // The entry was seeded from the PACKAGED `inlineHelpText`, so it is still + // no translation of the `description`, whatever the served field says. + const served = clone(SHOWCASE); + served.fields.vat_number.inlineHelpText = ''; + const field = serve(served, 'zh-CN').vat_number; + expect(field.inlineHelpText).toBe(''); + expect(field.description).toBe('Tax id used on invoices.'); + }); + + it('with no packaged base, a field authoring both texts still keeps its source-language `description`', () => { + const field = fieldsOf(translateObject(clone(SHOWCASE), BUNDLE, { locale: 'zh-CN' })).vat_number; + expect(field.inlineHelpText).toBe('个人客户请留空。'); + expect(field.description).toBe('Tax id used on invoices.'); + }); + 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.'; diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 0241a7c5ab2..848830f5676 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3599,13 +3599,15 @@ 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`, and on `inlineHelpText` when the field authors - * one (see {@link translateObject}). + * is served on an authored `inlineHelpText` when the field has one, and on + * `description` only when it has none (see {@link translateObject}). */ export interface ObjectFieldLike { name?: string; label?: string; description?: string; + /** `FieldSchema.inlineHelpText` — the help line the console draws first; `''` reads as absent. */ + inlineHelpText?: string; /** `FieldSchema.inlineTitle` — the inline child grid's heading on the parent's form. */ inlineTitle?: string; options?: Array<{ label?: string; value: string | number | boolean }>; @@ -4126,6 +4128,18 @@ function valueOverridesPackagedBase(base: unknown, key: string, value: unknown): return (base as Record)[key] !== value; } +/** + * Whether a field authors help text on `inlineHelpText` — a non-empty string, + * as the i18n extractor and the console's `inlineHelpText || description` + * read it (`''` is absent). {@link translateObject} asks it of the served + * field and of the packaged one to find the slot a field's `help` entry was + * seeded from. + */ +function authorsInlineHelpText(field: Record | undefined): boolean { + const value = field?.inlineHelpText; + return typeof value === 'string' && value.length > 0; +} + /** * Overlay the catalog onto an object document's EMBEDDED `listViews` — each * view's `label`, `description` and `bulkActionDefs[]` copy, the same strings @@ -4325,8 +4339,8 @@ function packagedRulesByName(packagedBase: unknown): Map.fields..help` * (`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); + * description`, an empty string reading as absent + * (`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. + * - so this translator serves the entry on the ONE slot it translates: on + * the field's non-empty `inlineHelpText` when it authors one, and on + * `description` only when it authors none. Serving it on `description` + * alone left the translation masked (the console read the source-language + * `inlineHelpText` first); serving it on both put the `inlineHelpText`'s + * translation on a `description` that says something else, which every + * reader of `description` (Studio, the designer) then showed. * * 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 + * `description` for it. A field that authors one keeps its source-language + * `description`, and gains none when it has none, until a description-specific + * bundle key exists. "Authors one" is asked of the served field AND of the + * packaged field the extractor read: a served `inlineHelpText` blanked over a + * packaged one leaves an entry that still translates the packaged + * `inlineHelpText`, never the `description`. 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 + * so the slot chosen above 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 @@ -4459,22 +4481,25 @@ 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 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). + // The bundle's field `help` entry is served on the slot the extractor + // seeded it from: the field's own `inlineHelpText` when it authors one — + // the slot the console draws first — and `description` only when neither + // the served nor the packaged field authors one; never on an undeclared + // `help`. The 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) + const servedInlineHelp = authorsInlineHelpText(def); + if (servedInlineHelp) { + if (!valueOverridesPackagedBase(packagedField, 'inlineHelpText', def.inlineHelpText)) { + next.inlineHelpText = translatedHelp; + } + } else if ( + !authorsInlineHelpText(packagedField) && + !valueOverridesPackagedBase(packagedField, 'description', def.description) ) { - next.inlineHelpText = translatedHelp; + next.description = translatedHelp; } } // The inline child grid's heading, under the same divergence rule. The