From 974f89a1ec479d4b479cef8b50bc7553a3f1eb70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:10:35 +0000 Subject: [PATCH 1/4] feat(app-shell): the Studio landing lists recent packages, stored by identity and labelled at render (objectui#11863, Q3) A /studio/PKG/TAB visit is recorded by the console's StudioRoute as a recent `package` entry: id `package:PKG`, name the package id, href `/studio/PKG`, no display text. RecentItemType gains `package`; useRecentItemLabel takes an optional `{ packages }` list that labels it, falling back to the machine name as objectui#11678 does for a missing item. BuilderLanding lists the last three above its package cards once its package list has answered. The app sidebar and the metadata-admin home, which load no full package list, leave package entries out. One `home.recentApps.itemType.package` row per pack. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- .changeset/11863-studio-recent-packages.md | 13 ++ .../StudioRoute.recentPackage-11863.test.tsx | 197 +++++++++++++++++ apps/console/src/components/StudioRoute.tsx | 53 ++++- .../src/context/RecentItemsProvider.tsx | 23 +- .../__tests__/RecentItemsProvider.test.tsx | 37 ++++ .../__tests__/useRecentItemLabel.test.tsx | 24 +++ .../app-shell/src/hooks/useRecentItemLabel.ts | 33 ++- .../app-shell/src/layout/UnifiedSidebar.tsx | 6 +- ...nifiedSidebar.recentPackage-11863.test.tsx | 152 +++++++++++++ ...tudioHomePage.recentPackage-11863.test.tsx | 86 ++++++++ .../views/metadata-admin/StudioHomePage.tsx | 14 +- ...ilderLanding.recentPackages-11863.test.tsx | 200 ++++++++++++++++++ .../views/studio-design/BuilderLanding.tsx | 50 ++++- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + 23 files changed, 865 insertions(+), 33 deletions(-) create mode 100644 .changeset/11863-studio-recent-packages.md create mode 100644 apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx create mode 100644 packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/StudioHomePage.recentPackage-11863.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx diff --git a/.changeset/11863-studio-recent-packages.md b/.changeset/11863-studio-recent-packages.md new file mode 100644 index 0000000000..48580490be --- /dev/null +++ b/.changeset/11863-studio-recent-packages.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +The Studio landing (`/studio`) lists the packages the author was last in, above its package cards (objectui#11863). A visit to a package's pillar builder (`/studio/PKG/TAB`) is recorded as a recent `package` entry. Before this, a Studio visit recorded nothing: the route tracker reads `/apps/APP` routes only. + +The entry stores the package's identity and no display text, as objectui#11678 did for objects, dashboards, pages and reports: its `id` is `package:PKG`, its `name` the package id, and its `href` the bare `/studio/PKG` route, which opens the Data pillar. The landing labels each entry from the package list it loads, so a renamed package shows its new name, and a package the list no longer has shows its id, as a missing object does. Moving between pillars of one package leaves the list as it is, so nothing is written. The package-less scope (`/studio/~org`) is not a package and records nothing. A principal the Studio entry gate refuses never reaches the builder, so records nothing. + +The app sidebar's Recent group and the metadata-admin app's home leave package entries out: neither loads the full package list, so they could only draw a package as its id. Home's Recently Accessed and the command palette's recent group already list other kinds only. + +**Clause-②: yes (widening).** The exported `RecentItemType` union gains `'package'`, so `RecentNamedItem`'s `type` and `RecentItem` carry it too, and a host's exhaustive `switch` or `Record` over the union must handle it. `useRecentItemLabel` takes an optional argument, `{ packages }`: the package list (`id` and `name` per row) that labels a `package` entry. A call with no argument resolves every other kind as before and draws a package entry as its id. No export is added or removed. `@object-ui/i18n` gains one key in all ten packs, `home.recentApps.itemType.package`, the label of the new kind. diff --git a/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx b/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx new file mode 100644 index 0000000000..d8098bee7c --- /dev/null +++ b/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11863 (Q3) — a visit to a package's pillar builder is recorded as a + * recent `package` entry, by IDENTITY and with no label (the objectui#11678 + * shape), so the `/studio` landing can list the packages an author was last in. + * + * ## Why the route, and why this instrument + * + * `useTrackRouteAsRecent` reads `/apps/APP/...` paths only, so before this card + * a `/studio/PKG/TAB` visit wrote nothing at all. `StudioRoute.tsx` owns the + * `/studio` subtree, so the visit is recorded there. This file mounts the REAL + * `studioRoutes` inside the REAL `RecentItemsProvider` and reads the list the + * provider holds and the copy it writes to localStorage: a transcription of the + * route tree, or a stubbed store, would be free to agree with itself. + * + * The builder itself is a stub (`./studioBuilder`), as in + * `StudioRoute.lazyBuilder-11798.test.tsx`: what is measured is what the ROUTE + * records, not what the builder renders. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; + +let auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } as unknown }; + +let permissionsFetch: (input: unknown, init?: unknown) => Promise = async () => + jsonResponse({ authenticated: true, systemPermissions: [] }); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + } as unknown as Response; +} + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, + createAuthenticatedFetch: () => (input: unknown, init?: unknown) => permissionsFetch(input, init), +})); + +// `AuthGuard` reads `useAuth` through the auth package's own module graph; the +// same seam `StudioRoute.test.tsx` documents. +vi.mock('../../../../packages/auth/src/useAuth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +// The real barrel is inherited, so `RecentItemsProvider` and `useRecentItems` +// below are the real store; only the frame `ProtectedRoute` mounts is passed +// through. +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}, + RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}, + RedirectWithSplash: () =>
, + LoadingFallback: () =>
, + LoadingScreen: () =>
, + getProductName: () => 'ObjectOS', + useHomePath: () => '/home', +})); + +vi.mock('./studioBuilder', () => ({ + BuilderLanding: () =>
pick a package
, + StudioDesignSurface: () =>
pillars
, +})); + +import { RecentItemsProvider, useRecentItems } from '@object-ui/app-shell'; +import { STUDIO_ENTRY_CAPABILITY } from './studioEntry'; +import { studioRoutes } from './StudioRoute'; + +/** Where the provider keeps user `u1`'s list (`scopedKey` in `UserStateAdapters`). */ +const STORAGE_KEY = 'objectui-recent-items:u:u1'; + +function RecentProbe() { + const { recentItems } = useRecentItems(); + return
{JSON.stringify(recentItems)}
; +} + +/** Navigates to `to` when clicked — a client-side transition, as a pillar tab is. */ +function Go({ to }: { to: string }) { + const navigate = useNavigate(); + return ( + + ); +} + +function LocationProbe() { + const { pathname } = useLocation(); + return
{pathname}
; +} + +function renderStudio(url: string) { + return render( + + + + + + + {studioRoutes} + home
} /> + + + , + ); +} + +const recent = () => JSON.parse(screen.getByTestId('recent').textContent ?? '[]') as Array>; + +const holder = async () => + jsonResponse({ authenticated: true, userId: 'u1', systemPermissions: [STUDIO_ENTRY_CAPABILITY], objects: {}, fields: {} }); + +let setItem: ReturnType; + +beforeEach(() => { + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + permissionsFetch = holder; + localStorage.clear(); + setItem = vi.spyOn(Storage.prototype, 'setItem'); +}); + +afterEach(() => { + cleanup(); + setItem.mockRestore(); +}); + +/** The writes of the recent list itself, leaving out anything else the tree stores. */ +const recentWrites = () => setItem.mock.calls.filter(([key]) => key === STORAGE_KEY); + +describe('/studio/PKG/TAB records the package as a recent entry (objectui#11863)', () => { + it('a pillar visit writes one package entry, keyed by the package id, with no label', async () => { + renderStudio('/studio/com.acme.crm/data'); + await screen.findByTestId('studio-pillar-builder'); + + await waitFor(() => expect(recent()).toHaveLength(1)); + const [entry] = recent(); + expect(entry).toEqual({ + id: 'package:com.acme.crm', + type: 'package', + name: 'com.acme.crm', + href: '/studio/com.acme.crm', + visitedAt: expect.any(String), + }); + // Identity only: no display text is minted from the route (objectui#11678). + expect(entry).not.toHaveProperty('label'); + // And it is what the persisted copy holds. + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')).toEqual([entry]); + }); + + it('the bare package route records it too, once it lands on the Data pillar', async () => { + renderStudio('/studio/com.acme.crm'); + await waitFor(() => expect(screen.getByTestId('pathname')).toHaveTextContent('/studio/com.acme.crm/data')); + await waitFor(() => expect(recent().map((r) => r.id)).toEqual(['package:com.acme.crm'])); + }); + + it('switching pillars inside the same package changes nothing, so it writes nothing', async () => { + renderStudio('/studio/com.acme.crm/data'); + await waitFor(() => expect(recent()).toHaveLength(1)); + expect(recentWrites()).toHaveLength(1); + + screen.getByTestId('go:/studio/com.acme.crm/automations').click(); + await waitFor(() => expect(screen.getByTestId('pathname')).toHaveTextContent('/studio/com.acme.crm/automations')); + + expect(recent()).toHaveLength(1); + expect(recentWrites()).toHaveLength(1); + }); + + it('the package-less scope is not a package, and the landing is not a visit: neither records anything', async () => { + renderStudio('/studio/~org/automations'); + await screen.findByTestId('studio-pillar-builder'); + cleanup(); + + renderStudio('/studio'); + await screen.findByTestId('studio-front-door'); + + expect(recent()).toEqual([]); + expect(recentWrites()).toHaveLength(0); + }); + + it('a principal the entry gate refuses records nothing: the visit is recorded behind the gate', async () => { + permissionsFetch = async () => jsonResponse({ authenticated: true, systemPermissions: ['setup.access'] }); + renderStudio('/studio/com.acme.crm/data'); + + await waitFor(() => expect(screen.getByTestId('pathname')).toHaveTextContent('/home')); + expect(recent()).toEqual([]); + expect(recentWrites()).toHaveLength(0); + }); +}); diff --git a/apps/console/src/components/StudioRoute.tsx b/apps/console/src/components/StudioRoute.tsx index ede0ec232c..e657a9f632 100644 --- a/apps/console/src/components/StudioRoute.tsx +++ b/apps/console/src/components/StudioRoute.tsx @@ -34,8 +34,8 @@ * Studio exists for. The gate belongs here, in this app's router. */ -import { lazy, Suspense, type ReactNode } from 'react'; -import { Link, Navigate, Outlet, Route } from 'react-router-dom'; +import { lazy, Suspense, useEffect, type ReactNode } from 'react'; +import { Link, Navigate, Outlet, Route, useParams } from 'react-router-dom'; import { LoadingFallback, LoadingScreen, @@ -43,6 +43,7 @@ import { STUDIO_ORG_SCOPE_SEGMENT, getProductName, useHomePath, + useRecentItems, } from '@object-ui/app-shell'; import { useObjectTranslation } from '@object-ui/i18n'; @@ -184,6 +185,43 @@ function StudioLanding() { ); } +/** + * A package's pillar builder, which records the visit as a recent `package` + * entry (objectui#11863), so the landing can list the packages an author was + * last in. `useTrackRouteAsRecent` reads `/apps/APP` routes only, and this + * module owns the `/studio` subtree, so the record is made here, behind the + * entry gate: a refused principal never mounts this element. + * + * The entry is the package's IDENTITY — its id, and `href` its bare route, + * which lands on the Data pillar — and no label: the landing labels it from the + * package list it loads (objectui#11678's shape). Keyed on the package, not the + * pillar, so moving between pillars of one package leaves the list as it is, + * and the provider writes nothing. The package-less scope (`~org`) is not a + * package and records nothing. + */ +function StudioPackageBuilder() { + const { packageId } = useParams(); + const { addRecentItem } = useRecentItems(); + useEffect(() => { + if (!packageId || packageId === STUDIO_ORG_SCOPE_SEGMENT) return; + addRecentItem({ + id: `package:${packageId}`, + type: 'package', + name: packageId, + href: `/studio/${encodeURIComponent(packageId)}`, + }); + // Driven by the package alone. `addRecentItem` is a `useCallback` result, + // whose identity is not a dependency (AGENTS.md #10), as in + // `useTrackRouteAsRecent`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [packageId]); + return ( + }> + + + ); +} + /** * The whole `/studio` subtree (ADR-0080/0084), as ONE element `App.tsx` drops * into its `` — the same shape `AppContent`'s `systemRoutes` uses. @@ -196,7 +234,7 @@ function StudioLanding() { * `/studio` front door (pick / create a writable package) * `/studio/~org` the package-less scope lands on its one pillar * `/studio/:packageId` a package lands on its Data pillar - * `/studio/:packageId/:tab` the pillar builder + * `/studio/:packageId/:tab` the pillar builder (a recent-package visit) * * `~org` is the reserved segment for the organization's own, package-less * flows (objectui#11553; `studioScope.ts` in app-shell says why `~` can never @@ -214,13 +252,6 @@ export const studioRoutes = ( element={} /> } /> - }> - - - } - /> + } /> ); diff --git a/packages/app-shell/src/context/RecentItemsProvider.tsx b/packages/app-shell/src/context/RecentItemsProvider.tsx index 2dedb2f431..c7e939cae5 100644 --- a/packages/app-shell/src/context/RecentItemsProvider.tsx +++ b/packages/app-shell/src/context/RecentItemsProvider.tsx @@ -10,10 +10,10 @@ * - 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). + * - An object, dashboard, page, report or Studio package 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; the package kind, objectui#11863). * - 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. * @@ -51,7 +51,7 @@ import { * members (`pnpm check:i18n-keys`); the two entry shapes below take their arms * from it. */ -export type RecentItemType = 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'metadata'; +export type RecentItemType = 'object' | 'dashboard' | 'page' | 'report' | 'package' | 'record' | 'metadata'; interface RecentItemBase { /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */ @@ -62,8 +62,8 @@ interface RecentItemBase { } /** - * An object, dashboard, page or report — stored by its identity, `type` and - * `name`, and by no display text (objectui#11678). + * An object, dashboard, page, report or Studio package — 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 @@ -74,7 +74,12 @@ interface RecentItemBase { */ export interface RecentNamedItem extends RecentItemBase { type: Exclude; - /** The item's machine name: the object's, dashboard's, page's or report's `name`. */ + /** + * The item's machine name: the object's, dashboard's, page's or report's + * `name`, or a package's `id` (objectui#11863). A package is the one kind + * with no metadata cache behind it: its label comes from the package list + * the rendering surface has loaded (`useRecentItemLabel`'s `packages`). + */ name: string; } @@ -106,7 +111,7 @@ interface RecentItemsContextValue { const STORAGE_BASE_KEY = 'objectui-recent-items'; const MAX_RECENT = 8; -const NAMED_TYPES: ReadonlySet = new Set(['object', 'dashboard', 'page', 'report']); +const NAMED_TYPES: ReadonlySet = new Set(['object', 'dashboard', 'page', 'report', 'package']); const TEXT_TYPES: ReadonlySet = new Set(['record', 'metadata']); /** diff --git a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx index cc82769f45..aa5fbf3345 100644 --- a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx +++ b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx @@ -367,3 +367,40 @@ describe('RecentItemsProvider — reading a list stored before the identity shap expect(JSON.stringify(saved)).not.toContain('Showcase Ops Dashboard'); }); }); + +describe('RecentItemsProvider — a Studio package entry (objectui#11863)', () => { + beforeEach(() => { + localStorage.clear(); + mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; + }); + + it('is kept by identity: no display text is stored, even when one is handed in', () => { + const { result } = renderHook(() => useRecentItems(), { wrapper }); + act(() => + result.current.addRecentItem({ + id: 'package:com.acme.crm', + type: 'package', + name: 'com.acme.crm', + href: '/studio/com.acme.crm', + // Not part of the shape; a caller that mints one anyway stores nothing of it. + label: 'Acme CRM', + } as never), + ); + const [entry] = result.current.recentItems; + expect(entry).toEqual({ + id: 'package:com.acme.crm', + type: 'package', + name: 'com.acme.crm', + href: '/studio/com.acme.crm', + visitedAt: expect.any(String), + }); + expect(localStorage.getItem('objectui-recent-items:u:user-1')).not.toContain('Acme CRM'); + }); + + it('reads back from storage as the same entry', () => { + const stored = { id: 'package:com.acme.crm', type: 'package', name: 'com.acme.crm', href: '/studio/com.acme.crm', visitedAt: '2026-10-08T09:00:00.000Z' }; + localStorage.setItem('objectui-recent-items:u:user-1', JSON.stringify([stored])); + const { result } = renderHook(() => useRecentItems(), { wrapper }); + expect(result.current.recentItems).toEqual([stored]); + }); +}); diff --git a/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx index d316443c73..2dfa48249a 100644 --- a/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx @@ -90,3 +90,27 @@ describe('useRecentItemLabel (objectui#11678)', () => { ).toBe('sys_user'); }); }); + +describe('useRecentItemLabel — a Studio package (objectui#11863)', () => { + const PKG: RecentItem = { id: 'package:com.acme.crm', type: 'package', name: 'com.acme.crm', href: '/studio/com.acme.crm', visitedAt: at }; + + it('is labelled by its row in the package list the surface hands in, read on each render', () => { + let packages = [{ id: 'com.acme.crm', name: 'Acme CRM' }]; + const { result, rerender } = renderHook(() => useRecentItemLabel({ packages }), { wrapper: wrapper('en') }); + expect(result.current(PKG)).toBe('Acme CRM'); + + // A rename reaches the next render: nothing about the name was stored. + packages = [{ id: 'com.acme.crm', name: 'Acme Sales' }]; + rerender(); + expect(result.current(PKG)).toBe('Acme Sales'); + }); + + it('a package the list does not have, or a call with no list, shows the machine name, as a missing page does', () => { + const missing = renderHook(() => useRecentItemLabel({ packages: [{ id: 'com.acme.hr', name: 'Acme HR' }] }), { + wrapper: wrapper('en'), + }); + expect(missing.result.current(PKG)).toBe('com.acme.crm'); + const noList = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') }); + expect(noList.result.current(PKG)).toBe('com.acme.crm'); + }); +}); diff --git a/packages/app-shell/src/hooks/useRecentItemLabel.ts b/packages/app-shell/src/hooks/useRecentItemLabel.ts index 3dd2bd6eb2..9e566b9a78 100644 --- a/packages/app-shell/src/hooks/useRecentItemLabel.ts +++ b/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -10,10 +10,10 @@ * 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. + * An object, dashboard, page, report or Studio package 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: @@ -32,11 +32,20 @@ * 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. + * same two helpers it uses; + * - a Studio package (objectui#11863): the `name` its row in `packages` has — + * the package list the rendering surface loaded, as the Studio landing does + * with `fetchPackages()`. A package has no metadata cache behind it, so the + * list is handed in rather than read here. The spec's manifest `name` is a + * plain string with no per-language form, so a rename shows on the next + * render and a switch of language leaves it as declared. * * 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. + * loaded. That covers a package missing from `packages`, or a call that passes + * no list: ⛔ so a surface that has no package list does not draw package + * entries at all, rather than show their ids (the app sidebar and the + * metadata-admin app's home, `StudioHomePage`, leave them out for that reason). * * A record or a Studio metadata item (`RecentTextItem`) carries its own text, * which is returned as stored. @@ -56,7 +65,13 @@ function nonEmpty(text: string | undefined): string | undefined { return typeof text === 'string' && text.trim() !== '' ? text : undefined; } -export function useRecentItemLabel(): RecentItemLabelResolver { +export function useRecentItemLabel( + sources: { + /** The package list the surface loaded, which labels a `package` entry by its row's `name`. */ + packages?: ReadonlyArray<{ id: string; name: string }> | null; + } = {}, +): RecentItemLabelResolver { + const { packages } = sources; const targetLabel = useNavTargetLabel(); const metadata = useMetadata(); const { objectPluralLabel, pageLabel, reportLabel } = useObjectLabel(); @@ -83,10 +98,12 @@ export function useRecentItemLabel(): RecentItemLabelResolver { const text = metadataText(findByName(metadata.reports, item.name)?.label, language); return nonEmpty(reportLabel({ name: item.name, label: text ?? '' })) ?? item.name; } + case 'package': + return nonEmpty(packages?.find((p) => p.id === item.name)?.name) ?? item.name; default: return item.label; } }, - [targetLabel, metadata, objectPluralLabel, pageLabel, reportLabel, language], + [targetLabel, metadata, objectPluralLabel, pageLabel, reportLabel, language, packages], ); } diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 9fbf38b65a..3c0930e3da 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -279,7 +279,11 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { }; }, [isMobile, setOpenMobile]); - const { recentItems } = useRecentItems(); + const { recentItems: allRecentItems } = useRecentItems(); + // A Studio package entry is labelled from the package list (objectui#11863), + // which this sidebar does not load, so it is left out here rather than drawn + // as its id. The Studio landing lists the recent packages. + const recentItems = allRecentItems.filter(item => item.type !== 'package'); // A recent entry's text is the item's own, resolved on this render // (objectui#11678); the entry stores identity only. const recentLabel = useRecentItemLabel(); diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx new file mode 100644 index 0000000000..1ec76b36ef --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * UnifiedSidebar — a recent Studio PACKAGE entry is left out of the app + * sidebar's Recent group (objectui#11863). + * + * A package entry stores the package's identity only, and its label comes from + * the package list (`useRecentItemLabel`'s `packages`). This sidebar loads no + * package list — `GET /api/v1/packages` is a Studio read, refused without + * `studio.access` or `setup.access` — so drawing the entry here could only show + * the raw package id, the regression objectui#11678 retired. The Studio landing + * lists recent packages instead. The other kinds are listed as before. + * + * Harness: the provider / chrome mocks of + * `UnifiedSidebar.navLabelInheritsTarget-9868.test.tsx`, with the store's list + * swapped per test; `useRecentItemLabel`, `@object-ui/layout` and + * `@object-ui/components` stay REAL. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const i18nState = vi.hoisted(() => ({ language: 'en' })); +/** What the store holds, swapped per test. */ +const recent = vi.hoisted(() => ({ items: [] as unknown[] })); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: i18nState.language, + }), + // No translation bundle: every convention lookup answers its fallback. + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback, + dashboardLabel: ({ label }: { label?: string }) => label, + appLabel: ({ label }: { label?: string }) => label, + }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: null, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => ({ + ...(await importOriginal()), + usePermissions: () => ({ can: () => true, hasCapabilities: () => true }), +})); + +let metadataState: { apps: unknown[]; objects: unknown[] }; +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => metadataState, +})); + +vi.mock('../../providers/ExpressionProvider', () => ({ + useExpressionContext: () => ({ evaluator: null }), + evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false', +})); + +vi.mock('../../utils', () => ({ + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => + apps.find((a) => a?.name === segment), + appRouteSegment: (app: { name?: string }) => app?.name, +})); + +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null })); +vi.mock('../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: recent.items }) })); +vi.mock('../../hooks/useFavorites', () => ({ + useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }), +})); +vi.mock('../../hooks/useNavPins', () => ({ + useNavPins: () => ({ togglePin: vi.fn(), applyPins: (items: unknown) => items }), +})); +vi.mock('../../hooks/useNavActionDispatch', () => ({ + useNavActionDispatch: () => vi.fn(), +})); +vi.mock('../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ context: 'app', currentAppName: 'crm' }), +})); +vi.mock('../ContextSelectors', () => ({ + useAppContextSelectors: () => ({ contextValues: {}, element: null }), + contextSelectorQueryKey: (id: string) => (id === 'active_package' ? 'package' : id), + STUDIO_PACKAGE_SELECTOR_ID: 'active_package', +})); +vi.mock('../LocalizedSidebarTrigger', () => ({ + LocalizedSidebarTrigger: () => null, +})); + +import { SidebarProvider } from '@object-ui/components'; +import { UnifiedSidebar } from '../UnifiedSidebar'; + +const at = '2026-10-08T09:00:00.000Z'; +const PACKAGE = { id: 'package:com.acme.crm', type: 'package', name: 'com.acme.crm', href: '/studio/com.acme.crm', visitedAt: at }; +const METADATA = { id: 'metadata:object:account', type: 'metadata', label: 'account', href: '/apps/crm/metadata/object/account', visitedAt: at }; + +function sidebarUi() { + metadataState = { apps: [{ name: 'crm', label: 'CRM', active: true, navigation: [] }], objects: [] }; + return ( + + + + + + ); +} + +beforeEach(() => { + localStorage.clear(); + i18nState.language = 'en'; + recent.items = []; +}); + +/** The Recent group, found by its label and opened (it starts collapsed). */ +function openRecent(): HTMLElement { + const label = screen.getByText('Recent'); + fireEvent.click(label); + return label.closest('[data-sidebar="group"]') as HTMLElement; +} + +describe('UnifiedSidebar — a recent package is not drawn without a package list (objectui#11863)', () => { + it('lists the other kinds and leaves the package entry out', () => { + recent.items = [PACKAGE, METADATA]; + render(sidebarUi()); + + const group = openRecent(); + expect(within(group).getByRole('link', { name: 'account' })).toHaveAttribute('href', METADATA.href); + expect(within(group).queryByText('com.acme.crm')).not.toBeInTheDocument(); + expect(group.querySelector(`a[href="${PACKAGE.href}"]`)).toBeNull(); + }); + + it('with only package entries there is no Recent group at all', () => { + recent.items = [PACKAGE]; + render(sidebarUi()); + + expect(screen.queryByText('Recent')).not.toBeInTheDocument(); + expect(screen.queryByText('com.acme.crm')).not.toBeInTheDocument(); + }); + + it('control: a store with no package entry draws its Recent group as before', () => { + recent.items = [METADATA]; + render(sidebarUi()); + + expect(within(openRecent()).getByRole('link', { name: 'account' })).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.recentPackage-11863.test.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.recentPackage-11863.test.tsx new file mode 100644 index 0000000000..946fef0347 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.recentPackage-11863.test.tsx @@ -0,0 +1,86 @@ +/** + * 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. + */ + +/** + * The metadata-admin app's home leaves a recent Studio PACKAGE entry out of its + * "Recently viewed" list (objectui#11863). + * + * A package entry stores the package's identity only and is labelled from the + * package list. This page's own list holds the writable bases only + * (`buildPackageScopeOptions`), so a read-only package would be drawn as its + * raw id, the regression objectui#11678 retired. The Studio landing lists + * recent packages instead; the other kinds are listed here as before. + * + * Harness: `StudioHomePage.displayLocale-10232.test.tsx`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; + +const recent = vi.hoisted(() => ({ items: [] as unknown[] })); + +vi.mock('./useMetadata.js', async (importOriginal) => ({ + ...(await importOriginal>()), + // The page's own package list: a writable base, the only kind it holds. + useMetadataClient: () => ({ + list: async () => [{ manifest: { id: 'com.acme.crm', name: 'Acme CRM', scope: 'project' } }], + }), + useMetadataTypes: () => ({ loading: false, entries: [] }), + useGlobalDiagnostics: () => ({ summary: { total: 0 }, countsByType: {}, packagesByType: {}, loading: false }), +})); + +vi.mock('./QuickFind.js', () => ({ MetadataQuickFind: () => null })); + +vi.mock('../../context/RecentItemsProvider.js', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecentItems: () => ({ recentItems: recent.items, addRecentItem: () => {}, clearRecentItems: () => {} }), +})); + +import { StudioHomePage } from './StudioHomePage'; + +afterEach(() => { + cleanup(); + recent.items = []; +}); + +const at = new Date().toISOString(); +const PACKAGE = { id: 'package:com.example.showcase', type: 'package', name: 'com.example.showcase', href: '/studio/com.example.showcase', visitedAt: at }; +const METADATA = { id: 'metadata:object:Account', type: 'metadata', label: 'Account', href: '/studio/metadata/object/Account', visitedAt: at }; + +function renderPage() { + render( + + + + + , + ); +} + +describe('metadata-admin home — a recent package is not drawn as its id (objectui#11863)', () => { + it('lists the other kinds and leaves the package entry out', async () => { + recent.items = [PACKAGE, METADATA]; + renderPage(); + + expect(await screen.findByText('Account')).toBeInTheDocument(); + expect(screen.queryByText('com.example.showcase')).not.toBeInTheDocument(); + expect(document.querySelector(`a[href="${PACKAGE.href}"]`)).toBeNull(); + }); + + it('with only package entries the list reads as empty', async () => { + recent.items = [PACKAGE]; + renderPage(); + + expect(await screen.findByText('Nothing here yet — items you open will appear for quick access.')).toBeInTheDocument(); + expect(screen.queryByText('com.example.showcase')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx index a4c907bc60..cf19100750 100644 --- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx @@ -94,7 +94,10 @@ const DOMAIN_ORDER = ['data', 'ui', 'automation', 'ai', 'identity', 'security', /** Types that are most useful to surface as one-click "New …" actions. */ const QUICK_CREATE_ORDER = ['object', 'view', 'page', 'dashboard', 'report', 'flow', 'action', 'agent']; -const RECENT_ICONS: Record> = { +/** The kinds this page lists: every kind but a Studio package (see `recentItems` below). */ +type ListedRecentItem = Exclude; + +const RECENT_ICONS: Record> = { object: Table2, dashboard: LayoutDashboard, page: FileText, @@ -146,7 +149,14 @@ function relativeTime(iso: string, locale: string): string { export function StudioHomePage() { const client = useMetadataClient(); const { loading, entries } = useMetadataTypes(client); - const { recentItems } = useRecentItems(); + const { recentItems: allRecentItems } = useRecentItems(); + // A Studio package entry is labelled from the package list (objectui#11863). + // This page's own list holds the writable bases only + // (`buildPackageScopeOptions`), so a read-only package would draw as its id: + // package entries are left out, and the Studio landing lists them. + const recentItems = allRecentItems.filter( + (item): item is ListedRecentItem => item.type !== 'package', + ); // An entry's text is resolved on this render, never stored (objectui#11678). const recentLabel = useRecentItemLabel(); const locale = useMetadataLocale(); diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx new file mode 100644 index 0000000000..a93d15752a --- /dev/null +++ b/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11863 (Q3) — the Studio landing lists the packages the author was + * last in, above its package cards. + * + * A recent `package` entry stores the package's identity and no label + * (objectui#11678's shape); the landing labels it from the package list it + * loads, on every render. So these cases pin what that buys and what it must + * not regress into: + * + * - the label is the package's CURRENT name: a rename between the visit and + * the render shows the new one, and no entry is drawn as its raw id while a + * live package is behind it; + * - a package the list no longer has draws its machine name, as a missing + * object, page or report does (`useRecentItemLabel`); + * - nothing is listed before the list answers, or when there are no recent + * packages, and the existing cards stay. + * + * The store is the REAL `RecentItemsProvider`, seeded through the localStorage + * copy it hydrates from (no signed-in user here, so the key is unscoped). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const packages = vi.hoisted(() => ({ + list: [] as Array>, + /** When set, `fetchPackages` waits for it, so the loading window can be observed. */ + gate: null as Promise | null, +})); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchPackages: vi.fn(async () => { + if (packages.gate) await packages.gate; + return packages.list; + }), + }; +}); + +// The create dialog is closed throughout; its form stack is not under test. +vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); + +import { BuilderLanding } from './BuilderLanding'; +import { RecentItemsProvider } from '../../context/RecentItemsProvider'; + +/** The key `RecentItemsProvider` hydrates from with no signed-in user. */ +const STORAGE_KEY = 'objectui-recent-items'; + +const at = '2026-10-08T09:00:00.000Z'; +const visit = (id: string) => ({ + id: `package:${id}`, + type: 'package', + name: id, + href: `/studio/${encodeURIComponent(id)}`, + visitedAt: at, +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + packages.list = []; + packages.gate = null; +}); + +function LocationProbe() { + const { pathname } = useLocation(); + return
{pathname}
; +} + +function renderLanding(stored: unknown[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + return render( + + + + + } /> + } /> + } /> + + + , + ); +} + +const recentSection = () => screen.getByTestId('studio-landing-recent'); +const recentLinks = () => within(recentSection()).getAllByRole('link'); + +describe('Studio landing → recent packages (objectui#11863)', () => { + it('lists the most recent packages first, each by its current name, each opening its /studio/PKG route', async () => { + packages.list = [ + { id: 'com.acme.crm', name: 'Acme CRM', writable: true, namespace: 'acme' }, + { id: 'com.acme.hr', name: 'Acme HR', writable: true, namespace: 'acmehr' }, + { id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' }, + ]; + renderLanding([visit('com.example.showcase'), visit('com.acme.crm')]); + + await screen.findByText('Recently viewed'); + const links = recentLinks(); + expect(links.map((a) => a.querySelector('.font-medium')?.textContent)).toEqual(['Showcase', 'Acme CRM']); + expect(links.map((a) => a.getAttribute('href'))).toEqual(['/studio/com.example.showcase', '/studio/com.acme.crm']); + + fireEvent.click(links[1]); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/com.acme.crm')); + expect(screen.getByTestId('package-route')).toBeInTheDocument(); + }); + + it('a package renamed since the visit shows its new name: the label is read on the render, never stored', async () => { + // The visit happened while the package was called something else; the + // stored entry carries no text from that moment to go stale. + packages.list = [{ id: 'com.acme.crm', name: 'Acme Sales', writable: true, namespace: 'acme' }]; + renderLanding([visit('com.acme.crm')]); + + await screen.findByText('Recently viewed'); + const [link] = recentLinks(); + expect(within(link).getByText('Acme Sales')).toBeInTheDocument(); + expect(within(link).queryByText('Acme CRM')).not.toBeInTheDocument(); + }); + + it('a package the list no longer has draws its machine name, as a missing object does', async () => { + packages.list = [{ id: 'com.acme.crm', name: 'Acme CRM', writable: true, namespace: 'acme' }]; + renderLanding([visit('com.acme.gone'), visit('com.acme.crm')]); + + await screen.findByText('Recently viewed'); + expect(recentLinks().map((a) => a.querySelector('.font-medium')?.textContent)).toEqual([ + 'com.acme.gone', + 'Acme CRM', + ]); + }); + + it('lists only package entries, and at most one row of three', async () => { + packages.list = ['a', 'b', 'c', 'd'].map((x) => ({ + id: `com.acme.${x}`, + name: `Pkg ${x.toUpperCase()}`, + writable: true, + namespace: `acme${x}`, + })); + renderLanding([ + visit('com.acme.a'), + { id: 'object:showcase_task', type: 'object', name: 'showcase_task', href: '/apps/showcase/showcase_task', visitedAt: at }, + visit('com.acme.b'), + visit('com.acme.c'), + visit('com.acme.d'), + ]); + + await screen.findByText('Recently viewed'); + expect(recentLinks().map((a) => a.querySelector('.font-medium')?.textContent)).toEqual([ + 'Pkg A', + 'Pkg B', + 'Pkg C', + ]); + }); + + it('nothing is listed while the package list loads, so no entry is drawn as its id', async () => { + let release!: () => void; + packages.gate = new Promise((resolve) => { + release = resolve; + }); + packages.list = [{ id: 'com.acme.crm', name: 'Acme CRM', writable: true, namespace: 'acme' }]; + renderLanding([visit('com.acme.crm')]); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.queryByTestId('studio-landing-recent')).not.toBeInTheDocument(); + expect(screen.queryByText('com.acme.crm')).not.toBeInTheDocument(); + + release(); + await screen.findByText('Recently viewed'); + expect(within(recentSection()).getByText('Acme CRM')).toBeInTheDocument(); + }); + + it('with no recent package there is no section, and the package cards are unchanged', async () => { + packages.list = [{ id: 'com.acme.crm', name: 'Acme CRM', writable: true, namespace: 'acme' }]; + renderLanding([ + { id: 'object:showcase_task', type: 'object', name: 'showcase_task', href: '/apps/showcase/showcase_task', visitedAt: at }, + ]); + + await screen.findByText('Acme CRM'); + expect(screen.queryByTestId('studio-landing-recent')).not.toBeInTheDocument(); + expect(screen.queryByText('Recently viewed')).not.toBeInTheDocument(); + expect(screen.getByText('My packages (writable)')).toBeInTheDocument(); + expect(screen.getByTestId('studio-landing-org-scope')).toBeInTheDocument(); + }); + + it('the package cards stay beside the recent row', async () => { + packages.list = [{ id: 'com.acme.crm', name: 'Acme CRM', writable: true, namespace: 'acme' }]; + renderLanding([visit('com.acme.crm')]); + + await screen.findByText('Recently viewed'); + // Once in the recent row, once as its writable card. + expect(screen.getAllByText('Acme CRM')).toHaveLength(2); + expect(screen.getByText('My packages (writable)')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx index 202a5d8a3c..11b6eadd1e 100644 --- a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -9,7 +9,8 @@ * (`/studio/:packageId/:tab`). Also served standalone at bare `/studio` so the * builder is bookmarkable. * - * Writable bases (where authoring happens) lead; the organization's own + * The packages the author was last in lead, when there are any (objectui#11863). + * Writable bases (where authoring happens) follow; the organization's own * package-less flows have one entry of their own (objectui#11553); read-only * code and installed packages are listed last: they open for browsing, and each * card points at the routes that customize one (objectui#11808). Writability is @@ -19,8 +20,10 @@ import * as React from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { Boxes, Building2, Hammer, Layers, Lock, Plus, Loader2, Copy, Store } from 'lucide-react'; +import { Boxes, Building2, Clock, Hammer, Layers, Lock, Plus, Loader2, Copy, Store } from 'lucide-react'; import { toast } from 'sonner'; +import { useRecentItems, type RecentNamedItem } from '../../context/RecentItemsProvider.js'; +import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js'; import { isMarketplaceEnabled } from '../../runtime-config.js'; @@ -44,6 +47,9 @@ const MARKETPLACE_PATH = '/apps/setup/system/marketplace'; const overlayPath = (packageId: string) => `/apps/setup/metadata?package=${encodeURIComponent(packageId)}`; +/** objectui#11863 — how many recent packages the landing lists: one row of cards. */ +const RECENT_PACKAGES_SHOWN = 3; + export function BuilderLanding(): React.ReactElement { const navigate = useNavigate(); const locale = useMetadataLocale(); @@ -67,6 +73,21 @@ export function BuilderLanding(): React.ReactElement { const open = (id: string) => navigate(`/studio/${encodeURIComponent(id)}/data`); + // objectui#11863 — the packages the author was last in, newest first, as the + // console's route owner records them (`StudioRoute`). An entry stores the + // package's identity only, and is labelled here from the list this page just + // loaded, so a renamed package shows its new name (objectui#11678's shape). + // Listed only once that list has answered, so no entry is drawn as its id + // while it loads; nothing is listed when there are none. + const { recentItems } = useRecentItems(); + const recentLabel = useRecentItemLabel({ packages: pkgs }); + const recentPackages = + pkgs === null + ? [] + : recentItems + .filter((item): item is RecentNamedItem => item.type === 'package') + .slice(0, RECENT_PACKAGES_SHOWN); + const writable = pkgs?.filter((p) => p.writable) ?? []; const readonly = pkgs?.filter((p) => !p.writable) ?? []; @@ -128,6 +149,31 @@ export function BuilderLanding(): React.ReactElement {
)} + {recentPackages.length > 0 && ( + <> +

+ {t('engine.home.recent', locale)} +

+
+ {recentPackages.map((item) => ( + + + + + + {recentLabel(item)} + {item.name} + + + ))} +
+ + )} +

{t('engine.studio.landing.mineHeading', locale)}

diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 246e4dcfd5..7e7bb587ae 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2854,6 +2854,7 @@ const ar = { dashboard: "لوحة تحكم", page: "صفحة", report: "تقرير", + package: "حزمة", record: "سجل", metadata: "البيانات الوصفية", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d75d8c1ba8..bbee5a9e76 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2646,6 +2646,7 @@ const de = { dashboard: "Dashboard", page: "Seite", report: "Bericht", + package: "Paket", record: "Datensatz", metadata: "Metadaten", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 740a55f83b..9258174aae 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3138,6 +3138,7 @@ const en = { dashboard: 'Dashboard', page: 'Page', report: 'Report', + package: 'Package', record: 'Record', metadata: 'Metadata', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 605631d8c5..53c3b5d271 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2697,6 +2697,7 @@ const es = { dashboard: "Panel", page: "Página", report: "Informe", + package: "Paquete", record: "Registro", metadata: "Metadatos", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3a58b3c6b9..14cbc2bdc6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2694,6 +2694,7 @@ const fr = { dashboard: "Tableau de bord", page: "Page", report: "Rapport", + package: "Package", record: "Enregistrement", metadata: "Métadonnées", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index cf1df2ac03..8f0f409652 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2648,6 +2648,7 @@ const ja = { dashboard: "ダッシュボード", page: "ページ", report: "レポート", + package: "パッケージ", record: "レコード", metadata: "メタデータ", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e00f908f6e..afa80f607e 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2645,6 +2645,7 @@ const ko = { dashboard: "대시보드", page: "페이지", report: "보고서", + package: "패키지", record: "레코드", metadata: "메타데이터", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index b649e60bbc..9ba1aaf298 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2692,6 +2692,7 @@ const pt = { dashboard: "Painel", page: "Página", report: "Relatório", + package: "Pacote", record: "Registro", metadata: "Metadados", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 39c9ef2030..9a44c53bda 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2761,6 +2761,7 @@ const ru = { dashboard: "Панель", page: "Страница", report: "Отчёт", + package: "Пакет", record: "Запись", metadata: "Метаданные", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 1e43b022e2..d9f5a23991 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2732,6 +2732,7 @@ const zh = { dashboard: '仪表盘', page: '页面', report: '报表', + package: '软件包', record: '记录', metadata: '元数据', }, From c55b3f62b8bc53df7b93f06370327ab063af3635 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:13:29 +0000 Subject: [PATCH 2/4] test(console, app-shell): read "writes nothing" off the persisted copy, and match the sidebar row by its label (objectui#11863) Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- .../StudioRoute.recentPackage-11863.test.tsx | 33 +++++++++++-------- ...nifiedSidebar.recentPackage-11863.test.tsx | 4 +-- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx b/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx index d8098bee7c..d074d77250 100644 --- a/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx +++ b/apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx @@ -14,6 +14,13 @@ * provider holds and the copy it writes to localStorage: a transcription of the * route tree, or a stubbed store, would be free to agree with itself. * + * "Writes nothing" is read off that persisted copy: absent when nothing was + * ever written (the provider only reads on mount), and byte-identical, its + * `visitedAt` included, when a later visit must not have written. A + * `Storage.prototype.setItem` spy is not the instrument here: under this + * project's DOM environment it counted zero calls for a write the persisted + * copy shows, so a zero from it says nothing. + * * The builder itself is a stub (`./studioBuilder`), as in * `StudioRoute.lazyBuilder-11798.test.tsx`: what is measured is what the ROUTE * records, not what the builder renders. @@ -21,7 +28,7 @@ import '@testing-library/jest-dom/vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; @@ -119,23 +126,19 @@ const recent = () => JSON.parse(screen.getByTestId('recent').textContent ?? '[]' const holder = async () => jsonResponse({ authenticated: true, userId: 'u1', systemPermissions: [STUDIO_ENTRY_CAPABILITY], objects: {}, fields: {} }); -let setItem: ReturnType; +/** The persisted copy of the list, as written; `null` when nothing was ever written. */ +const persisted = () => localStorage.getItem(STORAGE_KEY); beforeEach(() => { auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; permissionsFetch = holder; localStorage.clear(); - setItem = vi.spyOn(Storage.prototype, 'setItem'); }); afterEach(() => { cleanup(); - setItem.mockRestore(); }); -/** The writes of the recent list itself, leaving out anything else the tree stores. */ -const recentWrites = () => setItem.mock.calls.filter(([key]) => key === STORAGE_KEY); - describe('/studio/PKG/TAB records the package as a recent entry (objectui#11863)', () => { it('a pillar visit writes one package entry, keyed by the package id, with no label', async () => { renderStudio('/studio/com.acme.crm/data'); @@ -165,13 +168,17 @@ describe('/studio/PKG/TAB records the package as a recent entry (objectui#11863) it('switching pillars inside the same package changes nothing, so it writes nothing', async () => { renderStudio('/studio/com.acme.crm/data'); await waitFor(() => expect(recent()).toHaveLength(1)); - expect(recentWrites()).toHaveLength(1); + const before = persisted(); + expect(before).not.toBeNull(); + // Long enough that a second write would carry a different `visitedAt`. + await new Promise((r) => setTimeout(r, 20)); - screen.getByTestId('go:/studio/com.acme.crm/automations').click(); + act(() => screen.getByTestId('go:/studio/com.acme.crm/automations').click()); await waitFor(() => expect(screen.getByTestId('pathname')).toHaveTextContent('/studio/com.acme.crm/automations')); + await screen.findByTestId('studio-pillar-builder'); - expect(recent()).toHaveLength(1); - expect(recentWrites()).toHaveLength(1); + expect(persisted()).toBe(before); + expect(recent()).toEqual(JSON.parse(before ?? '[]')); }); it('the package-less scope is not a package, and the landing is not a visit: neither records anything', async () => { @@ -183,7 +190,7 @@ describe('/studio/PKG/TAB records the package as a recent entry (objectui#11863) await screen.findByTestId('studio-front-door'); expect(recent()).toEqual([]); - expect(recentWrites()).toHaveLength(0); + expect(persisted()).toBeNull(); }); it('a principal the entry gate refuses records nothing: the visit is recorded behind the gate', async () => { @@ -192,6 +199,6 @@ describe('/studio/PKG/TAB records the package as a recent entry (objectui#11863) await waitFor(() => expect(screen.getByTestId('pathname')).toHaveTextContent('/home')); expect(recent()).toEqual([]); - expect(recentWrites()).toHaveLength(0); + expect(persisted()).toBeNull(); }); }); diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx index 1ec76b36ef..ea65b25fa0 100644 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.recentPackage-11863.test.tsx @@ -130,7 +130,7 @@ describe('UnifiedSidebar — a recent package is not drawn without a package lis render(sidebarUi()); const group = openRecent(); - expect(within(group).getByRole('link', { name: 'account' })).toHaveAttribute('href', METADATA.href); + expect(within(group).getByRole('link', { name: /\baccount$/ })).toHaveAttribute('href', METADATA.href); expect(within(group).queryByText('com.acme.crm')).not.toBeInTheDocument(); expect(group.querySelector(`a[href="${PACKAGE.href}"]`)).toBeNull(); }); @@ -147,6 +147,6 @@ describe('UnifiedSidebar — a recent package is not drawn without a package lis recent.items = [METADATA]; render(sidebarUi()); - expect(within(openRecent()).getByRole('link', { name: 'account' })).toBeInTheDocument(); + expect(within(openRecent()).getByRole('link', { name: /\baccount$/ })).toBeInTheDocument(); }); }); From c39af0271821e49ebe54eafacd0e8f6cb9f7a956 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:50:55 +0000 Subject: [PATCH 3/4] fix(app-shell): narrow StudioHomePage's listed kinds on the type member, and drop a test cast (objectui#11863) `Exclude` over the `RecentItem` union removes no arm, since `RecentNamedItem`'s `type` is itself a union, so the icon map still demanded a `package` key and the package build failed (TS2741). The listed kinds are now `Exclude`. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- .../src/context/__tests__/RecentItemsProvider.test.tsx | 2 +- .../app-shell/src/views/metadata-admin/StudioHomePage.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx index aa5fbf3345..42ce77e33e 100644 --- a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx +++ b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx @@ -371,7 +371,7 @@ describe('RecentItemsProvider — reading a list stored before the identity shap describe('RecentItemsProvider — a Studio package entry (objectui#11863)', () => { beforeEach(() => { localStorage.clear(); - mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; + mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' }; }); it('is kept by identity: no display text is stored, even when one is handed in', () => { diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx index cf19100750..86bbe48db1 100644 --- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx @@ -95,9 +95,9 @@ const DOMAIN_ORDER = ['data', 'ui', 'automation', 'ai', 'identity', 'security', const QUICK_CREATE_ORDER = ['object', 'view', 'page', 'dashboard', 'report', 'flow', 'action', 'agent']; /** The kinds this page lists: every kind but a Studio package (see `recentItems` below). */ -type ListedRecentItem = Exclude; +type ListedRecentType = Exclude; -const RECENT_ICONS: Record> = { +const RECENT_ICONS: Record> = { object: Table2, dashboard: LayoutDashboard, page: FileText, @@ -155,7 +155,7 @@ export function StudioHomePage() { // (`buildPackageScopeOptions`), so a read-only package would draw as its id: // package entries are left out, and the Studio landing lists them. const recentItems = allRecentItems.filter( - (item): item is ListedRecentItem => item.type !== 'package', + (item): item is RecentItem & { type: ListedRecentType } => item.type !== 'package', ); // An entry's text is resolved on this render, never stored (objectui#11678). const recentLabel = useRecentItemLabel(); From 4f42ef8019674d7f9b702921172a12c18252deb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:45:52 +0000 Subject: [PATCH 4/4] docs(app-shell): name the Studio package kind of a recent entry, and keep the README's RecentList true for it (objectui#11863) Beside the sentence listing the recent-entry kinds stored by identity, the package README and the user-state guide now say that a Studio package entry (`type: 'package'`) is stored the same way and is labelled with `useRecentItemLabel({ packages })`, or left out where no package list is loaded. The README's RecentList snippet loads no package list, so it leaves package entries out. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- content/docs/guide/user-state-persistence.md | 2 +- packages/app-shell/README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/content/docs/guide/user-state-persistence.md b/content/docs/guide/user-state-persistence.md index a020557700..d02daffc70 100644 --- a/content/docs/guide/user-state-persistence.md +++ b/content/docs/guide/user-state-persistence.md @@ -187,7 +187,7 @@ The providers debounce backend writes with a 500ms window via `createDebouncedFl ## 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. +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. A Studio package entry (`type: 'package'`) is stored the same way; label it with `useRecentItemLabel({ packages })`, passing the package list you loaded, or leave it out where you have no list. 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. diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 9fedd742fb..4b5e96bc59 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -1003,9 +1003,11 @@ 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. +current language (a record entry keeps the title it was visited under). A +Studio package entry (`type: 'package'`) is stored the same way; label it with +`useRecentItemLabel({ packages })`, passing the package list you loaded, or +leave it out where you have no list. 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'; @@ -1013,7 +1015,9 @@ import { useRecentItems, useRecentItemLabel } from '@object-ui/app-shell'; function RecentList() { const { recentItems } = useRecentItems(); const recentLabel = useRecentItemLabel(); - return
    {recentItems.map((item) =>
  • {recentLabel(item)}
  • )}
; + // No package list is loaded here, so Studio package entries are left out. + const shown = recentItems.filter((item) => item.type !== 'package'); + return
    {shown.map((item) =>
  • {recentLabel(item)}
  • )}
; } ```