Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/search-page-record-hits.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions packages/app-shell/src/chrome/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>();
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
Expand All @@ -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);
}
Expand All @@ -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 (
<CommandDialog
Expand Down
133 changes: 126 additions & 7 deletions packages/app-shell/src/views/SearchResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ import {
ArrowLeft,
} from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import { useRecordSearch } from '@object-ui/react';
import { useMetadata } from '../providers/MetadataProvider';
import { useAdapter } from '../providers/AdapterProvider';
import { matchAppBySegment } from '../utils/appRoute';
import { resolveI18nLabel, getRecordDisplayName } from '../utils';
import { getIcon } from '../utils/getIcon';
import { resolveHref } from '@object-ui/layout';
import { useAuth } from '@object-ui/auth';

Expand Down Expand Up @@ -71,11 +75,15 @@ export function SearchResultsPage() {
const queryParam = searchParams.get('q') || '';
const [query, setQuery] = useState(queryParam);

const { apps: metadataApps } = useMetadata();
const { apps: metadataApps, objects: metadataObjects } = useMetadata();
const apps = metadataApps || [];
// Stable reference so the record-search memos below don't rerun every render
// (useMetadata().objects can hand back a fresh array each call).
const objects = useMemo(() => 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[] => {
Expand Down Expand Up @@ -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<string, any>();
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 (
<div className="flex flex-col gap-6 p-4 sm:p-6 max-w-4xl mx-auto">
{/* Header */}
Expand Down Expand Up @@ -150,14 +219,22 @@ export function SearchResultsPage() {
</div>

{/* Results count */}
<div className="text-sm text-muted-foreground">
{query.trim()
? t(results.length === 1 ? 'search.resultsCount' : 'search.resultsCountPlural', { count: results.length, query })
: t('search.itemsAvailable', { count: allItems.length })}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{query.trim()
? t(totalCount === 1 ? 'search.resultsCount' : 'search.resultsCountPlural', { count: totalCount, query })
: t('search.itemsAvailable', { count: allItems.length })}
</span>
{recordsSearching && (
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-pulse"
/>
)}
</div>

{/* Results */}
{results.length === 0 ? (
{!hasAnyResults && !recordsSearching ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-lg font-medium text-muted-foreground">{t('search.noResults')}</p>
Expand All @@ -167,10 +244,52 @@ export function SearchResultsPage() {
</div>
) : (
<div className="space-y-6">
{/* Record hits (record instances) grouped by object — server-ranked. */}
{recordGroups.map((group) => {
const GroupIcon = getIcon(group.icon);
return (
<div key={`records:${group.objectName}`}>
<h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<GroupIcon className="h-4 w-4" />
{group.label}
<Badge variant="secondary" className="ml-1 text-xs">
{group.hits.length}
</Badge>
</h2>
<div className="grid gap-2">
{group.hits.map((hit) => {
const HitIcon = getIcon(hit.icon);
return (
<Link
key={`${hit.objectName}:${hit.recordId}`}
to={`${baseUrl}/${hit.objectName}/record/${hit.recordId}`}
className="rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="flex items-center gap-3 p-3">
<div className="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-primary">
<HitIcon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{hit.display}</p>
{hit.subtitle && (
<p className="text-xs text-muted-foreground truncate">{hit.subtitle}</p>
)}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
})}

{/* 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 (
<div key={type}>
<h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<a href={typeof to === 'string' ? to : ''} {...rest}>
{children}
</a>
),
}));

vi.mock('@object-ui/i18n', async (importOriginal) => {
const actual = await importOriginal<any>();
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<any>();
// 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(<SearchResultsPage />);

// 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();
});
});
Loading