diff --git a/.changeset/12906-print-page-header-actions.md b/.changeset/12906-print-page-header-actions.md new file mode 100644 index 0000000000..6356997f4d --- /dev/null +++ b/.changeset/12906-print-page-header-actions.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/components': minor +--- + +A record whose record page declares `print` shows its header actions again (objectui#12906). + +- **Console.** A page that declares `print` holds only the printable component types, and `page:header` is not one of them, so such a record page drew none of the record's header actions: no declared `record_header` / `record_more` action, and no Edit / Share / Delete. The record view now draws them in the print page's screen-only chrome row, beside Print. Each is gated, ordered, folded into the `⋯` and dispatched exactly as on any other record page. The row sits outside the print page's root, as Print does, so the print stylesheet hides it on paper. A record page without `print` is unchanged. +- **`@object-ui/components`** exports `PageHeaderActions` (and its props type), `page:header`'s own action row lifted out of `PageHeaderRenderer`. `page:header` draws its actions through it, so the two surfaces share one resolver. It renders inside the record's `RecordContext` and `ActionProvider`. + +Clause-②: yes. A new export, `PageHeaderActions` / `PageHeaderActionsProps`, on `@object-ui/components`. No spec key, registry key or printable component type changes. diff --git a/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx b/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx new file mode 100644 index 0000000000..e8d948dcce --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.printPageHeaderActions-12906.test.tsx @@ -0,0 +1,284 @@ +/** + * 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#12906 — a record whose bound record page declares `print` shows its + * header actions in the print page's screen-only chrome, beside Print. + * + * A page that declares `print` may hold only the printable component types, + * and `page:header` (the one block that draws a record's `record_header` / + * `record_more` actions and the system Edit / Share / Delete) is not one of + * them. So the record view draws the same row, through `PageHeaderActions` + * (the resolver `page:header` itself draws with), in the chrome row that holds + * the Print button, outside the print page's root, where the print stylesheet + * hides it on paper exactly as it hides Print (objectui#11958). + * + * Pins: + * (a) on a print page, the declared action and the system set are drawn in + * the chrome beside Print; + * (b) that chrome is outside the print page's root, the condition the print + * stylesheet's hide rule reads, as Print is; + * (c) clicking the declared action reaches the record page's own action + * runtime, with the same request a non-print record page sends; + * (d) CONTROL: a record page without `print` draws no print chrome, and its + * `page:header` draws the same actions once. + * + * Harness: the `RecordDetailView.headerMenuIcons-12529` mount (the real record + * page under a real `I18nProvider` and `FavoritesProvider`), with the bound + * page served the way `RecordDetailView.printAction-11958` serves it. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const authFetchSpy = vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), +); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +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(), + }), +})); + +// Orthogonal chrome: this file observes the header actions, Print and the page root. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; +import { FavoritesProvider } from '../context/FavoritesProvider'; + +const OBJECT_NAME = 'invoice_12906'; +const RECORD_ID = 'INV-1'; +const RECORD_PATH = `/apps/billing/${OBJECT_NAME}/record/${RECORD_ID}`; +const SUBMIT_TARGET = `/api/v1/data/${OBJECT_NAME}/{id}/submit`; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Invoice', + // The permissive bucket: edit + delete both default open. + managedBy: 'platform', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Number' }, + }, + actions: [ + { + name: 'submit_signoff', + label: 'Submit for Sign-off', + type: 'api', + method: 'POST', + target: SUBMIT_TARGET, + locations: ['record_header'], + }, + ], + }, +]; + +/** The invoice's default record page: a print page, as the showcase invoice's is. */ +const PRINT_PAGE = { + name: 'invoice_document', + label: 'Invoice', + type: 'record', + object: OBJECT_NAME, + print: { paperSize: 'A4' }, + regions: [ + { name: 'header', components: [{ type: 'text', value: 'ACME Ltd' }] }, + { name: 'main', components: [{ type: 'text', value: 'Invoice body' }] }, + ], +}; + +async function mountRecordPage(pages: unknown[]) { + const metadata = { + objects: OBJECTS, + pages, + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => pages, + getItem: async () => null, + getItemsByType: () => pages, + } as any; + const dataSource = { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'INV-0001' })), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: async () => OBJECTS[0], + } as any; + const onEdit = vi.fn(); + render( + + + + + + } + /> + + + + + , + ); + await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled()); + await waitFor(() => expect(document.querySelector('[data-page-type]')).not.toBeNull()); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Submit for Sign-off' })).not.toBeNull()); + return { onEdit }; +} + +/** The one action toolbar the record page draws; there must be exactly one. */ +function theToolbar(): HTMLElement { + const toolbars = document.querySelectorAll('[role="toolbar"]'); + expect(toolbars).toHaveLength(1); + return toolbars[0] as HTMLElement; +} + +async function openOverflow(scope: HTMLElement): Promise { + const trigger = within(scope).getByRole('button', { name: 'More actions' }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + return Array.from(screen.getByRole('menu').querySelectorAll('[role="menuitem"]')).map((el) => + (el.textContent ?? '').trim(), + ); +} + +/** Click the declared action and hand back the one request it sent. */ +async function clickSubmit(scope: HTMLElement): Promise<{ url: string; method: string | undefined }> { + authFetchSpy.mockClear(); + fireEvent.click(within(scope).getByRole('button', { name: 'Submit for Sign-off' })); + await waitFor(() => + expect(authFetchSpy.mock.calls.some((c) => String((c as unknown[])[0]).includes('/submit'))).toBe(true), + ); + const call = authFetchSpy.mock.calls.find((c) => String((c as unknown[])[0]).includes('/submit')) as unknown as [ + string, + RequestInit, + ]; + return { url: call[0], method: call[1]?.method }; +} + +beforeEach(() => { + cleanup(); + localStorage.clear(); + authFetchSpy.mockClear(); + // Unrelated chrome reaches for the platform API; serve it from a double so + // happy-dom never opens a socket (objectui#6640). + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('RecordDetailView — a print page draws the record’s header actions in its screen-only chrome beside Print (objectui#12906)', () => { + it('(a) the declared record_header action and Edit / Share / Delete are drawn in the chrome beside Print', async () => { + const { onEdit } = await mountRecordPage([PRINT_PAGE]); + const chrome = document.querySelector('[data-record-print-chrome]') as HTMLElement; + expect(chrome).not.toBeNull(); + // Print and the action row are one chrome row. + expect(within(chrome).getByTestId('record-print-button')).toBeInTheDocument(); + const toolbar = theToolbar(); + expect(chrome.contains(toolbar)).toBe(true); + const rowNames = within(toolbar) + .getAllByRole('button') + .map((b) => b.getAttribute('aria-label') ?? (b.textContent ?? '').trim()); + expect(rowNames).toEqual(['Submit for Sign-off', 'Edit', 'More actions']); + // Edit is the system set's own handler, as on any record page. + fireEvent.click(within(toolbar).getByRole('button', { name: 'Edit' })); + expect(onEdit).toHaveBeenCalledWith({ id: RECORD_ID }); + expect(await openOverflow(toolbar)).toEqual(['Add to favorites', 'Share', 'Delete']); + }); + + it('(b) the chrome sits outside the print page root, the condition the print stylesheet hides by, as Print does', async () => { + await mountRecordPage([PRINT_PAGE]); + const pageRoot = document.querySelector('[data-print-page]') as HTMLElement; + expect(pageRoot).not.toBeNull(); + const sheet = document.querySelector('style[data-print-stylesheet]')?.textContent ?? ''; + // The hide rule: everything that is not the print page, holds it, or sits in it. + expect(sheet).toContain('body :not(:has([data-print-page])):not([data-print-page]):not([data-print-page] *) { display: none !important; }'); + const print = screen.getByTestId('record-print-button'); + const toolbar = theToolbar(); + const chrome = document.querySelector('[data-record-print-chrome]') as HTMLElement; + for (const el of [print, toolbar, chrome]) { + // Neither inside the page nor holding it: the hide rule matches it on paper. + expect(pageRoot.contains(el)).toBe(false); + expect(el.contains(pageRoot)).toBe(false); + } + // One mechanism: the actions share Print's own container. + expect(print.closest('[data-record-print-chrome]')).toBe(chrome); + expect(toolbar.closest('[data-record-print-chrome]')).toBe(chrome); + }); + + it('(c) clicking the declared action reaches the record page’s action runtime with the request a non-print record page sends', async () => { + await mountRecordPage([PRINT_PAGE]); + const onPrintPage = await clickSubmit(theToolbar()); + expect(onPrintPage.url.endsWith(`/api/v1/data/${OBJECT_NAME}/${RECORD_ID}/submit`)).toBe(true); + expect(onPrintPage.method).toBe('POST'); + + cleanup(); + await mountRecordPage([]); + const onDefaultPage = await clickSubmit(theToolbar()); + expect(onDefaultPage).toEqual(onPrintPage); + }); + + it('(d) CONTROL — a record page without `print` draws no print chrome; its page:header draws the actions once', async () => { + await mountRecordPage([]); + expect(document.querySelector('[data-print-page]')).toBeNull(); + expect(document.querySelector('[data-record-print-chrome]')).toBeNull(); + expect(screen.queryByTestId('record-print-button')).toBeNull(); + const toolbar = theToolbar(); + expect(toolbar.closest('header')).not.toBeNull(); + expect(screen.getAllByRole('button', { name: 'Submit for Sign-off' })).toHaveLength(1); + expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 02fc1b8cea..c6680e738e 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -11,7 +11,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { activityRowToFeedItem, InlineEditSaveBar, RecordMetaFooter, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, resolveTitleField, useRecordEditable } from '@object-ui/plugin-detail'; -import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components'; +import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale } from '@object-ui/i18n'; @@ -3363,34 +3363,29 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const pageDeclaresPrint = typeof pagePrint === 'object' && pagePrint !== null && !Array.isArray(pagePrint); const printHint = t('common.printDialogHint'); + // Shared cross-cutting chrome: lifecycle badge + presence avatars — + // rendered above the page tree so custom Page-assigned record pages + // don't lose these affordances. The badge is an authoring aid and + // draws for an authoring caller only (objectui#12194); the gate + // lives in `ManagedByBadge`, so an end user sees none here. + const sharedChrome = ( + <> + {recordPresence.length > 0 && ( + + )} + + + ); + return (
- {/* Shared cross-cutting chrome: lifecycle badge + presence avatars — - rendered above the page tree so custom Page-assigned record pages - don't lose these affordances. The badge is an authoring aid and - draws for an authoring caller only (objectui#12194); the gate - lives in `ManagedByBadge`, so an end user sees none here. */} -
- {recordPresence.length > 0 && ( - - )} - - {pageDeclaresPrint && ( - - )} -
+ {/* On a print page the shared chrome joins the print chrome row below, + so the row's buttons are not drawn under it. */} + {!pageDeclaresPrint && ( +
+ {sharedChrome} +
+ )} {originFrom.label} )} + {/* objectui#12906 — the print page's screen-only chrome. A page + that declares `print` holds only the printable component + types, and `page:header` is not one of them, so the record's + header actions have no block to draw them on such a page. + This row draws them: the object's declared `record_header` / + `record_more` actions and the system set the + `RecordContextProvider` above hands over (Edit / Share / + Delete), through `PageHeaderActions`, the ONE resolver + `page:header` draws them with, so each is gated, ordered, + folded and dispatched exactly as on any other record page. + The row sits outside the print page's root, as the Print + button beside it does, so the print stylesheet hides it on + paper by the same rule (objectui#11958). A page without + `print` draws none of it and keeps its own `page:header`. */} + {pageDeclaresPrint && ( +
+ {sharedChrome} + + +
+ )} {/* The pending approval's DECISION ACTIONS (objectui#3055). `sys_approval_request` declares them as object metadata — approve / reject (with decision attachments) / reassign / diff --git a/packages/components/src/__tests__/page-header-actions-shared-12906.test.tsx b/packages/components/src/__tests__/page-header-actions-shared-12906.test.tsx new file mode 100644 index 0000000000..1385d2840a --- /dev/null +++ b/packages/components/src/__tests__/page-header-actions-shared-12906.test.tsx @@ -0,0 +1,96 @@ +/** + * 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#12906 — `PageHeaderActions` is `page:header`'s own action row, + * lifted so a host can draw it where a page has no `page:header` (the + * console's print-page chrome). It is the ONE resolver for both surfaces, so + * for the same record context and the same authored keys it draws the same + * row `page:header` draws, and an empty row falls back to the slot it is + * handed, as `page:header`'s tail always did. + */ + +import { describe, it, expect } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +import { PageHeaderActions } from '../index'; + +const RECORD = { id: 'lead-1', name: 'Ada', status: 'open' }; +const OBJECT_SCHEMA = { name: 'lead', label: 'Lead' }; +const DECLARED = [ + { name: 'convert', label: 'Convert Lead', type: 'flow', locations: ['record_header'] }, + { name: 'qualify', label: 'Qualify', type: 'flow', locations: ['record_header'], visible: 'record.status == "open"' }, + { name: 'archive', label: 'Archive', type: 'flow', locations: ['record_more'] }, + { name: 'hidden_one', label: 'Never', type: 'flow', locations: ['record_header'], visible: false }, + { name: 'list_only', label: 'List Only', type: 'flow', locations: ['list_item'] }, +]; +const SYSTEM = [ + { name: 'sys_edit', label: 'Edit', type: 'script', locations: ['record_header'], order: 100, onClick: () => {} }, + { name: 'sys_delete', label: 'Delete', type: 'script', locations: ['record_header'], order: 120, component: 'action:menu', onClick: () => {} }, +]; + +function inRecord(node: React.ReactNode, headerSystemActions: unknown[] = SYSTEM) { + return ( + + + {node} + + + ); +} + +/** The toolbar's controls, by accessible name, in document order. */ +function rowOf(container: HTMLElement): string[] { + const toolbar = container.querySelector('[role="toolbar"]'); + expect(toolbar).not.toBeNull(); + return Array.from(toolbar!.querySelectorAll('button')).map( + (b) => b.getAttribute('aria-label') ?? (b.textContent ?? '').trim(), + ); +} + +/** The toolbar's markup with React's per-tree ids blanked. */ +function toolbarMarkup(container: HTMLElement): string { + const toolbar = container.querySelector('[role="toolbar"]') as HTMLElement; + return toolbar.outerHTML.replace(/(id|aria-controls|aria-describedby|aria-labelledby)="[^"]*"/g, '$1=""'); +} + +describe('PageHeaderActions — page:header’s action row, shared (objectui#12906)', () => { + it('draws the row page:header draws for the same record context and authored keys', () => { + const PageHeader = ComponentRegistry.get('page:header')!; + const viaHeader = render(inRecord()); + const headerRow = rowOf(viaHeader.container); + const headerMarkup = toolbarMarkup(viaHeader.container); + cleanup(); + + const viaShared = render(inRecord()); + expect(rowOf(viaShared.container)).toEqual(headerRow); + expect(toolbarMarkup(viaShared.container)).toBe(headerMarkup); + // The gates ran: the declared inline action, the host's Edit, the ⋯. + expect(headerRow).toEqual(['Convert Lead', 'Edit', 'More actions']); + }); + + it('draws the host system set alone when no action is declared', () => { + const { container } = render(inRecord()); + expect(rowOf(container)).toEqual(['Edit', 'More actions']); + }); + + it('draws the slot it is handed when no action survives', () => { + const { container } = render( + inRecord(} />, []), + ); + expect(container.querySelector('[role="toolbar"]')).toBeNull(); + expect(container.querySelector('[data-testid="empty-slot"]')).not.toBeNull(); + }); +}); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 81c1edb789..aae06b083f 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -88,6 +88,14 @@ export type { PageBlockSpan } from './renderers/layout/page-block-span'; export { usePageRegionPosture } from './renderers/layout/page-region-posture'; export type { PageRegionPosture } from './renderers/layout/page-region-posture'; +// `page:header`'s action row: a record's declared `record_header` / +// `record_more` actions and the host's system actions, through the one +// resolver `page:header` draws them with (objectui#12906). Exported for the +// console's record view, which draws the row in its screen-only chrome on a +// page that declares `print`, where `page:header` is not a printable block. +export { PageHeaderActions } from './renderers/layout/containers'; +export type { PageHeaderActionsProps } from './renderers/layout/containers'; + // THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one // tokeniser and one rename map for the whole repo. // diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 5af5a743b9..5603988dcc 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -1479,12 +1479,45 @@ function recordPictureUrl(value: unknown): string | undefined { return undefined; } -const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { - const { designer } = splitDesignerProps(props); - // The block's `aria` bag, on the `header` root of both layouts below (see - // "The `aria` bag of the four page blocks" above). - const displayLocale = useDisplayLocale(); - const headerAria = resolveInlineAriaProps(schema?.aria, displayLocale); +/** + * The props of {@link PageHeaderActions}. + * + * `actions`, `maxVisible` and `mobileMaxVisible` are `page:header`'s own + * authored keys, read as that block reads them; the system actions are not a + * prop, they come from `RecordContext.headerSystemActions` as they always did. + */ +export interface PageHeaderActionsProps { + /** The authored `actions` array: action ids (the contract) or, during the transition, inline action objects. */ + actions?: unknown; + /** The inline-button budget on desktop, as `page:header`'s `maxVisible`; an out-of-contract value falls back to 3. */ + maxVisible?: unknown; + /** The inline-button budget on mobile, as `page:header`'s `mobileMaxVisible`; an out-of-contract value falls back to 1. */ + mobileMaxVisible?: unknown; + /** Drawn in place of the action row when no action survives the gates. */ + emptySlot?: React.ReactNode; +} + +/** + * A record's header actions: the declared actions placed at `record_header` / + * `record_more` and the host's system actions (Edit / Share / Delete), gated, + * ordered, split into inline buttons and the `⋯` overflow, and dispatched + * through the action runner. + * + * This is `page:header`'s action row, lifted out of `PageHeaderRenderer` so + * that a host can draw the same row where a page has no `page:header` + * (objectui#12906): a page that declares `print` may hold only the printable + * component types, and `page:header` is not one of them, so the console's + * record view draws this row in its screen-only chrome beside Print. It is + * the ONE resolver for both surfaces, ⛔ never a second one. It must render + * inside the record's `RecordContext` and `ActionProvider`, as `page:header` + * does. + */ +export const PageHeaderActions: React.FC = ({ + actions: rawHeaderActions, + maxVisible: rawMaxVisible, + mobileMaxVisible: rawMobileMaxVisible, + emptySlot = null, +}) => { const ctx = useRecordContext(); // Record-level inline-edit session (objectui#2572 item 4): while a shared // inline draft is active, header actions flagged `disableDuringInlineEdit` @@ -1504,7 +1537,6 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // [ADR-0066 D4 / framework#3923] Shared capability gate — see filterAction. const mayInvoke = useCapabilityGate(); const isMobile = useIsMobile(); - const { objectLabel: tObjectLabel } = useObjectLabel(); // The ONE resolver for a declared action's authored strings (objectui#4265). // This header used to localize `label` only — via `useObjectLabel().actionLabel` // inline below — and dispatch the action's `confirmText` / `successMessage` @@ -1512,91 +1544,16 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // button rendered the translation and the confirm dialog rendered the English // literal from the metadata. const localizeActionTexts = useActionTextLocalizer(); - const { fieldOptionLabel } = useSafeFieldLabel(); - const { language } = useObjectTranslation(); - // Console-supplied chrome copy (objectstack#5407). `detail.moreActions` is - // the SAME key `action:menu`'s overflow trigger already reads, so the two - // `⋯` buttons a record page can show cannot read differently per locale. - const tt = useSafeTranslate(); // One id base for the disabled-reason descriptions the header's actions // render (objectui#11811); each action suffixes it, since `renderHeaderActions` // below draws them in a loop where no hook can be called. const disabledReasonIdBase = React.useId(); - // ── Manual refresh (objectui#3460) ──────────────────────────────────────── - // Rendered as page CHROME at the far end of the header row, NOT as a header - // action: business/system actions come and go per object and record state, - // while refresh has to sit in the same place on every record page. Keeping it - // out of the action pipeline also keeps it out of that pipeline's capability - // gating and `maxVisible` overflow budget — reading a record you are already - // looking at is not a permissioned operation, and the button must never be - // the one that gets collapsed into `⋯`. - // - // The host opts in by providing `RecordContext.refresh`; hosts that don't - // (previews, embedded drawers, non-record pages) render exactly as before. - const hostRefresh = ctx?.refresh; - const [manualRefreshing, setManualRefreshing] = React.useState(false); - const refreshSpinTimer = React.useRef | undefined>(undefined); - React.useEffect(() => () => clearTimeout(refreshSpinTimer.current), []); - const handleManualRefresh = React.useCallback(() => { - if (!hostRefresh) return; - void hostRefresh(); - setManualRefreshing(true); - clearTimeout(refreshSpinTimer.current); - refreshSpinTimer.current = setTimeout( - () => setManualRefreshing(false), - MANUAL_REFRESH_SPIN_MS, - ); - }, [hostRefresh]); - // Spec bridge may either inline `properties.*` onto the node or preserve - // the raw bag (see record:quick_actions for the same pattern). Read from - // both so a `{ properties: { title } }` schema is rendered correctly. - const titleSrc = schema?.title ?? schema?.properties?.title; - const subtitleSrc = schema?.subtitle ?? schema?.properties?.subtitle; - // The page's `description`, handed down when this header owns the page's - // title (objectui#12624): drawn under the title and subtitle, the order the - // console page header uses, instead of above the title by the page. - const pageDescription = React.useContext(PageDescriptionContext); + // Console-supplied chrome copy (objectstack#5407). `detail.moreActions` is + // the SAME key `action:menu`'s overflow trigger already reads, so the two + // `⋯` buttons a record page can show cannot read differently per locale. + const tt = useSafeTranslate(); const headerObjectSchema: any = (ctx as any)?.objectSchema; const headerObjectName: string | undefined = ctx?.objectName || headerObjectSchema?.name; - const explicitTitle = interpolate( - pickLocalized(titleSrc, language), - ctx?.data, - headerObjectSchema, - fieldOptionLabel, - headerObjectName, - ); - const subtitle = interpolate( - pickLocalized(subtitleSrc, language), - ctx?.data, - headerObjectSchema, - fieldOptionLabel, - headerObjectName, - ); - // `breadcrumb` is deliberately NOT read (objectui#11166, ruling RETIRE). All - // it ever drew was an empty `data-page-breadcrumb-slot` div that nothing - // filled, and the console's app header already draws the trail. The spec - // refuses `PageHeaderProps.breadcrumb` by name since 17.6.0 - // (objectstack#20758); a value that still reaches this renderer renders - // exactly as an absent one: ignored, no error. ⛔ Do not add a trail here; - // that is an ENFORCE ruling, not a fix. - - // Schema-level opt-outs let authors keep the historic "bare h1" header - // when they don't want a record chip (e.g. a non-record landing page). - const disableRecordChrome = - schema?.recordChrome === false || schema?.properties?.recordChrome === false; - // The favorite star is a control that stores something, so it draws only - // where the host wires a toggle: the `RecordContext` carries - // `onToggleFavorite` (objectui#12427), and that is the one switch. Metadata - // does not control it: `@objectstack/spec` 18 retires `showStar` - // (objectstack#23650), so this renderer no longer reads it and an authored - // value changes nothing (objectui#12627). - const showCopyId = schema?.showCopyId !== false && schema?.properties?.showCopyId !== false; - - // Inline header actions — authored pages embed action buttons directly on - // `page:header.actions` (or `.properties.actions`). Custom CRM record - // detail pages (lead → Convert Lead, opportunity → Mark Won/Lost, …) - // rely on this slot. Without this rendering they would silently disappear. - const rawHeaderActions = schema?.actions ?? schema?.properties?.actions; // System actions (Edit / Share / Delete) injected by the host via // `RecordContext.headerSystemActions`. Appended AFTER authored actions // and deduplicated by `name` so the host can always supply them @@ -2057,8 +2014,8 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const readMax = (v: any): number | undefined => typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : undefined; const maxVisible = isMobile - ? (readMax(schema?.mobileMaxVisible ?? schema?.properties?.mobileMaxVisible) ?? 1) - : (readMax(schema?.maxVisible ?? schema?.properties?.maxVisible) ?? 3); + ? (readMax(rawMobileMaxVisible) ?? 1) + : (readMax(rawMaxVisible) ?? 3); const isMoreOnly = (a: any): boolean => Array.isArray(a?.locations) && a.locations.length > 0 && @@ -2316,6 +2273,98 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { ); }; + return <>{renderHeaderActions() ?? emptySlot}; +}; + +const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { + const { designer } = splitDesignerProps(props); + // The block's `aria` bag, on the `header` root of both layouts below (see + // "The `aria` bag of the four page blocks" above). + const displayLocale = useDisplayLocale(); + const headerAria = resolveInlineAriaProps(schema?.aria, displayLocale); + const ctx = useRecordContext(); + const { objectLabel: tObjectLabel } = useObjectLabel(); + const { fieldOptionLabel } = useSafeFieldLabel(); + const { language } = useObjectTranslation(); + // Console-supplied chrome copy (objectstack#5407): the ⟳ button's label. + // The action row's copy is read by `PageHeaderActions` above. + const tt = useSafeTranslate(); + // ── Manual refresh (objectui#3460) ──────────────────────────────────────── + // Rendered as page CHROME at the far end of the header row, NOT as a header + // action: business/system actions come and go per object and record state, + // while refresh has to sit in the same place on every record page. Keeping it + // out of the action pipeline also keeps it out of that pipeline's capability + // gating and `maxVisible` overflow budget — reading a record you are already + // looking at is not a permissioned operation, and the button must never be + // the one that gets collapsed into `⋯`. + // + // The host opts in by providing `RecordContext.refresh`; hosts that don't + // (previews, embedded drawers, non-record pages) render exactly as before. + const hostRefresh = ctx?.refresh; + const [manualRefreshing, setManualRefreshing] = React.useState(false); + const refreshSpinTimer = React.useRef | undefined>(undefined); + React.useEffect(() => () => clearTimeout(refreshSpinTimer.current), []); + const handleManualRefresh = React.useCallback(() => { + if (!hostRefresh) return; + void hostRefresh(); + setManualRefreshing(true); + clearTimeout(refreshSpinTimer.current); + refreshSpinTimer.current = setTimeout( + () => setManualRefreshing(false), + MANUAL_REFRESH_SPIN_MS, + ); + }, [hostRefresh]); + // Spec bridge may either inline `properties.*` onto the node or preserve + // the raw bag (see record:quick_actions for the same pattern). Read from + // both so a `{ properties: { title } }` schema is rendered correctly. + const titleSrc = schema?.title ?? schema?.properties?.title; + const subtitleSrc = schema?.subtitle ?? schema?.properties?.subtitle; + // The page's `description`, handed down when this header owns the page's + // title (objectui#12624): drawn under the title and subtitle, the order the + // console page header uses, instead of above the title by the page. + const pageDescription = React.useContext(PageDescriptionContext); + const headerObjectSchema: any = (ctx as any)?.objectSchema; + const headerObjectName: string | undefined = ctx?.objectName || headerObjectSchema?.name; + const explicitTitle = interpolate( + pickLocalized(titleSrc, language), + ctx?.data, + headerObjectSchema, + fieldOptionLabel, + headerObjectName, + ); + const subtitle = interpolate( + pickLocalized(subtitleSrc, language), + ctx?.data, + headerObjectSchema, + fieldOptionLabel, + headerObjectName, + ); + // `breadcrumb` is deliberately NOT read (objectui#11166, ruling RETIRE). All + // it ever drew was an empty `data-page-breadcrumb-slot` div that nothing + // filled, and the console's app header already draws the trail. The spec + // refuses `PageHeaderProps.breadcrumb` by name since 17.6.0 + // (objectstack#20758); a value that still reaches this renderer renders + // exactly as an absent one: ignored, no error. ⛔ Do not add a trail here; + // that is an ENFORCE ruling, not a fix. + + // Schema-level opt-outs let authors keep the historic "bare h1" header + // when they don't want a record chip (e.g. a non-record landing page). + const disableRecordChrome = + schema?.recordChrome === false || schema?.properties?.recordChrome === false; + // The favorite star is a control that stores something, so it draws only + // where the host wires a toggle: the `RecordContext` carries + // `onToggleFavorite` (objectui#12427), and that is the one switch. Metadata + // does not control it: `@objectstack/spec` 18 retires `showStar` + // (objectstack#23650), so this renderer no longer reads it and an authored + // value changes nothing (objectui#12627). + const showCopyId = schema?.showCopyId !== false && schema?.properties?.showCopyId !== false; + + // Inline header actions — authored pages embed action buttons directly on + // `page:header.actions` (or `.properties.actions`). Custom CRM record + // detail pages (lead → Convert Lead, opportunity → Mark Won/Lost, …) + // rely on this slot. Without this rendering they would silently disappear. + const rawHeaderActions = schema?.actions ?? schema?.properties?.actions; + /** * The ⟳ button (objectui#3460), or `null` when the host provides no * `refresh`. @@ -2359,7 +2408,14 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { * `refresh` the slot is returned untouched, byte for byte what it was. */ const renderHeaderTail = (emptySlot: React.ReactNode): React.ReactNode => { - const actions = renderHeaderActions() ?? emptySlot; + const actions = ( + + ); const refreshButton = renderRefreshButton(); if (!refreshButton) return actions; return (