From a0cd96185e0b6c950bbf059a4d27e1b0f70ce952 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:58:07 +0000 Subject: [PATCH 1/3] feat(app-shell): one page header for every Setup page (objectui#12606) Inside the Setup app the console page header takes a Setup posture: a 32px icon tile, the title with the kind chip after it, the description under the title, Refresh as an icon button and the primary action rightmost. The posture is read off the route's :appName, so pages outside Setup keep the one-row header. - ConsolePageHeader gains badge, breadcrumb and refresh. - The metadata-type page under Setup drops the breadcrumb and the stat tiles, says the count in the description, makes the source select a filter chip and the per-row source chip a tooltip. - Record lists under Setup open without row numbers and draw Display only for an object with more than four views (two ListView host props). - Settings pages take "All settings" as the header's first crumb. - Packages, Packaged automation, Approvals Inbox, Audit Log Browser and App Marketplace draw their title row through the same header. Claude-Session: https://claude.ai/code/session_01XvN7kN2b8z5CLTwmJJhhJF Co-authored-by: Claude --- .changeset/12606-setup-page-header.md | 13 + .../src/pages/settings/SettingsView.tsx | 31 +- .../SettingsView.pageHeader-12553.test.tsx | 19 +- .../src/pages/system/ApprovalsInboxPage.tsx | 9 +- .../console/src/pages/system/AuditLogPage.tsx | 10 +- .../app-shell/src/console/ConsoleShell.tsx | 17 +- .../console/marketplace/MarketplacePage.tsx | 35 +-- packages/app-shell/src/console/setupApp.ts | 51 ++++ packages/app-shell/src/layout/PageHeader.tsx | 179 ++++++++--- .../PageHeader.setupPosture-12606.test.tsx | 110 +++++++ .../ObjectView.setupListChrome-12606.test.tsx | 161 ++++++++++ packages/app-shell/src/views/ObjectView.tsx | 29 +- .../src/views/metadata-admin/PackagesPage.tsx | 39 ++- .../src/views/metadata-admin/PageShell.tsx | 2 +- ...sourceListPage.setupPosture-12606.test.tsx | 126 ++++++++ .../views/metadata-admin/ResourceListPage.tsx | 277 ++++++++++++------ .../src/views/metadata-admin/i18n.ts | 4 + .../views/setup/PackagedAutomationPage.tsx | 39 ++- packages/plugin-list/src/ListView.tsx | 28 +- .../ListView.setupChrome-12606.test.tsx | 89 ++++++ 20 files changed, 1008 insertions(+), 260 deletions(-) create mode 100644 .changeset/12606-setup-page-header.md create mode 100644 packages/app-shell/src/console/setupApp.ts create mode 100644 packages/app-shell/src/layout/__tests__/PageHeader.setupPosture-12606.test.tsx create mode 100644 packages/app-shell/src/views/ObjectView.setupListChrome-12606.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.setupChrome-12606.test.tsx diff --git a/.changeset/12606-setup-page-header.md b/.changeset/12606-setup-page-header.md new file mode 100644 index 0000000000..b156a6d164 --- /dev/null +++ b/.changeset/12606-setup-page-header.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-list': minor +'@object-ui/console': minor +--- + +Setup draws one page header on every page: the `sys_*` record lists, the metadata-type pages, the settings pages and the component pages. + +- **One header, a Setup posture.** Inside the Setup app the console page header (`ConsolePageHeader`) puts the description on its own line under the title: a 32px icon tile, an 18px title with the page's kind chip after it, a 13px muted description, Refresh as an icon button and the primary action at the right. The title sits at the same place on every Setup page. Outside Setup the header keeps its one row. `ConsolePageHeader` takes three new optional props: `badge` (the kind chip after the title), `breadcrumb` (leading crumbs on the description line) and `refresh` (`{ onRefresh, label, busy? }`, drawn as one icon button before `actions`). +- **Metadata-type pages under Setup** (Positions, Capabilities, Permission Sets and their siblings) drop the "All Metadata Types" breadcrumb and the Items / Filtered tiles. The count reads "18 items" in the description, the source select is a filter chip, the table takes the record list's row height and sentence-case headers, and the source column shows plain text with its explanation in a tooltip instead of a chip on every row. The same page outside Setup (the metadata directory, Studio's "Open in Setup" target) is unchanged. +- **Record lists under Setup** open without the `#` row numbers, and draw the Display control only for an object that declares more than four views. `ListView` takes two new host props for this, `defaultRowNumbers` and `showDisplayControl`; neither is a view key. +- **Settings pages** keep their form and take the same header. "All settings" is now the first crumb of the header's description line instead of a button at the top of the form. +- **Component pages** — Packages, Packaged automation, Approvals Inbox, Audit Log Browser and App Marketplace — draw their title row through the same header, with Refresh as its icon button. diff --git a/apps/console/src/pages/settings/SettingsView.tsx b/apps/console/src/pages/settings/SettingsView.tsx index 032d03a52a..a7727db391 100644 --- a/apps/console/src/pages/settings/SettingsView.tsx +++ b/apps/console/src/pages/settings/SettingsView.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { ConsolePageHeader } from '@object-ui/app-shell'; -import { Loader2, ArrowLeft, RotateCcw, ShieldAlert } from 'lucide-react'; +import { Loader2, ArrowLeft, ChevronRight, RotateCcw, ShieldAlert } from 'lucide-react'; import { Button, Card, CardContent, Skeleton, Badge, getLazyIcon } from '@object-ui/components'; import { extractFieldErrors } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; @@ -288,11 +288,11 @@ export function SettingsView() { {/* The console's one page header (objectui#12553), the one an object list, a dashboard and the Approvals Inbox draw: the namespace's icon chip (when its manifest names an icon), the title, and the description - beside the title from `sm` up, in one row across the page above the - padded body. The Beta badge rides in the title node beside the name, - as an object list's badge does. Back to settings stays at the top of - the body, where it sat above the old heading. Below `sm` the header - hides the description. */} + beside the title from `sm` up — under it inside Setup (objectui#12606). + The Beta badge rides in the title node beside the name. "All settings" + is the description line's first crumb (objectui#12606); it sat at the + top of the body before. Below `sm` the one-row header hides the + description line. */} @@ -307,6 +307,19 @@ export function SettingsView() { } subtitle={description} + breadcrumb={ + + + + } icon={ Icon ? ( // eslint-disable-next-line react-hooks/static-components -- getLazyIcon returns a module-cached stable component per name, not one created during render @@ -315,12 +328,8 @@ export function SettingsView() { } />
- - {manifest.helpText ? ( -

{manifest.helpText}

+

{manifest.helpText}

) : null} {cryptoRefusal ? ( diff --git a/apps/console/src/pages/settings/__tests__/SettingsView.pageHeader-12553.test.tsx b/apps/console/src/pages/settings/__tests__/SettingsView.pageHeader-12553.test.tsx index 28efcd99a0..e36cfe5423 100644 --- a/apps/console/src/pages/settings/__tests__/SettingsView.pageHeader-12553.test.tsx +++ b/apps/console/src/pages/settings/__tests__/SettingsView.pageHeader-12553.test.tsx @@ -123,17 +123,26 @@ describe('Settings namespace page — the console page header (objectui#12553)', expect(h1s).toHaveLength(1); expect(header.contains(h1s[0])).toBe(true); expect(h1s[0].textContent).toBe('Plain'); - expect(header.querySelector('p')).toBeNull(); - expect(header.querySelector('svg')).toBeNull(); + // objectui#12606 — the description line holds the "All settings" crumb + // alone: no description text, and no icon chip (the only svg is the + // crumb's chevron). + const line = header.querySelector('p')!; + expect(line.textContent).toBe('console.settingsView.backToHub'); + expect(Array.from(header.querySelectorAll('svg')).every((svg) => line.contains(svg))).toBe(true); }); - it('Back to settings sits in the body below the header and opens the hub', async () => { + it('"All settings" is the header’s first crumb and opens the hub (objectui#12606)', async () => { renderView('mail'); await screen.findByDisplayValue('smtp.example.com'); const header = screen.getByTestId('page-header'); const back = screen.getByRole('button', { name: /console\.settingsView\.backToHub/ }); - expect(header.contains(back)).toBe(false); - expect(header.nextElementSibling!.contains(back)).toBe(true); + expect(header.contains(back)).toBe(true); + // First on the description line, before the description text. + const line = within(header).getByText('Outbound email transport.', { exact: false }); + expect(line.contains(back)).toBe(true); + expect(line.textContent!.indexOf('console.settingsView.backToHub')).toBe(0); + // Nothing of it is left in the body. + expect(header.nextElementSibling!.contains(back)).toBe(false); fireEvent.click(back); expect(screen.getByText('settings hub route')).toBeInTheDocument(); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 6ec8abbe14..0f79e6c279 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -86,7 +86,6 @@ import { CheckCircle2, XCircle, Clock, - RefreshCw, AlertCircle, CheckSquare, Search, @@ -1806,12 +1805,8 @@ export function ApprovalsInboxPage() { title={tr('title', 'Approvals Inbox')} subtitle={tr('subtitle', 'Review and act on approval requests.')} icon={} - actions={ - - } + // The header's own Refresh icon button (objectui#12606). + refresh={{ onRefresh: load, label: tr('refresh', 'Refresh'), busy: loading }} />
{error && ( diff --git a/apps/console/src/pages/system/AuditLogPage.tsx b/apps/console/src/pages/system/AuditLogPage.tsx index c7676b38b4..e40d46ea5d 100644 --- a/apps/console/src/pages/system/AuditLogPage.tsx +++ b/apps/console/src/pages/system/AuditLogPage.tsx @@ -61,7 +61,7 @@ import { } from '@object-ui/components'; import { LookupField } from '@object-ui/fields'; import { ConsolePageHeader, useAdapter } from '@object-ui/app-shell'; -import { RefreshCw, Search, X, AlertCircle, ScrollText } from 'lucide-react'; +import { Search, X, AlertCircle, ScrollText } from 'lucide-react'; import { ACTION_OPTIONS, ACTION_VARIANT } from './auditLogActions'; const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, ''); @@ -285,12 +285,8 @@ export function AuditLogPage() { title="Audit Log" subtitle="Read-only system activity and record changes. Newest first." icon={} - actions={ - - } + // The header's own Refresh icon button (objectui#12606). + refresh={{ onRefresh: load, label: 'Refresh', busy: loading }} />
{/* Filter bar */} diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index c3ac6cfee7..172463c264 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -27,6 +27,7 @@ import { AdapterProvider, useAdapter } from '../providers/AdapterProvider.js'; import { withSettleSignal } from '../observability/settleSignal.js'; import { MetadataProvider, useMetadata } from '../providers/MetadataProvider.js'; import { appRouteSegment } from '../utils/appRoute.js'; +import { SETUP_APP_PACKAGE_ID, SETUP_APP_NAME } from './setupApp.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; import { useHomePath } from '../hooks/useHomePath.js'; import { PreviewModeProvider } from '../preview/PreviewModeContext.js'; @@ -515,19 +516,9 @@ export function SystemRedirect() { return ; } -/** - * The Setup app's canonical package id (ADR-0048 — one app per package). - * `@objectstack/setup` declares it, and `/apps/` is the app's - * canonical URL (see `utils/appRoute.ts`). - */ -export const SETUP_APP_PACKAGE_ID = 'com.objectstack.setup'; - -/** - * The Setup app's metadata `name`. `matchAppBySegment()` treats the name as a - * per-tenant friendly alias, so a deployment that registers Setup without an - * owning package (runtime/DB apps, older bundles) is still resolvable by it. - */ -export const SETUP_APP_NAME = 'setup'; +// The Setup app's identity lives in `./setupApp.ts` (objectui#12606), which the +// page header reads too; re-exported here so the package root keeps its names. +export { SETUP_APP_PACKAGE_ID, SETUP_APP_NAME }; /** * Minimal shape this resolver needs off each App metadata record. Structurally diff --git a/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index 65cf692dc1..7a242e41a7 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -19,7 +19,7 @@ import { Button, Skeleton, } from '@object-ui/components'; -import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react'; +import { Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { useAdapter } from '@object-ui/react'; @@ -40,6 +40,7 @@ import { type OrgPackageSummary, } from './marketplaceApi.js'; import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; +import { PageHeader } from '../../layout/PageHeader.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; @@ -415,18 +416,16 @@ export function MarketplacePage() { ); return ( -
-
-
-
-
-

- {t('marketplace.subtitle')} -

-
-
+
+ {/* The console's one page header (objectui#12606): only this row is + this card's; the cards below are not. Refresh is the header's icon + button. */} +
-
+ } + /> +
@@ -692,5 +688,6 @@ export function MarketplacePage() {
)}
+
); } diff --git a/packages/app-shell/src/console/setupApp.ts b/packages/app-shell/src/console/setupApp.ts new file mode 100644 index 0000000000..c1a31b9389 --- /dev/null +++ b/packages/app-shell/src/console/setupApp.ts @@ -0,0 +1,51 @@ +/** + * 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 Setup app's identity, and the one signal a page reads to know it is + * drawn inside Setup (objectui#12606). + * + * The constants lived in `ConsoleShell.tsx`, which still re-exports them; they + * sit in this dependency-free module so the page header and the list pages can + * read them without importing the console shell. + */ + +import { useParams } from 'react-router-dom'; + +/** + * The Setup app's canonical package id (ADR-0048 — one app per package). + * `@objectstack/setup` declares it, and `/apps/` is the app's + * canonical URL (see `utils/appRoute.ts`). + */ +export const SETUP_APP_PACKAGE_ID = 'com.objectstack.setup'; + +/** + * The Setup app's metadata `name`. `matchAppBySegment()` treats the name as a + * per-tenant friendly alias, so a deployment that registers Setup without an + * owning package (runtime/DB apps, older bundles) is still resolvable by it. + */ +export const SETUP_APP_NAME = 'setup'; + +/** + * Does this `/apps/:appName` segment name the Setup app? The package id and + * the name both address it, in the order `matchAppBySegment()` resolves them. + */ +export function isSetupAppSegment(segment: string | null | undefined): boolean { + return segment === SETUP_APP_PACKAGE_ID || segment === SETUP_APP_NAME; +} + +/** + * Is the calling page drawn inside the Setup app? Read off the route the page + * is mounted under — the `:appName` param of `/apps/:appName/*` — so no host + * threads a prop for it. Outside that route (Studio, a test that mounts the + * page bare) the answer is `false` and the page keeps its default shape. + */ +export function useInSetupApp(): boolean { + const { appName } = useParams<{ appName?: string }>(); + return isSetupAppSegment(appName); +} diff --git a/packages/app-shell/src/layout/PageHeader.tsx b/packages/app-shell/src/layout/PageHeader.tsx index b9dbc8eba3..a9b9986761 100644 --- a/packages/app-shell/src/layout/PageHeader.tsx +++ b/packages/app-shell/src/layout/PageHeader.tsx @@ -17,12 +17,24 @@ * * Layout — ONE row from the `sm` breakpoint up (objectui#12423): * [accent hairline] - * [icon] Title | subtitle… [actions...] + * [icon] Title [kind] | crumb › subtitle… [refresh] [actions...] * Below `sm` the subtitle is hidden, so a phone draws icon, title and actions. + * + * Setup posture — inside the Setup app (objectui#12606) the description sits + * UNDER the title, so every Setup page (a `sys_*` list, a metadata type, a + * settings namespace, a component page) draws one header whose title lands + * at the same place: + * [accent hairline] + * [32px tile] Title (18px / 600) [kind] [refresh] [actions...] + * crumb › description (13px, muted) + * The posture is read off the route (`useInSetupApp`), not passed by the + * page, so a console page outside Setup keeps the one-row header. */ import * as React from 'react'; -import { cn } from '@object-ui/components'; +import { RefreshCw } from 'lucide-react'; +import { Button, cn } from '@object-ui/components'; +import { useInSetupApp } from '../console/setupApp.js'; /** * Props of the app-shell `` React component. @@ -86,6 +98,24 @@ export interface ConsolePageHeaderProps { * the chip will scale child `` elements to fit. */ icon?: React.ReactNode; + /** + * The page's kind chip, drawn right after the title (objectui#12606) — the + * `ManagedByBadge` of a `sys_*` list, a metadata type's writability badge. + * A rendered node, so the chip keeps its own words and its own gate. + */ + badge?: React.ReactNode; + /** + * Leading crumb(s), drawn at the start of the description line — the + * settings page's "All settings" link (objectui#12606). It never sits above + * the title, so the title lands where every other page's does. + */ + breadcrumb?: React.ReactNode; + /** + * The page's Refresh, drawn as one icon button before `actions`, so the + * primary action stays rightmost (objectui#12606). `label` is the button's + * accessible name and tooltip; `busy` spins the icon and disables it. + */ + refresh?: { onRefresh: () => void; label: string; busy?: boolean }; /** Right-aligned actions (buttons, dropdowns, etc.). */ actions?: React.ReactNode; /** @@ -127,6 +157,9 @@ export function PageHeader({ title, subtitle, icon, + badge, + breadcrumb, + refresh, actions, accentColor, sticky = false, @@ -137,6 +170,31 @@ export function PageHeader({ // header follows whatever brand the active app has injected. const accent = accentColor || 'hsl(var(--primary))'; const style = { '--page-header-accent': accent } as React.CSSProperties; + // objectui#12606 — Setup's posture: the description under the title. + const setup = useInSetupApp(); + + const refreshButton = refresh ? ( + + ) : null; + + const trailing = (refreshButton || actions) && ( +
+ {refreshButton} + {actions} +
+ ); return (
{/* Accent hairline — a 2px gradient strip that fades to transparent at both edges. Picks up the app's accent color, or the default primary @@ -161,53 +220,83 @@ export function PageHeader({ }} /> -
- {/* One row: icon, title, then the subtitle behind a hairline — all - siblings of this flex row, so nothing wraps beneath the title - (objectui#12423). `min-h-9` floors the row at the chip's `h-9`, so - a header with no icon and no action is as tall as one with them - (objectui#12553). It sits here, not on the padded row above: under - `border-box` a min-height there would count that row's padding. */} -
- {icon && ( -
- {icon} -
- )} -

- {title} -

- {subtitle && ( - <> -
+ ); +} + +/** + * The accent-tinted icon chip: 36px in the one-row header, 32px in Setup's + * (objectui#12606). Auto-sizes any nested SVG so callers don't have to think + * about sizing inside the chip. + */ +function IconTile({ size, children }: { size: 'row' | 'setup'; children: React.ReactNode }) { + return ( +
+ {children}
); } diff --git a/packages/app-shell/src/layout/__tests__/PageHeader.setupPosture-12606.test.tsx b/packages/app-shell/src/layout/__tests__/PageHeader.setupPosture-12606.test.tsx new file mode 100644 index 0000000000..544ff978a8 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/PageHeader.setupPosture-12606.test.tsx @@ -0,0 +1,110 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12606 — one page header for every Setup page. Inside the Setup app + * the console page header draws its Setup posture: a 32px icon tile, the title + * with the kind chip after it, the description on its own line UNDER the + * title, Refresh as an icon button and the primary action rightmost. Outside + * Setup the same component keeps objectui#12423's one row. + * + * The posture is read off the route's `:appName`, so each case mounts the + * header under `/apps/:appName/*` and only the segment changes. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { PageHeader } from '../PageHeader'; + +afterEach(cleanup); + +function mount(appName: string, onRefresh = vi.fn()) { + render( + + + } + badge={Identity} + refresh={{ onRefresh, label: 'Refresh' }} + actions={} + /> + } + /> + + , + ); + return { header: screen.getByTestId('page-header'), onRefresh }; +} + +describe('the console page header’s Setup posture (objectui#12606)', () => { + it.each(['setup', 'com.objectstack.setup'])( + 'under /apps/%s the description sits under the title, with the kind chip and the primary action', + (segment) => { + const { header, onRefresh } = mount(segment); + expect(header.getAttribute('data-posture')).toBe('setup'); + + const h1 = within(header).getByRole('heading', { level: 1 }); + expect(h1.className).toContain('text-lg'); + expect(h1.className).toContain('font-semibold'); + // The kind chip follows the title in the title line. + const chip = within(header).getByTestId('kind-chip'); + expect(chip.parentElement).toBe(h1.parentElement); + + // The description is its own line under the title line, 13px. + const description = within(header).getByTestId('page-header-description'); + expect(description.textContent).toBe('Everyone who can sign in.'); + expect(description.className).toContain('text-[13px]'); + expect(description.parentElement).toBe(h1.parentElement!.parentElement); + expect(description.previousElementSibling).toBe(h1.parentElement); + + // The icon tile is 32px. + expect(within(header).getByTestId('icon').parentElement!.className).toContain('h-8 w-8'); + + // Refresh is an icon button before the primary action, which is last. + const refresh = within(header).getByTestId('page-header-refresh'); + expect(refresh.getAttribute('aria-label')).toBe('Refresh'); + const primary = within(header).getByTestId('primary-action'); + expect(refresh.compareDocumentPosition(primary) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + fireEvent.click(refresh); + expect(onRefresh).toHaveBeenCalledTimes(1); + }, + ); + + it('CONTROL — outside Setup the same header keeps the one row', () => { + const { header } = mount('crm'); + expect(header.getAttribute('data-posture')).toBe('row'); + const h1 = within(header).getByRole('heading', { level: 1 }); + // The description is a sibling of the title in its one row. + const line = within(header).getByText('Everyone who can sign in.'); + expect(line.parentElement).toBe(h1.parentElement); + expect(screen.queryByTestId('page-header-description')).toBeNull(); + // The kind chip and the primary action still draw. + expect(within(header).getByTestId('kind-chip').parentElement).toBe(h1.parentElement); + expect(within(header).getByTestId('primary-action')).toBeTruthy(); + }); + + it('a Setup page with no description keeps the description line, so its title sits where the others do', () => { + render( + + + } /> + + , + ); + const description = screen.getByTestId('page-header-description'); + expect(description.textContent).toBe(''); + expect(description.className).toContain('min-h-[18px]'); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.setupListChrome-12606.test.tsx b/packages/app-shell/src/views/ObjectView.setupListChrome-12606.test.tsx new file mode 100644 index 0000000000..ccfe61eae4 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.setupListChrome-12606.test.tsx @@ -0,0 +1,161 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12606 — a `sys_*` record list under the Setup app drops the list + * chrome an administrator does not use there: the grid's `#` row numbers, and + * the Display control unless the object declares more than four views. + * + * Measured from a real `ObjectView` → `ListView` → `ObjectGrid` mount (the + * `ObjectView.listUrlState-11860` harness). "Under Setup" is the route's own + * `:appName` (`setup` / `com.objectstack.setup`); the control is the same + * object under another app, which keeps its numbers and its Display. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const { perms } = vi.hoisted(() => ({ + // ONE stable object: `ListView` names `perms` in its fetch dependency list. + perms: { + check: () => ({ allowed: true }), + checkField: () => true as boolean, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +// The grid registers `object-grid` on import. +import '@object-ui/plugin-grid'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, +}; +const view = (label: string) => ({ label, type: 'grid', columns: ['name'] }); + +const OBJECTS = [ + // Two views: under Setup, no Display. + { name: 'sys_team_12606', label: 'Team', pluralLabel: 'Teams', fields: FIELDS, listViews: { all: view('All'), mine: view('Mine') } }, + // Five views: more than four, so Display stays under Setup. + { + name: 'sys_log_12606', + label: 'Log', + pluralLabel: 'Logs', + fields: FIELDS, + listViews: { a: view('A'), b: view('B'), c: view('C'), d: view('D'), e: view('E') }, + }, +]; + +const ROWS = [{ id: '1', name: 'Core' }, { id: '2', name: 'Ops' }]; + +beforeEach(() => { + // The page's side reads (`/security/explain` and the like) answer empty. + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function mount(appName: string, objectName: string) { + const dataSource = { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; + render( + + + + {}} />} + /> + + + , + ); + await screen.findByText('Core', {}, { timeout: 8000 }); + await act(() => new Promise((resolve) => setTimeout(resolve, 200))); +} + +const rowNumberCells = () => document.querySelectorAll('[data-slot="row-number"]').length; + +describe('a record list under Setup drops the `#` column and, for four views or fewer, Display (objectui#12606)', () => { + it.each(['setup', 'com.objectstack.setup'])('under /apps/%s the grid renders no `#` column and no Display', async (app) => { + await mount(app, 'sys_team_12606'); + expect(rowNumberCells()).toBe(0); + expect(screen.queryByTestId('toolbar-display-trigger')).toBeNull(); + }); + + it('under Setup an object with more than four views keeps Display, still with no `#` column', async () => { + await mount('setup', 'sys_log_12606'); + expect(rowNumberCells()).toBe(0); + expect(screen.getByTestId('toolbar-display-trigger')).toBeTruthy(); + }); + + it('CONTROL — the same list under another app numbers its rows and draws Display', async () => { + await mount('crm', 'sys_team_12606'); + expect(rowNumberCells()).toBe(ROWS.length); + expect(screen.getByTestId('toolbar-display-trigger')).toBeTruthy(); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 887d98dda1..ed4e19f641 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -75,6 +75,7 @@ import { PageHeader } from '../layout/PageHeader.js'; import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext.js'; import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js'; import { ManagedByBadge } from '../components/ManagedByBadge.js'; +import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; import type { RowCrudPredicates } from '../utils/crudAffordances.js'; import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, type IdentityPasswordPolicy } from './identityImport.js'; @@ -1416,6 +1417,10 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: ConsoleObjectViewProps) { const navigate = useNavigate(); const { appName, objectName, viewId } = useParams(); + // objectui#12606 — inside the Setup app the list drops the chrome an + // administrator does not use there (see the `ListView` below). Read off + // the route's own `:appName`, the one signal the page header reads too. + const inSetup = isSetupAppSegment(appName); const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const { showDebug } = useMetadataInspector(); @@ -3643,10 +3648,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // objectui#12440 — the saved-view tabs, on the list's own // toolbar row (see `viewTabBar`). `undefined` below `sm`. toolbarLeading={viewTabBar ?? undefined} + // objectui#12606 — Setup's list: no row numbers in the + // leading cell, and the Display control only for an object + // that declares more than four views. Host props, not view + // keys: a console posture, never metadata. + defaultRowNumbers={!inSetup} + showDisplayControl={!inSetup || views.length > 4} dataSource={ds} /> ); - }, [activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar]); + }, [inSetup, views.length, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar]); // Memoize the merged views array so PluginObjectView doesn't get a new // reference on every render (which would trigger unnecessary data refetches). @@ -3744,15 +3755,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co actions move to a floating action button (see below). */}
- {/* The page lists the object's records, so it is titled - with the plural, as the nav entry that opens it is - (objectui#11696); the record page keeps the singular. */} - {objectPluralLabel(objectDef)} - - - } + // The page lists the object's records, so it is titled with + // the plural, as the nav entry that opens it is + // (objectui#11696); the record page keeps the singular. + title={objectPluralLabel(objectDef)} + // The kind chip, in the header's own slot after the title + // (objectui#12606). + badge={} subtitle={objectDef.description ? objectDesc(objectDef) : undefined} // `createElement`, not a JSX tag: the looked-up icon is a value, // which react-hooks/static-components refuses in tag position diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index ade4d28740..2899eab8db 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -21,7 +21,6 @@ import { Link, useLocation } from 'react-router-dom'; import { Package as PackageIcon, Plus, - RefreshCw, Search, Upload, Download, @@ -73,6 +72,7 @@ import { import { useMetadataLocale, t, tFormat } from './i18n.js'; import { useMetadataClient } from './useMetadata.js'; import { PackageFormDialog } from './PackageFormDialog.js'; +import { PageHeader } from '../../layout/PageHeader.js'; import { errorCodeIs } from '@object-ui/types'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; import { refusedItemsText } from '../../preview/publishRefusal.js'; @@ -1109,25 +1109,17 @@ export function PackagesPage() { ).length; return ( -
- {/* Hero */} -
-
-
- -
-
-

{t('engine.packages.title', locale)}

-

- {t('engine.packages.description', locale)} -

-
-
-
- +
+ {/* The console's one page header (objectui#12606): the posture — + description under the title inside Setup, beside it elsewhere — is + the header's own. Refresh is its icon button; New stays rightmost. */} + } + refresh={{ onRefresh: () => void load(), label: t('engine.packages.refresh', locale), busy: loading }} + actions={ + <> {t('engine.packages.new', locale)} -
-
+ + } + /> +
{importMsg && (
void load()} />
+
); } diff --git a/packages/app-shell/src/views/metadata-admin/PageShell.tsx b/packages/app-shell/src/views/metadata-admin/PageShell.tsx index 4b737bc077..13f6c8e169 100644 --- a/packages/app-shell/src/views/metadata-admin/PageShell.tsx +++ b/packages/app-shell/src/views/metadata-admin/PageShell.tsx @@ -68,7 +68,7 @@ export interface PageShellProps { * host read-only gate cannot be honoured in one header and forgotten in the * other, which is the shape of the bug that motivated it. */ -function WritabilityBadge({ +export function WritabilityBadge({ entry, readOnly, locale, diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx new file mode 100644 index 0000000000..55de048376 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx @@ -0,0 +1,126 @@ +/** + * objectui#12606 — the metadata-type page under the Setup route draws Setup's + * one page header: no "All Metadata Types" breadcrumb, no ITEMS / FILTERED + * tiles, the count in the description line, and the source as plain text + * with its explanation in a tooltip instead of a chip on every row. + * + * The same page outside Setup — the directory's list, Studio's "Open in + * Setup" target — keeps today's shell: that half is the control, mounted + * with the same items and only the route's `:appName` changed. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +// The activation column waits on `audienceAnchorsOf`'s dynamic import of this +// exact specifier; loaded here so its cost lands in the import phase. +import '@objectstack/spec/identity'; + +const TYPE = 'position'; +const ITEMS: Record[] = [ + { name: 'everyone', label: 'Everyone', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' }, + { name: 'auditor', label: 'Auditor', _packageId: 'com.example.showcase', _provenance: 'package' }, + { name: 'env_only', label: 'Environment only' }, +]; + +/** Stable singleton — the page's load effect keys on the client identity. */ +const CLIENT = { + list: async (type: string) => + type === 'package' + ? [{ manifest: { id: 'com.example.showcase', scope: 'project', name: 'Showcase' } }] + : type === TYPE + ? ITEMS + : [], +}; + +const ENTRY = { type: TYPE, label: 'Position', domain: 'security', allowEnvironmentOverlay: false, allowRuntimeCreate: true }; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY] }), +})); + +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => true, +})); + +vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({ + ...(await importOriginal>()), + useTenancyPosture: () => 'single', +})); + +// Imported AFTER the mocks so the page picks them up. +import { MetadataResourceListPage } from './ResourceListPage'; +import { t } from './i18n'; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ records: [], hasMore: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function mount(appName: string) { + return render( + + + } /> + + , + ); +} + +const ALL_TYPES = t('engine.breadcrumb.allTypes', 'en-US'); +const ITEMS_TILE = t('engine.list.items', 'en-US'); +const FILTERED_TILE = t('engine.list.filtered', 'en-US'); + +describe('the metadata-type page under the Setup route (objectui#12606)', () => { + it('renders no breadcrumb and no stat tiles, and says the count in the header’s description', async () => { + const { container } = mount('setup'); + await screen.findByRole('link', { name: 'everyone' }); + + const header = screen.getByTestId('metadata-list-setup-header'); + expect(header.getAttribute('data-posture')).toBe('setup'); + expect(within(header).getByRole('heading', { level: 1 }).textContent).toBe('Position'); + expect(within(header).getByTestId('page-header-description').textContent).toBe('3 items'); + + expect(screen.queryByText(ALL_TYPES)).toBeNull(); + expect(container.querySelector('nav')).toBeNull(); + expect(screen.queryByText(ITEMS_TILE)).toBeNull(); + expect(screen.queryByText(FILTERED_TILE)).toBeNull(); + + // The source select stays, as a filter chip. + expect(screen.getByTestId('metadata-source-filter').className).toContain('rounded-full'); + // The source column carries text with a tooltip, not a chip per row. + expect(screen.getAllByTestId('metadata-row-source')).toHaveLength(3); + expect(container.querySelector('tbody .text-\\[10px\\]')).toBeNull(); + }); + + it('CONTROL — the same page outside Setup still draws the breadcrumb and the stat tiles', async () => { + const { container } = mount('studio'); + await screen.findByRole('link', { name: 'everyone' }); + + expect(screen.queryByTestId('metadata-list-setup-header')).toBeNull(); + expect(screen.getByRole('link', { name: ALL_TYPES })).toBeInTheDocument(); + expect(screen.getByText(ITEMS_TILE)).toBeInTheDocument(); + expect(screen.getByText(FILTERED_TILE)).toBeInTheDocument(); + expect(screen.queryAllByTestId('metadata-row-source')).toHaveLength(0); + expect(container.querySelectorAll('tbody .text-\\[10px\\]').length).toBe(3); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index e4534709e0..acb5ffe146 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -15,7 +15,7 @@ import * as React from 'react'; import { Link, Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Plus, Search, RefreshCw, AlertTriangle, Lock } from 'lucide-react'; -import { Button } from '@object-ui/components'; +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from '@object-ui/components'; import { Input } from '@object-ui/components'; import { Badge } from '@object-ui/components'; import { Switch } from '@object-ui/components'; @@ -27,7 +27,10 @@ import { SelectValue, } from '@object-ui/components'; import { Empty, EmptyTitle, EmptyDescription, EmptyValue } from '@object-ui/components'; -import { PageShell } from './PageShell.js'; +import { PageShell, WritabilityBadge } from './PageShell.js'; +import { PageHeader } from '../../layout/PageHeader.js'; +import { useInSetupApp } from '../../console/setupApp.js'; +import { getIcon } from '../../utils/getIcon.js'; import { MetadataTypeActions } from './MetadataTypeActions.js'; import { CreatePackageDialog } from './PackagesPage.js'; import { @@ -140,6 +143,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string const envScope = isEnvironmentScope(searchParams); const canAuthor = useCanAuthorMetadata(); const posture = useTenancyPosture(); + // objectui#12606 — under the Setup app the page draws Setup's one page + // header: no "All Metadata Types" breadcrumb, no stat tiles, the count in + // the description. Read off the route's `:appName`; the same page anywhere + // else (the directory, Studio's "Open in Setup" target) keeps its shell. + const inSetup = useInSetupApp(); // objectui#12183 — under a wall the security catalog is the platform // operator's: the server refuses this caller every write of the type, // whatever it holds (`catalog-scope.ts`). Not keyed on the scope: the server's @@ -448,75 +456,30 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string return translated === tryKey ? col.label : translated; }; - return ( - 0 - ? [ - { - label: t('engine.list.invalid', locale), - value: ( - - - {invalidCount} - - ), - }, - ] - : []), - ...(warnOnlyCount > 0 - ? [ - { - label: t('engine.list.warnings', locale), - value: ( - - - {warnOnlyCount} - - ), - }, - ] - : []), - ]} - actions={ - <> - {/* Declarative type-level actions (GAP-1) scoped to the list - toolbar. Per-row (`list_item`) actions are not surfaced here - yet — they need the row's recordId from the grid. */} - setRefreshKey((k) => k + 1)} - /> - - {canCreate && ( - - )} - + const refresh = () => setRefreshKey((k) => k + 1); + const createButton = canCreate && ( + + ); + // Declarative type-level actions (GAP-1) scoped to the list toolbar. + // Per-row (`list_item`) actions are not surfaced here yet — they need the + // row's recordId from the grid. + const typeActions = ( + + ); + + const body = (
setQuery(e.target.value)} />
{activation && (