From a46d4a9fb9ab274a588c0a665fc5e7a3d59e01d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:07:11 +0000 Subject: [PATCH 1/2] fix(app-shell,plugin-detail): titles and links that name a list of records read the plural label (objectui#11733) objectui#11696 gave useObjectLabel() an objectPluralLabel resolver and used it for the object list page title and its breadcrumb. The other places that name a list of an object's records still read the singular. Each now reads objectPluralLabel; record-scoped text keeps objectLabel. - RecordDetailView: related-list section titles. deriveRelatedLists takes a listLabel option for the multi-relationship title it composes, and the detail page passes the same resolver. - record:related_list and record:reference_rail (plugin-detail): the untitled fallback heading, the rail cards and the rail's empty line. - RecordFormPage: the shared label is split; the breadcrumb link to the list reads the plural, the title and the save toast keep the singular. - ObjectView: the favorite entry and the record page's way back when the open view has no label; AppContent: the way back handed to the page a create lands on. - useRecentItemLabel: a recent object entry reads the plural at the call site; useNavTargetLabel's nav-entry inheritance is unchanged. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11733-list-links-plural.md | 16 ++ packages/app-shell/src/console/AppContent.tsx | 8 +- ...t.createLandingOriginPlural-11733.test.tsx | 189 +++++++++++++ ...ecentItemLabel.objectPlural-11733.test.tsx | 108 ++++++++ .../app-shell/src/hooks/useRecentItemLabel.ts | 30 ++- .../app-shell/src/utils/deriveRelatedLists.ts | 35 ++- .../ObjectView.listLinksPlural-11733.test.tsx | 248 ++++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 17 +- ...View.relatedListTitlePlural-11733.test.tsx | 225 ++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 15 +- ...cordFormPage.listLinkPlural-11733.test.tsx | 163 ++++++++++++ .../app-shell/src/views/RecordFormPage.tsx | 9 +- ...-reference-rail.titlePlural-11733.test.tsx | 133 ++++++++++ ...rd-related-list.titlePlural-11733.test.tsx | 106 ++++++++ .../src/renderers/record-reference-rail.tsx | 10 +- .../src/renderers/record-related-list.tsx | 9 +- 16 files changed, 1279 insertions(+), 42 deletions(-) create mode 100644 .changeset/11733-list-links-plural.md create mode 100644 packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx create mode 100644 packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx create mode 100644 packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx create mode 100644 packages/app-shell/src/views/RecordDetailView.relatedListTitlePlural-11733.test.tsx create mode 100644 packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx diff --git a/.changeset/11733-list-links-plural.md b/.changeset/11733-list-links-plural.md new file mode 100644 index 0000000000..3bd4db2a3f --- /dev/null +++ b/.changeset/11733-list-links-plural.md @@ -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. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index a00bd53220..faea75d5e2 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -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 @@ -635,7 +635,9 @@ 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, @@ -643,7 +645,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = 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({ diff --git a/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx new file mode 100644 index 0000000000..b2c9029242 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx @@ -0,0 +1,189 @@ +/** + * 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'; + +const { modalSchemas } = vi.hoisted(() => ({ modalSchemas: [] as any[] })); + +/** The global record-form modal: its schema carries the `onSuccess` under test. */ +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ModalForm: ({ schema }: any) => { + modalSchemas.push(schema); + return
; + }, +})); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ ObjectView: () =>
})); + +/** 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
{state?.from?.label ?? '(none)'}
; + }, + }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + 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>()), + 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>()), + useMetadata: () => METADATA, +})); + +const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + 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> = { + 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 { + 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( + + + + } /> + + + , + ); + 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'); + }); +}); diff --git a/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx new file mode 100644 index 0000000000..771b51dce6 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.objectPlural-11733.test.tsx @@ -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: …/`), 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 }) => ( + + {children} + + ); +} + +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('项目'); + }); +}); diff --git a/packages/app-shell/src/hooks/useRecentItemLabel.ts b/packages/app-shell/src/hooks/useRecentItemLabel.ts index 7e7289ba81..3dd2bd6eb2 100644 --- a/packages/app-shell/src/hooks/useRecentItemLabel.ts +++ b/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -18,10 +18,16 @@ * It holds no labelling rule of its own. Each kind asks what the console * already asks for that kind: * - * - an object or a dashboard: {@link useNavTargetLabel} — the text the - * sidebar shows for an unlabelled navigation entry that opens the same - * target (the metadata label, an inline locale map read in the active - * language, through the `useObjectLabel` bundle lookup); + * - an object: what the page the entry opens — the object's LIST — is titled, + * `useObjectLabel`'s `objectPluralLabel` over the object's cached metadata + * (the translated plural, else the declared `pluralLabel`, else the label + * the singular rung reads; objectui#11733). ⛔ Not {@link useNavTargetLabel}: + * its object rung is the spec's step 3 for an UNLABELLED NAVIGATION ENTRY + * ("the object's label", the singular), a ruling this entry does not reopen; + * - a dashboard: {@link useNavTargetLabel} — the text the sidebar shows for an + * unlabelled navigation entry that opens the same target (the metadata + * label, an inline locale map read in the active language, through the + * `useObjectLabel` bundle lookup); * - a page or a report: the same pair for that kind — the page's / report's * cached metadata `label`, through `useObjectLabel`'s `pageLabel` / * `reportLabel`. `useNavTargetLabel` has no page or report target because @@ -40,7 +46,7 @@ import { useMemo } from 'react'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { RecentItem } from '../context/RecentItemsProvider.js'; import { useMetadata } from '../providers/MetadataProvider.js'; -import { findByName, metadataText, useNavTargetLabel } from './useNavTargetLabel.js'; +import { findByName, metadataText, useNavTargetLabel, type LabelledRecord } from './useNavTargetLabel.js'; /** Answers the display text of a recent entry, in the current language. */ export type RecentItemLabelResolver = (item: RecentItem) => string; @@ -53,14 +59,20 @@ function nonEmpty(text: string | undefined): string | undefined { export function useRecentItemLabel(): RecentItemLabelResolver { const targetLabel = useNavTargetLabel(); const metadata = useMetadata(); - const { pageLabel, reportLabel } = useObjectLabel(); + const { objectPluralLabel, pageLabel, reportLabel } = useObjectLabel(); const { language } = useObjectTranslation(); return useMemo( () => (item: RecentItem) => { switch (item.type) { - case 'object': - return targetLabel({ kind: 'object', objectName: item.name }) ?? item.name; + case 'object': { + const def = findByName(metadata.objects, item.name) as (LabelledRecord & { pluralLabel?: unknown }) | undefined; + // The singular half is read exactly as `useNavTargetLabel` reads it; + // `pluralLabel` is a plain string in the spec, so only a string is one. + const text = metadataText(def?.label, language); + const plural = typeof def?.pluralLabel === 'string' ? def.pluralLabel : undefined; + return nonEmpty(objectPluralLabel({ name: item.name, label: text ?? '', pluralLabel: plural })) ?? item.name; + } case 'dashboard': return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name; case 'page': { @@ -75,6 +87,6 @@ export function useRecentItemLabel(): RecentItemLabelResolver { return item.label; } }, - [targetLabel, metadata, pageLabel, reportLabel, language], + [targetLabel, metadata, objectPluralLabel, pageLabel, reportLabel, language], ); } diff --git a/packages/app-shell/src/utils/deriveRelatedLists.ts b/packages/app-shell/src/utils/deriveRelatedLists.ts index c656b91db0..dc117ec283 100644 --- a/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -45,7 +45,9 @@ * MORE THAN ONE relationship (e.g. `opportunity.primary_account` + * `opportunity.partner_account`); each surfaces as its own list. When a * child appears more than once and gave no explicit `relatedListTitle`, the - * FK's label is suffixed to disambiguate ("Opportunity · Partner Account"). + * FK's label is suffixed to the list's name (`options.listLabel`, the + * plural on the detail page) to disambiguate ("Opportunities · Partner + * Account"). * - Self-references are allowed (e.g. `account.parent_account` → `account`): * the parent record lists the records whose self-FK points back at it * ("Child Accounts"). Suppress with `relatedList: false` if unwanted. @@ -152,6 +154,8 @@ export interface DerivedRelatedList { interface MergedObjectLike { name?: string; label?: string; + /** The spec's optional `pluralLabel`, read by a caller's `listLabel` only. */ + pluralLabel?: string; fields?: Record | any[]; /** * The object's DEFAULT list view, as merged onto the object def by @@ -227,6 +231,15 @@ export interface DeriveRelatedListsOptions { * flicker out during the fail-closed loading window. */ canRead?: (objectName: string) => boolean; + /** + * What a LIST of the child's records is called, for the multi-FK title this + * helper composes ("Opportunities · Partner Account"). The detail page + * passes `useObjectLabel().objectPluralLabel`, the resolver its single-FK + * titles use (objectui#11733), so both kinds of related-list title name the + * list the same way. This helper holds no translation of its own; omitted, + * the title keeps `childLabel`. + */ + listLabel?: (child: MergedObjectLike & { name: string }) => string; } /** @@ -242,13 +255,15 @@ export function deriveRelatedLists( if (!objectDef?.name || !Array.isArray(objects) || objects.length === 0) return []; const parentName = objectDef.name; - // Working entries carry the FK label so we can disambiguate multi-FK children - // after the full sweep; it is stripped from the returned descriptors. - type Working = DerivedRelatedList & { _fkLabel: string }; + // Working entries carry the FK label and the list's name so we can + // disambiguate multi-FK children after the full sweep; both are stripped + // from the returned descriptors. + type Working = DerivedRelatedList & { _fkLabel: string; _listLabel: string }; const owned: Working[] = []; const referenced: Working[] = []; const canRead = options?.canRead; + const listLabel = options?.listLabel; for (const child of objects) { if (!child?.name) continue; @@ -260,6 +275,8 @@ export function deriveRelatedLists( // child's own default list-view order, so compute it once per child // rather than once per FK. const inheritedSort = inheritedListViewSort(child.list); + const childLabel = child.label || child.name; + const childListLabel = (listLabel && listLabel(child as MergedObjectLike & { name: string })) || childLabel; for (const [fieldName, fieldDef] of fieldEntries(child.fields)) { if (!fieldDef) continue; const type = fieldDef.type; @@ -279,12 +296,13 @@ export function deriveRelatedLists( const entry: Working = { childObject: child.name, - childLabel: child.label || child.name, + childLabel, referenceField: fieldName, isOwned: type === 'master_detail', isPrimary: fieldDef.relatedList === 'primary', ...(inheritedSort ? { sort: inheritedSort } : {}), _fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName, + _listLabel: childListLabel, ...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle ? { title: fieldDef.relatedListTitle } : {}), @@ -310,13 +328,14 @@ export function deriveRelatedLists( const all = [...owned, ...referenced]; // Multi-FK disambiguation: when a child object points here through more than // one relationship and gave no explicit title, suffix the FK label so the two - // lists are distinguishable (e.g. "Opportunity · Partner Account"). + // lists are distinguishable (e.g. "Opportunities · Partner Account"; the list + // half is `options.listLabel`, see there). const counts: Record = {}; for (const r of all) counts[r.childObject] = (counts[r.childObject] || 0) + 1; - return all.map(({ _fkLabel, ...rest }) => { + return all.map(({ _fkLabel, _listLabel, ...rest }) => { if (!rest.title && counts[rest.childObject] > 1) { - return { ...rest, title: `${rest.childLabel} · ${_fkLabel}` }; + return { ...rest, title: `${_listLabel} · ${_fkLabel}` }; } return rest; }); diff --git a/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx b/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx new file mode 100644 index 0000000000..7dbf312fce --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.listLinksPlural-11733.test.tsx @@ -0,0 +1,248 @@ +/** + * 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 — what the object list page hands OUT names the list with the + * object's PLURAL label, as its own title does since objectui#11696. + * + * Two hand-offs, both read here from a real `ObjectView` mount inside a real + * i18next instance booted in the language under test (the + * `ObjectView.listTitlePlural-11696` harness): + * + * - the favorite the star button saves: an `object` favorite opens this list, + * and the sidebar shows the `label` it was saved with; + * - the record page's way back (`location.state.from.label`), when the open + * view has no label of its own. Both builders are driven: the list schema's + * `onNavigate` (the plugin list's row open) and the record-link bridge's + * `openRecord` (the grid's link column), which reach the record route by two + * different callbacks. + * + * 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: + * an object that declares no plural is named by its label everywhere. + * + * Direction, written before the run: with the three sites put back to + * `objectLabel`, every plural cell RED (`Project` / `项目`), the controls + * GREEN on both sides. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +const { favoriteCalls } = vi.hoisted(() => ({ favoriteCalls: [] as Array> })); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +// The favorite the star button saves is the subject, so the call is recorded. +vi.mock('../hooks/useFavorites', () => ({ + useFavorites: () => ({ + isFavorite: () => false, + toggleFavorite: (item: Record) => { + favoriteCalls.push(item); + }, + }), +})); + +// The list body is a probe: two buttons that open a record through the two +// callbacks the page hands its list — the schema's `onNavigate` and the +// record-link bridge's `openRecord`. +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const { useRelatedRecordActions } = await import('@object-ui/react'); + const ListProbe = ({ schema }: { schema: { objectName: string; onNavigate: (id: string, mode: 'view') => void } }) => { + const bridge = useRelatedRecordActions(); + return ( + <> + + ), +})); + +const OBJECTS = [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: { name: { type: 'text' } } }, + { name: 'showcase_note', label: 'Note', fields: { name: { type: 'text' } } }, +]; + +vi.mock('../providers/MetadataProvider', () => ({ + useMetadata: () => ({ objects: OBJECTS, loading: false }), +})); +vi.mock('../providers/AdapterProvider', () => ({ + useAdapter: () => null, +})); + +type Lang = 'en' | 'zh-CN'; + +/** The catalog as the console loads it: `zh-CN` translates both scalars. */ +const BUNDLE: Record> = { + en: {}, + 'zh-CN': { showcase: { objects: { showcase_project: { label: '项目', pluralLabel: '项目清单' } } } }, +}; + +beforeEach(() => { + toastSuccess.mockClear(); +}); +afterEach(cleanup); + +interface PageText { + link: string; + title: string; + toast: string; +} + +async function pageText(lang: Lang, mode: 'create' | 'edit', objectName: string): Promise { + 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); + const path = mode === 'create' ? `/apps/demo/${objectName}/new` : `/apps/demo/${objectName}/record/r1/edit`; + const route = mode === 'create' ? '/apps/:appName/:objectName/new' : '/apps/:appName/:objectName/record/:recordId/edit'; + render( + + + + } /> + + + , + ); + const title = (await screen.findByTestId('record-form-page-title')).textContent ?? ''; + // The breadcrumb's one link is the way to the object's list. + const crumb = screen.getByRole('navigation', { name: 'Breadcrumb' }); + const link = crumb.querySelector('a'); + expect(link?.getAttribute('href')).toBe(`/apps/demo/${objectName}`); + fireEvent.click(screen.getByTestId('fire-success')); + expect(toastSuccess).toHaveBeenCalledTimes(1); + return { link: link?.textContent ?? '', title, toast: String(toastSuccess.mock.calls[0][0]) }; +} + +describe('RecordFormPage — the list link reads the plural, the record copy the singular (objectui#11733)', () => { + it('en, create: "Projects" links to the list; the title and toast say "Project"', async () => { + expect(await pageText('en', 'create', 'showcase_project')).toEqual({ + link: 'Projects', + title: 'Create Project', + toast: 'Project created successfully', + }); + }); + + it('en, edit: "Projects" links to the list; the title and toast say "Project"', async () => { + expect(await pageText('en', 'edit', 'showcase_project')).toEqual({ + link: 'Projects', + title: 'Edit Project', + toast: 'Project updated successfully', + }); + }); + + it('zh-CN, create: the translated plural links to the list; the title and toast keep the translated label', async () => { + expect(await pageText('zh-CN', 'create', 'showcase_project')).toEqual({ + link: '项目清单', + title: '新建项目', + toast: '项目创建成功', + }); + }); + + it('control: an object that declares no plural is named by its label in all three', async () => { + expect(await pageText('en', 'create', 'showcase_note')).toEqual({ + link: 'Note', + title: 'Create Note', + toast: 'Note created successfully', + }); + }); +}); diff --git a/packages/app-shell/src/views/RecordFormPage.tsx b/packages/app-shell/src/views/RecordFormPage.tsx index 09acdddc34..7bc9876aa5 100644 --- a/packages/app-shell/src/views/RecordFormPage.tsx +++ b/packages/app-shell/src/views/RecordFormPage.tsx @@ -72,7 +72,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { const dataSource = useAdapter(); const { objects, loading: metadataLoading } = useMetadata(); const { t } = useObjectTranslation(); - const { objectLabel } = useObjectLabel(); + const { objectLabel, objectPluralLabel } = useObjectLabel(); const { user, getAuthConfig, activeOrganization } = useAuth(); // Pull deployment-level feature flags so action visibility predicates @@ -130,7 +130,12 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { } }, [navigate, recordDetailUrl]); + // Two names, not one (objectui#11733): the title and the toasts speak of ONE + // record, so they keep the singular; the breadcrumb link opens the object's + // list, so it reads the plural, as that list page's title and the header + // crumb above this page do. const label = objectDef ? objectLabel(objectDef as any) : objectName ?? ''; + const listLabel = objectDef ? objectPluralLabel(objectDef) : objectName ?? ''; // No inline `defaultValue` on these lookups: every key below is defined in // all ten locale packs and `all-locales-key-parity.test.ts` pins that // permanently, so i18next always resolves the pack value and a fallback @@ -329,7 +334,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { to={objectListUrl} className="hover:text-foreground transition-colors" > - {label} + {listLabel} diff --git a/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx new file mode 100644 index 0000000000..7ce0072ae8 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx @@ -0,0 +1,133 @@ +/** + * 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. + * + * `record:reference_rail` names an untitled entry with the related object's + * PLURAL label (objectui#11733). + * + * Each rail entry is a card listing the related object's records under a count + * badge, and the entries with no records are folded into one "+ N empty + * (…)" line that names them. Both names fell back to the singular bundle key, + * `objects.{name}.label`; they now read `objectPluralLabel`, which tries + * `objects.{name}.pluralLabel` first and falls back to that same singular key, + * then to the humanized object name, as before. + * + * Like `record:related_list`, the rail holds the related object's NAME only, + * so both its rungs are bundle keys and the fixtures carry the plural in the + * bundle, in `en` as in `zh-CN`, differing from the label in each. The control + * is an entry whose bundle translates only the label. + * + * Direction, written before the run: with both sites put back to + * `objectLabel`, the plural cells RED (`Task` / `任务`, `Opportunity` / `商机`); + * the control GREEN on both sides. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { RecordContextProvider } from '@object-ui/react'; + +import { RecordReferenceRailRenderer } from '../record-reference-rail'; + +/** Fires every observed entry as visible at once, so each card fetches. */ +class ImmediateIO { + constructor(private cb: (records: { isIntersecting: boolean }[]) => void) {} + observe() { this.cb([{ isIntersecting: true }]); } + disconnect() {} + unobserve() {} +} + +type Lang = 'en' | 'zh-CN'; + +const BUNDLE: Record> = { + en: { + showcase: { + objects: { + showcase_task: { label: 'Task', pluralLabel: 'Tasks' }, + showcase_opportunity: { label: 'Opportunity', pluralLabel: 'Opportunities' }, + }, + }, + }, + 'zh-CN': { + showcase: { + objects: { + showcase_task: { label: '任务', pluralLabel: '任务清单' }, + showcase_opportunity: { label: '商机', pluralLabel: '商机列表' }, + showcase_note: { label: '笔记' }, + }, + }, + }, +}; + +/** Tasks and notes have a record each; opportunities have none. */ +const makeDataSource = () => ({ + find: vi.fn(async (objectName: string) => + objectName === 'showcase_opportunity' + ? { data: [], total: 0 } + : { data: [{ id: `${objectName}-1`, name: 'One' }], total: 1 }, + ), +}); + +/** No `title` on any entry: every name is the fallback under test. */ +const RAIL = { + entries: [ + { objectName: 'showcase_task', relationshipField: 'project' }, + { objectName: 'showcase_note', relationshipField: 'project' }, + { objectName: 'showcase_opportunity', relationshipField: 'project' }, + ], +}; + +function mountIn(lang: Lang) { + 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); + return render( + + + + + + + , + ); +} + +/** The "+ N empty (…)" line, whose `title` lists the empty entries' names. */ +const emptyLineNames = async () => + (await screen.findByText(/^\+ ?1/)).closest('button')?.getAttribute('title'); + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', ImmediateIO as unknown as typeof IntersectionObserver); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('record:reference_rail names untitled entries with the plural (objectui#11733)', () => { + it('en: the card is "Tasks", the empty line names "Opportunities"', async () => { + mountIn('en'); + expect(await screen.findByText('Tasks')).toBeInTheDocument(); + expect(await emptyLineNames()).toBe('Opportunities'); + expect(screen.queryByText('Task')).toBeNull(); + }); + + it('zh-CN: the translated plurals', async () => { + mountIn('zh-CN'); + expect(await screen.findByText('任务清单')).toBeInTheDocument(); + expect(await emptyLineNames()).toBe('商机列表'); + expect(screen.queryByText('任务')).toBeNull(); + }); + + it('CONTROL: an entry whose bundle translates only the label keeps the label', async () => { + mountIn('zh-CN'); + expect(await screen.findByText('笔记')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx new file mode 100644 index 0000000000..330f0a3bee --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx @@ -0,0 +1,106 @@ +/** + * 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. + * + * `record:related_list` with no authored `title` is headed with the related + * object's PLURAL label (objectui#11733). + * + * The block lists the related object's records, so it is named as that + * object's list page is (objectui#11696). Its fallback read the singular bundle + * key, `objects.{name}.label`; it now reads `objectPluralLabel`, which tries + * `objects.{name}.pluralLabel` first and falls back to that same singular key, + * then to the humanized object name, exactly as before. + * + * This renderer has the related object's NAME only, never its definition, so + * both its rungs are bundle keys (the declared `label` was never read here + * either); the fixtures therefore carry the plural in the bundle, in `en` as in + * `zh-CN`, and the plural differs from the label in each, so the heading tells + * which key was read. Two controls: a bundle that translates only the label + * keeps it, and an object the bundle does not know is humanized. + * + * Mounted the way a page mounts the node: the JSON document through the real + * `SchemaRenderer`, this package's registration and the real `RelatedList`, + * inside a record context (the `titleI18nLabel-10993` harness), within a real + * i18next instance booted in the language under test. + * + * Direction, written before the run: with the fallback put back to + * `objectLabel`, the two plural cells RED (`Task` / `任务`); both controls + * GREEN on both sides. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { RecordContextProvider, SchemaRenderer } from '@object-ui/react'; +// Registers `record:related_list`, the block under test. +import '../../index'; + +afterEach(() => cleanup()); + +type Lang = 'en' | 'zh-CN'; + +const BUNDLE: Record> = { + en: { showcase: { objects: { showcase_task: { label: 'Task', pluralLabel: 'Tasks' } } } }, + 'zh-CN': { + showcase: { + objects: { + showcase_task: { label: '任务', pluralLabel: '任务清单' }, + showcase_note: { label: '笔记' }, + }, + }, + }, +}; + +const makeDataSource = () => ({ + find: vi.fn(async () => ({ data: [], total: 0 })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: { name: { type: 'text', label: 'Name' } } })), +}); + +/** A JSON document with no `title`: the heading is the fallback under test. */ +const doc = (objectName: string) => ({ + type: 'record:related_list', + properties: { objectName, relationshipField: 'project', columns: ['name'] }, +}); + +function mountIn(lang: Lang, objectName: 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); + return render( + + + + + , + ); +} + +describe('record:related_list heads an untitled list with the plural (objectui#11733)', () => { + it('en: "Tasks"', async () => { + mountIn('en', 'showcase_task'); + expect(await screen.findByText('Tasks')).toBeInTheDocument(); + expect(screen.queryByText('Task')).toBeNull(); + }); + + it('zh-CN: the translated plural', async () => { + mountIn('zh-CN', 'showcase_task'); + expect(await screen.findByText('任务清单')).toBeInTheDocument(); + expect(screen.queryByText('任务')).toBeNull(); + }); + + it('CONTROL: a bundle that translates only the label keeps the label', async () => { + mountIn('zh-CN', 'showcase_note'); + expect(await screen.findByText('笔记')).toBeInTheDocument(); + }); + + it('CONTROL: an object the bundle does not know is humanized, as before', async () => { + mountIn('en', 'showcase_note'); + expect(await screen.findByText('Showcase Note')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/packages/plugin-detail/src/renderers/record-reference-rail.tsx index f981402a83..572e77d6b7 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -8,7 +8,7 @@ * related data without leaving the Details tab. * * Each entry renders a tight card with: - * - localized object label + * - localized object PLURAL label (the card lists records; objectui#11733) * - total related count (badge) * - the top N related records (name only, truncated) * @@ -520,8 +520,8 @@ export const RecordReferenceRailRenderer: React.FC e.title || - (i18n?.objectLabel - ? i18n.objectLabel({ name: e.objectName, label: humanize(e.objectName) }) + (i18n?.objectPluralLabel + ? i18n.objectPluralLabel({ name: e.objectName, label: humanize(e.objectName) }) : humanize(e.objectName)), ); @@ -544,8 +544,8 @@ export const RecordReferenceRailRenderer: React.FC = ({ // Resolve a human-friendly title: // 1. authored `schema.title` wins — via pickLocalized so inline-i18n // shapes (`{ en, 'zh-CN' }`) resolve instead of rendering "[object Object]" - // 2. translated object label via i18n (key `objects.{name}.label`) + // 2. translated object PLURAL label via i18n (key `objects.{name}.pluralLabel`), + // else the translated label (key `objects.{name}.label`) — the block + // lists the related object's records, so it is named as that object's + // list page is (objectui#11733) // 3. humanized objectName (e.g. `opportunity_quote` → "Opportunity Quote") // 4. literal `'Related'` as final fallback - const resolvedObjectLabel = objectName && (i18n as any).objectLabel - ? (i18n as any).objectLabel({ name: objectName, label: humanizeLabel(objectName) }) + const resolvedObjectLabel = objectName && 'objectPluralLabel' in i18n + ? i18n.objectPluralLabel({ name: objectName, label: humanizeLabel(objectName) }) : objectName ? humanizeLabel(objectName) : ''; From 50e8f98db4654ef5116646c3f8841752322aceeb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:34:27 +0000 Subject: [PATCH 2/2] test(app-shell): type the modal probe in the objectui#11733 create-landing pin Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../AppContent.createLandingOriginPlural-11733.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx index b2c9029242..6ced0223b9 100644 --- a/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx +++ b/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx @@ -40,12 +40,17 @@ 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'; -const { modalSchemas } = vi.hoisted(() => ({ modalSchemas: [] as any[] })); +/** The part of the modal's schema this file reads. */ +interface ModalSchemaProbe { + objectName?: string; + onSuccess: (saved: { id: string }) => Promise | 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>()), - ModalForm: ({ schema }: any) => { + ModalForm: ({ schema }: { schema: ModalSchemaProbe }) => { modalSchemas.push(schema); return
; },