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/12606-setup-page-header.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 20 additions & 11 deletions apps/console/src/pages/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */}
<ConsolePageHeader
title={
<span className="inline-flex items-center gap-2">
Expand All @@ -307,6 +307,19 @@ export function SettingsView() {
</span>
}
subtitle={description}
breadcrumb={
<span className="mr-1.5 inline-flex items-center gap-1 align-top">
<button
type="button"
className="hover:text-foreground hover:underline"
onClick={() => navigate('/system/settings')}
data-testid="settings-breadcrumb-hub"
>
{t('console.settingsView.backToHub')}
</button>
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden="true" />
</span>
}
icon={
Icon ? (
// eslint-disable-next-line react-hooks/static-components -- getLazyIcon returns a module-cached stable component per name, not one created during render
Expand All @@ -315,12 +328,8 @@ export function SettingsView() {
}
/>
<div className="p-6 max-w-3xl mx-auto pb-32">
<Button variant="ghost" size="sm" onClick={() => navigate('/system/settings')}>
<ArrowLeft className="h-4 w-4 mr-1" /> {t('console.settingsView.backToHub')}
</Button>

{manifest.helpText ? (
<p className="text-sm text-muted-foreground mt-4 whitespace-pre-wrap">{manifest.helpText}</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{manifest.helpText}</p>
) : null}

{cryptoRefusal ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 2 additions & 7 deletions apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ import {
CheckCircle2,
XCircle,
Clock,
RefreshCw,
AlertCircle,
CheckSquare,
Search,
Expand Down Expand Up @@ -1825,12 +1824,8 @@ export function ApprovalsInboxPage() {
title={tr('title', 'Approvals Inbox')}
subtitle={tr('subtitle', 'Review and act on approval requests.')}
icon={<CheckSquare className="h-4 w-4" />}
actions={
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
{tr('refresh', 'Refresh')}
</Button>
}
// The header's own Refresh icon button (objectui#12606).
refresh={{ onRefresh: load, label: tr('refresh', 'Refresh'), busy: loading }}
/>
<div className="flex flex-col gap-4 sm:gap-6 p-4 sm:p-6 max-w-6xl">
{error && (
Expand Down
10 changes: 3 additions & 7 deletions apps/console/src/pages/system/AuditLogPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/, '');
Expand Down Expand Up @@ -285,12 +285,8 @@ export function AuditLogPage() {
title="Audit Log"
subtitle="Read-only system activity and record changes. Newest first."
icon={<ScrollText className="h-4 w-4" />}
actions={
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
}
// The header's own Refresh icon button (objectui#12606).
refresh={{ onRefresh: load, label: 'Refresh', busy: loading }}
/>
<div className="p-6 space-y-4 max-w-screen-2xl mx-auto">
{/* Filter bar */}
Expand Down
17 changes: 4 additions & 13 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -515,19 +516,9 @@ export function SystemRedirect() {
return <Navigate to={`${target}${location.search}${location.hash}`} replace />;
}

/**
* The Setup app's canonical package id (ADR-0048 — one app per package).
* `@objectstack/setup` declares it, and `/apps/<packageId>` 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
Expand Down
35 changes: 16 additions & 19 deletions packages/app-shell/src/console/marketplace/MarketplacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -471,18 +472,16 @@ export function MarketplacePage() {
);

return (
<div className="flex flex-col gap-6 p-4 sm:p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Store className="h-6 w-6 text-primary" aria-hidden="true" />
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">{t('marketplace.title')}</h1>
</div>
<p className="text-sm text-muted-foreground mt-1">
{t('marketplace.subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<div>
{/* 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. */}
<PageHeader
title={t('marketplace.title')}
subtitle={t('marketplace.subtitle')}
icon={<Store className="h-4 w-4" aria-hidden="true" />}
refresh={{ onRefresh: () => void load(), label: t('marketplace.refresh'), busy: loading }}
actions={
<Button
variant="outline"
size="sm"
Expand All @@ -493,12 +492,9 @@ export function MarketplacePage() {
? t('marketplace.installedCount', { count: installed.length })
: t('marketplace.installed')}
</Button>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCcw className="h-4 w-4 mr-1.5" aria-hidden="true" />
{t('marketplace.refresh')}
</Button>
</div>
</div>
}
/>
<div className="flex flex-col gap-6 p-4 sm:p-6">

<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
Expand Down Expand Up @@ -736,5 +732,6 @@ export function MarketplacePage() {
</div>
)}
</div>
</div>
);
}
51 changes: 51 additions & 0 deletions packages/app-shell/src/console/setupApp.ts
Original file line number Diff line number Diff line change
@@ -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/<packageId>` 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);
}
Loading
Loading