diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index ed7694537..a5d6bbdd6 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -43,6 +43,7 @@ import { splitWithHighlights, stripTypePrefix, } from "./utils"; +import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; @@ -157,6 +158,8 @@ const AdvancedNodeSearchDialog = ({ const [activeIndex, setActiveIndex] = useState(0); const [results, setResults] = useState([]); const [sort, setSort] = useState(DEFAULT_SORT_CONFIG); + const [discourseNodes, setDiscourseNodes] = useState([]); + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); const miniSearchRef = useRef | null>(null); @@ -165,14 +168,9 @@ const AdvancedNodeSearchDialog = ({ const inputRef = useRef(null); const [insertTarget, setInsertTarget] = useState(null); - const nodeConfigByType = useMemo(() => { - const discourseNodes = getDiscourseNodes().filter( - (node) => node.backedBy === "user", - ); - return Object.fromEntries( - discourseNodes.map((node) => [node.type, node]), - ) as Record; - }, []); + const nodeConfigByType = Object.fromEntries( + discourseNodes.map((node) => [node.type, node]), + ); const activeResult = results[activeIndex] ?? null; const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean); @@ -200,6 +198,7 @@ const AdvancedNodeSearchDialog = ({ setDebouncedSearchTerm(""); setActiveIndex(0); setSort(DEFAULT_SORT_CONFIG); + setSelectedNodeTypeIds([]); setResults([]); setIndexError(false); } @@ -221,10 +220,18 @@ const AdvancedNodeSearchDialog = ({ miniSearch: miniSearchRef.current, allResults: allResultsRef.current, searchTerm: debouncedSearchTerm, + typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, }); setResults(sortSearchResults({ hits: scoredHits, sort })); - }, [debouncedSearchTerm, indexError, isIndexLoading, isOpen, sort]); + }, [ + debouncedSearchTerm, + indexError, + isIndexLoading, + isOpen, + selectedNodeTypeIds, + sort, + ]); useEffect(() => { let cancelled = false; @@ -234,6 +241,7 @@ const AdvancedNodeSearchDialog = ({ const discourseNodes = getDiscourseNodes().filter( (node) => node.backedBy === "user", ); + setDiscourseNodes(discourseNodes); void buildSearchIndex(discourseNodes) .then(({ miniSearch, results: indexedResults }) => { @@ -270,7 +278,7 @@ const AdvancedNodeSearchDialog = ({ useEffect(() => { setActiveIndex(0); - }, [debouncedSearchTerm, sort]); + }, [debouncedSearchTerm, selectedNodeTypeIds, sort]); useEffect(() => { const panel = resultsPanelRef.current; @@ -419,7 +427,11 @@ const AdvancedNodeSearchDialog = ({ placeholder="Search discourse nodes..." value={searchTerm} /> - + void; + onPopoverOpenChange?: (isOpen: boolean) => void; +}; + +const getNodeIndicatorColor = (node: DiscourseNode): string => + formatHexColor(node.canvasSettings?.color) || "#000"; + +const NodeTypeFilterRow = ({ + isChecked, + node, + onSelectOnly, + onToggle, +}: { + isChecked: boolean; + node: DiscourseNode; + onSelectOnly: () => void; + onToggle: () => void; +}): React.ReactElement => ( +
+ + + {node.text} + + } + onChange={onToggle} + /> +
+); + +const FilterPopoverPanel = ({ + isOpen, + nodeTypes, + onSelectedIdsChange, + selectedIds, +}: { + isOpen: boolean; + nodeTypes: DiscourseNode[]; + onSelectedIdsChange: (ids: string[]) => void; + selectedIds: string[]; +}): React.ReactElement => { + const [query, setQuery] = useState(""); + const searchRef = useRef(null); + + const showTypeSearch = nodeTypes.length > NODE_TYPE_FILTER_SEARCH_THRESHOLD; + + const filteredNodes = useMemo( + () => filterDiscourseNodesByQuery(nodeTypes, query), + [nodeTypes, query], + ); + + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + const selectAllState = getSelectAllCheckState({ + selectedIds, + totalCount: nodeTypes.length, + }); + + const handleSelectAll = useCallback((): void => { + if (selectAllState === "off") { + onSelectedIdsChange(nodeTypes.map((node) => node.type)); + return; + } + onSelectedIdsChange([]); + }, [nodeTypes, onSelectedIdsChange, selectAllState]); + + const toggleType = useCallback( + (id: string): void => { + const nextSelectedIds = selectedIdSet.has(id) + ? selectedIds.filter((selectedId) => selectedId !== id) + : [...selectedIds, id]; + onSelectedIdsChange(nextSelectedIds); + }, + [onSelectedIdsChange, selectedIdSet, selectedIds], + ); + + const handleOnly = useCallback( + (id: string): void => { + onSelectedIdsChange([id]); + }, + [onSelectedIdsChange], + ); + + useEffect(() => { + if (!isOpen) { + setQuery(""); + return; + } + if (!showTypeSearch) return; + searchRef.current?.focus(); + }, [isOpen, showTypeSearch]); + + const hasTypeSearchQuery = query.trim().length > 0; + + return ( +
+ {showTypeSearch && ( +
+ ) => + setQuery(event.target.value) + } + placeholder="Filter types…" + small + value={query} + /> +
+ )} +
+ {filteredNodes.length === 0 ? ( +
+ No matching node types +
+ ) : ( + <> + {!hasTypeSearchQuery && ( +
+ Select all + } + onChange={handleSelectAll} + /> +
+ )} +
+ {filteredNodes.map((node) => ( + handleOnly(node.type)} + onToggle={() => toggleType(node.type)} + /> + ))} +
+ + )} +
+
+ ); +}; + +export const DiscourseNodeTypeFilter = ({ + nodeTypes, + onPopoverOpenChange, + onSelectedTypeIdsChange, + selectedTypeIds, +}: DiscourseNodeTypeFilterProps): React.ReactElement => { + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + const allTypeIds = useMemo( + () => nodeTypes.map((node) => node.type), + [nodeTypes], + ); + const isFilterActive = hasActiveTypeFilter({ + selectedTypeIds, + allTypeIds, + }); + const isFilterReady = nodeTypes.length > 0; + + const popoverSelectedIds = useMemo( + () => + toPopoverSelectedIds({ + selectedTypeIds, + allTypeIds, + }), + [allTypeIds, selectedTypeIds], + ); + + const activeFilterCount = isFilterActive ? selectedTypeIds.length : 0; + + const setPopoverOpen = useCallback( + (nextOpen: boolean): void => { + setIsOpen(nextOpen); + onPopoverOpenChange?.(nextOpen); + }, + [onPopoverOpenChange], + ); + + const handlePopoverInteraction = useCallback( + (nextOpen: boolean, event?: React.SyntheticEvent): void => { + if (!isFilterReady) return; + if (nextOpen) { + event?.stopPropagation(); + } + setPopoverOpen(nextOpen); + }, + [isFilterReady, setPopoverOpen], + ); + + const handlePopoverSelectedIdsChange = useCallback( + (nextPopoverSelectedIds: string[]): void => { + onSelectedTypeIdsChange( + fromPopoverSelectedIds({ + popoverSelectedIds: nextPopoverSelectedIds, + allTypeIds, + }), + ); + }, + [allTypeIds, onSelectedTypeIdsChange], + ); + + const isTriggerActive = isOpen || isFilterActive; + + const filterButton = ( + + ); + + return ( + + } + enforceFocus={false} + isOpen={isOpen} + minimal + modifiers={{ + flip: { enabled: true }, + preventOverflow: { + enabled: true, + boundariesElement: "viewport", + }, + }} + onClose={() => setPopoverOpen(false)} + onInteraction={handlePopoverInteraction} + popoverClassName="p-0 overflow-hidden" + popoverRef={popoverRef} + position={Position.BOTTOM_RIGHT} + target={filterButton} + usePortal + /> + ); +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts index 2a91d8f1d..c75c887c2 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts @@ -251,19 +251,25 @@ export const searchIndexedNodes = ({ miniSearch, allResults, searchTerm, + typeFilter, }: { miniSearch: MiniSearch; allResults: SearchResult[]; searchTerm: string; + typeFilter?: string[]; }): ScoredSearchHit[] => { const resultsByUid = new Map( allResults.map((result) => [result.uid, result]), ); + const allowedTypes = typeFilter?.length ? new Set(typeFilter) : null; return miniSearch .search(searchTerm, { fields: ["title", "nodeTypeLabel"], ...DISCOURSE_NODE_MINI_SEARCH_OPTIONS, + filter: allowedTypes + ? (result) => allowedTypes.has(String(result.type)) + : undefined, }) .filter((result) => result.score > DISCOURSE_NODE_MIN_SEARCH_SCORE) .slice(0, MAX_RESULTS) diff --git a/apps/roam/src/utils/discourseNodeTypeFilter.ts b/apps/roam/src/utils/discourseNodeTypeFilter.ts new file mode 100644 index 000000000..30508db1f --- /dev/null +++ b/apps/roam/src/utils/discourseNodeTypeFilter.ts @@ -0,0 +1,61 @@ +import { type DiscourseNode } from "~/utils/getDiscourseNodes"; + +/** Advanced search: empty `selectedTypeIds` means all types; partial list is an active filter. */ +export const NODE_TYPE_FILTER_SEARCH_THRESHOLD = 7; + +export type SelectAllCheckState = "off" | "indeterminate" | "on"; + +export const hasActiveTypeFilter = ({ + selectedTypeIds, + allTypeIds, +}: { + selectedTypeIds: string[]; + allTypeIds: string[]; +}): boolean => + selectedTypeIds.length > 0 && selectedTypeIds.length < allTypeIds.length; + +export const toPopoverSelectedIds = ({ + selectedTypeIds, + allTypeIds, +}: { + selectedTypeIds: string[]; + allTypeIds: string[]; +}): string[] => (selectedTypeIds.length === 0 ? allTypeIds : selectedTypeIds); + +export const fromPopoverSelectedIds = ({ + popoverSelectedIds, + allTypeIds, +}: { + popoverSelectedIds: string[]; + allTypeIds: string[]; +}): string[] => { + if ( + popoverSelectedIds.length === 0 || + popoverSelectedIds.length === allTypeIds.length + ) { + return []; + } + return popoverSelectedIds; +}; + +export const filterDiscourseNodesByQuery = ( + nodes: DiscourseNode[], + query: string, +): DiscourseNode[] => { + const trimmedQuery = query.trim().toLowerCase(); + if (!trimmedQuery) return nodes; + + return nodes.filter((node) => node.text.toLowerCase().includes(trimmedQuery)); +}; + +export const getSelectAllCheckState = ({ + selectedIds, + totalCount, +}: { + selectedIds: string[]; + totalCount: number; +}): SelectAllCheckState => { + if (selectedIds.length === 0) return "off"; + if (selectedIds.length === totalCount) return "on"; + return "indeterminate"; +};