Skip to content

Commit 885a96f

Browse files
fix(app-shell,plugin-detail): titles and links that name a list of records read the plural label (objectui#11733) (#11746)
Fixes #11733 Clause-②: no ## What changed PR objectui#11728 (objectui#11696) gave `useObjectLabel()` an `objectPluralLabel(objectDef)` resolver (the translated plural, else the declared `pluralLabel`, else the singular) and used it for the object list page title, the `/data` title and the object breadcrumb. Every other place that names a LIST of an object's records now reads it too. Record-scoped text keeps `objectLabel`. | Card location | Producer | Change | |---|---|---| | 1. Related-list section titles | `RecordDetailView`, the single-relationship title | `objectPluralLabel` over the child object ("Tasks") | | 1. Related-list section titles | `deriveRelatedLists`, the title it composes when a child points at the parent through more than one field | new `listLabel` option; `RecordDetailView` passes `objectPluralLabel`, so the title reads "Opportunities · Partner Project". It was built from the child's raw, untranslated label before | | 1. Renderer fallback | `record:related_list` with no `title` (`record-related-list.tsx`) | the bundle's `objects.NAME.pluralLabel`, else the translated label, else the humanized name | | same family | `record:reference_rail` entries with no `title`: each card title and the "+ N empty (…)" line (`record-reference-rail.tsx`) | the same as the row above | | 2. Record form page breadcrumb link | `RecordFormPage` | the shared `label` is split: the link reads `listLabel` (plural); the "Create …" / "Edit …" title and the save toast keep `label` (singular) | | 3. Favorites | `ObjectView`'s star button | the saved favorite's `label` is the plural | | 3. Recent items | `useRecentItemLabel`, `object` kind | calls `objectPluralLabel` at the call site; `useNavTargetLabel` is not touched | | 4. Record back-link fallback | `ObjectView`, both builders (the list schema's `onNavigate` and the record-link bridge's `openRecord`) | `viewOriginLabel(…) \|\| objectPluralLabel(objectDef)` | | 4. Record back-link fallback | `AppContent`, a create that lands on the new record's page | the handed-over `originLabel` is the plural | ## Where it landed beyond the claim's file surface, and why The claim named `RecordDetailView`, `record-related-list.tsx`, `RecordFormPage`, `ObjectView` and the recent-item label. Three more files, each the producer of a surface the card's acceptance covers: - `packages/app-shell/src/utils/deriveRelatedLists.ts`: it produces the multi-relationship related-list title (card location 1). Left alone, the record page would read "Tasks" next to "Opportunity · Partner Project". The new option is internal: `deriveRelatedLists` is not re-exported from app-shell's package entry (`exports` is `.` only, and `src/index.ts` names no `deriveRelatedLists`), so nothing published widens. - `packages/app-shell/src/console/AppContent.tsx`: the second producer of the record page's `location.state.from.label` (card location 4), on the create path. The enumeration pin covers it. - `packages/plugin-detail/src/renderers/record-reference-rail.tsx`: two `objectLabel(` hits under the enumeration pin's `packages/plugin-detail/src`, and both name lists. `Clause-②: no` still holds: no new export and no new locale key. `plugin-detail` reaches `objectPluralLabel` through the hook it already calls (`useSafeFieldLabel()` returns `useObjectLabel()`). `record-related-list.tsx` narrows with `'objectPluralLabel' in i18n` instead of the `as any` cast its singular read used. ## The nav entry, unchanged by ruling `useNavTargetLabel`'s `object` rung still calls `objectLabel`, per step 3 of the spec's nav-label rule, as the seat ruled on objectui#11696. The recent-item `object` kind no longer asks it, and calls `objectPluralLabel` itself. The recent-item pin's last case asserts that `useNavTargetLabel` still answers "Project" / "项目" for the same object. ## Enumeration pin `git grep -n 'objectLabel(' -- packages/app-shell/src packages/plugin-detail/src`, tests excluded, at head `50e8f98`: 12 hits (20 at base `17acfbb`), 0 of them in `plugin-detail`. `apps/console/src` has 0. Each remaining hit: | File | Symbol | Classification | |---|---|---| | `AppContent.tsx` | `handleCrudSuccess`'s `label` (save toasts `form.updateSuccess` / `form.createSuccess`) | record-scoped: the toast for the one saved record | | `AppContent.tsx` | the child-task branch of `handleRecordFormSuccess` (the same toasts) | record-scoped | | `AppContent.tsx` | the global record-form modal's `title` (`form.editTitle` / `form.createTitle`), 2 hits | record-scoped: "Edit …" / "Create …" | | `useNavTargetLabel.ts` | `case 'object'` | the nav-entry inheritance (spec step 3), unchanged by the seat's ruling on objectui#11696 | | `ObjectDataPage.tsx` | `NavigationOverlay` `title` | record-scoped: the record drawer title | | `ObjectView.tsx` | `useObjectActions({ objectLabel })` | record-scoped: the delete confirmation and toasts. The label is attributive to "record(s)": "Delete 3 Project records?", "Project deleted successfully" | | `ObjectView.tsx` | the `ImportWizard` `objectLabel` prop | an object-kind action phrase, the "New …" class: the "Import {{object}}" title and the template file name. Kept singular, as PR objectui#11728's contract review classified it. It is a one-line change if the seat reads it as naming a list | | `ObjectView.tsx` | two `NavigationOverlay` `title`s | record-scoped: the record drawer title | | `RecordDetailView.tsx` | `recordDelete.confirmCopy({ label })` in the header's delete action | record-scoped: names the one record | | `RecordFormPage.tsx` | `const label` | record-scoped: it now feeds only the title and the save toast | ## Tests Seven pins, each named after this issue. In every `zh-CN` fixture `pluralLabel` is a different word from `label` (`项目` / `项目清单`), so an assertion can tell which key was read. Each has a no-plural control. - `ObjectView.listLinksPlural-11733.test.tsx`: a real `ObjectView` in a real i18next instance (the 11696 harness). It reads the favorite the star button saves, and the way-back label each builder hands the record route (a list probe calls `schema.onNavigate` and the bridge's `openRecord`). The fixture's list view has no label, which is when the fallback applies. - `RecordFormPage.listLinkPlural-11733.test.tsx`: the link reads the plural; the title and toast read the singular. Cells: en create, en edit, zh-CN, control. - `RecordDetailView.relatedListTitlePlural-11733.test.tsx`: single- and multi-relationship titles, en and zh-CN, control. - `useRecentItemLabel.objectPlural-11733.test.tsx`: en, zh-CN, control, the machine-name floor, and the unchanged nav-entry inheritance. - `AppContent.createLandingOriginPlural-11733.test.tsx`: the `AppContent.declaredVisibilityKeys` harness with a real i18next instance. The modal's `onSuccess` is called with a saved id for a page-surface object, and the record route's probe shows the state it received. The save runs outside `act`: an awaited React 19 `act` there did not return before the test timeout. - `record-related-list.titlePlural-11733.test.tsx` and `record-reference-rail.titlePlural-11733.test.tsx`: plurals from the bundle only (these renderers hold the object's name, not its definition), en and zh-CN, with controls. ## Reverse verification (on `a46d4a9`, the implementation commit) Mutations went through objectstack's `scripts/ablation-replace.mjs`. Each anchor's declared hit count was met and each file's blob changed. A script-held trap restored every path, proven by each blob equal to `HEAD` and an empty `git diff HEAD`. The direction was written down before each run. - **Leg A**: every list-naming site back to the singular (11 anchors in 8 files). Predicted 19 red and 12 green. Observed `Tests 19 failed | 12 passed (31)`. The failures read `expected 'Project' to be 'Projects'`, `expected '项目' to be '项目清单'`, and the form link `'Project'` / `'项目'`. Every control stayed green. - **Leg B**: only `deriveRelatedLists` back to the raw label. Predicted 2 red and 1 green on the RecordDetailView pin. Observed `Tests 2 failed | 1 passed (3)`: "Unable to find an element with the text: Opportunities · Primary Project" and the same for "商机列表 · Primary Project". `50e8f98` changes only the types of the AppContent pin's modal probe. ## Gates | Command | Head | Exit | The gate's own verdict | |---|---|---|---| | `pnpm exec vitest run` over the 7 new pins | `50e8f98` | 0 | `Test Files 7 passed (7)` · `Tests 31 passed (31)` | | `pnpm exec vitest run packages/plugin-detail/` | `a46d4a9` | 0 | `Test Files 243 passed \| 1 skipped (244)` · `Tests 2401 passed \| 8 skipped (2409)` | | `pnpm exec vitest run` over 242 app-shell test files (declared narrowing, below) | `a46d4a9` | 0 | `Test Files 242 passed (242)` · `Tests 2141 passed (2141)` | | `pnpm exec vitest run` over 24 `apps/console` test files that name AppContent, ObjectView, RecordDetailView, RecordFormPage, useRecentItemLabel, ConsoleLayout or the related-list blocks | `a46d4a9` | 0 | `Test Files 24 passed (24)` · `Tests 603 passed (603)` | | `pnpm --filter @object-ui/app-shell type-check` (dependency closure built first: turbo, 28 tasks) | `50e8f98` | 0 | script echoed (`tsc --noEmit && tsc -p tsconfig.test.json`); the five app-shell pins are in its file list | | `pnpm --filter @object-ui/plugin-detail type-check` | `a46d4a9` (no plugin-detail file changed since) | 0 | script echoed; both plugin-detail pins are in its file list | | `pnpm exec eslint` on the 15 touched `.ts` / `.tsx` files | `50e8f98` | 0 | `0 errors`. No warning falls on a line this branch added except the existing `react-hooks/exhaustive-deps` warning on the `synthParts` deps line in `RecordDetailView`, which base `17acfbb` carries too (it names other members) | | `pnpm check:control-bytes` | `50e8f98` | 0 | `check-control-bytes: OK` | | `pnpm check:test-path-roots` | `50e8f98` | 0 | `check-test-path-roots: OK` | | `pnpm check:changeset-claims` | `50e8f98` | 0 | `No pending changeset names a file this change touches.` | | `pnpm check:pending-changeset-literals` | `50e8f98` | 0 | `No test source names a pending changeset.` | | `pnpm check:vi-mock-specifiers` / `vi-mock-inherit` / `vi-mock-override-shape` | `50e8f98` | 0 / 0 / 0 | each `OK` | | `pnpm check:new-line-citations` | `50e8f98` | 0 | `0 new citation(s)` | | `node scripts/check-changeset-presence.mjs` | `50e8f98` | 0 | `15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)` | | `node scripts/check-changeset-no-major.mjs` | `50e8f98` | 0 | `No changeset declares a major bump.` | **Declared narrowing.** The app-shell run covers the 242 of its 1043 tracked test files that name a touched module or one of its consumers. The selection is `git grep -l` over app-shell's test files for RecordDetailView, RecordFormPage, ObjectView, AppContent, useRecentItemLabel, deriveRelatedLists, useNavTargetLabel, RecentApps, HomeRail, CommandPalette, UnifiedSidebar, StudioHomePage, useFavorites, FavoritesProvider and the related-list / reference-rail blocks. The other files are left to CI's full `pnpm test`. Blind spot: a test that renders these modules through a component whose name matches none of those words. The narrowed runs are on `a46d4a9`; `50e8f98` changes one pin's types only, and that pin re-ran on `50e8f98`. ## Acceptance notes (observations, not filed; no carrier) - A favorite saved before this change keeps the label it was stored with. Favorites store their text, and object favorites have no refresh path (`refreshLabel` is called for record favorites only). Starring again saves the plural. Read from source, not measured. - `record:related_list` and `record:reference_rail` never read the related object's declared `label` or `pluralLabel`, because they hold its name only. Their fallback was bundle-then-humanized before and stays that way. With no bundle entry, an object with a declared plural is still headed by its humanized name there. - The relationship-field half of the multi-relationship title ("· Partner Project") is the field's authored `label`, untranslated, as before. - `ImportWizard` keeps the singular (see the enumeration table). Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9fc68aa commit 885a96f

16 files changed

Lines changed: 1284 additions & 42 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/plugin-detail': patch
4+
---
5+
6+
Titles and links outside the object list page that name a list of an object's records read the object's plural label (objectui#11733).
7+
8+
objectui#11696 titled the object list page and its breadcrumb with `useObjectLabel().objectPluralLabel`: the translated plural, else the declared `pluralLabel`, else the label. The other places that name the same kind of list now read it too:
9+
10+
- **Related-list titles on the record page** (`RecordDetailView`): a section listing a child object's records is titled with the child's plural ("Tasks"). A child that points at the parent through more than one field is titled with its plural and that field's label ("Opportunities · Partner Project"); that title was built from the child's untranslated label before.
11+
- **`record:related_list` and `record:reference_rail` with no authored title** (`@object-ui/plugin-detail`): the list heading, each rail card, and the rail's "+ N empty (…)" line read the bundle's `objects.{name}.pluralLabel`, else the translated label, else the humanized object name. These blocks hold the related object's name only, so they read bundle keys, not a declared `pluralLabel`.
12+
- **The record form page's breadcrumb link** (`RecordFormPage`): the link to the object's list reads the plural; the "Create …" / "Edit …" title and the save toast keep the singular.
13+
- **Favorites and recent items**: the favorite saved by the object list page's star button is labelled with the plural, and a recent `object` entry is named with the plural (`useRecentItemLabel`).
14+
- **The record page's back link**: when the open list view has no label, the link back to the list reads the plural, both when a row or a link cell opens the record (`ObjectView`) and when a create lands on the new record's page.
15+
16+
Unchanged: an unlabelled `object` navigation entry still inherits the singular (`useNavTargetLabel`, step 3 of the spec's navigation-label rule), and record-scoped text keeps the singular: the record page, the record drawer title, "New …", "Create …" / "Edit …", the delete confirmation and toasts, and the import wizard's object name.

‎packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
181181
const homePath = useHomePath();
182182
const previewDrafts = usePreviewDrafts();
183183
const { t } = useObjectTranslation();
184-
const { objectLabel } = useObjectLabel();
184+
const { objectLabel, objectPluralLabel } = useObjectLabel();
185185

186186
// Preload the metadata buckets that the routes under /apps/:appName/* assume
187187
// are fully loaded by render time (the lazy MetadataProvider only eagerly
@@ -635,15 +635,17 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
635635
originSp.delete(RECORD_FORM_LINK_PARAM);
636636
const originQs = originSp.toString();
637637
const originPath = `${location.pathname}${originQs ? `?${originQs}` : ''}`;
638-
const originLabel = objectLabel(currentObjectDef as any);
638+
// The origin is a list of the object's records, so the back link names
639+
// it with the plural, as ObjectView's row navigation does (objectui#11733).
640+
const originLabel = objectPluralLabel(currentObjectDef);
639641
navigate(target.url, {
640642
replace: true,
641643
state: originLabel ? { from: { pathname: originPath, label: originLabel } } : undefined,
642644
});
643645
return;
644646
}
645647
navigate(target.url, { replace: true });
646-
}, [handleCrudSuccess, isChildFormTask, formObjectDef, editingRecord, currentObjectDef, appName, activeApp?.name, location.pathname, navigate, closeRecordForm, objectLabel, t]);
648+
}, [handleCrudSuccess, isChildFormTask, formObjectDef, editingRecord, currentObjectDef, appName, activeApp?.name, location.pathname, navigate, closeRecordForm, objectLabel, objectPluralLabel, t]);
647649

648650
// Track recent items on route change.
649651
useTrackRouteAsRecent({
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11733 — after a create from the list lands on the new record's
11+
* page, the record page's way back names the list with the object's PLURAL
12+
* label.
13+
*
14+
* The console's create flow (objectstack#2604) takes a page-surface object's
15+
* new record to its detail route and hands that route the list it came from as
16+
* history state (`from: { pathname, label }`), which the record page draws as
17+
* its back link. The origin is the object's list, but the label was the
18+
* singular, `objectLabel`; it is now `objectPluralLabel`, as `ObjectView`'s own
19+
* row navigation names the same way back.
20+
*
21+
* Measured from a real `AppContent` mount inside a real i18next instance
22+
* booted in the language under test (the `AppContent.declaredVisibilityKeys`
23+
* harness): the global record-form modal is a stand-in whose `onSuccess` is
24+
* called with a saved id, and the record route's page is a probe that shows
25+
* the state it received. The object carries enough fields to derive the PAGE
26+
* surface (`RECORD_SURFACE_PAGE_THRESHOLD`), which is the branch that hands
27+
* over the label. The `zh-CN` plural differs from the `zh-CN` label on purpose;
28+
* the no-plural object is the control.
29+
*
30+
* Direction, written before the run: with the origin put back to
31+
* `objectLabel`, the two plural cells RED (`Project` / `项目`); the control
32+
* GREEN on both sides.
33+
*/
34+
35+
import '@testing-library/jest-dom/vitest';
36+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
37+
import React from 'react';
38+
import { render, cleanup, waitFor, screen } from '@testing-library/react';
39+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
40+
import { createI18n, I18nProvider } from '@object-ui/i18n';
41+
import { RECORD_SURFACE_PAGE_THRESHOLD } from '@object-ui/plugin-view';
42+
43+
/** The part of the modal's schema this file reads. */
44+
interface ModalSchemaProbe {
45+
objectName?: string;
46+
onSuccess: (saved: { id: string }) => Promise<void> | void;
47+
}
48+
const { modalSchemas } = vi.hoisted(() => ({ modalSchemas: [] as ModalSchemaProbe[] }));
49+
50+
/** The global record-form modal: its schema carries the `onSuccess` under test. */
51+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
52+
...(await importOriginal<Record<string, unknown>>()),
53+
ModalForm: ({ schema }: { schema: ModalSchemaProbe }) => {
54+
modalSchemas.push(schema);
55+
return <div data-testid="modal-form" />;
56+
},
57+
}));
58+
59+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
60+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
61+
CreateAppPage: () => <div data-testid="create-app-page" />,
62+
EditAppPage: () => <div data-testid="edit-app-page" />,
63+
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
64+
}));
65+
66+
vi.mock('../../layout/ConsoleLayout', () => ({
67+
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
68+
<div data-testid="console-layout">{children}</div>
69+
),
70+
}));
71+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
72+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
73+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
74+
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
75+
76+
/** The record route's page: shows the way-back label it was handed. */
77+
vi.mock('../../views/RecordDetailView', async () => {
78+
const { useLocation: useLoc } = await import('react-router-dom');
79+
return {
80+
RecordDetailView: () => {
81+
const state = useLoc().state as { from?: { label?: string } } | null;
82+
return <div data-testid="origin-label">{state?.from?.label ?? '(none)'}</div>;
83+
},
84+
};
85+
});
86+
87+
vi.mock('@object-ui/auth', async (importOriginal) => ({
88+
...(await importOriginal<Record<string, unknown>>()),
89+
useAuth: () => ({
90+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
91+
getAuthConfig: async () => ({ features: {} }),
92+
activeOrganization: null,
93+
}),
94+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
95+
}));
96+
97+
const dataSourceStub = {
98+
onConnectionStateChange: () => () => {},
99+
getConnectionState: () => 'connected',
100+
};
101+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
102+
...(await importOriginal<Record<string, unknown>>()),
103+
useAdapter: () => dataSourceStub,
104+
}));
105+
106+
/** Enough plain fields to derive the PAGE record surface. */
107+
const PAGE_FIELDS = Object.fromEntries(
108+
Array.from({ length: RECORD_SURFACE_PAGE_THRESHOLD + 1 }, (_, i) => [`f${i}`, { type: 'text', label: `F${i}` }]),
109+
);
110+
111+
const OBJECTS = [
112+
{ name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: PAGE_FIELDS },
113+
{ name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS },
114+
];
115+
116+
const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];
117+
118+
// One value for the whole run: the record route's effects key on these
119+
// members, and a fresh value per render would re-run them without end.
120+
const METADATA = {
121+
apps: APPS,
122+
objects: OBJECTS,
123+
loading: false,
124+
ensureType: undefined,
125+
error: null,
126+
refresh: async () => {},
127+
};
128+
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
129+
...(await importOriginal<Record<string, unknown>>()),
130+
useMetadata: () => METADATA,
131+
}));
132+
133+
const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
134+
vi.mock('@object-ui/react', async (importOriginal) => ({
135+
...(await importOriginal<Record<string, unknown>>()),
136+
useActionRunner: () => ACTION_RUNNER,
137+
useGlobalUndo: () => {},
138+
useMutationInvalidationBridge: () => {},
139+
}));
140+
141+
import { AppContent } from '../AppContent';
142+
143+
type Lang = 'en' | 'zh-CN';
144+
145+
/** The catalog as the console loads it: `zh-CN` translates both scalars. */
146+
const BUNDLE: Record<Lang, Record<string, unknown>> = {
147+
en: {},
148+
'zh-CN': { showcase: { objects: { showcase_project: { label: '项目', pluralLabel: '项目清单' } } } },
149+
};
150+
151+
beforeEach(() => {
152+
modalSchemas.length = 0;
153+
});
154+
afterEach(cleanup);
155+
156+
/** Create a record of `objectName` from its list; the way-back label its page receives. */
157+
async function originAfterCreate(lang: Lang, objectName: string): Promise<string> {
158+
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
159+
// The instance really booted in the language under test.
160+
expect(i18n.language).toBe(lang);
161+
i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true);
162+
render(
163+
<I18nProvider instance={i18n}>
164+
<MemoryRouter initialEntries={[`/apps/crm/${objectName}?form=new`]}>
165+
<Routes>
166+
<Route path="/apps/:appName/*" element={<AppContent />} />
167+
</Routes>
168+
</MemoryRouter>
169+
</I18nProvider>,
170+
);
171+
await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0));
172+
const schema = modalSchemas[modalSchemas.length - 1];
173+
expect(schema.objectName).toBe(objectName);
174+
// Not inside `act`: under React 19 an awaited `act` here did not return
175+
// before the test timeout (measured while writing this file), while the
176+
// save itself settles at once. The record route is awaited below instead;
177+
// `findByTestId` polls inside `act` on its own.
178+
await schema.onSuccess({ id: 'r9' });
179+
return (await screen.findByTestId('origin-label', undefined, { timeout: 5000 })).textContent ?? '';
180+
}
181+
182+
describe('AppContent — a create that lands on the record page names the way back with the plural (objectui#11733)', () => {
183+
it('en: "Projects"', async () => {
184+
expect(await originAfterCreate('en', 'showcase_project')).toBe('Projects');
185+
});
186+
187+
it('zh-CN: the translated plural', async () => {
188+
expect(await originAfterCreate('zh-CN', 'showcase_project')).toBe('项目清单');
189+
});
190+
191+
it('control: an object that declares no plural is named by its label', async () => {
192+
expect(await originAfterCreate('en', 'showcase_note')).toBe('Note');
193+
});
194+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11733 — a recent `object` entry is named with the object's PLURAL
11+
* label, and an unlabelled navigation entry still inherits the singular.
12+
*
13+
* A recent object entry opens the object's LIST (`useTrackRouteAsRecent`
14+
* records `href: …/<object>`), so it is named as that list page is titled
15+
* since objectui#11696: `objectPluralLabel` over the object's cached metadata.
16+
* It used to ask `useNavTargetLabel`, whose object rung is the spec's step 3
17+
* for an unlabelled nav entry — "the object's label", the singular. That rung
18+
* is a ruling this card does not reopen (objectui#11696), so the change is
19+
* made at the recent-item call site only, and the last case below pins that
20+
* `useNavTargetLabel` still answers the singular for the same object.
21+
*
22+
* The `zh-CN` plural differs from the `zh-CN` label on purpose, so each
23+
* assertion can tell which key was read; the no-plural object is the control.
24+
*
25+
* Direction, written before the run: with the recent-item object kind put back
26+
* on `useNavTargetLabel`, the two plural cells RED (`Project` / `项目`); the
27+
* control and the nav-entry case GREEN on both sides.
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
import type { ReactNode } from 'react';
32+
import { renderHook } from '@testing-library/react';
33+
import { MetadataCtx } from '@object-ui/react';
34+
import { createI18n, I18nProvider } from '@object-ui/i18n';
35+
import { useRecentItemLabel } from '../useRecentItemLabel';
36+
import { useNavTargetLabel } from '../useNavTargetLabel';
37+
import type { RecentItem } from '../../context/RecentItemsProvider';
38+
39+
type Lang = 'en' | 'zh-CN';
40+
41+
function metadata() {
42+
return {
43+
apps: [],
44+
objects: [
45+
{ name: 'showcase_project', label: 'Project', pluralLabel: 'Projects' },
46+
{ name: 'showcase_note', label: 'Note' },
47+
],
48+
dashboards: [],
49+
reports: [],
50+
pages: [],
51+
loading: false,
52+
error: null,
53+
refresh: async () => {},
54+
invalidate: () => {},
55+
ensureType: async () => [],
56+
getItem: async () => null,
57+
getItemsByType: () => [],
58+
getTypeStatus: () => 'ready' as const,
59+
};
60+
}
61+
62+
function wrapper(lang: Lang) {
63+
const i18n = createI18n({
64+
defaultLanguage: lang,
65+
detectBrowserLanguage: false,
66+
resources: { 'zh-CN': { showcase: { objects: { showcase_project: { label: '项目', pluralLabel: '项目清单' } } } } },
67+
});
68+
// The instance really booted in the language under test.
69+
expect(i18n.language).toBe(lang);
70+
return ({ children }: { children: ReactNode }) => (
71+
<I18nProvider instance={i18n} persistLanguage={false}>
72+
<MetadataCtx.Provider value={metadata() as never}>{children}</MetadataCtx.Provider>
73+
</I18nProvider>
74+
);
75+
}
76+
77+
const at = '2026-10-06T09:00:00.000Z';
78+
const recent = (name: string): RecentItem => ({ id: `object:${name}`, type: 'object', name, href: `/apps/demo/${name}`, visitedAt: at });
79+
80+
function recentLabel(lang: Lang, name: string): string {
81+
const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper(lang) });
82+
return result.current(recent(name));
83+
}
84+
85+
describe('useRecentItemLabel — a recent object entry names the list with the plural (objectui#11733)', () => {
86+
it('en: "Projects"', () => {
87+
expect(recentLabel('en', 'showcase_project')).toBe('Projects');
88+
});
89+
90+
it('zh-CN: the translated plural', () => {
91+
expect(recentLabel('zh-CN', 'showcase_project')).toBe('项目清单');
92+
});
93+
94+
it('control: an object that declares no plural is named by its label', () => {
95+
expect(recentLabel('en', 'showcase_note')).toBe('Note');
96+
});
97+
98+
it('an object the metadata does not hold still shows its machine name', () => {
99+
expect(recentLabel('en', 'showcase_gone')).toBe('showcase_gone');
100+
});
101+
102+
it('unchanged: an unlabelled nav entry for the same object still inherits the singular (spec step 3)', () => {
103+
const en = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('en') });
104+
expect(en.result.current({ kind: 'object', objectName: 'showcase_project' })).toBe('Project');
105+
const zh = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('zh-CN') });
106+
expect(zh.result.current({ kind: 'object', objectName: 'showcase_project' })).toBe('项目');
107+
});
108+
});

0 commit comments

Comments
 (0)