From 6e602ca4ebad1a6a5d878aa146835b02ea818c9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 22:28:54 +0000 Subject: [PATCH] feat(app-shell): render `global:search` and `global:notifications` instead of the placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are first-class `PageComponentType` members that the 2026-08-26 maintainer ruling on objectstack#12183 kept declared once objectstack#13117 evidenced both data sources shipped. Nothing registered a renderer, so a page that authored either drew the literal "Component Placeholder" scaffold. Neither block adds a data layer: - `global:search` mounts `useRecordSearch` (the hook the command palette and the full-page search results already use), which prefers the adapter's `searchAll` (`GET /api/v1/search`) and falls back to the per-object fanout. - `global:notifications` mounts `InboxPopover` over the shared inbox feed — the bell ADR-0012/ADR-0030 defines. The header bell's inbox wiring moves out of `AppHeader` into `useInboxBell`, so both bells cut from ONE read with ONE optimistic read overlay (#4225 / #4316). Both registrations publish no `inputs` (`ComponentPropsMap` declares an empty shape for each) and use `skipFallback: true` so neither claims a bare key. Fixes #6757 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- ...7-global-search-notifications-renderers.md | 38 +++ packages/app-shell/package.json | 4 + packages/app-shell/src/hooks/useInboxBell.ts | 164 +++++++++++ packages/app-shell/src/index.ts | 9 + packages/app-shell/src/layout/AppHeader.tsx | 154 ++-------- .../global-page-blocks.render.test.tsx | 277 ++++++++++++++++++ .../views/global-notifications-renderer.tsx | 115 ++++++++ .../src/views/global-search-renderer.tsx | 184 ++++++++++++ 8 files changed, 815 insertions(+), 130 deletions(-) create mode 100644 .changeset/6757-global-search-notifications-renderers.md create mode 100644 packages/app-shell/src/hooks/useInboxBell.ts create mode 100644 packages/app-shell/src/views/__tests__/global-page-blocks.render.test.tsx create mode 100644 packages/app-shell/src/views/global-notifications-renderer.tsx create mode 100644 packages/app-shell/src/views/global-search-renderer.tsx diff --git a/.changeset/6757-global-search-notifications-renderers.md b/.changeset/6757-global-search-notifications-renderers.md new file mode 100644 index 0000000000..5be1aca0ca --- /dev/null +++ b/.changeset/6757-global-search-notifications-renderers.md @@ -0,0 +1,38 @@ +--- +'@object-ui/app-shell': minor +--- + +Renderers for the `global:search` and `global:notifications` page blocks +(objectui#6757). A page that declared either member drew the literal "Component +Placeholder" scaffold: both are first-class `PageComponentType` members that the +2026-08-26 maintainer ruling on objectstack#12183 kept declared once the +readiness read in objectstack#13117 evidenced both data sources shipped, and +the renderer was the remaining half. + +Neither block adds a data layer — each is a new mount point on plumbing that was +already live: + +- `global:search` mounts `useRecordSearch` (`@object-ui/react`), the same hook + the ⌘K command palette and the full-page search results already use. It + prefers the adapter's `searchAll` (`GET /api/v1/search` — cross-object hits + with title/snippet/record) and inherits that hook's fanout fallback for + adapters without it. Scope is the metadata provider's searchable object set, + which is the hook's documented default when no `objectNames` whitelist is + given. +- `global:notifications` mounts `InboxPopover` — the bell ADR-0012/ADR-0030 + defines ("the bell reads `sys_inbox_message`") — over the shared inbox feed. + +To keep that second one honest, the header bell's inbox wiring (rows, badge +addends, and the three mark-read paths) moves out of `AppHeader` into a new +`useInboxBell` hook that both surfaces mount. Copying it would have re-opened +the two defects `sharedUserFeeds` closed — #4225 (two owners of one read issuing +it twice per page) and #4316 (two derivations of read-state disagreeing) — so a +bell in the header and a bell an author declared on a page now have no +representable state in which they disagree about a row. + +Both registrations publish **no** `inputs`: `ComponentPropsMap` declares an +empty shape for each ("declares no props at all" is the recorded intent), and +both use `skipFallback: true` so neither claims the bare `search` / +`notifications` keys. This does not change the Studio page palette — +`global:notifications` remains recorded there as a shell singleton, which is a +palette decision independent of whether a declared type renders. diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 5bde8d0427..7c4c3ed9a3 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -10,6 +10,8 @@ "./dist/console/home/CloudOnboardingNext.js", "./dist/console/marketplace/InstalledListWidget.js", "./dist/services/builtinComponents.js", + "./dist/views/global-notifications-renderer.js", + "./dist/views/global-search-renderer.js", "./dist/views/metadata-admin/index.js", "./dist/views/record-approvals-renderer.js", "./dist/views/record-attachments-renderer.js", @@ -21,6 +23,8 @@ "./src/console/home/CloudOnboardingNext.tsx", "./src/console/marketplace/InstalledListWidget.tsx", "./src/services/builtinComponents.tsx", + "./src/views/global-notifications-renderer.tsx", + "./src/views/global-search-renderer.tsx", "./src/views/metadata-admin/index.ts", "./src/views/record-approvals-renderer.tsx", "./src/views/record-attachments-renderer.tsx", diff --git a/packages/app-shell/src/hooks/useInboxBell.ts b/packages/app-shell/src/hooks/useInboxBell.ts new file mode 100644 index 0000000000..0666f05523 --- /dev/null +++ b/packages/app-shell/src/hooks/useInboxBell.ts @@ -0,0 +1,164 @@ +/** + * 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. + */ + +/** + * useInboxBell — everything an {@link InboxPopover} needs, from the shared feeds. + * + * The rows, the badge number and the three mark-read paths used to live inline + * in `AppHeader`, which was fine while the header held the only bell. It no + * longer does: `global:notifications` is a spec `PageComponentType` member an + * author may declare on a page (objectui#6757), and its renderer has to reach + * the SAME inbox — the one ADR-0012/ADR-0030 defines and `sharedUserFeeds` + * already serves to the header and to Home's action centre. + * + * Copying the wiring into the renderer was the alternative, and this repo has + * measured what that costs twice already: #4225 (two owners of one read issued + * it twice per page) and #4316 (two derivations of read-state disagreed, so the + * bell showed zero unread while Home listed five of the same rows as waiting). + * One hook, two consumers — the two surfaces have no representable state in + * which they disagree, because there is no second read and no second overlay. + * + * NOTHING is fetched here: `useSharedInboxFeed` / `useSharedPendingApprovalsCount` + * own the polling, its cadence, its hidden-tab throttle and its failure backoff. + * This hook adds only the optimistic read overlay and the mark-read writes. + * + * @module + */ +import { useCallback, useMemo, useState } from 'react'; +import { bearerAuthHeaders } from '../utils/authToken.js'; +import { useSharedInboxFeed, useSharedPendingApprovalsCount } from './sharedUserFeeds.js'; +import type { InboxNotification } from '../layout/inboxGrouping.js'; + +/** + * Same stable-reference rule the header used: a fresh empty Set per render + * would re-run every memo that depends on the overlay. + */ +const EMPTY_READ_IDS: ReadonlySet = new Set(); + +export interface InboxBell { + /** The shared inbox rows, with this surface's optimistic read flips applied. */ + notifications: InboxNotification[]; + /** Raw unread ROW count (the popover folds it into topics itself). */ + unreadCount: number; + /** The badge's second addend — pending approvals waiting on this user. */ + pendingApprovalsCount: number; + markAllRead: () => Promise; + markRead: (id: string) => Promise; + markManyRead: (ids: string[]) => Promise; +} + +export function useInboxBell(): InboxBell { + /** + * In-header notifications (ADR-0030), from the shared user feed (#4225). + * + * The rows are `sys_inbox_message` (the L5 in-app materialization, `mine` + * scope) joined with `sys_notification_receipt` for read-state — the bell + * does not read the re-modeled `sys_notification` L2 event (which carries no + * recipient/read columns). + */ + const { value: inboxMessages } = useSharedInboxFeed(); + + /** + * Optimistic read-state, layered over the shared rows. + * + * Mark-read used to mutate the header's own `notifications` state; the rows + * are shared, so a consumer may not write to them — one surface's optimistic + * flip must not become another's fact before the server agrees. Holding the + * flipped ids locally keeps the click instant while the next poll (which + * reads the persisted receipt) supersedes it. + */ + const [locallyRead, setLocallyRead] = useState>(EMPTY_READ_IDS); + const notifications = useMemo( + () => + locallyRead.size === 0 + ? inboxMessages + : inboxMessages.map((n) => (locallyRead.has(n.id) ? { ...n, is_read: true } : n)), + [inboxMessages, locallyRead], + ); + + /** + * M11.C15: pending approvals count — the topbar shortcut, and the second + * addend of the bell badge (`unreadTopics + pendingApprovalsCount`). + * + * Shared with Home's To-do card (#4197): one polled request serves both, so + * the badge and the card can no longer disagree. + */ + const pendingApprovalsCount = useSharedPendingApprovalsCount(); + + const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0); + + // Read-state lives in `sys_notification_receipt`, keyed + // (notification_id, user_id, channel) — ADR-0030. That object is + // engine-owned (ADR-0103: `enable.apiMethods` = get/list), so the generic + // data API REJECTS receipt writes — a direct create/update here silently + // failed and the next poll flipped rows back to unread. Mark-read goes + // through the framework's dedicated REST surface instead + // (`POST /api/v1/notifications/read[/all]`), which upserts the receipt + // server-side keyed by the notification EVENT id. Rows without a + // `notification_id` (legacy/synthetic) can't be keyed, so they update + // optimistically but don't persist. + const postMarkRead = useCallback(async (subPath: 'read' | 'read/all', ids?: string[]) => { + const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, ''); + await fetch(`${serverUrl}/api/v1/notifications/${subPath}`, { + method: 'POST', + credentials: 'include', + // Bearer too — see utils/authToken (#2548 split-origin fix). + headers: { 'Content-Type': 'application/json', ...bearerAuthHeaders() }, + body: JSON.stringify(ids ? { ids } : {}), + }); + }, []); + + /** Flip rows read in the local overlay — never in the shared feed's rows. */ + const markLocallyRead = useCallback((ids: readonly string[]) => { + if (ids.length === 0) return; + setLocallyRead((prev) => { + const next = new Set(prev); + for (const id of ids) next.add(id); + return next; + }); + }, []); + + const markRead = useCallback(async (id: string) => { + const target = notifications.find(n => n.id === id); + markLocallyRead([id]); + if (!target?.notification_id) return; + try { await postMarkRead('read', [target.notification_id]); } catch { /* best-effort */ } + }, [notifications, markLocallyRead, postMarkRead]); + + const markAllRead = useCallback(async () => { + const unread = notifications.filter(n => !n.is_read); + if (!unread.length) return; + markLocallyRead(notifications.map(n => n.id)); + try { await postMarkRead('read/all'); } catch { /* best-effort */ } + }, [notifications, markLocallyRead, postMarkRead]); + + // Per-group "mark all of this type read" (#2765): the inbox coalesces + // repeats of the same (topic, title) into one expandable row, and this marks + // every member read in a SINGLE request instead of one POST per row (a + // scheduled-digest group can hold 20). Rows without a `notification_id` + // (legacy/synthetic) still flip optimistically but can't be keyed server-side. + const markManyRead = useCallback(async (ids: string[]) => { + const idSet = new Set(ids); + const notifIds = notifications + .filter(n => idSet.has(n.id) && !n.is_read) + .map(n => n.notification_id) + .filter((v): v is string => !!v); + markLocallyRead(ids); + if (!notifIds.length) return; + try { await postMarkRead('read', notifIds); } catch { /* best-effort */ } + }, [notifications, markLocallyRead, postMarkRead]); + + return { + notifications, + unreadCount, + pendingApprovalsCount, + markAllRead, + markRead, + markManyRead, + }; +} diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 803f59b125..6200b1b679 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -300,6 +300,15 @@ import './views/record-attachments-renderer.js'; // `record:approvals` — schema-addressable approval panel referenced by // synthesized record pages when the record has approval requests (#3461). import './views/record-approvals-renderer.js'; +// `global:search` / `global:notifications` — the two spec `PageComponentType` +// members the 2026-08-26 ruling on objectstack#12183 kept declared because both +// data sources shipped (objectui#6757). Registered here, not in +// `@object-ui/components`, because they read this package's providers and feeds; +// without these two imports an authored page draws the "Component Placeholder" +// scaffold for `global:search` and a red unknown-type panel for +// `global:notifications`. +import './views/global-search-renderer.js'; +import './views/global-notifications-renderer.js'; // Phase 3c — generic metadata admin engine. Re-exported so plugins // can call `registerMetadataResource()` to override the per-type diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index f73ed34efa..f37b85214b 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -51,7 +51,7 @@ import { Hammer, } from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useOffline } from '@object-ui/react'; import { PresenceAvatars, useTenantPresence, type PresenceUser } from '@object-ui/collaboration'; import { ModeToggle } from './ModeToggle.js'; @@ -70,18 +70,14 @@ import { useAuth, getUserInitials, useWorkspaceAdminStatus } from '@object-ui/au import { useMetadata } from '../providers/MetadataProvider.js'; import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils/index.js'; import { getIcon } from '../utils/getIcon.js'; -import { bearerAuthHeaders } from '../utils/authToken.js'; import { useMobileViewSwitcher } from './MobileViewSwitcherContext.js'; import { useNavigationContext } from '../context/NavigationContext.js'; import { useCommandPalette } from '../context/CommandPaletteProvider.js'; import { useUrlOverlay } from '../hooks/useUrlOverlay.js'; import { KEYBOARD_SHORTCUTS_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; -import { - useSharedActivityFeed, - useSharedInboxFeed, - useSharedPendingApprovalsCount, -} from '../hooks/sharedUserFeeds.js'; +import { useSharedActivityFeed } from '../hooks/sharedUserFeeds.js'; +import { useInboxBell } from '../hooks/useInboxBell.js'; import { getProductName, getLogoUrl } from '../runtime-config.js'; import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger.js'; import { PreviewBadge } from './PreviewBadge.js'; @@ -104,10 +100,6 @@ function PathSep() { // header doesn't ship phantom collaborators in production. const EMPTY_PRESENCE_USERS: PresenceUser[] = []; -// Same stable-reference rule, for the optimistic mark-read overlay: a fresh -// empty Set per render would re-run every memo that depends on it. -const EMPTY_READ_IDS: ReadonlySet = new Set(); - export type AppHeaderVariant = 'app' | 'home' | 'orgs'; export interface AppHeaderProps { @@ -215,51 +207,31 @@ export function AppHeader({ */ const apiActivities = useSharedActivityFeed(); /** - * In-header notifications (ADR-0030), from the shared user feed (#4225). + * The bell's inbox — rows, badge addends and the three mark-read paths — now + * comes from `useInboxBell`, the ONE wiring of `sharedUserFeeds` onto an + * `InboxPopover` (#4225 / #4316). The `global:notifications` page block + * (objectui#6757) mounts the SAME hook, so a bell in the header and a bell an + * author declared on a page cannot disagree about a row's read-state: there + * is no second read and no second optimistic overlay left to drift. * * The rows are `sys_inbox_message` (the L5 in-app materialization, `mine` - * scope) joined with `sys_notification_receipt` for read-state — the bell - * does not read the re-modeled `sys_notification` L2 event (which carries no - * recipient/read columns). That query, its 10s cadence, its hidden-tab - * throttle, its visibility refetch and its failure backoff all moved into - * `sharedUserFeeds` unchanged; what was lost is only the SECOND copy of it. + * scope) joined with `sys_notification_receipt` for read-state (ADR-0030) — + * the bell does not read the re-modeled `sys_notification` L2 event. Home's + * action centre cuts from the same feed. `pendingApprovalsCount` is the + * badge's second addend, shared with Home's To-do card (#4197). * - * Home's action centre reads the same feed, so the two surfaces can no - * longer disagree about whether a message is read — the #4316 defect, where - * this bell showed zero unread while the card below listed five already-read - * messages as needing attention, has no representable state to occur in. + * Deliberately NOT gated on `isApp` (#4110): the read is scoped to the USER, + * not to the app in the URL — unlike the presence avatars and the connection + * dot below, which are app-shell chrome and are the reason that flag exists. */ - const { value: inboxMessages } = useSharedInboxFeed(); - - /** - * Optimistic read-state, layered over the shared rows. - * - * Mark-read used to mutate this component's own `notifications` state; the - * rows are shared now, so a consumer may not write to them — one surface's - * optimistic flip must not become another's fact before the server agrees. - * Holding the flipped ids locally keeps the click instant while the next - * poll (which reads the persisted receipt) supersedes it. - */ - const [locallyRead, setLocallyRead] = useState>(EMPTY_READ_IDS); - const notifications = useMemo( - () => - locallyRead.size === 0 - ? inboxMessages - : inboxMessages.map((n) => (locallyRead.has(n.id) ? { ...n, is_read: true } : n)), - [inboxMessages, locallyRead], - ); - - /** - * M11.C15: pending approvals count — the topbar shortcut, and the second - * addend of the bell badge (`unreadTopics + pendingApprovalsCount`). - * - * Shared with Home's To-do card (#4197): one polled request serves both, so - * the badge and the card can no longer disagree. Formerly a local effect - * gated on `isApp`, which meant the badge silently dropped this addend - * everywhere outside an app — the same user with the same data read 1 on - * Home and 3 inside an app. - */ - const pendingApprovalsCount = useSharedPendingApprovalsCount(); + const { + notifications, + unreadCount, + pendingApprovalsCount, + markAllRead, + markRead: markNotificationRead, + markManyRead, + } = useInboxBell(); /** * Presence is the OTHER half of what this component used to fetch here, and @@ -272,84 +244,6 @@ export function AppHeader({ * data scope, not surface. */ - /** - * ⚠️ The bell's inbox is deliberately NOT gated on `isApp` (#4110), and the - * shared feed keeps it that way: the read is scoped to the *user*, not to the - * app in the URL — unlike the presence avatars and the connection dot, which - * are app-shell chrome and are the reason that flag exists. While this poll - * was gated the popover held `[]` on Home / Organizations / the full-page AI - * screen forever: the "Unread" sub-filter read "You're all caught up" and - * "All" — which applies no predicate at all — read "No notifications", on the - * very page whose To-do card was listing the same `sys_inbox_message` row. - * - * Full server-push (SSE / WebSocket) is tracked separately; the shared feed's - * adaptive poll keeps perceived latency ~5s and is sufficient for pilots up - * to ~50 concurrent users. - */ - - const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0); - - // Read-state lives in `sys_notification_receipt`, keyed - // (notification_id, user_id, channel) — ADR-0030. That object is - // engine-owned (ADR-0103: `enable.apiMethods` = get/list), so the generic - // data API REJECTS receipt writes — the previous direct create/update here - // silently failed and the next poll flipped rows back to unread. Mark-read - // goes through the framework's dedicated REST surface instead - // (`POST /api/v1/notifications/read[/all]`), which upserts the receipt - // server-side keyed by the notification EVENT id. Rows without a - // `notification_id` (legacy/synthetic) can't be keyed, so they update - // optimistically but don't persist. - const postMarkRead = useCallback(async (subPath: 'read' | 'read/all', ids?: string[]) => { - const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, ''); - await fetch(`${serverUrl}/api/v1/notifications/${subPath}`, { - method: 'POST', - credentials: 'include', - // Bearer too — see utils/authToken (#2548 split-origin fix). - headers: { 'Content-Type': 'application/json', ...bearerAuthHeaders() }, - body: JSON.stringify(ids ? { ids } : {}), - }); - }, []); - - /** Flip rows read in the local overlay — never in the shared feed's rows. */ - const markLocallyRead = useCallback((ids: readonly string[]) => { - if (ids.length === 0) return; - setLocallyRead((prev) => { - const next = new Set(prev); - for (const id of ids) next.add(id); - return next; - }); - }, []); - - const markNotificationRead = useCallback(async (id: string) => { - const target = notifications.find(n => n.id === id); - markLocallyRead([id]); - if (!target?.notification_id) return; - try { await postMarkRead('read', [target.notification_id]); } catch { /* best-effort */ } - }, [notifications, markLocallyRead, postMarkRead]); - - const markAllRead = useCallback(async () => { - const unread = notifications.filter(n => !n.is_read); - if (!unread.length) return; - markLocallyRead(notifications.map(n => n.id)); - try { await postMarkRead('read/all'); } catch { /* best-effort */ } - }, [notifications, markLocallyRead, postMarkRead]); - - // Per-group "mark all of this type read" (#2765): the inbox coalesces - // repeats of the same (topic, title) into one expandable row, and this marks - // every member read in a SINGLE request instead of one POST per row (a - // scheduled-digest group can hold 20). Rows without a `notification_id` - // (legacy/synthetic) still flip optimistically but can't be keyed server-side. - const markManyRead = useCallback(async (ids: string[]) => { - const idSet = new Set(ids); - const notifIds = notifications - .filter(n => idSet.has(n.id) && !n.is_read) - .map(n => n.notification_id) - .filter((v): v is string => !!v); - markLocallyRead(ids); - if (!notifIds.length) return; - try { await postMarkRead('read', notifIds); } catch { /* best-effort */ } - }, [notifications, markLocallyRead, postMarkRead]); - const tenantPresence = useTenantPresence(); const activeUsers = presenceUsers ?? (tenantPresence.length > 0 ? tenantPresence : EMPTY_PRESENCE_USERS); // The `activities` prop still wins where a host passes one; otherwise the diff --git a/packages/app-shell/src/views/__tests__/global-page-blocks.render.test.tsx b/packages/app-shell/src/views/__tests__/global-page-blocks.render.test.tsx new file mode 100644 index 0000000000..35b39bb1d6 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/global-page-blocks.render.test.tsx @@ -0,0 +1,277 @@ +/** + * 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#6757 — a page that declares `global:search` or `global:notifications` + * renders a WORKING block, not the "Component Placeholder" scaffold. + * + * ## Why "not the placeholder" is not the assertion + * + * An empty render is also not the placeholder, and so is a red unknown-type + * panel with the wrong text. Each case below therefore asserts CONTENT that + * only the real renderer can produce, and content that had to travel through + * the block's data path to get there: + * + * - `global:search` — the search box exists AND a record hit returned by the + * adapter's `searchAll` (`GET /api/v1/search`, via `useRecordSearch`) is on + * screen, linking to that record's page. + * - `global:notifications` — the bell exists AND its badge carries the unread + * TOPIC count folded from `sys_inbox_message` joined with + * `sys_notification_receipt` (ADR-0030), which is the number the header + * bell shows for the same rows. + * + * The placeholder assertion is kept as a second, weaker line in each case, + * because it is the literal symptom the card reported. + * + * ## Ablation (per member) + * + * Comment out the `ComponentRegistry.register(...)` call in the renderer under + * test and the matching case goes red: + * - `global:search` falls back to the eager palette placeholder registered by + * `@object-ui/components` (`PALETTE_PLACEHOLDER_BLOCKS`), so the DOM carries + * "Component Placeholder" and no search box; + * - `global:notifications` has no placeholder at all (it is not in that eager + * set), so `SchemaRenderer` draws its red unknown-type panel. + * + * ## Harness notes + * + * Real `@object-ui/components`, real `SchemaRenderer`, real registry — the + * ORDER this file's imports produce is the production order (app-shell depends + * on components, so `placeholders.tsx` registers before these two overwrite + * it), and asserting through `SchemaRenderer` is what makes this a page-render + * test rather than a component unit test. Only `useAuth` is stubbed: the shared + * inbox feed is keyed on the signed-in user, and there is no exported auth + * context to provide. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }, + activeOrganization: null, + isAuthenticated: true, + }), +})); + +// Module scope, never a `beforeAll`: the cold transform of these graphs is +// billed to the import phase, which has no test/hook timeout (AGENTS.md +// §测试纪律, objectui#3010). +import '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, AdapterCtx, MetadataCtx } from '@object-ui/react'; +import '../global-search-renderer'; +import '../global-notifications-renderer'; +import { __resetSharedUserFeeds } from '../../hooks/sharedUserFeeds'; + +/* ── Fixtures ─────────────────────────────────────────────────────────────── */ + +/** One cross-object hit, in `MetadataProtocol.searchAll`'s wire shape. */ +const SEARCH_RESPONSE = { + hits: [ + { + object: 'crm_account', + id: 'a1', + title: 'Wayne Enterprises', + snippet: 'ACC-000005', + record: { id: 'a1', name: 'Wayne Enterprises' }, + }, + ], +}; + +/** Two unread messages on two distinct topics ⇒ the bell folds them to "2". */ +const INBOX_ROWS = [ + { + id: 'ibx_1', + user_id: 'u1', + notification_id: 'ntf_1', + topic: 'crm.lead.assigned', + title: 'Lead assigned: Wayne Enterprises', + action_url: '/apps/crm/crm_lead/record/l1', + created_at: '2026-08-11T04:00:00Z', + }, + { + id: 'ibx_2', + user_id: 'u1', + notification_id: 'ntf_2', + topic: 'approval.reminder', + title: 'Approval reminder: INV-1008', + action_url: '/apps/crm/sys_approval_request/record/a1', + created_at: '2026-08-11T03:00:00Z', + }, +]; + +const RECEIPT_ROWS = INBOX_ROWS.map((r, i) => ({ + id: `rcp_${i + 1}`, + notification_id: r.notification_id, + user_id: 'u1', + channel: 'inbox', + state: 'delivered', // NOT read +})); + +const searchCalls: Array<{ query: string; options: unknown }> = []; + +const fakeAdapter = { + searchAll: (query: string, options?: unknown) => { + searchCalls.push({ query, options }); + return Promise.resolve(SEARCH_RESPONSE); + }, + find: (object: string) => { + if (object === 'sys_inbox_message') return Promise.resolve({ data: INBOX_ROWS }); + if (object === 'sys_notification_receipt') return Promise.resolve({ data: RECEIPT_ROWS }); + return Promise.resolve({ data: [] }); + }, + getClient: () => undefined, +}; + +/** + * Stable module-level value: `MetadataCtx` consumers list the context value in + * effect deps, and a fresh object per render re-runs them forever. + */ +const METADATA = { + apps: [], + objects: [{ name: 'crm_account', label: 'Account', icon: 'Building2' }], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +/** A page that DECLARES the member, rendered through the normal recursion. */ +const page = (type: string) => ({ + type: 'page:section', + id: 'section_1', + children: [{ type, id: `blk_${type}` }], +}); + +function renderPage(type: string) { + return render( + + + + + } /> + + + + , + ); +} + +beforeEach(() => { + searchCalls.length = 0; + __resetSharedUserFeeds(); + // The approvals count is a REST read, not an adapter one. 404 is the + // "plugin not installed" answer the feed degrades to 0 on and retires the + // poll for, which keeps the badge equal to the unread topic fold alone. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{}', { status: 404 })), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + __resetSharedUserFeeds(); +}); + +/* ── The two members ──────────────────────────────────────────────────────── */ + +describe('objectui#6757 — spec `PageComponentType` members that had no renderer', () => { + it('registers both members under the `global:` namespace, not the bare names', () => { + // A registration under the bare `search` / `notifications` key would shadow + // far more generic tags; `skipFallback: true` is what prevents it. + expect(ComponentRegistry.get('global:search')).toBeTruthy(); + expect(ComponentRegistry.get('global:notifications')).toBeTruthy(); + expect(ComponentRegistry.get('search')).toBeFalsy(); + expect(ComponentRegistry.get('notifications')).toBeFalsy(); + }); + + it('publishes NO `inputs` for either — both spec shapes are empty', () => { + // `ComponentPropsMap['global:search'|'global:notifications']` declare no + // props at all. Declaring one here would advertise an authoring key the + // contract rejects by name. + expect(ComponentRegistry.getConfig('global:search')?.inputs ?? []).toEqual([]); + expect(ComponentRegistry.getConfig('global:notifications')?.inputs ?? []).toEqual([]); + }); + + describe('global:search', () => { + it('renders a working search box and puts a `searchAll` hit on the page', async () => { + renderPage('global:search'); + + // 1. Real content: the search control itself, with its accessible name. + const box = screen.getByRole('searchbox', { + name: 'Search objects, dashboards, pages, reports', + }); + expect(box).toBeInTheDocument(); + + // 2. Real content that had to travel the data path: type, and the hit the + // adapter's `searchAll` returned reaches the DOM as a record link. + // `useRecordSearch` debounces 250ms before it fires, hence the explicit + // window — this is a deliberate debounce, not a module-load race. + fireEvent.change(box, { target: { value: 'wayne' } }); + const hit = await screen.findByText('Wayne Enterprises', {}, { timeout: 4000 }); + expect(hit).toBeInTheDocument(); + expect(screen.getByText('ACC-000005')).toBeInTheDocument(); + expect(hit.closest('a')).toHaveAttribute('href', '/apps/crm/crm_account/record/a1'); + + // The block asked the platform's unified endpoint, not the per-object + // fanout: `searchAll` is `GET /api/v1/search`. + expect(searchCalls.map((c) => c.query)).toEqual(['wayne']); + + // 3. The literal symptom the card reported. + expect(screen.queryByText('Component Placeholder')).toBeNull(); + }); + }); + + describe('global:notifications', () => { + it('renders the bell and badges the unread topics from the inbox feed', async () => { + renderPage('global:notifications'); + + // 1. Real content: the bell control, with its accessible name. + const bell = await screen.findByRole('button', { name: 'Open inbox' }); + expect(bell).toBeInTheDocument(); + + // 2. Real content that had to travel the data path: two unread rows on + // two topics, joined against their (unread) receipts, fold to "2". + await waitFor(() => { + expect(bell).toHaveTextContent('2'); + }); + + // 3. The literal symptom the card reported, plus the OTHER failure shape: + // with no registration at all this block draws SchemaRenderer's red + // unknown-type panel rather than the placeholder. + expect(screen.queryByText('Component Placeholder')).toBeNull(); + expect(screen.queryByText(/Unknown component type/i)).toBeNull(); + }); + + it('opens the inbox and lists the rows the feed returned', async () => { + renderPage('global:notifications'); + const bell = await screen.findByRole('button', { name: 'Open inbox' }); + await waitFor(() => expect(bell).toHaveTextContent('2')); + + fireEvent.click(bell); + + expect( + await screen.findByText('Lead assigned: Wayne Enterprises'), + ).toBeInTheDocument(); + expect(screen.getByText('Approval reminder: INV-1008')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/app-shell/src/views/global-notifications-renderer.tsx b/packages/app-shell/src/views/global-notifications-renderer.tsx new file mode 100644 index 0000000000..e960379b2c --- /dev/null +++ b/packages/app-shell/src/views/global-notifications-renderer.tsx @@ -0,0 +1,115 @@ +/** + * 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. + */ + +/** + * `global:notifications` — the notification bell, addressable from a page + * schema (objectui#6757). + * + * ## Why this exists + * + * `global:notifications` is a first-class member of `@objectstack/spec`'s + * `PageComponentType`, and the maintainer ruling of 2026-08-26 (objectstack#12183) + * kept it declared because its data source shipped. Nothing rendered it, so a + * page that authored it drew `PlaceholderRenderer`'s literal "Component + * Placeholder" scaffold — the author-time gate accepted the metadata and the + * screen showed a dashed box. + * + * ## What backs it — nothing new + * + * ADR-0012 / ADR-0030: *the bell reads `sys_inbox_message`*. That read already + * exists (`sharedUserFeeds`), the popover already exists (`InboxPopover`), and + * the wiring between them is `useInboxBell` — the same hook `AppHeader` mounts + * for the chrome bell. Registering this block adds a mount point, not a data + * layer: header bell and authored bell cut from ONE feed with ONE optimistic + * read overlay, so they have no representable state in which they disagree + * (the property #4225 and #4316 bought and this block must not spend). + * + * ## Declared propless, deliberately + * + * `ComponentPropsMap['global:notifications']` is an EMPTY shape ("declares no + * props at all" is the recorded intent), so this registration publishes NO + * `inputs`. Declaring even `className` here would advertise an authoring key + * the contract rejects by name — the forward direction of + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` is a vice on + * exactly that move. The node-level `className` the SchemaRenderer threads + * through is a NODE key (`PageComponentSchema`), not a prop, and needs no + * declaration. + * + * Registered in app-shell rather than `@object-ui/components` for the same + * reason `record:approvals` is: the feed hooks depend on `@object-ui/auth` and + * on this package's providers, which `@object-ui/components` deliberately does + * not pull in. The side-effect registration is imported from the app-shell + * barrel (`src/index.ts`). + * + * This does NOT put the block back in the Studio page palette: `PALETTE_EXCLUSIONS` + * still records it as a shell singleton, and that is a palette decision about + * authoring ergonomics, independent of whether a declared type renders. + */ + +import * as React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { InboxPopover } from '../layout/InboxPopover.js'; +import { useInboxBell } from '../hooks/useInboxBell.js'; +import { useSharedActivityFeed } from '../hooks/sharedUserFeeds.js'; + +/** Keep the designer's own data attributes on the wrapper, drop the rest. */ +const splitDesigner = (props: Record) => { + const { 'data-obj-id': id, 'data-obj-type': type, style } = props || {}; + return { 'data-obj-id': id, 'data-obj-type': type, style }; +}; + +export interface GlobalNotificationsRendererProps { + schema?: Record; + className?: string; + [k: string]: any; +} + +export const GlobalNotificationsRenderer: React.FC = ({ + className, + schema: _schema, + ...props +}) => { + const { + notifications, + unreadCount, + pendingApprovalsCount, + markAllRead, + markRead, + markManyRead, + } = useInboxBell(); + const activities = useSharedActivityFeed(); + + return ( +
+ +
+ ); +}; + +// `register('notifications', …, { namespace: 'global' })` — the BARE name plus a +// namespace, never a pre-prefixed one (the registry prepends it itself, and a +// pre-prefixed name lands under `global:global:notifications`). +// `skipFallback: true` keeps it off the top-level `notifications` key, which is +// far too generic a tag to claim. No `inputs`: the spec shape is empty. +ComponentRegistry.register('notifications', GlobalNotificationsRenderer, { + namespace: 'global', + skipFallback: true, + category: 'navigation', + label: 'Notifications', + icon: 'Bell', +}); + +export default GlobalNotificationsRenderer; diff --git a/packages/app-shell/src/views/global-search-renderer.tsx b/packages/app-shell/src/views/global-search-renderer.tsx new file mode 100644 index 0000000000..bb0037dd46 --- /dev/null +++ b/packages/app-shell/src/views/global-search-renderer.tsx @@ -0,0 +1,184 @@ +/** + * 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. + */ + +/** + * `global:search` — the cross-object search box, addressable from a page + * schema (objectui#6757). + * + * ## Why this exists + * + * `global:search` is a first-class member of `@objectstack/spec`'s + * `PageComponentType`, kept declared by the maintainer ruling of 2026-08-26 + * (objectstack#12183) because its data source shipped. Nothing rendered it, so + * a page that authored it drew `PlaceholderRenderer`'s literal "Component + * Placeholder" scaffold: the author-time gate accepted the metadata and the + * screen showed a dashed box. + * + * ## What backs it — nothing new + * + * `useRecordSearch` (`@object-ui/react`) is the platform's global-search read: + * it prefers the adapter's `searchAll` (`GET /api/v1/search`, cross-object hits + * with title/snippet/record and per-object caps) and falls back to the + * per-object `find({ $search })` fanout only for adapters that do not expose + * it. Two app-shell surfaces already consume it — the ⌘K `CommandPalette` and + * the full-page `SearchResultsPage` — and this block is the third mount point + * of the SAME hook, not a fourth search path. + * + * Scope is the whole searchable object set the `MetadataProvider` holds + * (`searchable !== false`), because a page block has no app-nav context of its + * own the way the palette and the results page do; that is `useRecordSearch`'s + * documented default when `objectNames` is omitted, not a local widening. + * + * ## Declared propless, deliberately + * + * `ComponentPropsMap['global:search']` is an EMPTY shape, so this registration + * publishes NO `inputs` — the same reason as `global:notifications`. It also + * keeps the block in `EXPECTED_WITHOUT_INPUTS`, where the placeholder + * registration already put it, rather than moving it into a coverage set whose + * forward direction would then judge keys the contract does not declare. + * + * Registered in app-shell rather than `@object-ui/components` because the + * adapter and metadata providers live here. The eager palette placeholder in + * `components/renderers/placeholders.tsx` STAYS: it is the fallback for a host + * that embeds `@object-ui/components` without app-shell, and this module + * imports that package, so its registration always runs first and this one + * overwrites it. + */ + +import * as React from 'react'; +import { useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { useRecordSearch, useMetadata, useAdapter } from '@object-ui/react'; +import { Input, Card, CardContent, Badge } from '@object-ui/components'; +import { Search } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { getIcon } from '../utils/getIcon.js'; +import { getRecordDisplayName } from '../utils/index.js'; +import { useNavigationContext } from '../context/NavigationContext.js'; + +/** Keep the designer's own data attributes on the wrapper, drop the rest. */ +const splitDesigner = (props: Record) => { + const { 'data-obj-id': id, 'data-obj-type': type, style } = props || {}; + return { 'data-obj-id': id, 'data-obj-type': type, style }; +}; + +export interface GlobalSearchRendererProps { + schema?: Record; + className?: string; + [k: string]: any; +} + +export const GlobalSearchRenderer: React.FC = ({ + className, + schema: _schema, + ...props +}) => { + const { t } = useObjectTranslation(); + const [query, setQuery] = useState(''); + const { objects: metadataObjects } = useMetadata(); + const dataSource = useAdapter(); + const { appName } = useParams(); + const { currentAppName } = useNavigationContext(); + + // `useMetadata().objects` can hand back a fresh array each call; the hook + // derives its own signature from the names, but a stable reference keeps the + // memo above it from churning (same reason SearchResultsPage does it). + const objects = useMemo(() => metadataObjects || [], [metadataObjects]); + + const { results, isSearching } = useRecordSearch({ + query, + objects, + dataSource, + enabled: Boolean(dataSource), + getDisplayName: getRecordDisplayName, + }); + + const baseUrl = `/apps/${appName || currentAppName || ''}`; + const placeholder = t('search.placeholder', { + defaultValue: 'Search objects, dashboards, pages, reports…', + }) as string; + const ariaLabel = t('search.inputAriaLabel', { + defaultValue: 'Search objects, dashboards, pages, reports', + }) as string; + + return ( +
+
+ + ) => setQuery(e.target.value)} + /> +
+ + {isSearching && ( +

+ {t('console.commandPalette.searching', { defaultValue: 'Searching…' })} +

+ )} + + {results.length > 0 && ( +
    + {results.map((hit) => { + const HitIcon = getIcon(hit.icon); + return ( +
  • + + + +
    + +
    +
    +

    {hit.display}

    + {hit.subtitle && ( +

    {hit.subtitle}

    + )} +
    + + {hit.objectLabel} + +
    +
    + +
  • + ); + })} +
+ )} +
+ ); +}; + +// Bare name + namespace (the registry prepends it itself); `skipFallback: true` +// keeps this off the top-level `search` key. No `inputs`: the spec shape is empty. +ComponentRegistry.register('search', GlobalSearchRenderer, { + namespace: 'global', + skipFallback: true, + category: 'navigation', + label: 'Global Search', + icon: 'Search', +}); + +export default GlobalSearchRenderer;