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
36 changes: 36 additions & 0 deletions .changeset/translation-refs-container-default-form-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/lint": patch
---

fix(lint): `translation-target-unknown` reads a view container's DEFAULT `form.sections` (#5415)

`validate-translation-references` derives the `_sections` names an object may
legally be translated by from a list of anchors: `fieldGroups[].key`, the named
sections on `listViews.*` / `formViews.*`, the named sections on a page's
`record:details` component, and the view record's own `sections`. The list was
missing one: the view CONTAINER's **default form** — the `form` that
`defineView({ list, form, formViews })` declares and that `ObjectForm` renders
when no named form view is asked for.

`collectViewRecord` iterated `['listViews', 'formViews']`, and `view.form` is
neither of those nor the record's own `sections`, so `view.form.sections[].name`
contributed **nothing** to the fact set. The renderer resolves those headings
through exactly the same `sectionLabel(object, section.name, …)` convention as
any named form view, so a bundle that correctly translated one of them was
reported as keyed to a section "which nothing on object X declares", with a hint
advising the author to delete a translation that renders. On the in-repo
showcase contact surface — whose object declares `field.group` and no
`fieldGroups[]`, so the default form is its **only** section anchor — all four
headings were in that state, and the hint went as far as "declares no named
section at all".

The default form now feeds the same section collector as `formViews.*`, bound
by `bindingOf(view.form) ?? listBinding` — i.e. `form.data.object` first, then
the record-level object, then the list beside it — which is the resolution the
CLI i18n walker performs for the same surface, so the rule that DEMANDS a key
and the rule that ACCEPTS one agree on which object a heading belongs to. Each
anchor is now a call into one collector rather than its own copy of the loop.

Nothing tightens: an unnamed section is still untranslatable (it has no stable
key to look up), and a `_sections` key no anchor declares is still reported —
now with the real anchors enumerated in the hint.
134 changes: 134 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
TRANSLATION_TARGET_UNKNOWN,
TRANSLATION_OPTION_KEY_UNKNOWN,
} from './validate-translation-references.js';
// Real shipped metadata — see the `#5415` describe block for why this is
// imported rather than reduced by hand.
import { Contact } from '../../../examples/app-showcase/src/data/objects/contact.object.js';
import { ContactViews } from '../../../examples/app-showcase/src/ui/views/contact.view.js';

/** A stack shaped like the HotCRM lead surface: fields, options, a view, an action. */
const leadStack = (translations: unknown[]) => ({
Expand Down Expand Up @@ -532,4 +536,134 @@ describe('validateTranslationReferences — section anchors', () => {
expect(findings).toHaveLength(1);
expect(findings[0].hint).toContain('declares no named section at all');
});

// #5415. `collectViewRecord` walked `['listViews', 'formViews']` and the
// record's own `sections`; the CONTAINER's default form — `defineView({ form:
// … })`, the one `ObjectForm` renders when no named form view is asked for —
// was in neither, so its named sections contributed nothing and a correct
// translation of a heading that DOES render was reported as an unknown
// target.
it('resolves a section named on the container default `form`', () => {
const stack = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: { type: 'grid', name: 'all_leads', data: { provider: 'object', object: 'crm_lead' } },
form: {
type: 'simple',
data: { provider: 'object', object: 'crm_lead' },
sections: [{ name: 'contact_info', label: 'Contact Info' }],
},
},
],
translations: [
{ en: { objects: { crm_lead: { label: 'Lead', _sections: { contact_info: { label: 'Contact' } } } } } },
],
};
expect(validateTranslationReferences(stack)).toEqual([]);
});

it('binds the default `form` by its OWN data, not by the list beside it', () => {
// Two objects in one record: the list shows leads, the form edits contacts.
// The section belongs to whatever `form.data.object` says — the same
// resolution the CLI i18n walker's `viewObjectName` performs.
//
// Both directions are asserted in ONE stack on purpose. "crm_lead is still
// reported" alone would pass just as well if the default form contributed
// NOTHING (the pre-#5415 behaviour) — it is the `crm_contact` half, which
// resolves only once the form is collected under its own binding, that
// makes the pair falsifiable.
const stack = (objectName: string) => ({
objects: [
{ name: 'crm_lead', fields: { name: { type: 'text' } } },
{ name: 'crm_contact', fields: { name: { type: 'text' } } },
],
views: [
{
list: { type: 'grid', name: 'all_leads', data: { provider: 'object', object: 'crm_lead' } },
form: {
type: 'simple',
data: { provider: 'object', object: 'crm_contact' },
sections: [{ name: 'contact_info', label: 'Contact Info' }],
},
},
],
translations: [
{ en: { objects: { [objectName]: { label: 'X', _sections: { contact_info: { label: 'Contact' } } } } } },
],
});

expect(validateTranslationReferences(stack('crm_contact'))).toEqual([]);

const findings = validateTranslationReferences(stack('crm_lead'));
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._sections.contact_info');
});
});

/**
* #5415, pinned against the metadata the repo actually ships.
*
* `examples/app-showcase` is imported here rather than reduced by hand on
* purpose: the defect was an anchor MISSING from a list, and a hand-written
* fixture can only pin the anchors whoever wrote it remembered. The showcase
* contact surface is the exact shape that exposed it —
*
* - the object declares `field.group` and NO `fieldGroups[]`, so the object
* side contributes no section anchor at all;
* - the container's default `form` names all four sections;
* - `formViews.create` names none of its own (a sparse create override with
* one unnamed section) — which is what keeps the "still reports a real
* unknown" control below honest.
*
* So every `_sections` key this surface legitimately carries comes from
* `view.form.sections[].name`, and nothing else.
*/
describe('validateTranslationReferences — the showcase contact surface (#5415)', () => {
const showcaseContactStack = (translations: unknown[]) => ({
objects: [Contact],
views: [ContactViews],
translations,
});

const sectionBundle = (sections: Record<string, unknown>) => [
{ 'zh-CN': { objects: { showcase_contact: { _sections: sections } } } },
];

it('accepts every section the default form names', () => {
// `ObjectForm` renders these four headings and resolves each through
// `sectionLabel(object, section.name, …)` — translating them is correct.
const findings = validateTranslationReferences(
showcaseContactStack(
sectionBundle({
contact: { label: '联系方式' },
work: { label: '工作' },
status: { label: '状态' },
notes: { label: '备注' },
}),
),
);
expect(findings).toEqual([]);
}, 60_000);

it('still reports a section name nothing declares, and names the real ones', () => {
// The over-widening control: `contract` is a typo of `contact`, and
// `who_is_this` is the LABEL of `formViews.create`'s unnamed section — an
// unnamed section is not translatable, so neither key may resolve.
const findings = validateTranslationReferences(
showcaseContactStack(sectionBundle({ contract: { label: '合同' }, who_is_this: { label: '这是谁' } })),
);
expect(findings).toHaveLength(2);
expect(findings.map((f) => f.rule)).toEqual([TRANSLATION_TARGET_UNKNOWN, TRANSLATION_TARGET_UNKNOWN]);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.showcase_contact._sections.contract',
'translations[0]["zh-CN"].objects.showcase_contact._sections.who_is_this',
]);
// The hint now enumerates the anchors the object really has, instead of
// claiming it "declares no named section at all".
for (const finding of findings) {
expect(finding.hint).toContain('Declared sections: contact, notes, status, work');
expect(finding.hint).not.toContain('declares no named section at all');
}
}, 60_000);
});
56 changes: 38 additions & 18 deletions packages/lint/src/validate-translation-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ function emptyFacts(): ObjectFacts {
* at the record root. A record-level lookup alone resolves to nothing on
* the canonical shape, which silently drops the whole record — a rule that
* then reports every view key the app ships.
*
* A third thing was learned later, from the showcase (#5415): the container's
* DEFAULT form (`form`) is a section anchor too. It is not one of the
* `formViews.*` entries and it is not the record's own `sections` either, so
* its named sections contributed NOTHING to the fact set — and a
* bundle that correctly translated one of them was reported as keyed to a
* section "nothing declares", with a hint advising the author to delete a
* translation that renders. `ObjectForm` reads that form and resolves its
* headings through the same `sectionLabel(object, section.name, …)` convention
* as any named form view, so every anchor below feeds ONE collector: the list
* of anchors is now a list of call sites, not four copies of a loop that can
* drift apart one at a time.
*/
function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => ObjectFacts): void {
const recordObject = viewObjectName(view);
Expand All @@ -228,6 +240,22 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec
if (objectName && name) factsFor(objectName).views.add(name);
};

/**
* Register the `_sections` names one form-ish container declares.
*
* Form sections carry an OPTIONAL `name` that exists purely for the
* `_sections` lookup (`ui/view.zod.ts`: "Stable section identifier for i18n
* lookup"). A section without one cannot be translated at all, so it
* contributes nothing here.
*/
const addSections = (container: AnyRec, binding: string | undefined) => {
if (!binding) return;
for (const section of asArray(container.sections)) {
const sectionName = strName(section.name);
if (sectionName) factsFor(binding).sections.add(sectionName);
}
};

const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined;
if (isRec(view.list)) addView(listBinding, strName(view.list.name));
addView(recordObject ?? listBinding, strName(view.name));
Expand All @@ -240,27 +268,19 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec
const binding = bindingOf(sub) ?? listBinding;
addView(binding, subKey);
addView(binding, strName(sub.name));

// Form sections carry an OPTIONAL `name` that exists purely for the
// `_sections` lookup (`ui/view.zod.ts`: "Stable section identifier for
// i18n lookup"). A section without one cannot be translated at all, so
// it contributes nothing here.
if (binding) {
for (const section of asArray(sub.sections)) {
const sectionName = strName(section.name);
if (sectionName) factsFor(binding).sections.add(sectionName);
}
}
addSections(sub, binding);
}
}

const sectionBinding = recordObject ?? listBinding;
if (sectionBinding) {
for (const section of asArray(view.sections)) {
const sectionName = strName(section.name);
if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
}
}
// The container's default form — the one `defineView({ form: … })` declares
// and `ObjectForm` renders when no named form view is asked for. Bound the
// way the CLI i18n walker's `viewObjectName` resolves `view.form.data.object`
// (#5415), so the two agree on which object the headings belong to.
// Deliberately sections only: the default form has no map key, and whether it
// contributes a `_views` name is the neighbouring question #5164 owns.
if (isRec(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding);

addSections(view, recordObject ?? listBinding);
}

/** The object a view (or one of its containers) binds to, across the shapes it is authored in. */
Expand Down
Loading