From a49e3a725000ac9bfc66fecbbb476e815c31f390 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 18 May 2026 19:21:19 -0400 Subject: [PATCH 1/8] basic filter --- .../AdvancedSearchDialog.tsx | 41 +- .../AdvancedNodeSearchDialog/utils.ts | 6 + .../components/DiscourseNodeTypeFilter.tsx | 394 ++++++++++++++++++ apps/roam/src/hooks/useCloseOnClickOutside.ts | 29 ++ .../roam/src/utils/discourseNodeTypeFilter.ts | 61 +++ 5 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 apps/roam/src/components/DiscourseNodeTypeFilter.tsx create mode 100644 apps/roam/src/hooks/useCloseOnClickOutside.ts create mode 100644 apps/roam/src/utils/discourseNodeTypeFilter.ts diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index ed7694537..362d066f0 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -27,6 +27,7 @@ import { type InsertTarget, } from "~/utils/advancedSearchFooterUtils"; import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl"; +import { DiscourseNodeTypeFilter } from "~/components/DiscourseNodeTypeFilter"; import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; @@ -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,10 @@ 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 = useMemo( + () => Object.fromEntries(discourseNodes.map((node) => [node.type, node])), + [discourseNodes], + ); const activeResult = results[activeIndex] ?? null; const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean); @@ -200,6 +199,7 @@ const AdvancedNodeSearchDialog = ({ setDebouncedSearchTerm(""); setActiveIndex(0); setSort(DEFAULT_SORT_CONFIG); + setSelectedNodeTypeIds([]); setResults([]); setIndexError(false); } @@ -221,21 +221,32 @@ 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; setIsIndexLoading(true); setIndexError(false); - const discourseNodes = getDiscourseNodes().filter( + const userDiscourseNodes = getDiscourseNodes().filter( (node) => node.backedBy === "user", ); + setDiscourseNodes(userDiscourseNodes); - void buildSearchIndex(discourseNodes) + void buildSearchIndex(userDiscourseNodes) .then(({ miniSearch, results: indexedResults }) => { if (cancelled) return; miniSearchRef.current = miniSearch; @@ -270,7 +281,7 @@ const AdvancedNodeSearchDialog = ({ useEffect(() => { setActiveIndex(0); - }, [debouncedSearchTerm, sort]); + }, [debouncedSearchTerm, selectedNodeTypeIds, sort]); useEffect(() => { const panel = resultsPanelRef.current; @@ -419,7 +430,11 @@ const AdvancedNodeSearchDialog = ({ placeholder="Search discourse nodes..." value={searchTerm} /> - + ; 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/components/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/DiscourseNodeTypeFilter.tsx new file mode 100644 index 000000000..07e794a09 --- /dev/null +++ b/apps/roam/src/components/DiscourseNodeTypeFilter.tsx @@ -0,0 +1,394 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { Button, Icon, Popover, Position } from "@blueprintjs/core"; +import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; +import { type DiscourseNode } from "~/utils/getDiscourseNodes"; +import { useCloseOnClickOutside } from "~/hooks/useCloseOnClickOutside"; +import { + NODE_TYPE_FILTER_SEARCH_THRESHOLD, + filterDiscourseNodesByQuery, + fromPopoverSelectedIds, + getSelectAllCheckState, + hasActiveTypeFilter, + toPopoverSelectedIds, + type SelectAllCheckState, +} from "~/utils/discourseNodeTypeFilter"; + +export type DiscourseNodeTypeFilterProps = { + nodeTypes: DiscourseNode[]; + selectedTypeIds: string[]; + onSelectedTypeIdsChange: (ids: string[]) => void; +}; + +const getNodeIndicatorColor = (node: DiscourseNode): string => + formatHexColor(node.canvasSettings?.color) || "#000"; + +const FilterCheckbox = ({ + state, +}: { + state: SelectAllCheckState; +}): React.ReactElement => ( + + {state === "on" && } + {state === "indeterminate" && } + +); + +const NodeTypeFilterRow = ({ + isChecked, + node, + onSelectOnly, + onToggle, +}: { + isChecked: boolean; + node: DiscourseNode; + onSelectOnly: () => void; + onToggle: () => void; +}): React.ReactElement => { + const [isRowHovered, setIsRowHovered] = useState(false); + const [isOnlyHovered, setIsOnlyHovered] = useState(false); + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggle(); + } + }} + onMouseEnter={() => setIsRowHovered(true)} + onMouseLeave={() => { + setIsRowHovered(false); + setIsOnlyHovered(false); + }} + role="button" + tabIndex={0} + > + + + {node.text} + +
+ ); +}; + +const FilterPopoverPanel = ({ + isOpen, + nodeTypes, + onSelectedIdsChange, + selectedIds, +}: { + isOpen: boolean; + nodeTypes: DiscourseNode[]; + onSelectedIdsChange: (ids: string[]) => void; + selectedIds: string[]; +}): React.ReactElement => { + const [query, setQuery] = useState(""); + const [isSelectAllHovered, setIsSelectAllHovered] = useState(false); + 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…" + /> +
+ )} +
+ {filteredNodes.length === 0 ? ( +
+ No matching node types +
+ ) : ( + <> + {!hasTypeSearchQuery && ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelectAll(); + } + }} + onMouseEnter={() => setIsSelectAllHovered(true)} + onMouseLeave={() => setIsSelectAllHovered(false)} + role="button" + tabIndex={0} + > + + + Select all + +
+ )} +
+ {filteredNodes.map((node) => ( + handleOnly(node.type)} + onToggle={() => toggleType(node.type)} + /> + ))} +
+ + )} +
+
+ ); +}; + +const DiscourseNodeTypeFilterInner = ({ + nodeTypes, + 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 popoverSelectedIds = useMemo( + () => + toPopoverSelectedIds({ + selectedTypeIds, + allTypeIds, + }), + [allTypeIds, selectedTypeIds], + ); + + const activeFilterCount = isFilterActive ? selectedTypeIds.length : 0; + + const closePopover = useCallback((): void => { + setIsOpen(false); + }, []); + + useCloseOnClickOutside({ + isOpen, + onClose: closePopover, + popoverRef, + targetRef: triggerRef, + }); + + const handlePopoverInteraction = useCallback( + (nextOpen: boolean, event?: React.SyntheticEvent): void => { + if (nextOpen) { + event?.stopPropagation(); + } + setIsOpen(nextOpen); + }, + [], + ); + + const handlePopoverSelectedIdsChange = useCallback( + (nextPopoverSelectedIds: string[]): void => { + onSelectedTypeIdsChange( + fromPopoverSelectedIds({ + popoverSelectedIds: nextPopoverSelectedIds, + allTypeIds, + }), + ); + }, + [allTypeIds, onSelectedTypeIdsChange], + ); + + const handlePopoverRef = useCallback((element: HTMLElement | null): void => { + popoverRef.current = element; + }, []); + + const isTriggerActive = isOpen || isFilterActive; + + const triggerStyle: CSSProperties = { + position: "relative", + minWidth: 30, + minHeight: 30, + padding: 0, + color: isTriggerActive ? "#5f57c0" : "rgba(31, 31, 31, 0.6)", + background: isTriggerActive ? "rgba(95, 87, 192, 0.1)" : "transparent", + }; + + const countPillStyle: CSSProperties = { + position: "absolute", + top: 2, + right: 2, + minWidth: 14, + height: 14, + padding: "0 3px", + borderRadius: 7, + background: "#5f57c0", + color: "#fff", + fontSize: 9, + fontWeight: 600, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + lineHeight: 1, + }; + + const filterButton = ( + + ); + + if (nodeTypes.length === 0) { + return ( + + {filterButton} + + ); + } + + return ( + + + } + enforceFocus={false} + isOpen={isOpen} + minimal + modifiers={{ + flip: { enabled: true }, + preventOverflow: { + enabled: true, + boundariesElement: "viewport", + }, + }} + onClose={closePopover} + onInteraction={handlePopoverInteraction} + popoverClassName="p-0 overflow-hidden" + popoverRef={handlePopoverRef} + position={Position.BOTTOM_RIGHT} + target={filterButton} + usePortal + /> + + ); +}; + +export const DiscourseNodeTypeFilter = React.memo(DiscourseNodeTypeFilterInner); diff --git a/apps/roam/src/hooks/useCloseOnClickOutside.ts b/apps/roam/src/hooks/useCloseOnClickOutside.ts new file mode 100644 index 000000000..b4d754e57 --- /dev/null +++ b/apps/roam/src/hooks/useCloseOnClickOutside.ts @@ -0,0 +1,29 @@ +import { useEffect, type RefObject } from "react"; + +export const useCloseOnClickOutside = ({ + isOpen, + onClose, + popoverRef, + targetRef, +}: { + isOpen: boolean; + onClose: () => void; + popoverRef: RefObject; + targetRef: RefObject; +}): void => { + useEffect(() => { + if (!isOpen) return; + + const handleMouseDown = (event: MouseEvent): void => { + const clickTarget = event.target; + if (!(clickTarget instanceof Element)) return; + if (popoverRef.current?.contains(clickTarget)) return; + if (targetRef.current?.contains(clickTarget)) return; + onClose(); + }; + + document.addEventListener("mousedown", handleMouseDown, true); + return () => + document.removeEventListener("mousedown", handleMouseDown, true); + }, [isOpen, onClose, popoverRef, targetRef]); +}; 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"; +}; From 5e02f5a37a407facc67b0b8e277afb7d236d9604 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 21 May 2026 00:39:00 -0400 Subject: [PATCH 2/8] Address PR review feedback for node type filter. Fix indeterminate checkbox sizing, use Blueprint controls, improve Escape handling when the filter popover is open, disable the trigger while types load, colocate click-outside handling, and add unit tests for filter state helpers. Co-authored-by: Cursor --- apps/roam/package.json | 3 +- .../AdvancedSearchDialog.tsx | 7 +- .../components/DiscourseNodeTypeFilter.tsx | 190 +++++++++--------- apps/roam/src/hooks/useCloseOnClickOutside.ts | 29 --- .../src/utils/discourseNodeTypeFilter.test.ts | 132 ++++++++++++ 5 files changed, 240 insertions(+), 121 deletions(-) delete mode 100644 apps/roam/src/hooks/useCloseOnClickOutside.ts create mode 100644 apps/roam/src/utils/discourseNodeTypeFilter.test.ts diff --git a/apps/roam/package.json b/apps/roam/package.json index e911904ad..deda45a1c 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts", - "check-types": "tsc --noEmit --skipLibCheck" + "check-types": "tsc --noEmit --skipLibCheck", + "test": "tsx --test src/utils/discourseNodeTypeFilter.test.ts" }, "license": "Apache-2.0", "devDependencies": { diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 362d066f0..6a46168d2 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -160,6 +160,7 @@ const AdvancedNodeSearchDialog = ({ const [sort, setSort] = useState(DEFAULT_SORT_CONFIG); const [discourseNodes, setDiscourseNodes] = useState([]); const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); + const [isTypeFilterPopoverOpen, setIsTypeFilterPopoverOpen] = useState(false); const miniSearchRef = useRef | null>(null); @@ -380,6 +381,7 @@ const AdvancedNodeSearchDialog = ({ event.preventDefault(); void onInsert(); } else if (event.key === "Escape") { + if (isTypeFilterPopoverOpen) return; event.preventDefault(); onClose(); } @@ -388,6 +390,7 @@ const AdvancedNodeSearchDialog = ({ activeResult, contentState, insertTarget, + isTypeFilterPopoverOpen, onClose, onInsert, onOpen, @@ -401,7 +404,7 @@ const AdvancedNodeSearchDialog = ({ return ( diff --git a/apps/roam/src/components/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/DiscourseNodeTypeFilter.tsx index 07e794a09..e56cf8d60 100644 --- a/apps/roam/src/components/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/DiscourseNodeTypeFilter.tsx @@ -5,11 +5,11 @@ import React, { useRef, useState, type CSSProperties, + type RefObject, } from "react"; -import { Button, Icon, Popover, Position } from "@blueprintjs/core"; +import { Button, Icon, InputGroup, Popover, Position } from "@blueprintjs/core"; import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { type DiscourseNode } from "~/utils/getDiscourseNodes"; -import { useCloseOnClickOutside } from "~/hooks/useCloseOnClickOutside"; import { NODE_TYPE_FILTER_SEARCH_THRESHOLD, filterDiscourseNodesByQuery, @@ -24,6 +24,35 @@ export type DiscourseNodeTypeFilterProps = { nodeTypes: DiscourseNode[]; selectedTypeIds: string[]; onSelectedTypeIdsChange: (ids: string[]) => void; + onPopoverOpenChange?: (isOpen: boolean) => void; +}; + +const useCloseOnClickOutside = ({ + isOpen, + onClose, + popoverRef, + targetRef, +}: { + isOpen: boolean; + onClose: () => void; + popoverRef: RefObject; + targetRef: RefObject; +}): void => { + useEffect(() => { + if (!isOpen) return; + + const handleMouseDown = (event: MouseEvent): void => { + const clickTarget = event.target; + if (!(clickTarget instanceof Element)) return; + if (popoverRef.current?.contains(clickTarget)) return; + if (targetRef.current?.contains(clickTarget)) return; + onClose(); + }; + + document.addEventListener("mousedown", handleMouseDown, true); + return () => + document.removeEventListener("mousedown", handleMouseDown, true); + }, [isOpen, onClose, popoverRef, targetRef]); }; const getNodeIndicatorColor = (node: DiscourseNode): string => @@ -38,7 +67,9 @@ const FilterCheckbox = ({ className={`inline-flex h-4 w-4 items-center justify-center rounded border border-solid border-gray-300 bg-white ${state === "off" ? "" : "border-blue-600 bg-blue-600"}`} > {state === "on" && } - {state === "indeterminate" && } + {state === "indeterminate" && ( + + )} ); @@ -52,58 +83,39 @@ const NodeTypeFilterRow = ({ node: DiscourseNode; onSelectOnly: () => void; onToggle: () => void; -}): React.ReactElement => { - const [isRowHovered, setIsRowHovered] = useState(false); - const [isOnlyHovered, setIsOnlyHovered] = useState(false); - - return ( -
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggle(); - } - }} - onMouseEnter={() => setIsRowHovered(true)} - onMouseLeave={() => { - setIsRowHovered(false); - setIsOnlyHovered(false); +}): React.ReactElement => ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggle(); + } + }} + role="button" + tabIndex={0} + > + + + {node.text} + -
- ); -}; + onMouseDown={(event) => event.preventDefault()} + small + text="Only" + /> +
+); const FilterPopoverPanel = ({ isOpen, @@ -117,7 +129,6 @@ const FilterPopoverPanel = ({ selectedIds: string[]; }): React.ReactElement => { const [query, setQuery] = useState(""); - const [isSelectAllHovered, setIsSelectAllHovered] = useState(false); const searchRef = useRef(null); const showTypeSearch = nodeTypes.length > NODE_TYPE_FILTER_SEARCH_THRESHOLD; @@ -174,12 +185,15 @@ const FilterPopoverPanel = ({
{showTypeSearch && (
- setQuery(event.target.value)} + ) => + setQuery(event.target.value) + } placeholder="Filter types…" + small + value={query} />
)} @@ -192,14 +206,8 @@ const FilterPopoverPanel = ({ <> {!hasTypeSearchQuery && (
{ if (event.key === "Enter" || event.key === " ") { @@ -207,8 +215,6 @@ const FilterPopoverPanel = ({ handleSelectAll(); } }} - onMouseEnter={() => setIsSelectAllHovered(true)} - onMouseLeave={() => setIsSelectAllHovered(false)} role="button" tabIndex={0} > @@ -236,8 +242,9 @@ const FilterPopoverPanel = ({ ); }; -const DiscourseNodeTypeFilterInner = ({ +export const DiscourseNodeTypeFilter = ({ nodeTypes, + onPopoverOpenChange, onSelectedTypeIdsChange, selectedTypeIds, }: DiscourseNodeTypeFilterProps): React.ReactElement => { @@ -253,6 +260,7 @@ const DiscourseNodeTypeFilterInner = ({ selectedTypeIds, allTypeIds, }); + const isFilterReady = nodeTypes.length > 0; const popoverSelectedIds = useMemo( () => @@ -265,9 +273,17 @@ const DiscourseNodeTypeFilterInner = ({ const activeFilterCount = isFilterActive ? selectedTypeIds.length : 0; + const setPopoverOpen = useCallback( + (nextOpen: boolean): void => { + setIsOpen(nextOpen); + onPopoverOpenChange?.(nextOpen); + }, + [onPopoverOpenChange], + ); + const closePopover = useCallback((): void => { - setIsOpen(false); - }, []); + setPopoverOpen(false); + }, [setPopoverOpen]); useCloseOnClickOutside({ isOpen, @@ -278,12 +294,13 @@ const DiscourseNodeTypeFilterInner = ({ const handlePopoverInteraction = useCallback( (nextOpen: boolean, event?: React.SyntheticEvent): void => { + if (!isFilterReady) return; if (nextOpen) { event?.stopPropagation(); } - setIsOpen(nextOpen); + setPopoverOpen(nextOpen); }, - [], + [isFilterReady, setPopoverOpen], ); const handlePopoverSelectedIdsChange = useCallback( @@ -298,10 +315,6 @@ const DiscourseNodeTypeFilterInner = ({ [allTypeIds, onSelectedTypeIdsChange], ); - const handlePopoverRef = useCallback((element: HTMLElement | null): void => { - popoverRef.current = element; - }, []); - const isTriggerActive = isOpen || isFilterActive; const triggerStyle: CSSProperties = { @@ -335,12 +348,15 @@ const DiscourseNodeTypeFilterInner = ({ ); - if (nodeTypes.length === 0) { - return ( - - {filterButton} - - ); + if (!isFilterReady) { + return {filterButton}; } return ( - + ); }; - -export const DiscourseNodeTypeFilter = React.memo(DiscourseNodeTypeFilterInner); diff --git a/apps/roam/src/hooks/useCloseOnClickOutside.ts b/apps/roam/src/hooks/useCloseOnClickOutside.ts deleted file mode 100644 index b4d754e57..000000000 --- a/apps/roam/src/hooks/useCloseOnClickOutside.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, type RefObject } from "react"; - -export const useCloseOnClickOutside = ({ - isOpen, - onClose, - popoverRef, - targetRef, -}: { - isOpen: boolean; - onClose: () => void; - popoverRef: RefObject; - targetRef: RefObject; -}): void => { - useEffect(() => { - if (!isOpen) return; - - const handleMouseDown = (event: MouseEvent): void => { - const clickTarget = event.target; - if (!(clickTarget instanceof Element)) return; - if (popoverRef.current?.contains(clickTarget)) return; - if (targetRef.current?.contains(clickTarget)) return; - onClose(); - }; - - document.addEventListener("mousedown", handleMouseDown, true); - return () => - document.removeEventListener("mousedown", handleMouseDown, true); - }, [isOpen, onClose, popoverRef, targetRef]); -}; diff --git a/apps/roam/src/utils/discourseNodeTypeFilter.test.ts b/apps/roam/src/utils/discourseNodeTypeFilter.test.ts new file mode 100644 index 000000000..9043dd5cb --- /dev/null +++ b/apps/roam/src/utils/discourseNodeTypeFilter.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { type DiscourseNode } from "~/utils/getDiscourseNodes"; +import { + filterDiscourseNodesByQuery, + fromPopoverSelectedIds, + getSelectAllCheckState, + hasActiveTypeFilter, + toPopoverSelectedIds, +} from "./discourseNodeTypeFilter"; + +const mockNode = (type: string, text: string): DiscourseNode => + ({ type, text }) as DiscourseNode; + +const ALL_TYPE_IDS = ["claim", "evidence", "question"]; + +describe("hasActiveTypeFilter", () => { + it("returns false when no types are selected (all types)", () => { + assert.equal( + hasActiveTypeFilter({ selectedTypeIds: [], allTypeIds: ALL_TYPE_IDS }), + false, + ); + }); + + it("returns false when every type is selected", () => { + assert.equal( + hasActiveTypeFilter({ + selectedTypeIds: ALL_TYPE_IDS, + allTypeIds: ALL_TYPE_IDS, + }), + false, + ); + }); + + it("returns true for a partial selection", () => { + assert.equal( + hasActiveTypeFilter({ + selectedTypeIds: ["claim"], + allTypeIds: ALL_TYPE_IDS, + }), + true, + ); + }); +}); + +describe("toPopoverSelectedIds", () => { + it("maps empty parent selection to all type ids for the popover", () => { + assert.deepEqual( + toPopoverSelectedIds({ + selectedTypeIds: [], + allTypeIds: ALL_TYPE_IDS, + }), + ALL_TYPE_IDS, + ); + }); + + it("passes through a partial parent selection", () => { + assert.deepEqual( + toPopoverSelectedIds({ + selectedTypeIds: ["claim", "evidence"], + allTypeIds: ALL_TYPE_IDS, + }), + ["claim", "evidence"], + ); + }); +}); + +describe("fromPopoverSelectedIds", () => { + it("maps empty popover selection to no parent filter", () => { + assert.deepEqual( + fromPopoverSelectedIds({ + popoverSelectedIds: [], + allTypeIds: ALL_TYPE_IDS, + }), + [], + ); + }); + + it("maps full popover selection to no parent filter", () => { + assert.deepEqual( + fromPopoverSelectedIds({ + popoverSelectedIds: ALL_TYPE_IDS, + allTypeIds: ALL_TYPE_IDS, + }), + [], + ); + }); + + it("maps partial popover selection to parent filter ids", () => { + assert.deepEqual( + fromPopoverSelectedIds({ + popoverSelectedIds: ["claim"], + allTypeIds: ALL_TYPE_IDS, + }), + ["claim"], + ); + }); +}); + +describe("getSelectAllCheckState", () => { + it("returns off, on, and indeterminate for selection counts", () => { + assert.equal( + getSelectAllCheckState({ selectedIds: [], totalCount: 3 }), + "off", + ); + assert.equal( + getSelectAllCheckState({ selectedIds: ["a", "b", "c"], totalCount: 3 }), + "on", + ); + assert.equal( + getSelectAllCheckState({ selectedIds: ["a"], totalCount: 3 }), + "indeterminate", + ); + }); +}); + +describe("filterDiscourseNodesByQuery", () => { + const nodes = [ + mockNode("claim", "Claim"), + mockNode("evidence", "Evidence"), + mockNode("question", "Research Question"), + ]; + + it("returns all nodes when the query is empty", () => { + assert.deepEqual(filterDiscourseNodesByQuery(nodes, ""), nodes); + assert.deepEqual(filterDiscourseNodesByQuery(nodes, " "), nodes); + }); + + it("filters nodes by label case-insensitively", () => { + assert.deepEqual(filterDiscourseNodesByQuery(nodes, "quest"), [nodes[2]]); + }); +}); From 44e9fa0e046479532a748fc9882ce82edf84d60d Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 21 May 2026 00:40:54 -0400 Subject: [PATCH 3/8] Fix eslint no-floating-promises in filter unit tests. Co-authored-by: Cursor --- apps/roam/src/utils/discourseNodeTypeFilter.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/roam/src/utils/discourseNodeTypeFilter.test.ts b/apps/roam/src/utils/discourseNodeTypeFilter.test.ts index 9043dd5cb..41adbf0e3 100644 --- a/apps/roam/src/utils/discourseNodeTypeFilter.test.ts +++ b/apps/roam/src/utils/discourseNodeTypeFilter.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-floating-promises -- node:test describe/it are fire-and-forget */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { type DiscourseNode } from "~/utils/getDiscourseNodes"; From 7e4ff3fd0b939fedb8de31a3dba02b37bd06bc71 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 21 May 2026 12:18:54 -0400 Subject: [PATCH 4/8] remove test --- apps/roam/package.json | 3 +- .../src/utils/discourseNodeTypeFilter.test.ts | 133 ------------------ 2 files changed, 1 insertion(+), 135 deletions(-) delete mode 100644 apps/roam/src/utils/discourseNodeTypeFilter.test.ts diff --git a/apps/roam/package.json b/apps/roam/package.json index deda45a1c..e911904ad 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -9,8 +9,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts", - "check-types": "tsc --noEmit --skipLibCheck", - "test": "tsx --test src/utils/discourseNodeTypeFilter.test.ts" + "check-types": "tsc --noEmit --skipLibCheck" }, "license": "Apache-2.0", "devDependencies": { diff --git a/apps/roam/src/utils/discourseNodeTypeFilter.test.ts b/apps/roam/src/utils/discourseNodeTypeFilter.test.ts deleted file mode 100644 index 41adbf0e3..000000000 --- a/apps/roam/src/utils/discourseNodeTypeFilter.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* eslint-disable @typescript-eslint/no-floating-promises -- node:test describe/it are fire-and-forget */ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { type DiscourseNode } from "~/utils/getDiscourseNodes"; -import { - filterDiscourseNodesByQuery, - fromPopoverSelectedIds, - getSelectAllCheckState, - hasActiveTypeFilter, - toPopoverSelectedIds, -} from "./discourseNodeTypeFilter"; - -const mockNode = (type: string, text: string): DiscourseNode => - ({ type, text }) as DiscourseNode; - -const ALL_TYPE_IDS = ["claim", "evidence", "question"]; - -describe("hasActiveTypeFilter", () => { - it("returns false when no types are selected (all types)", () => { - assert.equal( - hasActiveTypeFilter({ selectedTypeIds: [], allTypeIds: ALL_TYPE_IDS }), - false, - ); - }); - - it("returns false when every type is selected", () => { - assert.equal( - hasActiveTypeFilter({ - selectedTypeIds: ALL_TYPE_IDS, - allTypeIds: ALL_TYPE_IDS, - }), - false, - ); - }); - - it("returns true for a partial selection", () => { - assert.equal( - hasActiveTypeFilter({ - selectedTypeIds: ["claim"], - allTypeIds: ALL_TYPE_IDS, - }), - true, - ); - }); -}); - -describe("toPopoverSelectedIds", () => { - it("maps empty parent selection to all type ids for the popover", () => { - assert.deepEqual( - toPopoverSelectedIds({ - selectedTypeIds: [], - allTypeIds: ALL_TYPE_IDS, - }), - ALL_TYPE_IDS, - ); - }); - - it("passes through a partial parent selection", () => { - assert.deepEqual( - toPopoverSelectedIds({ - selectedTypeIds: ["claim", "evidence"], - allTypeIds: ALL_TYPE_IDS, - }), - ["claim", "evidence"], - ); - }); -}); - -describe("fromPopoverSelectedIds", () => { - it("maps empty popover selection to no parent filter", () => { - assert.deepEqual( - fromPopoverSelectedIds({ - popoverSelectedIds: [], - allTypeIds: ALL_TYPE_IDS, - }), - [], - ); - }); - - it("maps full popover selection to no parent filter", () => { - assert.deepEqual( - fromPopoverSelectedIds({ - popoverSelectedIds: ALL_TYPE_IDS, - allTypeIds: ALL_TYPE_IDS, - }), - [], - ); - }); - - it("maps partial popover selection to parent filter ids", () => { - assert.deepEqual( - fromPopoverSelectedIds({ - popoverSelectedIds: ["claim"], - allTypeIds: ALL_TYPE_IDS, - }), - ["claim"], - ); - }); -}); - -describe("getSelectAllCheckState", () => { - it("returns off, on, and indeterminate for selection counts", () => { - assert.equal( - getSelectAllCheckState({ selectedIds: [], totalCount: 3 }), - "off", - ); - assert.equal( - getSelectAllCheckState({ selectedIds: ["a", "b", "c"], totalCount: 3 }), - "on", - ); - assert.equal( - getSelectAllCheckState({ selectedIds: ["a"], totalCount: 3 }), - "indeterminate", - ); - }); -}); - -describe("filterDiscourseNodesByQuery", () => { - const nodes = [ - mockNode("claim", "Claim"), - mockNode("evidence", "Evidence"), - mockNode("question", "Research Question"), - ]; - - it("returns all nodes when the query is empty", () => { - assert.deepEqual(filterDiscourseNodesByQuery(nodes, ""), nodes); - assert.deepEqual(filterDiscourseNodesByQuery(nodes, " "), nodes); - }); - - it("filters nodes by label case-insensitively", () => { - assert.deepEqual(filterDiscourseNodesByQuery(nodes, "quest"), [nodes[2]]); - }); -}); From d4fa67e23f7e9caf7c9676d123f638bb12e82087 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 21 May 2026 12:27:36 -0400 Subject: [PATCH 5/8] cleanup styling --- .../AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx | 2 +- .../DiscourseNodeTypeFilter.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename apps/roam/src/components/{ => AdvancedNodeSearchDialog}/DiscourseNodeTypeFilter.tsx (98%) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 6a46168d2..0b5c8b4e7 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -27,7 +27,6 @@ import { type InsertTarget, } from "~/utils/advancedSearchFooterUtils"; import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl"; -import { DiscourseNodeTypeFilter } from "~/components/DiscourseNodeTypeFilter"; import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; @@ -44,6 +43,7 @@ import { splitWithHighlights, stripTypePrefix, } from "./utils"; +import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; diff --git a/apps/roam/src/components/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx similarity index 98% rename from apps/roam/src/components/DiscourseNodeTypeFilter.tsx rename to apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx index e56cf8d60..11db45522 100644 --- a/apps/roam/src/components/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx @@ -206,7 +206,7 @@ const FilterPopoverPanel = ({ <> {!hasTypeSearchQuery && (
{ @@ -224,7 +224,7 @@ const FilterPopoverPanel = ({
)} -
+
{filteredNodes.map((node) => ( Date: Thu, 21 May 2026 12:47:13 -0400 Subject: [PATCH 6/8] final clean --- .../AdvancedSearchDialog.tsx | 11 +-- .../DiscourseNodeTypeFilter.tsx | 95 ++++++------------- 2 files changed, 34 insertions(+), 72 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 0b5c8b4e7..959ca1133 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -169,9 +169,8 @@ const AdvancedNodeSearchDialog = ({ const inputRef = useRef(null); const [insertTarget, setInsertTarget] = useState(null); - const nodeConfigByType = useMemo( - () => Object.fromEntries(discourseNodes.map((node) => [node.type, node])), - [discourseNodes], + const nodeConfigByType = Object.fromEntries( + discourseNodes.map((node) => [node.type, node]), ); const activeResult = results[activeIndex] ?? null; @@ -242,12 +241,12 @@ const AdvancedNodeSearchDialog = ({ setIsIndexLoading(true); setIndexError(false); - const userDiscourseNodes = getDiscourseNodes().filter( + const discourseNodes = getDiscourseNodes().filter( (node) => node.backedBy === "user", ); - setDiscourseNodes(userDiscourseNodes); + setDiscourseNodes(discourseNodes); - void buildSearchIndex(userDiscourseNodes) + void buildSearchIndex(discourseNodes) .then(({ miniSearch, results: indexedResults }) => { if (cancelled) return; miniSearchRef.current = miniSearch; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx index 11db45522..4b428cde8 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx @@ -27,34 +27,6 @@ export type DiscourseNodeTypeFilterProps = { onPopoverOpenChange?: (isOpen: boolean) => void; }; -const useCloseOnClickOutside = ({ - isOpen, - onClose, - popoverRef, - targetRef, -}: { - isOpen: boolean; - onClose: () => void; - popoverRef: RefObject; - targetRef: RefObject; -}): void => { - useEffect(() => { - if (!isOpen) return; - - const handleMouseDown = (event: MouseEvent): void => { - const clickTarget = event.target; - if (!(clickTarget instanceof Element)) return; - if (popoverRef.current?.contains(clickTarget)) return; - if (targetRef.current?.contains(clickTarget)) return; - onClose(); - }; - - document.addEventListener("mousedown", handleMouseDown, true); - return () => - document.removeEventListener("mousedown", handleMouseDown, true); - }, [isOpen, onClose, popoverRef, targetRef]); -}; - const getNodeIndicatorColor = (node: DiscourseNode): string => formatHexColor(node.canvasSettings?.color) || "#000"; @@ -285,13 +257,6 @@ export const DiscourseNodeTypeFilter = ({ setPopoverOpen(false); }, [setPopoverOpen]); - useCloseOnClickOutside({ - isOpen, - onClose: closePopover, - popoverRef, - targetRef: triggerRef, - }); - const handlePopoverInteraction = useCallback( (nextOpen: boolean, event?: React.SyntheticEvent): void => { if (!isFilterReady) return; @@ -369,36 +334,34 @@ export const DiscourseNodeTypeFilter = ({ } return ( - - - } - enforceFocus={false} - isOpen={isOpen} - minimal - modifiers={{ - flip: { enabled: true }, - preventOverflow: { - enabled: true, - boundariesElement: "viewport", - }, - }} - onClose={closePopover} - onInteraction={handlePopoverInteraction} - popoverClassName="p-0 overflow-hidden" - popoverRef={popoverRef} - position={Position.BOTTOM_RIGHT} - target={filterButton} - usePortal - /> - + + } + enforceFocus={false} + isOpen={isOpen} + minimal + modifiers={{ + flip: { enabled: true }, + preventOverflow: { + enabled: true, + boundariesElement: "viewport", + }, + }} + onClose={closePopover} + onInteraction={handlePopoverInteraction} + popoverClassName="p-0 overflow-hidden" + popoverRef={popoverRef} + position={Position.BOTTOM_RIGHT} + target={filterButton} + usePortal + /> ); }; From deb8ae880c8a4b0dc6e17d47798f5cafa8056376 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 22 May 2026 16:27:11 -0400 Subject: [PATCH 7/8] lint fix --- .../AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx index 4b428cde8..d6f7cb8c0 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx @@ -5,7 +5,6 @@ import React, { useRef, useState, type CSSProperties, - type RefObject, } from "react"; import { Button, Icon, InputGroup, Popover, Position } from "@blueprintjs/core"; import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; From 1c9a2d36e2f1fb2743c2806ceab3850d24084555 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 13:19:10 -0400 Subject: [PATCH 8/8] cleanup --- .../AdvancedSearchDialog.tsx | 11 +- .../DiscourseNodeTypeFilter.tsx | 133 ++++++------------ 2 files changed, 45 insertions(+), 99 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 959ca1133..a5d6bbdd6 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -160,7 +160,6 @@ const AdvancedNodeSearchDialog = ({ const [sort, setSort] = useState(DEFAULT_SORT_CONFIG); const [discourseNodes, setDiscourseNodes] = useState([]); const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); - const [isTypeFilterPopoverOpen, setIsTypeFilterPopoverOpen] = useState(false); const miniSearchRef = useRef | null>(null); @@ -221,9 +220,7 @@ const AdvancedNodeSearchDialog = ({ miniSearch: miniSearchRef.current, allResults: allResultsRef.current, searchTerm: debouncedSearchTerm, - typeFilter: selectedNodeTypeIds.length - ? selectedNodeTypeIds - : undefined, + typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, }); setResults(sortSearchResults({ hits: scoredHits, sort })); @@ -380,7 +377,6 @@ const AdvancedNodeSearchDialog = ({ event.preventDefault(); void onInsert(); } else if (event.key === "Escape") { - if (isTypeFilterPopoverOpen) return; event.preventDefault(); onClose(); } @@ -389,7 +385,6 @@ const AdvancedNodeSearchDialog = ({ activeResult, contentState, insertTarget, - isTypeFilterPopoverOpen, onClose, onInsert, onOpen, @@ -403,7 +398,7 @@ const AdvancedNodeSearchDialog = ({ return ( diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx index d6f7cb8c0..3ce067d4c 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx @@ -4,9 +4,14 @@ import React, { useMemo, useRef, useState, - type CSSProperties, } from "react"; -import { Button, Icon, InputGroup, Popover, Position } from "@blueprintjs/core"; +import { + Button, + Checkbox, + InputGroup, + Popover, + Position, +} from "@blueprintjs/core"; import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { type DiscourseNode } from "~/utils/getDiscourseNodes"; import { @@ -16,7 +21,6 @@ import { getSelectAllCheckState, hasActiveTypeFilter, toPopoverSelectedIds, - type SelectAllCheckState, } from "~/utils/discourseNodeTypeFilter"; export type DiscourseNodeTypeFilterProps = { @@ -29,21 +33,6 @@ export type DiscourseNodeTypeFilterProps = { const getNodeIndicatorColor = (node: DiscourseNode): string => formatHexColor(node.canvasSettings?.color) || "#000"; -const FilterCheckbox = ({ - state, -}: { - state: SelectAllCheckState; -}): React.ReactElement => ( - - {state === "on" && } - {state === "indeterminate" && ( - - )} - -); - const NodeTypeFilterRow = ({ isChecked, node, @@ -55,27 +44,23 @@ const NodeTypeFilterRow = ({ onSelectOnly: () => void; onToggle: () => void; }): React.ReactElement => ( -
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggle(); +
+ + + {node.text} + } - }} - role="button" - tabIndex={0} - > - - - {node.text} ); - if (!isFilterReady) { - return {filterButton}; - } - return ( setPopoverOpen(false)} onInteraction={handlePopoverInteraction} popoverClassName="p-0 overflow-hidden" popoverRef={popoverRef}