diff --git a/src/application/services/js-services/http/misc-api.ts b/src/application/services/js-services/http/misc-api.ts index 18e67130..7280d20a 100644 --- a/src/application/services/js-services/http/misc-api.ts +++ b/src/application/services/js-services/http/misc-api.ts @@ -45,13 +45,19 @@ export interface SearchSummaryResult { const SEARCH_RESULT_LIMIT = 10; const SEARCH_PREVIEW_SIZE = 80; +const SEARCH_SCORE_THRESHOLD = 0.2; export async function searchWorkspaceDocuments(workspaceId: string, query: string) { const url = `/api/search/${workspaceId}`; return executeAPIRequest(() => getAxios()?.get>(url, { - params: { query, limit: SEARCH_RESULT_LIMIT, preview_size: SEARCH_PREVIEW_SIZE }, + params: { + query, + limit: SEARCH_RESULT_LIMIT, + preview_size: SEARCH_PREVIEW_SIZE, + score: SEARCH_SCORE_THRESHOLD, + }, headers: { 'x-request-time': Date.now().toString() }, }) ); @@ -68,6 +74,7 @@ export async function searchWorkspaceDocumentPage(workspaceId: string, query: st offset, preview_size: SEARCH_PREVIEW_SIZE, mode: 'keyword', + score: SEARCH_SCORE_THRESHOLD, }, headers: { 'x-request-time': Date.now().toString() }, }) diff --git a/src/components/app/search/BestMatch.tsx b/src/components/app/search/BestMatch.tsx index 805cfa63..2c347089 100644 --- a/src/components/app/search/BestMatch.tsx +++ b/src/components/app/search/BestMatch.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { View } from '@/application/types'; import { getDatabaseIdFromExtra } from '@/application/view-utils'; -import { SearchService } from '@/application/services/domains'; +import { SearchService, ViewService } from '@/application/services/domains'; import type { SearchDocumentPageResponse, SearchDocumentResponseItem, @@ -38,6 +38,25 @@ function resolveSearchResultView(outline: View[], item: SearchDocumentResponseIt ); } +function getSearchResultViewIdCandidates(item: SearchDocumentResponseItem): string[] { + return Array.from(new Set([item.object_id, item.database_view_id, item.database_id].filter(Boolean) as string[])); +} + +async function loadSearchResultView( + item: SearchDocumentResponseItem, + currentWorkspaceId: string +): Promise { + const workspaceId = item.workspace_id || currentWorkspaceId; + + for (const viewId of getSearchResultViewIdCandidates(item)) { + try { + return await ViewService.get(workspaceId, viewId); + } catch { + // Search can return database/object ids that are not view ids. Try the next candidate. + } + } +} + function previewLines(text?: string | null, limit = 2): string | undefined { const lines = text ?.split('\n') @@ -101,14 +120,19 @@ function BestMatch({ const searchSeqRef = useRef(0); const buildSearchItems = useCallback( - (results: SearchDocumentResponseItem[]) => { - if (!outline) return []; + async (results: SearchDocumentResponseItem[]) => { + if (!outline || !currentWorkspaceId) return []; const seenTargets = new Set(); const items: SearchViewListItem[] = []; - - for (const item of results) { - const view = resolveSearchResultView(outline, item); + const resolvedViews = await Promise.all( + results.map( + async (item) => resolveSearchResultView(outline, item) || loadSearchResultView(item, currentWorkspaceId) + ) + ); + + for (const [index, item] of results.entries()) { + const view = resolvedViews[index]; const rowId = item.database_row_id || undefined; if (!view || view.extra?.is_space) continue; @@ -133,7 +157,7 @@ function BestMatch({ return items; }, - [outline, t] + [currentWorkspaceId, outline, t] ); const handleSearch = useCallback( @@ -169,9 +193,12 @@ function BestMatch({ const res = mergeSearchResults([], page.items || []); const shouldGenerateSummary = aiEnabled && res.some((item) => item.content); + const searchItems = await buildSearchItems(res); + + if (searchSeqRef.current !== searchSeq) return; setSearchResults(res); - setItems(buildSearchItems(res)); + setItems(searchItems); setHasMore(canLoadMoreSearchResults(page, 0)); setNextOffset(page.next_offset ?? null); setSummaryLoading(shouldGenerateSummary); @@ -227,9 +254,12 @@ function BestMatch({ if (searchSeqRef.current !== searchSeq) return; const mergedResults = mergeSearchResults(searchResults, page.items || []); + const searchItems = await buildSearchItems(mergedResults); + + if (searchSeqRef.current !== searchSeq) return; setSearchResults(mergedResults); - setItems(buildSearchItems(mergedResults)); + setItems(searchItems); setHasMore(canLoadMoreSearchResults(page, nextOffset)); setNextOffset(page.next_offset ?? null); // eslint-disable-next-line diff --git a/src/components/app/search/__tests__/BestMatch.test.tsx b/src/components/app/search/__tests__/BestMatch.test.tsx index 8094901b..3783ca76 100644 --- a/src/components/app/search/__tests__/BestMatch.test.tsx +++ b/src/components/app/search/__tests__/BestMatch.test.tsx @@ -1,6 +1,9 @@ import { describe, expect, it, beforeEach, jest } from '@jest/globals'; import { render, screen } from '@testing-library/react'; +import { ViewLayout } from '@/application/types'; +import type { View } from '@/application/types'; + import BestMatch from '../BestMatch'; import type { ReactNode } from 'react'; @@ -8,7 +11,8 @@ import type { ReactNode } from 'react'; const mockUseAIEnabled = jest.fn(); const mockSearchWorkspaceDocumentPage = jest.fn(); const mockGenerateSearchSummary = jest.fn(); -const mockAppOutline: never[] = []; +const mockGetView = jest.fn(); +let mockAppOutline: View[] = []; const mockT = (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key; jest.mock('react-i18next', () => ({ @@ -28,6 +32,9 @@ jest.mock('@/application/services/domains', () => ({ searchWorkspaceDocumentPage: (...args: unknown[]) => mockSearchWorkspaceDocumentPage(...args), generateSearchSummary: (...args: unknown[]) => mockGenerateSearchSummary(...args), }, + ViewService: { + get: (...args: unknown[]) => mockGetView(...args), + }, })); jest.mock('@/components/app/search/SearchAIOverview', () => ({ @@ -36,18 +43,38 @@ jest.mock('@/components/app/search/SearchAIOverview', () => ({ jest.mock('@/components/app/search/ViewList', () => ({ __esModule: true, - default: ({ header }: { header?: ReactNode }) => ( -
{header ?
{header}
: null}
+ default: ({ header, items }: { header?: ReactNode; items?: Array<{ id: string; view: { name: string } }> }) => ( +
+ {header ?
{header}
: null} + {items?.map((item) => ( +
{item.view.name}
+ ))} +
), })); -function renderBestMatch() { - return render(); +function createView(overrides: Partial = {}): View { + return { + view_id: 'view-id', + name: 'Page', + icon: null, + layout: ViewLayout.Document, + extra: null, + children: [], + is_published: false, + is_private: false, + ...overrides, + }; +} + +function renderBestMatch(searchValue = '') { + return render(); } describe('BestMatch', () => { beforeEach(() => { jest.clearAllMocks(); + mockAppOutline = []; mockUseAIEnabled.mockReturnValue(true); mockSearchWorkspaceDocumentPage.mockResolvedValue({ has_more: false, @@ -55,6 +82,7 @@ describe('BestMatch', () => { next_offset: null, }); mockGenerateSearchSummary.mockResolvedValue({ summaries: [] }); + mockGetView.mockRejectedValue(new Error('not found')); }); it('does not mount the AI overview header when server info disables AI', () => { @@ -72,4 +100,26 @@ describe('BestMatch', () => { expect(screen.getByTestId('search-header')).toBeTruthy(); expect(screen.getByTestId('ai-overview')).toBeTruthy(); }); + + it('loads view metadata for search results missing from the current outline', async () => { + mockUseAIEnabled.mockReturnValue(false); + mockSearchWorkspaceDocumentPage.mockResolvedValue({ + has_more: false, + items: [ + { + object_id: 'deep-view-id', + workspace_id: 'workspace-id', + score: 1, + content: 'Annie OKRs', + }, + ], + next_offset: null, + }); + mockGetView.mockResolvedValue(createView({ view_id: 'deep-view-id', name: 'Annie OKRs' })); + + renderBestMatch('annie'); + + expect(await screen.findByText('Annie OKRs')).toBeTruthy(); + expect(mockGetView).toHaveBeenCalledWith('workspace-id', 'deep-view-id'); + }); });