Skip to content

Commit e680c8e

Browse files
authored
feat(unify): paginate topics/subtopics semantic search (formbricks#8206)
1 parent 7d6ea1d commit e680c8e

2 files changed

Lines changed: 124 additions & 39 deletions

File tree

apps/web/modules/ee/unify-feedback/topics-subtopics/actions.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/
1212
import { semanticSearchFeedbackRecords } from "@/modules/hub/service";
1313
import type { SemanticSearchResultItem } from "@/modules/hub/types";
1414

15-
const TOPICS_PREVIEW_LIMIT = 10;
15+
const TOPICS_PREVIEW_PAGE_SIZE = 50;
1616
const SEARCH_CONCURRENCY = 4;
1717

1818
const ZSemanticSearchFeedbackRecordsAction = z.object({
1919
workspaceId: ZId,
2020
query: z.string().trim().min(1).max(500),
21-
limit: z.number().min(1).max(50).optional(),
2221
minScore: z.number().min(0).max(1).optional(),
22+
cursors: z.record(z.string(), z.string()).optional(),
2323
});
2424

2525
export type TTopicsPreviewSearchResult = SemanticSearchResultItem & {
@@ -29,6 +29,7 @@ export type TTopicsPreviewSearchResult = SemanticSearchResultItem & {
2929

3030
export type TTopicsPreviewSearchActionResult = {
3131
results: TTopicsPreviewSearchResult[];
32+
cursors: Record<string, string>;
3233
unavailable: boolean;
3334
unavailableMessage?: string;
3435
};
@@ -70,23 +71,31 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient
7071

7172
const directories = await getFeedbackDirectoriesByWorkspaceId(parsedInput.workspaceId);
7273
if (directories.length === 0) {
73-
return { results: [], unavailable: false };
74+
return { results: [], cursors: {}, unavailable: false };
7475
}
7576

76-
const limit = parsedInput.limit ?? TOPICS_PREVIEW_LIMIT;
77+
// On "load more" only re-query directories that returned a cursor on the previous page.
78+
// Filtering against the workspace's directories above also guards against cursor-bag
79+
// tampering — any directory id not in this set is silently dropped.
80+
const targetDirectories = parsedInput.cursors
81+
? directories.filter((d) => Boolean(parsedInput.cursors?.[d.id]))
82+
: directories;
83+
7784
const searches: {
7885
directory: (typeof directories)[number];
7986
result: Awaited<ReturnType<typeof semanticSearchFeedbackRecords>>;
8087
}[] = [];
81-
for (let i = 0; i < directories.length; i += SEARCH_CONCURRENCY) {
82-
const chunk = directories.slice(i, i + SEARCH_CONCURRENCY);
88+
for (let i = 0; i < targetDirectories.length; i += SEARCH_CONCURRENCY) {
89+
const chunk = targetDirectories.slice(i, i + SEARCH_CONCURRENCY);
8390
const chunkResults = await Promise.all(
8491
chunk.map(async (directory) => {
92+
const cursor = parsedInput.cursors?.[directory.id];
8593
const result = await semanticSearchFeedbackRecords({
8694
tenant_id: directory.id,
8795
query: parsedInput.query,
88-
limit,
96+
limit: TOPICS_PREVIEW_PAGE_SIZE,
8997
min_score: parsedInput.minScore,
98+
...(cursor ? { cursor } : {}),
9099
});
91100
return { directory, result };
92101
})
@@ -102,17 +111,36 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient
102111
}))
103112
);
104113

114+
const nextCursors: Record<string, string> = {};
115+
for (const { directory, result } of searches) {
116+
const nextCursor = result.data?.next_cursor;
117+
if (nextCursor) {
118+
nextCursors[directory.id] = nextCursor;
119+
}
120+
}
121+
122+
// A directory returning 0/503 is a transient outage we want to surface even when
123+
// other directories returned data — otherwise the failing directory silently drops
124+
// out of nextCursors and stays excluded from every subsequent "load more".
125+
const transientOutage = searches.find(({ result }) => {
126+
const status = result.error?.status;
127+
return status === 0 || status === 503;
128+
})?.result.error;
129+
105130
if (successfulResults.length > 0) {
106131
return {
107-
results: successfulResults.toSorted((a, b) => b.score - a.score).slice(0, limit),
108-
unavailable: false,
132+
results: successfulResults.toSorted((a, b) => b.score - a.score),
133+
cursors: nextCursors,
134+
unavailable: Boolean(transientOutage),
135+
...(transientOutage ? { unavailableMessage: transientOutage.message } : {}),
109136
};
110137
}
111138

112139
const firstError = searches.find(({ result }) => result.error)?.result.error;
113140
if (firstError?.status === 0 || firstError?.status === 503) {
114141
return {
115142
results: [],
143+
cursors: {},
116144
unavailable: true,
117145
unavailableMessage: firstError.message,
118146
};
@@ -122,6 +150,6 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient
122150
throw new Error(firstError.message);
123151
}
124152

125-
return { results: [], unavailable: false };
153+
return { results: [], cursors: {}, unavailable: false };
126154
}
127155
);

apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-preview.tsx

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,20 @@ export const TopicsSubtopicsPreview = ({
2525
}: Readonly<TopicsSubtopicsPreviewProps>) => {
2626
const { t } = useTranslation();
2727
const [query, setQuery] = useState("");
28+
// The query bound to the current results + cursors. Kept separate from `query` (the
29+
// live input) so that editing the input mid-pagination does not corrupt "load more"
30+
// by submitting a different query against the existing cursors.
31+
const [activeQuery, setActiveQuery] = useState("");
2832
const [results, setResults] = useState<TTopicsPreviewSearchResult[]>([]);
33+
const [cursors, setCursors] = useState<Record<string, string>>({});
2934
const [hasSearched, setHasSearched] = useState(false);
3035
const [isSearching, setIsSearching] = useState(false);
36+
const [isLoadingMore, setIsLoadingMore] = useState(false);
3137
const [error, setError] = useState<string | null>(null);
3238
const [unavailableMessage, setUnavailableMessage] = useState<string | null>(null);
3339

40+
const hasMore = Object.keys(cursors).length > 0;
41+
3442
const hasDirectories = Object.keys(directoryMap).length > 0;
3543

3644
const exampleSearches = [
@@ -41,24 +49,26 @@ export const TopicsSubtopicsPreview = ({
4149

4250
const runSearch = async (searchQuery: string) => {
4351
const trimmedQuery = searchQuery.trim();
44-
if (!trimmedQuery || isSearching) return;
52+
if (!trimmedQuery || isSearching || isLoadingMore) return;
4553

4654
setQuery(trimmedQuery);
55+
setActiveQuery(trimmedQuery);
4756
setIsSearching(true);
4857
setHasSearched(true);
4958
setError(null);
5059
setUnavailableMessage(null);
60+
setCursors({});
5161

5262
try {
5363
const response = await semanticSearchFeedbackRecordsAction({
5464
workspaceId,
5565
query: trimmedQuery,
56-
limit: 10,
5766
minScore: 0.7,
5867
});
5968

6069
if (response?.data) {
6170
setResults(response.data.results);
71+
setCursors(response.data.cursors);
6272
setUnavailableMessage(response.data.unavailable ? (response.data.unavailableMessage ?? "") : null);
6373
} else {
6474
setResults([]);
@@ -77,6 +87,35 @@ export const TopicsSubtopicsPreview = ({
7787
await runSearch(query);
7888
};
7989

90+
const handleLoadMore = async () => {
91+
if (isLoadingMore || isSearching || !hasMore || !activeQuery) return;
92+
setIsLoadingMore(true);
93+
94+
try {
95+
const response = await semanticSearchFeedbackRecordsAction({
96+
workspaceId,
97+
query: activeQuery,
98+
minScore: 0.7,
99+
cursors,
100+
});
101+
102+
if (response?.data) {
103+
const data = response.data;
104+
setResults((prev) => [...prev, ...data.results]);
105+
setCursors(data.cursors);
106+
if (data.unavailable) {
107+
setUnavailableMessage(data.unavailableMessage ?? "");
108+
}
109+
} else {
110+
setError(getFormattedErrorMessage(response) ?? t("workspace.unify.semantic_search_failed"));
111+
}
112+
} catch {
113+
setError(t("workspace.unify.semantic_search_failed"));
114+
} finally {
115+
setIsLoadingMore(false);
116+
}
117+
};
118+
80119
return (
81120
<PageContentWrapper>
82121
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
@@ -105,10 +144,13 @@ export const TopicsSubtopicsPreview = ({
105144
value={query}
106145
onChange={(event) => setQuery(event.target.value)}
107146
placeholder={t("workspace.unify.semantic_search_placeholder")}
108-
disabled={!hasDirectories || isSearching}
147+
disabled={!hasDirectories || isSearching || isLoadingMore}
109148
aria-label={t("workspace.unify.semantic_search_input_label")}
110149
/>
111-
<Button type="submit" disabled={!query.trim() || !hasDirectories} loading={isSearching}>
150+
<Button
151+
type="submit"
152+
disabled={!query.trim() || !hasDirectories || isLoadingMore}
153+
loading={isSearching}>
112154
<SearchIcon className="size-4" aria-hidden="true" />
113155
{t("workspace.unify.search_feedback")}
114156
</Button>
@@ -122,7 +164,7 @@ export const TopicsSubtopicsPreview = ({
122164
type="button"
123165
size="sm"
124166
variant="secondary"
125-
disabled={!hasDirectories || isSearching}
167+
disabled={!hasDirectories || isSearching || isLoadingMore}
126168
onClick={() => runSearch(label)}>
127169
{label}
128170
</Button>
@@ -158,32 +200,47 @@ export const TopicsSubtopicsPreview = ({
158200
)}
159201

160202
{results.length > 0 && (
161-
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
162-
<div className="border-b border-slate-200 px-4 py-3">
163-
<p className="text-sm font-medium text-slate-900">
164-
{t("workspace.unify.semantic_search_results_count", { count: results.length })}
165-
</p>
166-
</div>
167-
<div className="divide-y divide-slate-100">
168-
{results.map((result) => (
169-
<div key={`${result.tenant_id}-${result.feedback_record_id}`} className="space-y-2 p-4">
170-
<div className="flex flex-wrap items-center gap-2">
171-
<Badge text={result.directory_name} type="gray" size="tiny" />
172-
<span className="text-xs text-slate-500">
173-
{t("workspace.unify.semantic_search_relevance", {
174-
score: Math.round(result.score * 100),
175-
})}
176-
</span>
203+
<div className="space-y-3">
204+
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
205+
<div className="border-b border-slate-200 px-4 py-3">
206+
<p className="text-sm font-medium text-slate-900">
207+
{t("workspace.unify.semantic_search_results_count", { count: results.length })}
208+
</p>
209+
</div>
210+
<div className="divide-y divide-slate-100">
211+
{results.map((result) => (
212+
<div key={`${result.tenant_id}-${result.feedback_record_id}`} className="space-y-2 p-4">
213+
<div className="flex flex-wrap items-center gap-2">
214+
<Badge text={result.directory_name} type="gray" size="tiny" />
215+
<span className="text-xs text-slate-500">
216+
{t("workspace.unify.semantic_search_relevance", {
217+
score: Math.round(result.score * 100),
218+
})}
219+
</span>
220+
</div>
221+
<p className="text-sm font-medium text-slate-900">
222+
{result.field_label || t("workspace.unify.field_label")}
223+
</p>
224+
<p className="whitespace-pre-wrap text-sm text-slate-700">
225+
{result.value_text || t("workspace.unify.semantic_search_missing_text")}
226+
</p>
177227
</div>
178-
<p className="text-sm font-medium text-slate-900">
179-
{result.field_label || t("workspace.unify.field_label")}
180-
</p>
181-
<p className="whitespace-pre-wrap text-sm text-slate-700">
182-
{result.value_text || t("workspace.unify.semantic_search_missing_text")}
183-
</p>
184-
</div>
185-
))}
228+
))}
229+
</div>
186230
</div>
231+
232+
{hasMore && (
233+
<div className="flex justify-center">
234+
<Button
235+
variant="secondary"
236+
size="sm"
237+
onClick={handleLoadMore}
238+
disabled={isLoadingMore || isSearching}
239+
loading={isLoadingMore}>
240+
{t("common.load_more")}
241+
</Button>
242+
</div>
243+
)}
187244
</div>
188245
)}
189246
</div>

0 commit comments

Comments
 (0)