diff --git a/.changeset/11678-recent-items-identity.md b/.changeset/11678-recent-items-identity.md new file mode 100644 index 0000000000..f73eb84a08 --- /dev/null +++ b/.changeset/11678-recent-items-identity.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': minor +--- + +"Recently Accessed" shows each item's own label, in the current language, and a visit that does not change the list no longer writes the `ui.recent` preference (objectui#11678). + +The route tracker used to store a label it made from the route's machine name at visit time: a dashboard labelled "Delivery Operations" was listed as "Showcase Ops Dashboard", and stayed in English after switching to 中文. An object, dashboard, page or report entry now stores its identity only, `type` and `name`, and every surface that lists recent items (the Home cards and rail, the sidebar's Recent group, Studio home, the command palette) labels it when it renders, through the new `useRecentItemLabel` hook. That hook reuses the resolver each kind already has: the navigation target label for an object or a dashboard, and the page's or report's metadata label through the `pageLabel` / `reportLabel` bundle lookup. An item whose metadata is not loaded shows its machine name until it is. A record keeps the title it was visited under, and a Studio metadata item its name. + +Lists already stored in the old shape are read by identity, which was always in the entry's `id`; their stored label is dropped, so they show the current label on first render. + +`RecentItemsProvider` now refuses to write a list equal to the one it holds, whoever reports the visit: revisiting the item already at the head of the list changes nothing and writes nothing, and its `visitedAt` stays the time it became the most recent entry. Going from an object's list to one of its views no longer saves the list a second time. + +**Breaking (types):** `RecentItem` is now a union. `RecentNamedItem` (`object`, `dashboard`, `page`, `report`) carries `name` and no `label`; `RecentTextItem` (`record`, `metadata`) carries `label`. `addRecentItem` takes `RecentItemInput`, so a call that passes a `label` for an object, dashboard, page or report no longer compiles, and code that reads `item.label` off a recent entry should call `useRecentItemLabel()` instead. `useRecentItemLabel`, `RecentItemLabelResolver`, `RecentItemInput`, `RecentItemType`, `RecentNamedItem` and `RecentTextItem` are new exports of `@object-ui/app-shell`. + +**Clause-②: yes (narrowing).** The package entry gains six exports, and `addRecentItem` refuses the label-carrying shape it used to take for those four kinds. diff --git a/content/docs/guide/user-state-persistence.md b/content/docs/guide/user-state-persistence.md index e33e78f2b0..a020557700 100644 --- a/content/docs/guide/user-state-persistence.md +++ b/content/docs/guide/user-state-persistence.md @@ -185,6 +185,12 @@ The providers debounce backend writes with a 500ms window via `createDebouncedFl `localStorage` is written **synchronously** on every mutation, so reloads always show the latest state immediately even if the debounced backend write hasn't fired yet. +## Recent items store identity, not labels + +An object, dashboard, page or report entry in the recent list is stored as its `type` and `name` (plus `href` and `visitedAt`), never as display text, so a rename or a language switch shows on the next render instead of being frozen at visit time. Surfaces label an entry with `useRecentItemLabel()`, which asks the resolver the console already uses for that kind; an item whose metadata is not loaded yet shows its machine name. Record entries (`type: 'record'`) and Studio metadata entries (`type: 'metadata'`) carry a `label`: a record's title, a metadata item's name. + +The provider writes nothing when a visit leaves the list unchanged: re-adding the entry already at the head keeps its `visitedAt` and costs no `save()`. Lists persisted in the earlier label-carrying shape are read by identity (the name is the part of `id` after `type:`) and their stored label is dropped. + ## Race-safety Each provider keeps a monotonic `hydrationToken`. If the user switches accounts while a `load()` is in flight, the late response is discarded. This prevents seeing User A's favorites flash into User B's session. @@ -199,6 +205,7 @@ Each provider keeps a monotonic `hydrationToken`. If the user switches accounts | `useFavorites()` | `@object-ui/app-shell` | `{ favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites, setPinned, isPinned, pinnedNavIds }` | | `useNavPins()` | `@object-ui/app-shell` | Thin shim over `useFavorites` for sidebar pinning — `{ pinnedIds, togglePin, isPinned, applyPins, clearPins }`. | | `useRecentItems()` | `@object-ui/app-shell` | `{ recentItems, addRecentItem, clearRecentItems }` | +| `useRecentItemLabel()` | `@object-ui/app-shell` | `(item) => string` — a recent entry's label, resolved on render from the item's metadata in the current language. | | `useFlowPaletteRecents()` | `@object-ui/app-shell` | `{ recents, recordRecent }` — flow-designer add-node MRU; falls back to localStorage outside a provider. | | `createObjectStackUserStateAdapter(opts)` | `@object-ui/data-objectstack` | Official adapter over the platform's `sys_user_preference` store. `{ dataSource, userId, key, resource?, onError? }` — one instance per `(user, key)` pair. | diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 281ba7edc4..70715a36f4 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -954,6 +954,23 @@ const { recentItems, addRecentItem } = useRecentItems(); const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins(); ``` +A recent object, dashboard, page or report entry stores its identity only +(`type` and `name`), never display text: label it where you render it with +`useRecentItemLabel()`, which reads the item's current metadata label in the +current language (a record entry keeps the title it was visited under). The +provider writes nothing when a visit leaves the list unchanged, such as +revisiting the item already at its head. + +```tsx +import { useRecentItems, useRecentItemLabel } from '@object-ui/app-shell'; + +function RecentList() { + const { recentItems } = useRecentItems(); + const recentLabel = useRecentItemLabel(); + return ; +} +``` + Nav pins and Favorites share a single `favorites` collection. `FavoriteItem` carries optional `type: 'nav'`, `pinned`, and `navId` fields so a single adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index 0e643c454b..3d7ad3a263 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -35,6 +35,7 @@ import { useObjectTranslation } from '@object-ui/i18n'; import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils/index.js'; import { getIcon } from '../utils/getIcon.js'; import { useRecentItems } from '../context/RecentItemsProvider.js'; +import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js'; import { useCommandPalette } from '../context/CommandPaletteProvider.js'; import { resolveHref, resolveNavItemLabel } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; @@ -125,6 +126,9 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour // user types anything. Filtered down to record-type entries so we // don't double up with the per-app nav above. const { recentItems } = useRecentItems(); + // The one way a recent entry is labelled (objectui#11678); for a record it + // is the title the entry was visited under. + const recentLabel = useRecentItemLabel(); const recentRecords = useMemo( () => recentItems.filter((it) => it.type === 'record').slice(0, 5), [recentItems], @@ -215,11 +219,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour {recentRecords.map((item) => ( runCommand(() => navigate(item.href))} > - {item.label} + {recentLabel(item)} ))} diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx index 2a7965f41f..d234de48cb 100644 --- a/packages/app-shell/src/console/home/HomePage.tsx +++ b/packages/app-shell/src/console/home/HomePage.tsx @@ -19,6 +19,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMetadata } from '../../providers/MetadataProvider.js'; import { useRecentItems } from '../../hooks/useRecentItems.js'; +import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; import { useFavorites } from '../../hooks/useFavorites.js'; import { useObjectTranslation } from '@object-ui/i18n'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; @@ -237,6 +238,9 @@ export function HomePage() { const { t } = useObjectTranslation(); const { apps, loading } = useMetadata(); const { recentItems } = useRecentItems(); + // A recent entry stores identity; the rail labels it with this, on the + // render, in the current language (objectui#11678). + const recentLabel = useRecentItemLabel(); const { favorites } = useFavorites(); const { user } = useAuth(); const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus(); @@ -593,7 +597,7 @@ export function HomePage() { {/* Continue where you left off + ambient activity */}
- navigate(href)} t={t} /> + navigate(href)} t={t} labelOf={recentLabel} /> navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
diff --git a/packages/app-shell/src/console/home/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx index d830bf11a9..ebb8def5c8 100644 --- a/packages/app-shell/src/console/home/HomeRail.tsx +++ b/packages/app-shell/src/console/home/HomeRail.tsx @@ -20,6 +20,7 @@ import { useDisplayLocale } from '@object-ui/i18n'; import type { ActivityItem } from '../../layout/ActivityFeed.js'; import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js'; import type { RecentItem } from '../../hooks/useRecentItems.js'; +import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js'; import { recentItemTypeLabel } from './recentItemTypeLabel.js'; import { timeAgo } from '../../utils/relativeTime.js'; @@ -236,7 +237,24 @@ const RECENT_TONE: Record = { page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', }; -export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen: (href: string) => void; t: TFn }) { +/** + * `labelOf` is `useRecentItemLabel()`, handed in by the page like `t`: an entry + * stores identity, not text, so its label is resolved on the render, in the + * current language (objectui#11678). Taken as a prop so this module — which + * also holds the action center and the activity card — does not itself load + * the metadata cache the resolver reads. + */ +export function HomeContinue({ + items, + onOpen, + t, + labelOf, +}: { + items: RecentItem[]; + onOpen: (href: string) => void; + t: TFn; + labelOf: RecentItemLabelResolver; +}) { return ( {items.length === 0 ? ( @@ -250,7 +268,7 @@ export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen key={it.id} icon={RECENT_ICON[it.type] || FileText} iconClass={RECENT_TONE[it.type] || 'bg-muted text-muted-foreground'} - label={it.label} + label={labelOf(it)} meta={recentItemTypeLabel(t, it.type)} onClick={() => onOpen(it.href)} /> diff --git a/packages/app-shell/src/console/home/RecentApps.tsx b/packages/app-shell/src/console/home/RecentApps.tsx index ba64b6d290..e52fbf30ae 100644 --- a/packages/app-shell/src/console/home/RecentApps.tsx +++ b/packages/app-shell/src/console/home/RecentApps.tsx @@ -12,6 +12,7 @@ import { Card, CardContent, cn } from '@object-ui/components'; import { Clock, ArrowUpRight, Database, FileText, LayoutDashboard, File } from 'lucide-react'; import { recentItemTypeLabel } from './recentItemTypeLabel.js'; import type { RecentItem } from '../../hooks/useRecentItems.js'; +import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; interface RecentAppsProps { items: RecentItem[]; @@ -36,6 +37,9 @@ const TYPE_ICONS: Record> = export function RecentApps({ items }: RecentAppsProps) { const navigate = useNavigate(); const { t } = useObjectTranslation(); + // The entry stores identity, not text: its label is the item's own, read in + // the current language on this render (objectui#11678). + const recentLabel = useRecentItemLabel(); if (items.length === 0) return null; @@ -75,7 +79,7 @@ export function RecentApps({ items }: RecentAppsProps) {
-

{item.label}

+

{recentLabel(item)}

{typeLabel}

diff --git a/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx index b52f25cd06..f0494f3c1e 100644 --- a/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx @@ -62,6 +62,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => { import { useObjectTranslation } from '@object-ui/i18n'; import { HomeContinue } from '../HomeRail.js'; +import { useRecentItemLabel } from '../../../hooks/useRecentItemLabel.js'; import { RecentApps } from '../RecentApps.js'; import { StarredApps } from '../StarredApps.js'; @@ -79,7 +80,9 @@ const ITEM_LABEL = 'Quarterly revenue'; */ function Rail({ items }: { items: RecentItem[] }) { const { t } = useObjectTranslation(); - return {}} t={t} />; + // HomePage hands the rail its labeller the same way (objectui#11678). + const labelOf = useRecentItemLabel(); + return {}} t={t} labelOf={labelOf} />; } /** @@ -98,15 +101,21 @@ function typeLabelOf(node: HTMLElement, itemLabel: string): string { } function renderAllThree(kind: string) { - const recent: RecentItem[] = [ + // A synthetic kind is no member of the `RecentItem` union, so the entry is + // cast. It carries BOTH `name` (what a named kind — `report` here — is + // labelled from when no metadata is loaded) and `label` (what any other kind + // shows), so every surface renders ITEM_LABEL whichever arm the kind falls in + // (objectui#11678). + const recent = [ { id: 'r1', + name: ITEM_LABEL, label: ITEM_LABEL, href: '/x', - type: kind as RecentItem['type'], + type: kind, visitedAt: new Date().toISOString(), }, - ]; + ] as unknown as RecentItem[]; const favorites: FavoriteItem[] = [ { id: 'f1', diff --git a/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx b/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx new file mode 100644 index 0000000000..0d8f66ac92 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx @@ -0,0 +1,258 @@ +/** + * 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. + */ + +/** + * "Recently Accessed" shows each item's OWN label, resolved when it is + * rendered, in the current language (objectui#11678). + * + * The card measured /home after visiting the showcase's Delivery Operations + * dashboard, its Team Schedule page and its Capability Map page: the list read + * "Showcase Ops Dashboard", "Showcase Task Schedule", "Showcase Capability + * Map" — `titleize` run over the route's machine names at visit time, stored + * in `ui.recent`, and still English after switching to 中文. + * + * Driven end to end with real parts: the route tracker records the three + * visits, the real `RecentItemsProvider` stores them, and both list surfaces + * render them through `useRecentItemLabel` over a real + * `MetadataCtx` and a real i18next instance: the /home rail (`HomeContinue`, + * which `HomePage` hands the resolver as `labelOf`) and the `RecentApps` cards + * (which call it themselves). The metadata is what the `/meta` + * read serves in each language (the console re-reads it on a language switch, + * `MetadataProvider key={language}`), and the zh-CN bundle carries one page + * label, so the three entries take three different roads to their zh-CN text: + * + * - the dashboard: its served label, translated by the server; + * - Team Schedule: its served label is English, the client bundle's + * `pages..label` key translates it; + * - Capability Map: translated nowhere — it shows its own label, which is + * what its own page shows, and never the minted name. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ComponentType } from 'react'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { createI18n, I18nProvider, useObjectTranslation } from '@object-ui/i18n'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' }, isAuthenticated: true, isLoading: false }), +})); + +import { RecentApps } from '../RecentApps.js'; +import { HomeContinue } from '../HomeRail.js'; +import { useRecentItemLabel } from '../../../hooks/useRecentItemLabel.js'; +import { RecentItemsProvider, useRecentItems } from '../../../context/RecentItemsProvider.js'; +import { UserStateAdaptersProvider } from '../../../context/UserStateAdapters.js'; +import { useTrackRouteAsRecent } from '../../../hooks/useTrackRouteAsRecent.js'; + +const APP = 'showcase_app'; +const DASHBOARD = `/apps/${APP}/dashboard/showcase_ops_dashboard`; +const SCHEDULE = `/apps/${APP}/page/showcase_task_schedule`; +const CAPABILITY_MAP = `/apps/${APP}/page/showcase_capability_map`; + +/** What the `/meta` read serves, per language — labels as the showcase authors them. */ +const SERVED = { + en: { + dashboards: [{ name: 'showcase_ops_dashboard', label: 'Delivery Operations' }], + pages: [ + { name: 'showcase_task_schedule', label: 'Team Schedule (Gantt)' }, + { name: 'showcase_capability_map', label: 'Capability Map' }, + ], + objects: [{ name: 'showcase_task', label: 'Tasks' }], + }, + 'zh-CN': { + dashboards: [{ name: 'showcase_ops_dashboard', label: '交付运营' }], + pages: [ + { name: 'showcase_task_schedule', label: 'Team Schedule (Gantt)' }, + { name: 'showcase_capability_map', label: 'Capability Map' }, + ], + objects: [{ name: 'showcase_task', label: '任务' }], + }, +} as const; + +/** The minted text the old tracker stored — none of it may reach the screen. */ +const MINTED = ['Showcase Ops Dashboard', 'Showcase Task Schedule', 'Showcase Capability Map']; + +function metadata(language: keyof typeof SERVED) { + const served = SERVED[language]; + return { + apps: [{ name: APP, label: 'Showcase' }], + objects: served.objects, + dashboards: served.dashboards, + reports: [], + pages: served.pages, + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }; +} + +function newI18n() { + return createI18n({ + defaultLanguage: 'en', + detectBrowserLanguage: false, + resources: { + // The app bundle's page-label key (`useObjectLabel().pageLabel`). + 'zh-CN': { showcase: { pages: { showcase_task_schedule: { label: '团队排期' } } } }, + }, + }); +} + +function Visit({ pathname }: { pathname: string }) { + useTrackRouteAsRecent({ pathname, appName: APP, objects: [...SERVED.en.objects] }); + return null; +} + +function Home() { + const { recentItems } = useRecentItems(); + return ; +} + +/** The /home rail, wired as `HomePage` wires it. */ +function Rail() { + const { recentItems } = useRecentItems(); + const { t } = useObjectTranslation(); + const labelOf = useRecentItemLabel(); + return {}} t={t} labelOf={labelOf} />; +} + +function tree( + i18n: ReturnType, + language: keyof typeof SERVED, + pathname: string, + Surface: ComponentType = Home, +) { + return ( + + + + + + + + + + + + + ); +} + +/** The rail's row labels, most recent first. */ +function railLabels(): string[] { + return screen + .getAllByRole('button') + .map((row) => (row.querySelector('span.truncate')?.textContent ?? '').trim()); +} + +/** The card titles, most recent first. */ +function shownLabels(): string[] { + return screen + .getAllByTestId(/^recent-item-/) + .map((card) => (card.querySelector('h3')?.textContent ?? '').trim()); +} + +beforeEach(() => { + localStorage.clear(); +}); +afterEach(() => cleanup()); + +describe('"Recently Accessed" labels each item when it is rendered (objectui#11678)', () => { + it('the three measured pages show their own labels in en, then in zh-CN — the same stored entries', async () => { + const i18n = newI18n(); + const view = render(tree(i18n, 'en', DASHBOARD)); + view.rerender(tree(i18n, 'en', SCHEDULE)); + view.rerender(tree(i18n, 'en', CAPABILITY_MAP)); + + expect(shownLabels()).toEqual(['Capability Map', 'Team Schedule (Gantt)', 'Delivery Operations']); + + const stored = localStorage.getItem('objectui-recent-items:u:u1') ?? ''; + // Instrument check: the visits were stored, so the next read is of a real list. + expect(stored).toContain('showcase_ops_dashboard'); + // What is stored is identity: no label of any language, and no minted text. + for (const text of [...MINTED, 'Delivery Operations', 'Team Schedule', 'Capability Map']) { + expect(stored).not.toContain(text); + } + + // Switch to 中文. Nothing is re-visited: the stored list is unchanged and + // only the render's language (and the metadata served in it) moved. + await act(async () => { + await i18n.changeLanguage('zh-CN'); + }); + view.rerender(tree(i18n, 'zh-CN', CAPABILITY_MAP)); + + expect(shownLabels()).toEqual(['Capability Map', '团队排期', '交付运营']); + expect(localStorage.getItem('objectui-recent-items:u:u1')).toBe(stored); + }); + + it('the /home rail shows the same labels, in en then zh-CN', async () => { + const i18n = newI18n(); + const view = render(tree(i18n, 'en', DASHBOARD, Rail)); + view.rerender(tree(i18n, 'en', SCHEDULE, Rail)); + view.rerender(tree(i18n, 'en', CAPABILITY_MAP, Rail)); + expect(railLabels()).toEqual(['Capability Map', 'Team Schedule (Gantt)', 'Delivery Operations']); + + await act(async () => { + await i18n.changeLanguage('zh-CN'); + }); + view.rerender(tree(i18n, 'zh-CN', CAPABILITY_MAP, Rail)); + expect(railLabels()).toEqual(['Capability Map', '团队排期', '交付运营']); + for (const text of MINTED) expect(screen.queryByText(text)).toBeNull(); + }); + + it('a list stored before the identity shape shows the items’ current labels, not the minted text', () => { + localStorage.setItem( + 'objectui-recent-items:u:u1', + JSON.stringify([ + { id: 'page:showcase_capability_map', label: 'Showcase Capability Map', href: CAPABILITY_MAP, type: 'page', visitedAt: '2026-10-06T09:02:00.000Z' }, + { id: 'page:showcase_task_schedule', label: 'Showcase Task Schedule', href: SCHEDULE, type: 'page', visitedAt: '2026-10-06T09:01:00.000Z' }, + { id: 'dashboard:showcase_ops_dashboard', label: 'Showcase Ops Dashboard', href: DASHBOARD, type: 'dashboard', visitedAt: '2026-10-06T09:00:00.000Z' }, + // Renamed since the visit: the stored "Task" is stale, the object is "Tasks" now. + { id: 'object:showcase_task', label: 'Task', href: `/apps/${APP}/showcase_task`, type: 'object', visitedAt: '2026-10-06T08:59:00.000Z' }, + ]), + ); + // `/home` — the tracker records nothing here. + render(tree(newI18n(), 'en', '/home')); + + expect(shownLabels()).toEqual(['Capability Map', 'Team Schedule (Gantt)', 'Delivery Operations', 'Tasks']); + for (const text of MINTED) expect(screen.queryByText(text)).toBeNull(); + }); + + it('an item whose metadata has not loaded shows its machine name, then its label once it arrives', () => { + const i18n = newI18n(); + const empty = { ...metadata('en'), dashboards: [], pages: [] }; + const ui = (value: unknown) => ( + + + + + + + + + + + + + ); + const view = render(ui(empty)); + // The navigation renderer's backstop, not a titleized guess. + expect(shownLabels()).toEqual(['showcase_ops_dashboard']); + + view.rerender(ui(metadata('en'))); + expect(shownLabels()).toEqual(['Delivery Operations']); + }); +}); diff --git a/packages/app-shell/src/context/RecentItemsProvider.tsx b/packages/app-shell/src/context/RecentItemsProvider.tsx index 2b86c8afca..2dedb2f431 100644 --- a/packages/app-shell/src/context/RecentItemsProvider.tsx +++ b/packages/app-shell/src/context/RecentItemsProvider.tsx @@ -10,6 +10,12 @@ * - Writes are debounced and pushed to the adapter; localStorage stays in * sync as a cold-start cache. * - Storage key is scoped by `user.id` to avoid cross-account leakage. + * - An object, dashboard, page or report entry is stored by IDENTITY — its + * `type` and `name` — never by display text; its label is resolved when it is + * rendered (`useRecentItemLabel`), in the language of that render + * (objectui#11678). + * - A list equal to the one already persisted is not written again: a visit + * that does not change the list writes nothing, whoever reported the visit. * * The legacy `useRecentItems()` import path (`hooks/useRecentItems`) is * preserved via a re-export shim so existing call sites keep working. @@ -39,19 +45,57 @@ import { // Types // --------------------------------------------------------------------------- -export interface RecentItem { +/** + * Every kind of recent entry. Spelled as one literal union because the + * `home.recentApps.itemType.*` key family is checked against exactly these + * members (`pnpm check:i18n-keys`); the two entry shapes below take their arms + * from it. + */ +export type RecentItemType = 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'metadata'; + +interface RecentItemBase { /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */ id: string; - label: string; href: string; - type: 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'metadata'; - /** ISO timestamp of last visit */ + /** ISO timestamp of the visit that made this entry the most recent one */ visitedAt: string; } +/** + * An object, dashboard, page or report — stored by its identity, `type` and + * `name`, and by no display text (objectui#11678). + * + * Its label is resolved every time it is rendered, from the item's own + * metadata in the current language (`useRecentItemLabel`). A label stored at + * visit time is frozen in the language and the spelling of that moment, and + * the route the tracker reads offers nothing to make one from but the machine + * name — which is how "Showcase Ops Dashboard" came to stand for a dashboard + * labelled "Delivery Operations". + */ +export interface RecentNamedItem extends RecentItemBase { + type: Exclude; + /** The item's machine name: the object's, dashboard's, page's or report's `name`. */ + name: string; +} + +/** + * A record, or a Studio metadata item. Neither has a metadata label to resolve + * when rendered, so the entry carries the text it was visited under: a record's + * display title (data read off the record), a metadata item's name. + */ +export interface RecentTextItem extends RecentItemBase { + type: Extract; + label: string; +} + +export type RecentItem = RecentNamedItem | RecentTextItem; + +/** What `addRecentItem` takes: an entry without its visit time, each kind with its own members. */ +export type RecentItemInput = Omit | Omit; + interface RecentItemsContextValue { recentItems: RecentItem[]; - addRecentItem: (item: Omit) => void; + addRecentItem: (item: RecentItemInput) => void; clearRecentItems: () => void; } @@ -62,10 +106,76 @@ interface RecentItemsContextValue { const STORAGE_BASE_KEY = 'objectui-recent-items'; const MAX_RECENT = 8; +const NAMED_TYPES: ReadonlySet = new Set(['object', 'dashboard', 'page', 'report']); +const TEXT_TYPES: ReadonlySet = new Set(['record', 'metadata']); + +/** + * One stored entry in the shape this provider keeps, or `null` when it is not + * an entry. + * + * Every list this provider holds comes through here — localStorage, the + * adapter's `load()`, another tab's write, and `addRecentItem`'s argument — so + * the persisted shape has one reader, and what is written back is always the + * identity shape. + * + * ⚠️ The lists already in users' `ui.recent` rows and localStorage predate that + * shape: an object, dashboard, page or report entry there carries a `label` + * minted at visit time and no `name` (objectui#11678). That label is NOT read. + * The identity was always in `id` — `:`, the only id the console's + * tracker ever wrote for those four kinds — so the name is read from there and + * the minted text is dropped, which puts the item's current label in its place + * on the next render. An entry of those kinds whose `id` carries no `:` + * prefix names nothing that can be resolved, and is dropped. + */ +function readEntry(raw: unknown): RecentItem | null { + if (!raw || typeof raw !== 'object') return null; + const entry = raw as Record; + const { id, type, href } = entry; + if (typeof id !== 'string' || typeof type !== 'string' || typeof href !== 'string') return null; + const visitedAt = typeof entry.visitedAt === 'string' ? entry.visitedAt : ''; + if (NAMED_TYPES.has(type)) { + const prefix = `${type}:`; + const name = + typeof entry.name === 'string' + ? entry.name + : id.startsWith(prefix) + ? id.slice(prefix.length) + : ''; + if (name === '') return null; + return { id, type: type as RecentNamedItem['type'], name, href, visitedAt }; + } + if (TEXT_TYPES.has(type) && typeof entry.label === 'string') { + return { id, type: type as RecentTextItem['type'], label: entry.label, href, visitedAt }; + } + return null; +} + +/** A stored list, read entry by entry through {@link readEntry} and capped. */ +function readList(raw: unknown): RecentItem[] { + if (!Array.isArray(raw)) return []; + const items: RecentItem[] = []; + for (const value of raw) { + const item = readEntry(value); + if (item) items.push(item); + } + return items.slice(0, MAX_RECENT); +} + +/** What makes an entry this entry — everything but its visit time. */ +function contentOf(item: RecentItem): string { + switch (item.type) { + case 'record': + case 'metadata': + return JSON.stringify([item.id, item.type, item.href, item.label]); + default: + return JSON.stringify([item.id, item.type, item.href, item.name]); + } +} + function loadRecent(userId?: string | null): RecentItem[] { try { const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId)); - return raw ? JSON.parse(raw) : []; + return raw ? readList(JSON.parse(raw)) : []; } catch { return []; } @@ -96,17 +206,26 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) { const [recentItems, setRecentItems] = useState(() => loadRecent(userId)); + // The list as last installed — always the one the persisted copies hold, + // since every list arrives either FROM storage (`adopt`) or is written TO it + // (`commit`). Decisions read it outside any state updater: an updater may run + // more than once for one update (React StrictMode does it on purpose), and a + // write made inside one runs again with it. + const itemsRef = useRef(recentItems); + + /** Install a list that was just read from storage, so it is already persisted. */ + const adopt = useCallback((items: RecentItem[]) => { + itemsRef.current = items; + setRecentItems(items); + }, []); + useEffect(() => { - setRecentItems(loadRecent(userId)); - }, [userId]); + adopt(loadRecent(userId)); + }, [userId, adopt]); // Cross-tab sync — see FavoritesProvider for the rationale. useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => { - setRecentItems( - Array.isArray(value) - ? value.filter(it => it && typeof (it as any).id === 'string').slice(0, MAX_RECENT) - : [], - ); + adopt(readList(value)); }); const hydrationToken = useRef(0); @@ -118,10 +237,8 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) { try { const remote = await adapter.load(); if (cancelled || token !== hydrationToken.current) return; - const sane = (Array.isArray(remote) ? remote : []) - .filter(it => it && typeof (it as any).id === 'string') - .slice(0, MAX_RECENT); - setRecentItems(sane); + const sane = readList(remote); + adopt(sane); saveRecent(sane, userId); } catch { // ignore — degrade to localStorage @@ -130,7 +247,7 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; }; - }, [adapter, userId]); + }, [adapter, userId, adopt]); const flusher = useMemo( () => createDebouncedFlush(async items => { @@ -140,8 +257,15 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) { ); useEffect(() => () => { void flusher.flush(); }, [flusher]); + // The store's own guard (objectui#11678): whoever asks for the write, a list + // equal to the one already held — and so already persisted — changes no + // state, writes no localStorage and calls no `adapter.save()`: no + // `sys_user_preference` PATCH, and so no audit row for one. const commit = useCallback( (next: RecentItem[]) => { + if (JSON.stringify(next) === JSON.stringify(itemsRef.current)) return; + itemsRef.current = next; + setRecentItems(next); saveRecent(next, userId); if (adapter) flusher.schedule(next); }, @@ -149,22 +273,21 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) { ); const addRecentItem = useCallback( - (item: Omit) => { - setRecentItems(prev => { - const filtered = prev.filter(r => r.id !== item.id); - const updated = [ - { ...item, visitedAt: new Date().toISOString() }, - ...filtered, - ].slice(0, MAX_RECENT); - commit(updated); - return updated; - }); + (input: RecentItemInput) => { + const item = readEntry({ ...input, visitedAt: new Date().toISOString() }); + if (!item) return; + const prev = itemsRef.current; + // Revisiting the entry already at the head changes nothing, so it records + // nothing — not even a fresher `visitedAt`, which would be a write + // carrying no information. The head's `visitedAt` is when it BECAME the + // most recent entry. + if (prev.length > 0 && contentOf(prev[0]) === contentOf(item)) return; + commit([item, ...prev.filter(r => r.id !== item.id)].slice(0, MAX_RECENT)); }, [commit], ); const clearRecentItems = useCallback(() => { - setRecentItems([]); commit([]); }, [commit]); diff --git a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx index 2ddcc14186..cc82769f45 100644 --- a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx +++ b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx @@ -58,7 +58,7 @@ describe('RecentItemsProvider', () => { it('hydrates synchronously from user-scoped localStorage on mount', () => { const seeded: RecentItem[] = [ - { id: 'object:c', label: 'C', href: '/c', type: 'object', visitedAt: '2024-01-01' }, + { id: 'object:c', type: 'object', name: 'c', href: '/c', visitedAt: '2024-01-01' }, ]; localStorage.setItem('objectui-recent-items:u:user-1', JSON.stringify(seeded)); @@ -69,12 +69,12 @@ describe('RecentItemsProvider', () => { it('addRecentItem prepends and updates timestamp on revisit', () => { const { result } = renderHook(() => useRecentItems(), { wrapper }); - act(() => result.current.addRecentItem({ id: 'a', label: 'A', href: '/a', type: 'object' })); - act(() => result.current.addRecentItem({ id: 'b', label: 'B', href: '/b', type: 'object' })); - act(() => result.current.addRecentItem({ id: 'a', label: 'A', href: '/a', type: 'object' })); + act(() => result.current.addRecentItem({ id: 'object:a', type: 'object', name: 'a', href: '/a' })); + act(() => result.current.addRecentItem({ id: 'object:b', type: 'object', name: 'b', href: '/b' })); + act(() => result.current.addRecentItem({ id: 'object:a', type: 'object', name: 'a', href: '/a' })); // 'a' moves back to the front; no duplicate. - expect(result.current.recentItems.map(r => r.id)).toEqual(['a', 'b']); + expect(result.current.recentItems.map(r => r.id)).toEqual(['object:a', 'object:b']); }); it('caps recent items at 8', () => { @@ -83,48 +83,48 @@ describe('RecentItemsProvider', () => { act(() => { for (let i = 0; i < 12; i++) { result.current.addRecentItem({ - id: `r-${i}`, - label: `R ${i}`, - href: `/r/${i}`, + id: `object:r-${i}`, type: 'object', + name: `r-${i}`, + href: `/r/${i}`, }); } }); expect(result.current.recentItems).toHaveLength(8); // Most recent first. - expect(result.current.recentItems[0].id).toBe('r-11'); + expect(result.current.recentItems[0].id).toBe('object:r-11'); }); it('persists to user-scoped localStorage only', () => { const { result } = renderHook(() => useRecentItems(), { wrapper }); act(() => - result.current.addRecentItem({ id: 'x', label: 'X', href: '/x', type: 'page' }), + result.current.addRecentItem({ id: 'page:x', type: 'page', name: 'x', href: '/x' }), ); expect(localStorage.getItem('objectui-recent-items')).toBeNull(); const stored = JSON.parse(localStorage.getItem('objectui-recent-items:u:user-1') || '[]'); - expect(stored[0]).toMatchObject({ id: 'x', type: 'page' }); + expect(stored[0]).toMatchObject({ id: 'page:x', type: 'page', name: 'x' }); }); it('hydrates from adapter and overrides localStorage state', async () => { localStorage.setItem( 'objectui-recent-items:u:user-1', - JSON.stringify([{ id: 'local', label: 'L', href: '/l', type: 'object', visitedAt: 't' }]), + JSON.stringify([{ id: 'object:local', type: 'object', name: 'local', href: '/l', visitedAt: 't' }]), ); const remote: RecentItem[] = [ - { id: 'remote', label: 'R', href: '/r', type: 'dashboard', visitedAt: 't' }, + { id: 'dashboard:remote', type: 'dashboard', name: 'remote', href: '/r', visitedAt: 't' }, ]; const adapter = makeAdapter(remote); const { result } = renderHook(() => useHarness(), { wrapper }); - expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['local']); + expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['object:local']); act(() => result.current.attach('recent', adapter)); await waitFor(() => { - expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['remote']); + expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['dashboard:remote']); }); expect(JSON.parse(localStorage.getItem('objectui-recent-items:u:user-1') || '[]')).toEqual(remote); }); @@ -138,22 +138,22 @@ describe('RecentItemsProvider', () => { adapter.saveMock.mockClear(); act(() => { - result.current.recent.addRecentItem({ id: '1', label: '1', href: '/1', type: 'object' }); - result.current.recent.addRecentItem({ id: '2', label: '2', href: '/2', type: 'object' }); - result.current.recent.addRecentItem({ id: '3', label: '3', href: '/3', type: 'object' }); + result.current.recent.addRecentItem({ id: 'object:1', type: 'object', name: '1', href: '/1' }); + result.current.recent.addRecentItem({ id: 'object:2', type: 'object', name: '2', href: '/2' }); + result.current.recent.addRecentItem({ id: 'object:3', type: 'object', name: '3', href: '/3' }); }); expect(adapter.saveMock).not.toHaveBeenCalled(); await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 }); const persisted = adapter.saveMock.mock.calls[0][0] as RecentItem[]; - expect(persisted.map(p => p.id).sort()).toEqual(['1', '2', '3']); + expect(persisted.map(p => p.id).sort()).toEqual(['object:1', 'object:2', 'object:3']); }); it('clearRecentItems empties the list and persists []', () => { const { result } = renderHook(() => useRecentItems(), { wrapper }); - act(() => result.current.addRecentItem({ id: 'a', label: 'A', href: '/a', type: 'object' })); + act(() => result.current.addRecentItem({ id: 'object:a', type: 'object', name: 'a', href: '/a' })); act(() => result.current.clearRecentItems()); expect(result.current.recentItems).toEqual([]); @@ -164,7 +164,206 @@ describe('RecentItemsProvider', () => { const { result } = renderHook(() => useRecentItems()); expect(result.current.recentItems).toEqual([]); expect(() => - result.current.addRecentItem({ id: 'x', label: 'X', href: '/x', type: 'object' }), + result.current.addRecentItem({ id: 'object:x', type: 'object', name: 'x', href: '/x' }), ).not.toThrow(); }); }); + +/** + * objectui#11678 — "a visit that does not change the list writes nothing" is a + * property of the STORE, not of whoever reports the visit: the route tracker, + * a record page, a host's own call. Each write the provider makes is a + * localStorage write plus an `adapter.save()` — a `sys_user_preference` PATCH + * and an audit row — so these count both. + */ +describe('RecentItemsProvider — no change, no write (objectui#11678)', () => { + const KEY = 'objectui-recent-items:u:user-1'; + + beforeEach(() => { + localStorage.clear(); + }); + + /** An attached, hydrated adapter whose saves are counted from zero. */ + async function mountWithAdapter() { + const adapter = makeAdapter([]); + const view = renderHook(() => useHarness(), { wrapper }); + act(() => view.result.current.attach('recent', adapter)); + await waitFor(() => expect(adapter.loadMock).toHaveBeenCalled()); + await act(async () => { + await Promise.resolve(); + }); + adapter.saveMock.mockClear(); + return { ...view, adapter }; + } + + /** Every localStorage write to the recent-items key, counted. */ + function countStorageWrites() { + const spy = vi.spyOn(Storage.prototype, 'setItem'); + return { + count: () => spy.mock.calls.filter(([key]) => key === KEY).length, + restore: () => spy.mockRestore(), + }; + } + + it('re-adding the entry already at the head writes nothing — not even a fresher visitedAt', async () => { + const { result, adapter } = await mountWithAdapter(); + act(() => + result.current.recent.addRecentItem({ id: 'dashboard:ops', type: 'dashboard', name: 'ops', href: '/d/ops' }), + ); + await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 }); + const visitedAt = result.current.recent.recentItems[0].visitedAt; + + const writes = countStorageWrites(); + try { + act(() => + result.current.recent.addRecentItem({ id: 'dashboard:ops', type: 'dashboard', name: 'ops', href: '/d/ops' }), + ); + await new Promise(r => setTimeout(r, 700)); + expect(writes.count()).toBe(0); + } finally { + writes.restore(); + } + expect(adapter.saveMock).toHaveBeenCalledTimes(1); + expect(result.current.recent.recentItems[0].visitedAt).toBe(visitedAt); + }); + + it('clearing an already-empty list writes nothing', async () => { + const { result, adapter } = await mountWithAdapter(); + const writes = countStorageWrites(); + try { + act(() => result.current.recent.clearRecentItems()); + await new Promise(r => setTimeout(r, 700)); + expect(writes.count()).toBe(0); + } finally { + writes.restore(); + } + expect(adapter.saveMock).not.toHaveBeenCalled(); + }); + + it('control: a head whose content changed (a record title that resolved) IS written', async () => { + const { result, adapter } = await mountWithAdapter(); + act(() => + result.current.recent.addRecentItem({ id: 'record:acct:1', type: 'record', label: 'acct-1', href: '/r/1' }), + ); + await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 }); + act(() => + result.current.recent.addRecentItem({ id: 'record:acct:1', type: 'record', label: 'Acme Corp', href: '/r/1' }), + ); + await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(2), { timeout: 1500 }); + expect(result.current.recent.recentItems).toEqual([ + { id: 'record:acct:1', type: 'record', label: 'Acme Corp', href: '/r/1', visitedAt: expect.any(String) }, + ]); + }); +}); + +/** + * objectui#11678 — the lists already in users' `ui.recent` rows carry the old + * shape: an object / dashboard / page / report entry with a `label` minted at + * visit time ("Showcase Ops Dashboard") and no `name`. The reader keeps the + * entry and drops the minted text: the identity was always in `id` + * (`:`), so the item's current label replaces it on render. + */ +describe('RecentItemsProvider — reading a list stored before the identity shape (objectui#11678)', () => { + const LEGACY = [ + { + id: 'dashboard:showcase_ops_dashboard', + label: 'Showcase Ops Dashboard', + href: '/apps/showcase_app/dashboard/showcase_ops_dashboard', + type: 'dashboard', + visitedAt: '2026-10-06T09:00:00.000Z', + }, + { + id: 'object:showcase_task', + label: 'Task', + href: '/apps/showcase_app/showcase_task', + type: 'object', + visitedAt: '2026-10-06T08:59:00.000Z', + }, + // A record and a Studio metadata item keep their text: neither has a + // metadata label to resolve. + { + id: 'record:showcase_task:t1', + label: 'Write the brief', + href: '/apps/showcase_app/showcase_task/record/t1', + type: 'record', + visitedAt: '2026-10-06T08:58:00.000Z', + }, + { + id: 'metadata:object:sys_user', + label: 'sys_user', + href: '/apps/setup/metadata/object/sys_user', + type: 'metadata', + visitedAt: '2026-10-06T08:57:00.000Z', + }, + // A host's own write whose id carries no `:` names nothing to resolve. + { id: 'my-page', label: 'Mine', href: '/x', type: 'page', visitedAt: '2026-10-06T08:56:00.000Z' }, + ]; + const READ: RecentItem[] = [ + { + id: 'dashboard:showcase_ops_dashboard', + type: 'dashboard', + name: 'showcase_ops_dashboard', + href: '/apps/showcase_app/dashboard/showcase_ops_dashboard', + visitedAt: '2026-10-06T09:00:00.000Z', + }, + { + id: 'object:showcase_task', + type: 'object', + name: 'showcase_task', + href: '/apps/showcase_app/showcase_task', + visitedAt: '2026-10-06T08:59:00.000Z', + }, + { + id: 'record:showcase_task:t1', + type: 'record', + label: 'Write the brief', + href: '/apps/showcase_app/showcase_task/record/t1', + visitedAt: '2026-10-06T08:58:00.000Z', + }, + { + id: 'metadata:object:sys_user', + type: 'metadata', + label: 'sys_user', + href: '/apps/setup/metadata/object/sys_user', + visitedAt: '2026-10-06T08:57:00.000Z', + }, + ]; + + beforeEach(() => { + localStorage.clear(); + }); + + it('from localStorage: the identity is read out of the id, the minted label is dropped', () => { + localStorage.setItem('objectui-recent-items:u:user-1', JSON.stringify(LEGACY)); + const { result } = renderHook(() => useRecentItems(), { wrapper }); + expect(result.current.recentItems).toEqual(READ); + }); + + it('from the adapter: the same read, and reading it writes nothing back', async () => { + const adapter = makeAdapter(LEGACY as unknown as RecentItem[]); + const { result } = renderHook(() => useHarness(), { wrapper }); + act(() => result.current.attach('recent', adapter)); + await waitFor(() => expect(result.current.recent.recentItems).toEqual(READ)); + await new Promise(r => setTimeout(r, 700)); + expect(adapter.saveMock).not.toHaveBeenCalled(); + }); + + it('the next change writes the identity shape, with no legacy label left in it', async () => { + const adapter = makeAdapter(LEGACY as unknown as RecentItem[]); + const { result } = renderHook(() => useHarness(), { wrapper }); + act(() => result.current.attach('recent', adapter)); + await waitFor(() => expect(result.current.recent.recentItems).toEqual(READ)); + act(() => + result.current.recent.addRecentItem({ + id: 'page:showcase_capability_map', + type: 'page', + name: 'showcase_capability_map', + href: '/apps/showcase_app/page/showcase_capability_map', + }), + ); + await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 }); + const saved = adapter.saveMock.mock.calls[0][0] as RecentItem[]; + expect(saved.slice(1)).toEqual(READ); + expect(JSON.stringify(saved)).not.toContain('Showcase Ops Dashboard'); + }); +}); diff --git a/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx new file mode 100644 index 0000000000..d316443c73 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx @@ -0,0 +1,92 @@ +/** + * 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. + */ + +/** + * `useRecentItemLabel` — each kind of recent entry is labelled by the resolver + * the console already has for that kind, and nothing is minted (objectui#11678). + * + * The dashboard / page / zh-CN roads are pinned through the real Home cards in + * `console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx`; this file + * covers the kinds Home does not list (a report) and the two kinds that carry + * their own text. + */ + +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 type { RecentItem } from '../../context/RecentItemsProvider'; + +function metadata() { + return { + apps: [], + objects: [{ name: 'showcase_task', label: 'Tasks' }], + dashboards: [], + reports: [{ name: 'q3_results', label: 'Q3 Results' }], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }; +} + +function wrapper(language: string) { + const i18n = createI18n({ + defaultLanguage: language, + detectBrowserLanguage: false, + resources: { 'zh-CN': { showcase: { reports: { q3_results: { label: '第三季度结果' } } } } }, + }); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +const at = '2026-10-06T09:00:00.000Z'; +const REPORT: RecentItem = { id: 'report:q3_results', type: 'report', name: 'q3_results', href: '/r', visitedAt: at }; + +describe('useRecentItemLabel (objectui#11678)', () => { + it('a report: its metadata label in en, its bundle label in zh-CN', () => { + const en = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') }); + expect(en.result.current(REPORT)).toBe('Q3 Results'); + const zh = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN') }); + expect(zh.result.current(REPORT)).toBe('第三季度结果'); + }); + + it('an object: its current metadata label', () => { + const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') }); + expect( + result.current({ id: 'object:showcase_task', type: 'object', name: 'showcase_task', href: '/o', visitedAt: at }), + ).toBe('Tasks'); + }); + + it('an item with no metadata label shows its machine name — never a titleized one', () => { + const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') }); + expect( + result.current({ id: 'page:welcome_tour', type: 'page', name: 'welcome_tour', href: '/p', visitedAt: at }), + ).toBe('welcome_tour'); + }); + + it('a record and a Studio metadata item show the text they carry', () => { + const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN') }); + expect( + result.current({ id: 'record:showcase_task:t1', type: 'record', label: 'Write the brief', href: '/x', visitedAt: at }), + ).toBe('Write the brief'); + expect( + result.current({ id: 'metadata:object:sys_user', type: 'metadata', label: 'sys_user', href: '/m', visitedAt: at }), + ).toBe('sys_user'); + }); +}); diff --git a/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx b/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx index d1047e1095..c0d221d789 100644 --- a/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx @@ -6,9 +6,9 @@ * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook } from '@testing-library/react'; -import type { ReactNode } from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act, render, renderHook } from '@testing-library/react'; +import { useEffect, type ReactNode } from 'react'; vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), @@ -21,7 +21,11 @@ import { useRecentItems, type RecentItem, } from '../../context/RecentItemsProvider'; -import { UserStateAdaptersProvider } from '../../context/UserStateAdapters'; +import { + UserStateAdaptersProvider, + useAttachUserStateAdapters, + type UserDataAdapter, +} from '../../context/UserStateAdapters'; function wrapper({ children }: { children: ReactNode }) { return ( @@ -47,46 +51,66 @@ describe('useTrackRouteAsRecent', () => { localStorage.clear(); }); - it('records an object route', () => { + // objectui#11678 — an object, dashboard, page or report is recorded by its + // identity (`type` + `name`) and with NO label: the route has nothing but the + // machine name to make one from, and the label is resolved where the entry + // is rendered (`useRecentItemLabel`). `toEqual`, not `toMatchObject`, so a + // `label` coming back fails the pin. + it('records an object route by identity, with no label', () => { const { result } = renderHook( () => useHarness('/apps/sales/contact', 'sales', [{ name: 'contact', label: 'Contacts' }]), { wrapper }, ); - expect(result.current[0]).toMatchObject({ + expect(result.current[0]).toEqual({ id: 'object:contact', - label: 'Contacts', - href: '/apps/sales/contact', type: 'object', + name: 'contact', + href: '/apps/sales/contact', + visitedAt: expect.any(String), }); }); - it('records a dashboard route', () => { + it('records a dashboard route by identity — no label minted from the slug', () => { const { result } = renderHook( () => useHarness('/apps/sales/dashboard/sales_overview', 'sales'), { wrapper }, ); - expect(result.current[0]).toMatchObject({ + expect(result.current[0]).toEqual({ id: 'dashboard:sales_overview', - label: 'Sales Overview', type: 'dashboard', + name: 'sales_overview', + href: '/apps/sales/dashboard/sales_overview', + visitedAt: expect.any(String), }); }); - it('records a page route', () => { + it('records a page route by identity', () => { const { result } = renderHook( () => useHarness('/apps/cs/page/welcome-tour', 'cs'), { wrapper }, ); - expect(result.current[0]).toMatchObject({ id: 'page:welcome-tour', type: 'page' }); + expect(result.current[0]).toEqual({ + id: 'page:welcome-tour', + type: 'page', + name: 'welcome-tour', + href: '/apps/cs/page/welcome-tour', + visitedAt: expect.any(String), + }); }); - it('records a report route', () => { + it('records a report route by identity', () => { const { result } = renderHook( () => useHarness('/apps/sales/report/q3-results', 'sales'), { wrapper }, ); - expect(result.current[0]).toMatchObject({ id: 'report:q3-results', type: 'report' }); + expect(result.current[0]).toEqual({ + id: 'report:q3-results', + type: 'report', + name: 'q3-results', + href: '/apps/sales/report/q3-results', + visitedAt: expect.any(String), + }); }); it('records a Studio metadata item route', () => { @@ -190,3 +214,105 @@ describe('useTrackRouteAsRecent', () => { expect(ids).toEqual(['object:order', 'object:contact']); }); }); + +/** + * objectui#11678 — "revisiting the most recent item makes no write". + * + * Measured before the fix (the real provider and this hook over a counting + * adapter): coming back to the entry already at the head of the list, and + * moving from an object's list route to one of its view routes, each saved the + * list a second time — identical but for a fresher `visitedAt` — and every + * save is a `sys_user_preference` PATCH. These drive the hook through real + * route changes and count `adapter.save()` calls; the provider's own pins are + * in `RecentItemsProvider.test.tsx`. + */ +describe('useTrackRouteAsRecent — a visit that does not change the list writes nothing (objectui#11678)', () => { + const OBJECTS = [{ name: 'showcase_task', label: 'Task' }]; + + function makeAdapter() { + const save = vi.fn().mockResolvedValue(undefined); + const adapter: UserDataAdapter = { load: vi.fn().mockResolvedValue([]), save }; + return { adapter, save }; + } + + function Visit({ pathname, adapter }: { pathname: string; adapter: UserDataAdapter }) { + const attach = useAttachUserStateAdapters(); + useEffect(() => { + attach('recent', adapter); + return () => attach('recent', null); + }, [attach, adapter]); + useTrackRouteAsRecent({ pathname, appName: 'showcase_app', objects: OBJECTS }); + return null; + } + + /** Let hydration settle and the 500ms write debounce run out. */ + async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(700); + }); + } + + /** + * Mount on a route the hook ignores, so the adapter is attached and hydrated + * before the first tracked visit (a visit landing before hydration resolves + * is overwritten by it — a separate race this file does not pin). + */ + async function mount(adapter: UserDataAdapter) { + const ui = (pathname: string) => wrapper({ children: }); + const view = render(ui('/home')); + await settle(); + return (pathname: string) => view.rerender(ui(pathname)); + } + + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('revisiting the most recent item makes no write', async () => { + const { adapter, save } = makeAdapter(); + const go = await mount(adapter); + + go('/apps/showcase_app/dashboard/showcase_ops_dashboard'); + await settle(); + expect(save).toHaveBeenCalledTimes(1); + + go('/home'); + await settle(); + go('/apps/showcase_app/dashboard/showcase_ops_dashboard'); + await settle(); + expect(save).toHaveBeenCalledTimes(1); + }); + + it('an object’s list route and its view route are one visit, saved once', async () => { + const { adapter, save } = makeAdapter(); + const go = await mount(adapter); + + go('/apps/showcase_app/showcase_task'); + await settle(); + go('/apps/showcase_app/showcase_task/view/all_tasks'); + await settle(); + expect(save).toHaveBeenCalledTimes(1); + }); + + it('control: a visit that DOES change the list is saved', async () => { + const { adapter, save } = makeAdapter(); + const go = await mount(adapter); + + go('/apps/showcase_app/dashboard/showcase_ops_dashboard'); + await settle(); + go('/apps/showcase_app/page/showcase_task_schedule'); + await settle(); + go('/apps/showcase_app/dashboard/showcase_ops_dashboard'); + await settle(); + expect(save).toHaveBeenCalledTimes(3); + const last = save.mock.calls[2][0] as RecentItem[]; + expect(last.map(r => r.id)).toEqual([ + 'dashboard:showcase_ops_dashboard', + 'page:showcase_task_schedule', + ]); + }); +}); diff --git a/packages/app-shell/src/hooks/useNavTargetLabel.ts b/packages/app-shell/src/hooks/useNavTargetLabel.ts index 8fb687bee0..c65f5f3004 100644 --- a/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -53,8 +53,12 @@ import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout'; import { useMetadata } from '../providers/MetadataProvider.js'; -/** A metadata label (plain string or inline locale map) as display text, or `undefined` when there is none. */ -function metadataText(label: unknown, language: string | undefined): string | undefined { +/** + * A metadata label (plain string or inline locale map) as display text, or + * `undefined` when there is none. Shared with `useRecentItemLabel`, which asks + * the same question of a page or a report (objectui#11678). + */ +export function metadataText(label: unknown, language: string | undefined): string | undefined { const text = resolveInlineI18nLabel(label as Parameters[0], language); return typeof text === 'string' && text.trim() !== '' ? text : undefined; } @@ -65,13 +69,14 @@ function nonEmpty(text: string | undefined): string | undefined { } /** The two members this hook reads off a cached metadata record. */ -interface LabelledRecord { +export interface LabelledRecord { name?: unknown; label?: unknown; listViews?: unknown; } -function findByName(items: readonly unknown[] | undefined, name: string): LabelledRecord | undefined { +/** The cached metadata record named `name`, or `undefined`. Shared with `useRecentItemLabel`. */ +export function findByName(items: readonly unknown[] | undefined, name: string): LabelledRecord | undefined { return (items ?? []).find( (item): item is LabelledRecord => !!item && typeof item === 'object' && (item as LabelledRecord).name === name, diff --git a/packages/app-shell/src/hooks/useRecentItemLabel.ts b/packages/app-shell/src/hooks/useRecentItemLabel.ts new file mode 100644 index 0000000000..7e7289ba81 --- /dev/null +++ b/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * useRecentItemLabel — what a "recently accessed" entry is called NOW, in the + * current language (objectui#11678). + * + * An object, dashboard, page or report entry stores its identity only + * (`RecentNamedItem`: `type` + `name`), so this hook is where its text comes + * from — on every render, never stored. A rename, or a switch of language, + * shows on the next render; nothing is minted from the route. + * + * 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); + * - 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 + * the navigation ruling names none, so the pair is composed here from the + * same two helpers it uses. + * + * With no label anywhere the entry shows its machine name — the backstop the + * navigation renderer uses too, including before the type's metadata has + * loaded. + * + * A record or a Studio metadata item (`RecentTextItem`) carries its own text, + * which is returned as stored. + */ + +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'; + +/** Answers the display text of a recent entry, in the current language. */ +export type RecentItemLabelResolver = (item: RecentItem) => string; + +/** `''` from a bundle lookup that found nothing and had no metadata to fall back to. */ +function nonEmpty(text: string | undefined): string | undefined { + return typeof text === 'string' && text.trim() !== '' ? text : undefined; +} + +export function useRecentItemLabel(): RecentItemLabelResolver { + const targetLabel = useNavTargetLabel(); + const metadata = useMetadata(); + const { 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 'dashboard': + return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name; + case 'page': { + const text = metadataText(findByName(metadata.pages, item.name)?.label, language); + return nonEmpty(pageLabel({ name: item.name, label: text ?? '' })) ?? item.name; + } + case 'report': { + const text = metadataText(findByName(metadata.reports, item.name)?.label, language); + return nonEmpty(reportLabel({ name: item.name, label: text ?? '' })) ?? item.name; + } + default: + return item.label; + } + }, + [targetLabel, metadata, pageLabel, reportLabel, language], + ); +} diff --git a/packages/app-shell/src/hooks/useRecentItems.ts b/packages/app-shell/src/hooks/useRecentItems.ts index 57947fe003..15998114f6 100644 --- a/packages/app-shell/src/hooks/useRecentItems.ts +++ b/packages/app-shell/src/hooks/useRecentItems.ts @@ -11,4 +11,15 @@ * @module */ -export { useRecentItems, type RecentItem } from '../context/RecentItemsProvider.js'; +export { + useRecentItems, + type RecentItem, + type RecentItemInput, + type RecentItemType, + type RecentNamedItem, + type RecentTextItem, +} from '../context/RecentItemsProvider.js'; +// ⛔ Not `useRecentItemLabel`: it reads the metadata cache, so re-exporting it +// here would make every module that only wants the list — `RecordDetailView` +// among them — load `MetadataProvider` and its metadata client at module load +// (objectui#11678). The entry exports it from its own module. diff --git a/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts b/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts index a1208ab7e1..ebe80a5a9b 100644 --- a/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts +++ b/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts @@ -19,8 +19,16 @@ * /apps/:appName/metadata/:type/:name * /apps/:appName/metadata/:type/:name/history * - * Pass `objects` (the list available in the current app) so we can resolve - * a human-readable label for object routes. + * Pass `objects` (the list available in the current app) so only an object + * the app has is recorded. + * + * An object, dashboard, page or report is recorded by IDENTITY — its type and + * its `name` — and with no label (objectui#11678). The route holds nothing but + * the machine name, so a label made here could only be that name dressed up + * (`titleize` used to turn `showcase_ops_dashboard` into "Showcase Ops + * Dashboard" for a dashboard labelled "Delivery Operations"), frozen in the + * visit's language. The label is resolved where the entry is rendered, from the + * item's metadata in the current language: `useRecentItemLabel`. * * @module */ @@ -29,14 +37,14 @@ import { useRecentItems } from '../context/RecentItemsProvider.js'; import type { ObjectLike as SpecObjectLike } from '@objectstack/spec/system'; /** - * The two members this hook reads off an object in the current app, TAKEN from + * The two members this hook's callers hand it for an object, TAKEN from * `@objectstack/spec/system`'s `ObjectLike` rather than restated under its name * (objectui#7265). * * That export is the spec's own minimal projection of an object metadata * document — the one `translateObject` consumes — and `name` / `label` mean * exactly what they mean here: the object's identifier and its display label. - * This hook needs no more than those two, so it PICKS them; the alternative, + * This hook takes no more than those two, so it PICKS them; the alternative, * importing the whole shape, would drag in `fields` / `actions` / `pluralLabel` * that no caller of this hook supplies and this hook never reads. * @@ -44,6 +52,10 @@ import type { ObjectLike as SpecObjectLike } from '@objectstack/spec/system'; * members wide, and it stops compiling the day the spec renames or retires * either one — which a hand-written `{ name: string; label?: string }` never * would. Pinned in `spec-symbol-parity.test.ts`. + * + * Since objectui#11678 the hook reads `name` only: an object is recorded by + * identity and labelled when rendered. `label` stays on the projection so the + * published options type does not move under its callers. */ type ObjectLike = Pick; @@ -52,7 +64,7 @@ export interface UseTrackRouteAsRecentOptions { pathname: string; /** Currently selected app name. Used to build the `href` and namespace. */ appName: string | undefined; - /** Objects available in the current app — used to resolve labels. */ + /** Objects available in the current app — an object route is recorded only for one of these. */ objects?: ObjectLike[]; /** Optional override; defaults to `/apps`. */ basePathSegment?: string; @@ -63,10 +75,6 @@ export interface UseTrackRouteAsRecentOptions { /** Segments after `appName` that are NOT object names but route prefixes. */ const ROUTE_PREFIXES = new Set(['view', 'record', 'page', 'dashboard', 'design', 'report']); -function titleize(slug: string): string { - return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -} - /** Decode a URL path segment, falling back to the raw value on malformed input. */ function safeDecode(value: string): string { try { @@ -128,9 +136,9 @@ export function useTrackRouteAsRecent({ if (obj) { addRecentItem({ id: `object:${obj.name}`, - label: obj.label || obj.name, - href: `${basePath}/${obj.name}`, type: 'object', + name: obj.name, + href: `${basePath}/${obj.name}`, }); } return; @@ -140,29 +148,17 @@ export function useTrackRouteAsRecent({ switch (seg2) { case 'dashboard': - addRecentItem({ - id: `dashboard:${seg3}`, - label: titleize(seg3), - href: `${basePath}/dashboard/${seg3}`, - type: 'dashboard', - }); - break; case 'page': + case 'report': { + const name = safeDecode(seg3); addRecentItem({ - id: `page:${seg3}`, - label: titleize(seg3), - href: `${basePath}/page/${seg3}`, - type: 'page', - }); - break; - case 'report': - addRecentItem({ - id: `report:${seg3}`, - label: titleize(seg3), - href: `${basePath}/report/${seg3}`, - type: 'report', + id: `${seg2}:${name}`, + type: seg2, + name, + href: `${basePath}/${seg2}/${seg3}`, }); break; + } default: break; } diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index b98ed749a7..456fcfbd77 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -46,6 +46,9 @@ export { type ObjectUiGlobal, } from './observability/settleSignal.js'; export { useRecentItems } from './hooks/useRecentItems.js'; +// From its own module, not the `useRecentItems` shim: it reads the metadata +// cache, and the shim must not drag that into a list-only importer's graph. +export { useRecentItemLabel } from './hooks/useRecentItemLabel.js'; // Types export type { @@ -64,7 +67,12 @@ export type { export type { RecentItem, + RecentItemInput, + RecentItemType, + RecentNamedItem, + RecentTextItem, } from './hooks/useRecentItems.js'; +export type { RecentItemLabelResolver } from './hooks/useRecentItemLabel.js'; // Console building blocks — compose these in your App.tsx to build the console // routing tree. See examples/console-starter/src/App.tsx for a minimal example. diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index de8ef08f83..9fbf38b65a 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -45,6 +45,7 @@ import { useExpressionContext, evaluateVisibility } from '../providers/Expressio import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { useRecentItems } from '../hooks/useRecentItems.js'; +import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js'; import { useFavorites } from '../hooks/useFavorites.js'; import { useNavPins } from '../hooks/useNavPins.js'; import { useNavActionDispatch } from '../hooks/useNavActionDispatch.js'; @@ -279,6 +280,9 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { }, [isMobile, setOpenMobile]); const { recentItems } = useRecentItems(); + // A recent entry's text is the item's own, resolved on this render + // (objectui#11678); the entry stores identity only. + const recentLabel = useRecentItemLabel(); const { favorites, removeFavorite } = useFavorites(); const { apps: metadataApps, objects: metadataObjects } = useMetadata(); @@ -691,12 +695,12 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { {recentItems.slice(0, 5).map(item => ( - + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} - {item.label} + {recentLabel(item)} diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx index b5c1830624..a83bec8a3e 100644 --- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx @@ -45,11 +45,14 @@ vi.mock('../../context/RecentItemsProvider.js', async (importOriginal) => ({ ...(await importOriginal>()), useRecentItems: () => ({ recentItems: [ + // A Studio metadata item — the entry kind that carries its own text, so + // the row reads 'Account' with no metadata cache behind it + // (objectui#11678 stores an object entry by identity only). { - id: 'object:account', + id: 'metadata:object:Account', + type: 'metadata', label: 'Account', - href: '/studio/object/account', - type: 'object', + href: '/studio/metadata/object/Account', visitedAt: new Date(Date.now() - THREE_DAYS).toISOString(), }, ], diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx index 3ff2d079a9..a4c907bc60 100644 --- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx @@ -46,6 +46,7 @@ import { import { Button } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; import { useRecentItems, type RecentItem } from '../../context/RecentItemsProvider.js'; +import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; import { useMetadataClient, useMetadataTypes, @@ -146,6 +147,8 @@ export function StudioHomePage() { const client = useMetadataClient(); const { loading, entries } = useMetadataTypes(client); const { recentItems } = useRecentItems(); + // An entry's text is resolved on this render, never stored (objectui#11678). + const recentLabel = useRecentItemLabel(); const locale = useMetadataLocale(); // The visit times read the DISPLAY locale — not `locale` above, which picks // this page's strings (objectui#10232). @@ -428,7 +431,7 @@ export function StudioHomePage() { - {item.label} + {recentLabel(item)} {relativeTime(item.visitedAt, displayLocale)} diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs index c073c6eb66..357b3e38e7 100644 --- a/scripts/check-i18n-call-site-keys.mjs +++ b/scripts/check-i18n-call-site-keys.mjs @@ -1087,7 +1087,9 @@ export const DYNAMIC_KEY_FAMILIES = [ }, { head: 'home.recentApps.itemType.', - vocabulary: { module: 'packages/app-shell/src/context/RecentItemsProvider.tsx', name: 'RecentItem', kind: 'interfaceField', field: 'type' }, + // `RecentItem` became a union of two entry shapes (objectui#11678); its + // `type` members are the one literal union both shapes draw their arms from. + vocabulary: { module: 'packages/app-shell/src/context/RecentItemsProvider.tsx', name: 'RecentItemType', kind: 'union' }, }, { head: 'managedByBadge.',