Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/11863-studio-recent-packages.md
Original file line number Diff line number Diff line change
@@ -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.
204 changes: 204 additions & 0 deletions apps/console/src/components/StudioRoute.recentPackage-11863.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// 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.
*
* "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.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, 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<Response> = 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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
RedirectWithSplash: () => <div data-testid="redirect" />,
LoadingFallback: () => <div data-testid="loading" />,
LoadingScreen: () => <div data-testid="error-screen" />,
getProductName: () => 'ObjectOS',
useHomePath: () => '/home',
}));

vi.mock('./studioBuilder', () => ({
BuilderLanding: () => <div data-testid="studio-front-door">pick a package</div>,
StudioDesignSurface: () => <div data-testid="studio-pillar-builder">pillars</div>,
}));

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 <pre data-testid="recent">{JSON.stringify(recentItems)}</pre>;
}

/** Navigates to `to` when clicked — a client-side transition, as a pillar tab is. */
function Go({ to }: { to: string }) {
const navigate = useNavigate();
return (
<button type="button" data-testid={`go:${to}`} onClick={() => navigate(to)}>
{to}
</button>
);
}

function LocationProbe() {
const { pathname } = useLocation();
return <div data-testid="pathname">{pathname}</div>;
}

function renderStudio(url: string) {
return render(
<RecentItemsProvider>
<MemoryRouter initialEntries={[url]}>
<LocationProbe />
<RecentProbe />
<Go to="/studio/com.acme.crm/automations" />
<Routes>
{studioRoutes}
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
</Routes>
</MemoryRouter>
</RecentItemsProvider>,
);
}

const recent = () => JSON.parse(screen.getByTestId('recent').textContent ?? '[]') as Array<Record<string, unknown>>;

const holder = async () =>
jsonResponse({ authenticated: true, userId: 'u1', systemPermissions: [STUDIO_ENTRY_CAPABILITY], objects: {}, fields: {} });

/** 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();
});

afterEach(() => {
cleanup();
});

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

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(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 () => {
renderStudio('/studio/~org/automations');
await screen.findByTestId('studio-pillar-builder');
cleanup();

renderStudio('/studio');
await screen.findByTestId('studio-front-door');

expect(recent()).toEqual([]);
expect(persisted()).toBeNull();
});

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(persisted()).toBeNull();
});
});
53 changes: 42 additions & 11 deletions apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@
* 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,
STUDIO_ORG_SCOPE_PILLAR,
STUDIO_ORG_SCOPE_SEGMENT,
getProductName,
useHomePath,
useRecentItems,
} from '@object-ui/app-shell';
import { useObjectTranslation } from '@object-ui/i18n';

Expand Down Expand Up @@ -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 (
<Suspense fallback={<LoadingFallback />}>
<StudioDesignSurface />
</Suspense>
);
}

/**
* The whole `/studio` subtree (ADR-0080/0084), as ONE element `App.tsx` drops
* into its `<Routes>` — the same shape `AppContent`'s `systemRoutes` uses.
Expand All @@ -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
Expand All @@ -214,13 +252,6 @@ export const studioRoutes = (
element={<Navigate to={STUDIO_ORG_SCOPE_PILLAR} replace />}
/>
<Route path=":packageId" element={<Navigate to="data" replace />} />
<Route
path=":packageId/:tab"
element={
<Suspense fallback={<LoadingFallback />}>
<StudioDesignSurface />
</Suspense>
}
/>
<Route path=":packageId/:tab" element={<StudioPackageBuilder />} />
</Route>
);
2 changes: 1 addition & 1 deletion content/docs/guide/user-state-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 8 additions & 4 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1003,17 +1003,21 @@ 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';

function RecentList() {
const { recentItems } = useRecentItems();
const recentLabel = useRecentItemLabel();
return <ul>{recentItems.map((item) => <li key={item.id}>{recentLabel(item)}</li>)}</ul>;
// No package list is loaded here, so Studio package entries are left out.
const shown = recentItems.filter((item) => item.type !== 'package');
return <ul>{shown.map((item) => <li key={item.id}>{recentLabel(item)}</li>)}</ul>;
}
```

Expand Down
23 changes: 14 additions & 9 deletions packages/app-shell/src/context/RecentItemsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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" */
Expand All @@ -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
Expand All @@ -74,7 +74,12 @@ interface RecentItemBase {
*/
export interface RecentNamedItem extends RecentItemBase {
type: Exclude<RecentItemType, 'record' | 'metadata'>;
/** 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;
}

Expand Down Expand Up @@ -106,7 +111,7 @@ interface RecentItemsContextValue {
const STORAGE_BASE_KEY = 'objectui-recent-items';
const MAX_RECENT = 8;

const NAMED_TYPES: ReadonlySet<string> = new Set(['object', 'dashboard', 'page', 'report']);
const NAMED_TYPES: ReadonlySet<string> = new Set(['object', 'dashboard', 'page', 'report', 'package']);
const TEXT_TYPES: ReadonlySet<string> = new Set(['record', 'metadata']);

/**
Expand Down
Loading
Loading