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
9 changes: 8 additions & 1 deletion src/application/services/js-services/http/misc-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchDocumentResponseItem[]>(() =>
getAxios()?.get<APIResponse<SearchDocumentResponseItem[]>>(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() },
})
);
Expand All @@ -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() },
})
Expand Down
48 changes: 39 additions & 9 deletions src/components/app/search/BestMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<View | undefined> {
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')
Expand Down Expand Up @@ -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<string>();
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;
Expand All @@ -133,7 +157,7 @@ function BestMatch({

return items;
},
[outline, t]
[currentWorkspaceId, outline, t]
);

const handleSearch = useCallback(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
60 changes: 55 additions & 5 deletions src/components/app/search/__tests__/BestMatch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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';

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', () => ({
Expand All @@ -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', () => ({
Expand All @@ -36,25 +43,46 @@ jest.mock('@/components/app/search/SearchAIOverview', () => ({

jest.mock('@/components/app/search/ViewList', () => ({
__esModule: true,
default: ({ header }: { header?: ReactNode }) => (
<div data-testid='view-list'>{header ? <div data-testid='search-header'>{header}</div> : null}</div>
default: ({ header, items }: { header?: ReactNode; items?: Array<{ id: string; view: { name: string } }> }) => (
<div data-testid='view-list'>
{header ? <div data-testid='search-header'>{header}</div> : null}
{items?.map((item) => (
<div key={item.id}>{item.view.name}</div>
))}
</div>
),
}));

function renderBestMatch() {
return render(<BestMatch askingAI={false} searchValue='' onAskAI={jest.fn()} onClose={jest.fn()} />);
function createView(overrides: Partial<View> = {}): 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(<BestMatch askingAI={false} searchValue={searchValue} onAskAI={jest.fn()} onClose={jest.fn()} />);
}

describe('BestMatch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAppOutline = [];
mockUseAIEnabled.mockReturnValue(true);
mockSearchWorkspaceDocumentPage.mockResolvedValue({
has_more: false,
items: [],
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', () => {
Expand All @@ -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');
});
});
Loading