Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/29153-field-help-on-inline-help-text.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': patch
---
Expand All @@ -10,5 +10,5 @@
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.
14 changes: 14 additions & 0 deletions .changeset/29286-field-help-description-slot.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 52 additions & 7 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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([]);
});
Expand All @@ -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('已启用双因素认证');
});
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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 = '由评分规则计算,不可手动编辑。';
Expand All @@ -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.' },
},
Expand All @@ -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: '个人客户请留空。' },
},
},
},
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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.';
Expand Down
79 changes: 52 additions & 27 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -4126,6 +4128,18 @@ function valueOverridesPackagedBase(base: unknown, key: string, value: unknown):
return (base as Record<string, unknown>)[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<string, unknown> | 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
Expand Down Expand Up @@ -4325,8 +4339,8 @@ function packagedRulesByName(packagedBase: unknown): Map<string, Record<string,
/**
* Apply the active locale to an object metadata document. Translates the
* object's `label` / `pluralLabel` / `description`, walks each field to
* translate its `label`, its `description` and authored `inlineHelpText`
* (both from the bundle's field `help` entry — see below), and per-option
* translate its `label`, its authored `inlineHelpText` or else its
* `description` (from the bundle's field `help` entry — see below), and per-option
* `label`s, walks any
* inline-declared `actions` through {@link translateAction}, and overlays the
* copy of each EMBEDDED `listViews` entry from the `_views` keys the i18n
Expand Down Expand Up @@ -4382,34 +4396,42 @@ function packagedRulesByName(packagedBase: unknown): Map<string, Record<string,
* Each `validations[].message` follows it too, judged against the packaged
* rule of the same name — see {@link translateObjectValidations}.
*
* ## A field's translated help — on `description`, and on an authored `inlineHelpText` — ADR-0029 D9.2a
* ## A field's translated help — on the slot the entry was seeded from — ADR-0029 D9.2a
*
* The bundle addresses a field's help as `objects.<object>.fields.<field>.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
Expand Down Expand Up @@ -4459,22 +4481,25 @@ export function translateObject<T extends ObjectLike>(
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
Expand Down
Loading