diff --git a/.changeset/7219-retire-viewdescription-catalog-key.md b/.changeset/7219-retire-viewdescription-catalog-key.md new file mode 100644 index 000000000..a8c7b42d8 --- /dev/null +++ b/.changeset/7219-retire-viewdescription-catalog-key.md @@ -0,0 +1,47 @@ +--- +'@object-ui/i18n': minor +--- + +Retire `useObjectLabel().viewDescription()` and the `_views..description` +catalog convention it resolved (objectui#7219, maintainer ruling 2026-09-02, +option B — enforce-or-remove). + +**Breaking for translation bundles, deliberately — and this text is the notice.** +Out-of-repo translation bundles that authored +`.objects.._views..description` cannot be seen from +this repo, so there is no census to point at and no migration script to run: +that key now resolves nowhere, and an entry left under it is simply ignored. +Nothing throws, and nothing else on that node changes. + +**What replaces it.** A list view's description has exactly ONE channel: the +`I18nLabel` value authored on the view entry itself — a string, or an inline +locale map: + +```ts +listViews: { + by_unit: { + label: 'By business unit', + description: { en: 'Open work only.', 'zh-CN': '仅未完成的工作。' }, + }, +} +``` + +`ObjectView` relays that value to the renderer and `plugin-list`'s `ListView` +resolves it against the display locale (objectui#7199, shipped before this +change), so the authored channel already works end to end. **Migration:** move +the sentence out of the translation bundle and onto the view entry as a locale +map. + +**Why removed rather than wired in.** The member was declared and resolved but +had zero callers and zero in-repo bundle usage — an entry authored under the +catalog key reached no screen. Wiring it in would have put two vocabularies on +one concept (`I18nLabel` on the entry, and the catalog key) and required a +precedence rule between them, which is the ambiguity rather than the fix. + +The two sibling members on the same node are **unaffected**: `viewLabel` and +`viewEmptyState` still resolve `_views..label` and +`_views..emptyState.{title,message}`, and the shared `viewSuffixes` key +builder they use is unchanged — only the `'description'` tail is gone. Pin tests +in `@object-ui/i18n` and `@object-ui/app-shell` were retargeted onto those two +survivors plus a case that authors the catalog `description` and asserts the +authored value is what a consumer resolves. diff --git a/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx b/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx index 556c399b9..5027b3c7f 100644 --- a/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx +++ b/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx @@ -53,6 +53,7 @@ import { useObjectLabel, isSpecTranslationData, transformSpecTranslations, + pickLocalized, } from '@object-ui/i18n'; import { ViewItemNameSchema } from '@objectstack/spec/ui'; import { mergeViewsIntoObjects } from '../providers/MetadataProvider'; @@ -66,10 +67,22 @@ const OBJECT = { name: 'showcase_contact', label: 'Contact', fields: { name: { t * `ui/views/contact.view.ts`): a default `list` with no `name`, plus one named * secondary view so the "named views are unaffected" leg rides the same fixture. */ +/** + * The `I18nLabel` description authored on the default list — the ONE channel a + * view description has after objectui#7219. Deliberately a different string + * from `ZH_PAYLOAD`'s catalog `description` below, so the two cannot be + * confused for one another at an assertion. + */ +const AUTHORED_LIST_DESCRIPTION = { + en: 'Every contact record, authored on the view.', + zh: '作者撰写:全部联系人记录。', +}; + const CONTAINER = { name: 'showcase_contact', list: { label: 'All Contacts', + description: AUTHORED_LIST_DESCRIPTION, type: 'grid', data: { provider: 'object', object: 'showcase_contact' }, columns: [{ field: 'name' }], @@ -153,7 +166,7 @@ const viewTab = (obj: any, id: string) => ({ id, ...obj.listViews[id] }); const translationArg = (view: any): string => view.name || view.id; describe('default list view identity → _views translation key (objectui#3770)', () => { - it('resolves the default list label/description/emptyState under `_views.default`', () => { + it('resolves the default list label/emptyState under `_views.default`', () => { const labels = withServerBundle(ZH_PAYLOAD); const obj = mergedObject(CONTAINER); const primaryId = defaultListViewId(obj.name, obj.list)!; @@ -163,9 +176,9 @@ describe('default list view identity → _views translation key (objectui#3770)' const entry = viewTab(obj, primaryId); expect(labels.viewLabel(obj.name, translationArg(entry), entry.label)).toBe('联系人'); - expect(labels.viewDescription(obj.name, translationArg(entry), undefined)).toBe( - '全部联系人记录', - ); + // `description` is deliberately NOT in this list any more — the catalog key + // on this same node was retired by objectui#7219 and its inertness is the + // case below. `label` and `emptyState` are the surfaces that stayed. expect( labels.viewEmptyState(obj.name, translationArg(entry), { title: 'No contacts', @@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)' ).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' }); }); + it('leaves a `_views..description` catalog entry INERT (objectui#7219)', () => { + // Ruled 2026-09-02, option B: the catalog convention + // `objects.._views..description` is retired with the + // `useObjectLabel().viewDescription()` member that resolved it. A list + // view's description has exactly one channel — the `I18nLabel` authored on + // the view entry, relayed by ObjectView (objectui#7199) and resolved at the + // render site with `pickLocalized`, the call `plugin-list`'s `ListView` + // makes on `schema.description`. + // + // ⚠️ This deliberately does more than assert the member is gone; that alone + // would be green on any tree where it never existed. `ZH_PAYLOAD` AUTHORS + // the catalog `description`, the control below proves that node is live and + // this fixture reaches it, and the authored value is a DIFFERENT string + // that arrives through the real pipeline (`expandViewContainer` → + // `mergeViewsIntoObjects`) rather than being typed in at the assertion. + const labels = withServerBundle(ZH_PAYLOAD); + const obj = mergedObject(CONTAINER); + const entry = viewTab(obj, defaultListViewId(obj.name, obj.list)!); + + // CONTROL — the `_views.default` node carrying the catalog `description` + // resolves: its `label` sibling comes back translated from the bundle. + expect(labels.viewLabel(obj.name, translationArg(entry), entry.label)).toBe('联系人'); + + // Nothing on the hook reads that node's `description`: not at runtime… + expect(Object.keys(labels)).not.toContain('viewDescription'); + // …and not in the return type, the half `@object-ui/i18n`'s changeset + // announces (`tsconfig.test.json` compiles this file, so this is checked). + // @ts-expect-error removed from the hook's return type by objectui#7219. + expect(labels.viewDescription).toBeUndefined(); + + // The authored `I18nLabel` survives the merge onto the entry ObjectView + // hands its relay… + expect(entry.description).toEqual(AUTHORED_LIST_DESCRIPTION); + // …and that is what resolves for the audience locale… + expect(pickLocalized(entry.description, 'zh')).toBe('作者撰写:全部联系人记录。'); + // …never the catalog string, which is what a wired-in catalog channel would + // have put on screen instead. + expect(pickLocalized(entry.description, 'zh')).not.toBe( + ZH_PAYLOAD.objects.showcase_contact._views.default.description, + ); + }); + it('does NOT resolve the retired `_views.list` spelling', () => { // The dialect this issue removed. A bundle authored against it must miss and // fall back to the metadata label — same as any other unknown key — so the diff --git a/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx b/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx index 63eb355a3..e95f1bfad 100644 --- a/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx +++ b/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx @@ -165,9 +165,13 @@ describe('useObjectLabel identity (objectui#5564)', () => { ); expect(Object.keys(unbound.seen[0]).sort()).toEqual(Object.keys(bound.seen[0]).sort()); - // 27 is the surface measured on the card; a new resolver must land on both - // paths at once, because there is only one path. - expect(Object.keys(unbound.seen[0])).toHaveLength(27); + // 26 is the surface measured on the card, minus `viewDescription`: the + // member and its `_views..description` catalog convention were retired + // by objectui#7219 (ruled 2026-09-02), taking the count from 27 to 26. A new + // resolver must land on both paths at once, because there is only one path + // — and a retired one leaves both at once for the same reason, which is what + // the equality above measures and this count anchors to an absolute. + expect(Object.keys(unbound.seen[0])).toHaveLength(26); expect(typeof unbound.seen[0].objectLabel).toBe('function'); expect(unbound.seen[0].objectLabel({ name: 'lead', label: 'Lead' })).toBe('Lead'); }); diff --git a/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx b/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx index 101100eca..5dbd29676 100644 --- a/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx +++ b/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'; import React from 'react'; import { I18nProvider, useObjectTranslation } from '../provider'; import { useObjectLabel } from '../useObjectLabel'; +import { pickLocalized } from '../pickLocalized'; const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement( @@ -58,13 +59,10 @@ describe('useObjectLabel().viewLabel', () => { 'Sales Pipeline', ), ).toBe('Localized pipeline'); - expect( - result.current.labels.viewDescription( - 'crm_opportunity', - 'crm_opportunity.pipeline_kanban', - 'Manage opportunities by stage', - ), - ).toBe('Localized pipeline description'); + // The bundle above also authors `description` on that same `_views` node. + // It resolves NOWHERE — that key is the retired catalog convention + // (objectui#7219), and its inertness is pinned in its own describe below, + // where the authored channel that replaced it is asserted alongside. expect( result.current.labels.viewEmptyState( 'crm_opportunity', @@ -110,6 +108,107 @@ describe('useObjectLabel().viewLabel', () => { }); }); +/** + * objectui#7219 (maintainer ruling 2026-09-02, option B): the catalog convention + * `{ns}.objects.{objectName}._views.{viewName}.description` is retired together + * with the `useObjectLabel().viewDescription()` member that resolved it. + * + * A list view's description has exactly ONE channel: the `I18nLabel` value + * authored on the view entry — a string or an inline locale map — which + * `ObjectView` relays (objectui#7199) and the render site resolves with + * `pickLocalized`, the call `plugin-list`'s `ListView` makes. The catalog key + * was declared and resolved here with zero callers and zero in-repo bundle + * usage, so an entry authored under it reached no screen; wiring it in instead + * would have put two vocabularies on one concept and required a precedence + * rule, which is the ambiguity rather than the fix. + * + * ⚠️ WHY THIS IS NOT AN ABSENCE ASSERTION. A pin that only checked the member + * is gone would be green on any tree where it never existed — including a tree + * where the resolver was quietly broken. So this case AUTHORS the catalog entry + * and then measures three things that only hold together in the ruled world: + * + * 1. CONTROL — the catalog node is live and reachable from here: `label` and + * `emptyState`, the two siblings sitting on the very same `_views` node, + * resolve out of the bundle. An instrument that answered "no translation" + * for those would make the description's silence meaningless. + * 2. Nothing on the hook reads that node's `description` — at runtime, and in + * the return TYPE (`tsconfig.test.json` compiles this file, so the + * `@ts-expect-error` below is a real check of the published contract). + * 3. The description a consumer renders is the AUTHORED value, and it is a + * DIFFERENT string from the catalog one. + * + * A reintroduced catalog channel fails this at either precedence: + * catalog-over-authored changes the resolved string (3), authored-over-catalog + * puts the member and its type back (2). + */ +describe('`_views..description` is an inert catalog entry (objectui#7219)', () => { + /** What an out-of-repo translation bundle would author under the retired key. */ + const CATALOG_DESCRIPTION = 'Catalog pipeline description — must not surface'; + /** The surviving channel: the `I18nLabel` authored on the view entry itself. */ + const AUTHORED_DESCRIPTION = { en: 'Authored pipeline description', zh: '作者撰写的视图说明' }; + + it('has no reader on the hook, and the authored value is what a consumer resolves', () => { + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + crm_opportunity: { + _views: { + pipeline_kanban: { + label: 'Localized pipeline', + // The retired catalog key, authored exactly as a bundle would. + description: CATALOG_DESCRIPTION, + emptyState: { + title: 'No localized records', + message: 'Create a localized record to begin.', + }, + }, + }, + }, + }, + }, + }, + true, + true, + ); + const { labels } = result.current; + + // 1. CONTROL — this node IS live: both surviving siblings resolve off it. + expect( + labels.viewLabel('crm_opportunity', 'crm_opportunity.pipeline_kanban', 'Sales Pipeline'), + ).toBe('Localized pipeline'); + expect( + labels.viewEmptyState('crm_opportunity', 'crm_opportunity.pipeline_kanban', { + title: 'No opportunities', + message: 'Create one to begin.', + }), + ).toEqual({ + title: 'No localized records', + message: 'Create a localized record to begin.', + }); + + // 2. Nothing on the hook reads that node's `description` — at runtime… + expect(Object.keys(labels)).not.toContain('viewDescription'); + // …and not in the return type either, which is the half the changeset + // announces to consumers. + // @ts-expect-error removed from the hook's return type by objectui#7219. + expect(labels.viewDescription).toBeUndefined(); + + // 3. What a consumer renders is the AUTHORED value on the view entry, + // through the same `pickLocalized` call `ListView` makes… + const viewEntry = { name: 'pipeline_kanban', description: AUTHORED_DESCRIPTION }; + expect(pickLocalized(viewEntry.description, 'en')).toBe('Authored pipeline description'); + // …and never the catalog string authored on the same node above. + expect(pickLocalized(viewEntry.description, 'en')).not.toBe(CATALOG_DESCRIPTION); + }); +}); + /** * objectstack#5164 ruling A (2026-08-06): the canonical `_views` translation key * is the runtime view identity's BARE name. The extractor now derives it from the @@ -122,7 +221,8 @@ describe('useObjectLabel().viewLabel', () => { * * These pin BOTH directions of the narrowing: the bare key resolves, and the * prefixed spelling falls through to the metadata default on every surface that - * goes through `viewSuffixes` (label / description / emptyState). + * goes through `viewSuffixes` (label / emptyState — `description` is no longer + * one of them, objectui#7219). */ describe('useObjectLabel() view keys — bare-key-only resolution (objectui#3502)', () => { afterEach(() => { @@ -170,13 +270,6 @@ describe('useObjectLabel() view keys — bare-key-only resolution (objectui#3502 'Sales Pipeline', ), ).toBe('Sales Pipeline'); - expect( - result.current.labels.viewDescription( - 'crm_opportunity', - 'crm_opportunity.pipeline_kanban', - 'Manage opportunities by stage', - ), - ).toBe('Manage opportunities by stage'); expect( result.current.labels.viewEmptyState( 'crm_opportunity', diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index b7f18d897..0834ec2f1 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -481,15 +481,27 @@ export function useObjectLabel() { viewLabel: (objectName: string, viewName: string, fallback: string) => resolve(viewSuffixes(objectName, viewName, 'label'), fallback), - /** - * Resolve translated list-view description. - * Convention: `{ns}.objects.{objectName}._views.{viewName}.description`. + /* + * There is deliberately NO `viewDescription` member here, and no + * `{ns}.objects.{objectName}._views.{viewName}.description` convention for + * it to resolve (objectui#7219, maintainer ruling 2026-09-02, option B). + * + * A list view's description has exactly ONE channel: the `I18nLabel` value + * authored on the view entry -- a string or an inline locale map -- which + * `ObjectView` relays and the render site resolves with `pickLocalized` + * (objectui#7199). The catalog key used to be declared and resolved right + * here, between its two wired-up siblings, but had zero callers and zero + * in-repo bundle usage: a bundle entry written under it reached no screen. + * + * Wiring it in instead was weighed and NOT taken -- two vocabularies for + * one concept plus a precedence rule is the ambiguity, not the fix. Leaving + * it declared and unfulfilled was not taken either; the standing rule is + * remove, not phase out, when a surface has measured zero use. + * + * `viewSuffixes` is NOT retired with it: `viewLabel` above and + * `viewEmptyState` below share that helper and keep resolving. Only the + * `'description'` tail passed to it is gone. */ - viewDescription: (objectName: string, viewName: string, fallback?: string) => { - const fb = fallback ?? ''; - const resolved = resolve(viewSuffixes(objectName, viewName, 'description'), fb); - return resolved || undefined; - }, /** * Resolve translated list-view emptyState. Returns a {title, message}