Skip to content

Commit 0ebdfbf

Browse files
ENG-1732: Add sorting to advanced node search (#1053)
* Add client-side sorting to advanced node search dialog. Sort by relevance, title, date created, or author with asc/desc controls so users can reorder results after search without re-querying. Co-authored-by: Cursor <cursoragent@cursor.com> * fix styling * lint * clean * use Blueprint * remove useMemo * Address review feedback for sort control and dialog reset. Reset all search state when the dialog closes, and use a single direction toggle beside the sort field menu. Co-authored-by: Cursor <cursoragent@cursor.com> * Put sort direction controls in the sort menu footer. Match Roam's pattern: field list above a divider, Ascending/Descending button group below. Co-authored-by: Cursor <cursoragent@cursor.com> * add one button to control direction --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e59fa9e commit 0ebdfbf

3 files changed

Lines changed: 321 additions & 18 deletions

File tree

apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,20 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU
2020
import renderOverlay, {
2121
RoamOverlayProps,
2222
} from "roamjs-components/util/renderOverlay";
23+
import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl";
2324
import getDiscourseNodes, {
2425
type DiscourseNode,
2526
} from "~/utils/getDiscourseNodes";
2627
import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors";
2728
import {
2829
DEBOUNCE_MS,
30+
DEFAULT_SORT_CONFIG,
2931
type SearchResult,
32+
type SortConfig,
3033
buildSearchIndex,
3134
formatMetadataDate,
3235
searchIndexedNodes,
36+
sortSearchResults,
3337
splitWithHighlights,
3438
stripTypePrefix,
3539
} from "./utils";
@@ -144,6 +148,8 @@ const AdvancedNodeSearchDialog = ({
144148
const [isIndexLoading, setIsIndexLoading] = useState(false);
145149
const [indexError, setIndexError] = useState(false);
146150
const [activeIndex, setActiveIndex] = useState(0);
151+
const [results, setResults] = useState<SearchResult[]>([]);
152+
const [sort, setSort] = useState<SortConfig>(DEFAULT_SORT_CONFIG);
147153
const miniSearchRef = useRef<MiniSearch<
148154
SearchResult & { id: string }
149155
> | null>(null);
@@ -160,19 +166,6 @@ const AdvancedNodeSearchDialog = ({
160166
) as Record<string, DiscourseNode>;
161167
}, []);
162168

163-
const results =
164-
isOpen &&
165-
!isIndexLoading &&
166-
!indexError &&
167-
debouncedSearchTerm &&
168-
miniSearchRef.current
169-
? searchIndexedNodes({
170-
miniSearch: miniSearchRef.current,
171-
allResults: allResultsRef.current,
172-
searchTerm: debouncedSearchTerm,
173-
})
174-
: [];
175-
176169
const activeResult = results[activeIndex] ?? null;
177170
const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean);
178171

@@ -191,6 +184,38 @@ const AdvancedNodeSearchDialog = ({
191184
};
192185
}, [isOpen]);
193186

187+
useEffect(() => {
188+
if (!isOpen) {
189+
setSearchTerm("");
190+
setDebouncedSearchTerm("");
191+
setActiveIndex(0);
192+
setSort(DEFAULT_SORT_CONFIG);
193+
setResults([]);
194+
setIndexError(false);
195+
}
196+
}, [isOpen]);
197+
198+
useEffect(() => {
199+
if (
200+
!isOpen ||
201+
isIndexLoading ||
202+
indexError ||
203+
!debouncedSearchTerm ||
204+
!miniSearchRef.current
205+
) {
206+
setResults([]);
207+
return;
208+
}
209+
210+
const scoredHits = searchIndexedNodes({
211+
miniSearch: miniSearchRef.current,
212+
allResults: allResultsRef.current,
213+
searchTerm: debouncedSearchTerm,
214+
});
215+
216+
setResults(sortSearchResults({ hits: scoredHits, sort }));
217+
}, [debouncedSearchTerm, indexError, isIndexLoading, isOpen, sort]);
218+
194219
useEffect(() => {
195220
let cancelled = false;
196221
setIsIndexLoading(true);
@@ -235,7 +260,7 @@ const AdvancedNodeSearchDialog = ({
235260

236261
useEffect(() => {
237262
setActiveIndex(0);
238-
}, [debouncedSearchTerm]);
263+
}, [debouncedSearchTerm, sort]);
239264

240265
useEffect(() => {
241266
const panel = resultsPanelRef.current;
@@ -245,6 +270,10 @@ const AdvancedNodeSearchDialog = ({
245270
activeRow?.scrollIntoView({ block: "nearest" });
246271
}, [activeIndex, activeResult?.uid, debouncedSearchTerm]);
247272

273+
const handleSortChange = useCallback((nextSort: SortConfig): void => {
274+
setSort(nextSort);
275+
}, []);
276+
248277
const onKeyDown = useCallback(
249278
(event: React.KeyboardEvent<HTMLDivElement>) => {
250279
if (event.key === "ArrowDown" && results.length) {
@@ -305,7 +334,19 @@ const AdvancedNodeSearchDialog = ({
305334
placeholder="Search discourse nodes..."
306335
value={searchTerm}
307336
/>
308-
<Button icon="cross" minimal onClick={onClose} title="Close search" />
337+
338+
<DiscourseNodeSortControl
339+
disabled={isIndexLoading || indexError}
340+
onSortChange={handleSortChange}
341+
sort={sort}
342+
/>
343+
<Button
344+
className="shrink-0"
345+
icon="cross"
346+
minimal
347+
onClick={onClose}
348+
title="Close search"
349+
/>
309350
</div>
310351
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
311352
{showSplitView ? (

apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ import {
1313
export const DEBOUNCE_MS = 250;
1414
export const MAX_RESULTS = 50;
1515

16+
export type SortField = "relevance" | "alphabetical" | "dateCreated" | "author";
17+
export type SortDirection = "asc" | "desc";
18+
19+
export type SortConfig = {
20+
field: SortField;
21+
direction: SortDirection;
22+
};
23+
24+
export const DEFAULT_SORT_CONFIG: SortConfig = {
25+
field: "relevance",
26+
direction: "desc",
27+
};
28+
29+
export const SORT_FIELD_LABELS: Record<SortField, string> = {
30+
relevance: "Relevance",
31+
alphabetical: "Alphabetical",
32+
dateCreated: "Date created",
33+
author: "Author",
34+
};
35+
1636
export type SearchResult = {
1737
uid: string;
1838
title: string;
@@ -24,6 +44,11 @@ export type SearchResult = {
2444
authorName: string;
2545
};
2646

47+
export type ScoredSearchHit = {
48+
result: SearchResult;
49+
score: number;
50+
};
51+
2752
type MiniSearchDocument = SearchResult & {
2853
id: string;
2954
};
@@ -148,6 +173,80 @@ export const buildSearchIndex = async (
148173
return { miniSearch, results };
149174
};
150175

176+
const compareNumbers = (
177+
a: number,
178+
b: number,
179+
direction: SortDirection,
180+
): number => (direction === "desc" ? b - a : a - b);
181+
182+
const compareStrings = (
183+
a: string,
184+
b: string,
185+
direction: SortDirection,
186+
): number => {
187+
const comparison = a.localeCompare(b, undefined, { sensitivity: "base" });
188+
return direction === "desc" ? -comparison : comparison;
189+
};
190+
191+
const getSortableTitle = (result: SearchResult): string =>
192+
stripTypePrefix(result.title);
193+
194+
const getCreatedTime = (result: SearchResult): number =>
195+
Number(result.createdAt) || 0;
196+
197+
export const isNonDefaultSort = (sort: SortConfig): boolean =>
198+
sort.field !== DEFAULT_SORT_CONFIG.field ||
199+
sort.direction !== DEFAULT_SORT_CONFIG.direction;
200+
201+
export const sortSearchResults = ({
202+
hits,
203+
sort,
204+
}: {
205+
hits: ScoredSearchHit[];
206+
sort: SortConfig;
207+
}): SearchResult[] => {
208+
const sorted = [...hits];
209+
210+
sorted.sort((aHit, bHit) => {
211+
const a = aHit.result;
212+
const b = bHit.result;
213+
let comparison = 0;
214+
215+
switch (sort.field) {
216+
case "relevance":
217+
comparison = compareNumbers(aHit.score, bHit.score, sort.direction);
218+
break;
219+
case "alphabetical":
220+
comparison = compareStrings(
221+
getSortableTitle(a),
222+
getSortableTitle(b),
223+
sort.direction,
224+
);
225+
break;
226+
case "dateCreated":
227+
comparison = compareNumbers(
228+
getCreatedTime(a),
229+
getCreatedTime(b),
230+
sort.direction,
231+
);
232+
break;
233+
case "author": {
234+
const aAuthor = a.authorName.trim();
235+
const bAuthor = b.authorName.trim();
236+
if (!aAuthor && !bAuthor) comparison = 0;
237+
else if (!aAuthor) comparison = 1;
238+
else if (!bAuthor) comparison = -1;
239+
else comparison = compareStrings(aAuthor, bAuthor, sort.direction);
240+
break;
241+
}
242+
}
243+
244+
return comparison || a.uid.localeCompare(b.uid);
245+
});
246+
247+
return sorted.map((hit) => hit.result);
248+
};
249+
151250
export const searchIndexedNodes = ({
152251
miniSearch,
153252
allResults,
@@ -156,7 +255,7 @@ export const searchIndexedNodes = ({
156255
miniSearch: MiniSearch<MiniSearchDocument>;
157256
allResults: SearchResult[];
158257
searchTerm: string;
159-
}): SearchResult[] => {
258+
}): ScoredSearchHit[] => {
160259
const resultsByUid = new Map(
161260
allResults.map((result) => [result.uid, result]),
162261
);
@@ -168,6 +267,10 @@ export const searchIndexedNodes = ({
168267
})
169268
.filter((result) => result.score > DISCOURSE_NODE_MIN_SEARCH_SCORE)
170269
.slice(0, MAX_RESULTS)
171-
.map((result) => resultsByUid.get(String(result.id)))
172-
.filter((result): result is SearchResult => !!result);
270+
.map((result) => {
271+
const searchResult = resultsByUid.get(String(result.id));
272+
if (!searchResult) return null;
273+
return { result: searchResult, score: result.score };
274+
})
275+
.filter((hit): hit is ScoredSearchHit => !!hit);
173276
};

0 commit comments

Comments
 (0)