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
16 changes: 16 additions & 0 deletions .changeset/11733-list-links-plural.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
---

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).

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:

- **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.
- **`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`.
- **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.
- **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`).
- **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.

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.
8 changes: 5 additions & 3 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
const homePath = useHomePath();
const previewDrafts = usePreviewDrafts();
const { t } = useObjectTranslation();
const { objectLabel } = useObjectLabel();
const { objectLabel, objectPluralLabel } = useObjectLabel();

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

// Track recent items on route change.
useTrackRouteAsRecent({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11733 — after a create from the list lands on the new record's
* page, the record page's way back names the list with the object's PLURAL
* label.
*
* The console's create flow (objectstack#2604) takes a page-surface object's
* new record to its detail route and hands that route the list it came from as
* history state (`from: { pathname, label }`), which the record page draws as
* its back link. The origin is the object's list, but the label was the
* singular, `objectLabel`; it is now `objectPluralLabel`, as `ObjectView`'s own
* row navigation names the same way back.
*
* Measured from a real `AppContent` mount inside a real i18next instance
* booted in the language under test (the `AppContent.declaredVisibilityKeys`
* harness): the global record-form modal is a stand-in whose `onSuccess` is
* called with a saved id, and the record route's page is a probe that shows
* the state it received. The object carries enough fields to derive the PAGE
* surface (`RECORD_SURFACE_PAGE_THRESHOLD`), which is the branch that hands
* over the label. The `zh-CN` plural differs from the `zh-CN` label on purpose;
* the no-plural object is the control.
*
* Direction, written before the run: with the origin put back to
* `objectLabel`, the two plural cells RED (`Project` / `项目`); the control
* GREEN on both sides.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { RECORD_SURFACE_PAGE_THRESHOLD } from '@object-ui/plugin-view';

/** The part of the modal's schema this file reads. */
interface ModalSchemaProbe {
objectName?: string;
onSuccess: (saved: { id: string }) => Promise<void> | void;
}
const { modalSchemas } = vi.hoisted(() => ({ modalSchemas: [] as ModalSchemaProbe[] }));

/** The global record-form modal: its schema carries the `onSuccess` under test. */
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ModalForm: ({ schema }: { schema: ModalSchemaProbe }) => {
modalSchemas.push(schema);
return <div data-testid="modal-form" />;
},
}));

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

vi.mock('../../layout/ConsoleLayout', () => ({
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="console-layout">{children}</div>
),
}));
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));

/** The record route's page: shows the way-back label it was handed. */
vi.mock('../../views/RecordDetailView', async () => {
const { useLocation: useLoc } = await import('react-router-dom');
return {
RecordDetailView: () => {
const state = useLoc().state as { from?: { label?: string } } | null;
return <div data-testid="origin-label">{state?.from?.label ?? '(none)'}</div>;
},
};
});

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));

/** Enough plain fields to derive the PAGE record surface. */
const PAGE_FIELDS = Object.fromEntries(
Array.from({ length: RECORD_SURFACE_PAGE_THRESHOLD + 1 }, (_, i) => [`f${i}`, { type: 'text', label: `F${i}` }]),
);

const OBJECTS = [
{ name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: PAGE_FIELDS },
{ name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS },
];

const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];

// One value for the whole run: the record route's effects key on these
// members, and a fresh value per render would re-run them without end.
const METADATA = {
apps: APPS,
objects: OBJECTS,
loading: false,
ensureType: undefined,
error: null,
refresh: async () => {},
};
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => METADATA,
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ACTION_RUNNER,
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

import { AppContent } from '../AppContent';

type Lang = 'en' | 'zh-CN';

/** The catalog as the console loads it: `zh-CN` translates both scalars. */
const BUNDLE: Record<Lang, Record<string, unknown>> = {
en: {},
'zh-CN': { showcase: { objects: { showcase_project: { label: '项目', pluralLabel: '项目清单' } } } },
};

beforeEach(() => {
modalSchemas.length = 0;
});
afterEach(cleanup);

/** Create a record of `objectName` from its list; the way-back label its page receives. */
async function originAfterCreate(lang: Lang, objectName: string): Promise<string> {
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
// The instance really booted in the language under test.
expect(i18n.language).toBe(lang);
i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true);
render(
<I18nProvider instance={i18n}>
<MemoryRouter initialEntries={[`/apps/crm/${objectName}?form=new`]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0));
const schema = modalSchemas[modalSchemas.length - 1];
expect(schema.objectName).toBe(objectName);
// Not inside `act`: under React 19 an awaited `act` here did not return
// before the test timeout (measured while writing this file), while the
// save itself settles at once. The record route is awaited below instead;
// `findByTestId` polls inside `act` on its own.
await schema.onSuccess({ id: 'r9' });
return (await screen.findByTestId('origin-label', undefined, { timeout: 5000 })).textContent ?? '';
}

describe('AppContent — a create that lands on the record page names the way back with the plural (objectui#11733)', () => {
it('en: "Projects"', async () => {
expect(await originAfterCreate('en', 'showcase_project')).toBe('Projects');
});

it('zh-CN: the translated plural', async () => {
expect(await originAfterCreate('zh-CN', 'showcase_project')).toBe('项目清单');
});

it('control: an object that declares no plural is named by its label', async () => {
expect(await originAfterCreate('en', 'showcase_note')).toBe('Note');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11733 — a recent `object` entry is named with the object's PLURAL
* label, and an unlabelled navigation entry still inherits the singular.
*
* A recent object entry opens the object's LIST (`useTrackRouteAsRecent`
* records `href: …/<object>`), so it is named as that list page is titled
* since objectui#11696: `objectPluralLabel` over the object's cached metadata.
* It used to ask `useNavTargetLabel`, whose object rung is the spec's step 3
* for an unlabelled nav entry — "the object's label", the singular. That rung
* is a ruling this card does not reopen (objectui#11696), so the change is
* made at the recent-item call site only, and the last case below pins that
* `useNavTargetLabel` still answers the singular for the same object.
*
* The `zh-CN` plural differs from the `zh-CN` label on purpose, so each
* assertion can tell which key was read; the no-plural object is the control.
*
* Direction, written before the run: with the recent-item object kind put back
* on `useNavTargetLabel`, the two plural cells RED (`Project` / `项目`); the
* control and the nav-entry case GREEN on both sides.
*/

import { describe, it, expect } from 'vitest';
import type { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { MetadataCtx } from '@object-ui/react';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { useRecentItemLabel } from '../useRecentItemLabel';
import { useNavTargetLabel } from '../useNavTargetLabel';
import type { RecentItem } from '../../context/RecentItemsProvider';

type Lang = 'en' | 'zh-CN';

function metadata() {
return {
apps: [],
objects: [
{ name: 'showcase_project', label: 'Project', pluralLabel: 'Projects' },
{ name: 'showcase_note', label: 'Note' },
],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};
}

function wrapper(lang: Lang) {
const i18n = createI18n({
defaultLanguage: lang,
detectBrowserLanguage: false,
resources: { 'zh-CN': { showcase: { objects: { showcase_project: { label: '项目', pluralLabel: '项目清单' } } } } },
});
// The instance really booted in the language under test.
expect(i18n.language).toBe(lang);
return ({ children }: { children: ReactNode }) => (
<I18nProvider instance={i18n} persistLanguage={false}>
<MetadataCtx.Provider value={metadata() as never}>{children}</MetadataCtx.Provider>
</I18nProvider>
);
}

const at = '2026-10-06T09:00:00.000Z';
const recent = (name: string): RecentItem => ({ id: `object:${name}`, type: 'object', name, href: `/apps/demo/${name}`, visitedAt: at });

function recentLabel(lang: Lang, name: string): string {
const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper(lang) });
return result.current(recent(name));
}

describe('useRecentItemLabel — a recent object entry names the list with the plural (objectui#11733)', () => {
it('en: "Projects"', () => {
expect(recentLabel('en', 'showcase_project')).toBe('Projects');
});

it('zh-CN: the translated plural', () => {
expect(recentLabel('zh-CN', 'showcase_project')).toBe('项目清单');
});

it('control: an object that declares no plural is named by its label', () => {
expect(recentLabel('en', 'showcase_note')).toBe('Note');
});

it('an object the metadata does not hold still shows its machine name', () => {
expect(recentLabel('en', 'showcase_gone')).toBe('showcase_gone');
});

it('unchanged: an unlabelled nav entry for the same object still inherits the singular (spec step 3)', () => {
const en = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('en') });
expect(en.result.current({ kind: 'object', objectName: 'showcase_project' })).toBe('Project');
const zh = renderHook(() => useNavTargetLabel(), { wrapper: wrapper('zh-CN') });
expect(zh.result.current({ kind: 'object', objectName: 'showcase_project' })).toBe('项目');
});
});
Loading
Loading