From 55beeb868f43ca7f16140f3993735c8e4785ed9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:15:19 +0000 Subject: [PATCH] feat(search): surface record hits on the full search page + i18n group labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the ⌘K command-palette fix (#3371). The full-page search (/apps/:app/search) still matched only navigation metadata (objects, dashboards, pages, reports), never records. - SearchResultsPage now runs the same global record search the palette uses (`useRecordSearch` → /api/v1/search), scoped to the app's searchable nav objects, and renders the record hits grouped by object above the metadata matches (with a searching indicator and record-count in the header). - Both the search page and the command palette resolve each object group's heading through `resolveI18nLabel`, so localized object labels display instead of falling back to the raw object name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011s54R8tmaqmvj6xjLPfv7c --- .changeset/search-page-record-hits.md | 15 ++ .../app-shell/src/chrome/CommandPalette.tsx | 19 ++- .../app-shell/src/views/SearchResultsPage.tsx | 133 +++++++++++++++++- .../SearchResultsPage.records.test.tsx | 122 ++++++++++++++++ 4 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 .changeset/search-page-record-hits.md create mode 100644 packages/app-shell/src/views/__tests__/SearchResultsPage.records.test.tsx diff --git a/.changeset/search-page-record-hits.md b/.changeset/search-page-record-hits.md new file mode 100644 index 0000000000..28f91fc5d1 --- /dev/null +++ b/.changeset/search-page-record-hits.md @@ -0,0 +1,15 @@ +--- +"@object-ui/app-shell": patch +--- + +The full-page search (`/apps/:app/search`) now surfaces record hits, not just +metadata nav items. + +Following the ⌘K command-palette fix (#3371), the search results page was still +matching only navigation entries (objects, dashboards, pages, reports). It now +runs the same global record search (`useRecordSearch` → `/api/v1/search`), +scoped to the app's searchable objects, and renders the record hits grouped by +object above the metadata matches. Both the search page and the palette now +resolve each object group's heading through the i18n label resolver, so +localized object labels display correctly instead of falling back to the raw +object name. diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index de66cdc57b..8249cf4be1 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -117,6 +117,17 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour ); const showRecentRecords = open && inputValue.trim().length === 0 && recentRecords.length > 0; + // Index object defs by name so group headings can resolve the object's + // localized label (labels may be `{ key, defaultValue }` i18n objects, which + // the hook's plain-string `objectLabel` can't carry). + const objectsByName = useMemo(() => { + const map = new Map(); + for (const obj of objects || []) { + if (typeof obj?.name === 'string') map.set(obj.name, obj); + } + return map; + }, [objects]); + // Group the (server-ranked) record hits by object so the palette lists them // under per-object headings — issue #3371 asks for record hits "grouped by // object". The object with the top-ranked hit leads (first-seen order), and @@ -130,7 +141,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour for (const hit of recordHits) { let group = byObject.get(hit.objectName); if (!group) { - group = { objectLabel: hit.objectLabel, icon: hit.icon, hits: [] }; + // Prefer the i18n-resolved object label; fall back to the hit's plain + // label (already objectName when the def had no string label). + const objDef = objectsByName.get(hit.objectName); + const label = resolveI18nLabel(objDef?.label, t) || hit.objectLabel; + group = { objectLabel: label, icon: hit.icon, hits: [] }; byObject.set(hit.objectName, group); order.push(hit.objectName); } @@ -140,7 +155,7 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour const group = byObject.get(name)!; return { objectName: name, ...group }; }); - }, [recordHits]); + }, [recordHits, objectsByName, t]); return ( metadataObjects || [], [metadataObjects]); const activeApp = matchAppBySegment(apps, appName) || apps[0]; const baseUrl = `/apps/${appName}`; const { user, activeOrganization } = useAuth(); + const dataSource = useAdapter(); // Build searchable items from navigation const allItems = useMemo((): SearchResult[] => { @@ -121,6 +129,67 @@ export function SearchResultsPage() { return groups; }, [results]); + // Record search — the same global-search path the ⌘K palette uses + // (`/api/v1/search` via `useRecordSearch`/`searchAll`), scoped to the app's + // searchable nav objects so record links resolve within this app. This is + // what makes the full-page search actually surface records, not just the + // metadata nav items above (issue #3371 follow-up). + const searchableObjectNames = useMemo(() => { + if (!activeApp) return [] as string[]; + return flattenNavigation(activeApp.navigation || []) + .filter((i: any) => i.type === 'object' && typeof i.objectName === 'string') + .map((i: any) => i.objectName as string); + }, [activeApp]); + + const { results: recordHits, isSearching: recordsSearching } = useRecordSearch({ + query, + objects, + dataSource, + objectNames: searchableObjectNames, + enabled: Boolean(dataSource) && query.trim().length >= 2, + // The full page has room for more than the palette's terse list. + topPerObject: 5, + maxObjectsQueried: 12, + getDisplayName: getRecordDisplayName, + }); + + // Index object defs by name for i18n-resolved group headings and icons. + const objectsByName = useMemo(() => { + const map = new Map(); + for (const obj of objects) { + if (typeof obj?.name === 'string') map.set(obj.name, obj); + } + return map; + }, [objects]); + + // Group record hits by object, preserving the server's cross-object ranking + // for which object leads. + const recordGroups = useMemo(() => { + const order: string[] = []; + const byObject = new Map< + string, + { label: string; icon?: string; hits: typeof recordHits } + >(); + for (const hit of recordHits) { + let group = byObject.get(hit.objectName); + if (!group) { + const objDef = objectsByName.get(hit.objectName); + const label = resolveI18nLabel(objDef?.label, t) || hit.objectLabel; + group = { label, icon: objDef?.icon ?? hit.icon, hits: [] }; + byObject.set(hit.objectName, group); + order.push(hit.objectName); + } + group.hits.push(hit); + } + return order.map((name) => { + const group = byObject.get(name)!; + return { objectName: name, ...group }; + }); + }, [recordHits, objectsByName, t]); + + const totalCount = results.length + recordHits.length; + const hasAnyResults = totalCount > 0; + return (
{/* Header */} @@ -150,14 +219,22 @@ export function SearchResultsPage() {
{/* Results count */} -
- {query.trim() - ? t(results.length === 1 ? 'search.resultsCount' : 'search.resultsCountPlural', { count: results.length, query }) - : t('search.itemsAvailable', { count: allItems.length })} +
+ + {query.trim() + ? t(totalCount === 1 ? 'search.resultsCount' : 'search.resultsCountPlural', { count: totalCount, query }) + : t('search.itemsAvailable', { count: allItems.length })} + + {recordsSearching && ( + + )}
{/* Results */} - {results.length === 0 ? ( + {!hasAnyResults && !recordsSearching ? (

{t('search.noResults')}

@@ -167,10 +244,52 @@ export function SearchResultsPage() {
) : (
+ {/* Record hits (record instances) grouped by object — server-ranked. */} + {recordGroups.map((group) => { + const GroupIcon = getIcon(group.icon); + return ( +
+

+ + {group.label} + + {group.hits.length} + +

+
+ {group.hits.map((hit) => { + const HitIcon = getIcon(hit.icon); + return ( + + + +
+ +
+
+

{hit.display}

+ {hit.subtitle && ( +

{hit.subtitle}

+ )} +
+
+
+ + ); + })} +
+
+ ); + })} + + {/* Navigation matches (objects, dashboards, pages, reports). */} {Object.entries(grouped).map(([type, items]) => { const TypeIcon = TYPE_ICONS[type] || Database; const typeLabelKey = `search.type${type.charAt(0).toUpperCase()}${type.slice(1)}s`; - const badgeKey = `search.badge${type.charAt(0).toUpperCase()}${type.slice(1)}`; return (

diff --git a/packages/app-shell/src/views/__tests__/SearchResultsPage.records.test.tsx b/packages/app-shell/src/views/__tests__/SearchResultsPage.records.test.tsx new file mode 100644 index 0000000000..65a4ff7008 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/SearchResultsPage.records.test.tsx @@ -0,0 +1,122 @@ +/** + * 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 full-page search (`/apps/:app/search`) must surface record hits from the + * global search endpoint — not just metadata nav items — grouped by object, + * with the object's i18n-resolved label as the heading and a link to the + * record page (issue #3371 follow-up). We drive it with a stubbed + * `useRecordSearch` so the assertions cover the page's rendering/wiring. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ appName: 'crm' }), + useSearchParams: () => [new URLSearchParams('q=wayne'), vi.fn()], + // Render Link as a plain anchor so we can assert the resolved href. + Link: ({ to, children, ...rest }: any) => ( + + {children} + + ), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Echo the i18n defaultValue (or key), enough to resolve `{key,defaultValue}`. + useObjectTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + // Hits are inlined here (not a shared const) so the hoisted factory doesn't + // reference an uninitialized outer binding. + const recordHits = [ + { + objectName: 'crm_account', + // Deliberately the raw name — the page should display the i18n-resolved + // label from the object def instead of this. + objectLabel: 'crm_account', + recordId: 'a1', + display: 'Wayne Enterprises', + subtitle: 'ACC-000005', + icon: 'Building2', + score: 80, + raw: {}, + }, + { + objectName: 'crm_opportunity', + objectLabel: 'crm_opportunity', + recordId: 'o1', + display: 'Wayne Q1 Expansion', + icon: 'Target', + score: 60, + raw: {}, + }, + ]; + return { + ...actual, + useRecordSearch: () => ({ results: recordHits, isSearching: false, error: undefined }), + }; +}); + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ + apps: [ + { + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'n1', type: 'object', objectName: 'crm_account', label: 'Accounts' }, + ], + }, + ], + objects: [ + { name: 'crm_account', label: { key: 'object.crm_account', defaultValue: 'Accounts' }, icon: 'Building2' }, + { name: 'crm_opportunity', label: { key: 'object.crm_opportunity', defaultValue: 'Opportunities' }, icon: 'Target' }, + ], + }), +})); + +vi.mock('../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ find: vi.fn(), searchAll: vi.fn() }), +})); + +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }), +})); + +import { SearchResultsPage } from '../SearchResultsPage'; + +describe('SearchResultsPage — record hits', () => { + it('renders record hits grouped by object with i18n-resolved headings and record links', () => { + render(); + + // Record display names are shown. + expect(screen.getByText('Wayne Enterprises')).toBeInTheDocument(); + expect(screen.getByText('Wayne Q1 Expansion')).toBeInTheDocument(); + + // Group headings use the i18n-resolved object label (defaultValue), not the + // hit's raw `objectLabel` (which was the object name). + expect(screen.getByText('Accounts')).toBeInTheDocument(); + expect(screen.getByText('Opportunities')).toBeInTheDocument(); + + // The account hit links to the record page under the current app. + const link = screen.getByText('Wayne Enterprises').closest('a'); + expect(link).toHaveAttribute('href', '/apps/crm/crm_account/record/a1'); + + // The snippet renders as a secondary line. + expect(screen.getByText('ACC-000005')).toBeInTheDocument(); + }); +});