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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
47 changes: 47 additions & 0 deletions .changeset/7219-retire-viewdescription-catalog-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
'@object-ui/i18n': minor
---

Retire `useObjectLabel().viewDescription()` and the `_views.<view>.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
`<ns>.objects.<objectName>._views.<viewName>.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.<view>.label` and
`_views.<view>.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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ import {
useObjectLabel,
isSpecTranslationData,
transformSpecTranslations,
pickLocalized,
} from '@object-ui/i18n';
import { ViewItemNameSchema } from '@objectstack/spec/ui';
import { mergeViewsIntoObjects } from '../providers/MetadataProvider';
Expand All@@ -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' }],
Expand DownExpand Up@@ -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)!;
Expand All@@ -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',
Expand All@@ -174,6 +187,48 @@ describe('default list view identity → _views translation key (objectui#3770)'
).toMatchObject({ title: '暂无联系人', message: '新建一个联系人开始。' });
});

it('leaves a `_views.<view>.description` catalog entry INERT (objectui#7219)', () => {
// Ruled 2026-09-02, option B: the catalog convention
// `objects.<object>._views.<view>.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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<view>.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');
});
Expand Down
123 changes: 108 additions & 15 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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.<view>.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
Expand All@@ -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(() => {
Expand DownExpand Up@@ -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',
Expand Down
28 changes: 20 additions & 8 deletions packages/i18n/src/useObjectLabel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}
Expand Down
Loading