diff --git a/.changeset/12606-setup-page-header.md b/.changeset/12606-setup-page-header.md new file mode 100644 index 0000000000..c6e6904e24 --- /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. `ListView` takes a new host prop for this, `defaultRowNumbers` (default `true`); it is not 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 035ecfa9a5..03dd6907bc 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, @@ -1825,12 +1824,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 0d198f1e63..007e34b809 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'; @@ -471,18 +472,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. */} +
-
+ } + /> +
@@ -736,5 +732,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..bd884fa930 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.setupListChrome-12606.test.tsx @@ -0,0 +1,147 @@ +/** + * 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 grid's + * `#` row numbers. The Display control stays on every list, under Setup too + * (the maintainer's ruling C on the card withdrew the Display hide rule). + * + * 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: four or fewer, and Display still draws under Setup. + { name: 'sys_team_12606', label: 'Team', pluralLabel: 'Teams', fields: FIELDS, listViews: { all: view('All'), mine: view('Mine') } }, +]; + +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 keeps Display (objectui#12606)', () => { + it.each(['setup', 'com.objectstack.setup'])('under /apps/%s the grid renders no `#` column, and Display still draws', async (app) => { + await mount(app, 'sys_team_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..a65757d5a9 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 its `#` row + // numbers (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,14 @@ 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 opens without row numbers in + // the leading cell. A host prop, not a view key: a console + // posture, never metadata. + defaultRowNumbers={!inSetup} 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, 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 +3753,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..139149a02d 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 && (
)} - {/* Toolbar */} -
+ {/* Toolbar — first in the body when no import message precedes it. */} +
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..6823de5351 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 && (