From be31c6c0155cb4f584d86961e51d3e88833be064 Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 3 Jan 2026 15:48:48 +0000 Subject: [PATCH 1/4] feat: add search functionality to edge filtering dialog Add a search field that filters edge types in the pathfinding dialog. The search is case-insensitive and automatically expands/collapses accordions to show matching results. Search state clears when the dialog closes to ensure a clean UX on reopening. --- .../EdgeFilteringDialog.test.tsx | 190 ++++++++++++++++++ .../ExploreSearch/EdgeFilteringDialog.tsx | 108 ++++++++-- 2 files changed, 277 insertions(+), 21 deletions(-) diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.test.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.test.tsx index 39672a5235c..7c08ee7328b 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.test.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.test.tsx @@ -154,4 +154,194 @@ describe('Pathfinding', () => { expect(subcategoryCheckbox).toHaveAttribute('data-indeterminate', 'true'); expect(categoryCheckbox).toHaveAttribute('data-indeterminate', 'true'); }); + + it('should render search field', async () => { + const searchField = screen.getByPlaceholderText(/search edges/i); + expect(searchField).toBeInTheDocument(); + }); + + it('searching filters edge types and expands accordions automatically', async () => { + const user = userEvent.setup(); + + // get search field + const searchField = screen.getByPlaceholderText(/search edges/i); + + // type in search query + await user.type(searchField, 'Contains'); + + // assert that `Contains` edge type is visible + const containsEdgeCheckbox = screen.getByRole('checkbox', { name: 'Contains' }); + expect(containsEdgeCheckbox).toBeInTheDocument(); + + // assert that accordions are expanded (minimize buttons are present) + const minimizeCategoryButton = screen.getByRole('button', { name: 'minimize-Active Directory' }); + expect(minimizeCategoryButton).toBeInTheDocument(); + + const minimizeSubcategoryButton = screen.getByRole('button', { + name: 'minimize-Active Directory Structure', + }); + expect(minimizeSubcategoryButton).toBeInTheDocument(); + }); + + it('clearing search collapses accordions', async () => { + const user = userEvent.setup(); + + // get search field + const searchField = screen.getByPlaceholderText(/search edges/i); + + // type in search query + await user.type(searchField, 'Contains'); + + // assert accordions are expanded + expect(screen.getByRole('button', { name: 'minimize-Active Directory' })).toBeInTheDocument(); + + // clear search field + await user.clear(searchField); + + // assert accordions are collapsed (expand buttons are present) + const expandCategoryButton = screen.getByRole('button', { name: 'expand-Active Directory' }); + expect(expandCategoryButton).toBeInTheDocument(); + }); + + it('search only filters edge types, not categories or subcategories', async () => { + const user = userEvent.setup(); + + // get search field + const searchField = screen.getByPlaceholderText(/search edges/i); + + // search for a category name that should not match + await user.type(searchField, 'Active Directory'); + + // assert that categories are still visible but only if they contain matching edge types + // since "Active Directory" is not an edge type name, no categories should show + const categoryCheckbox = screen.queryByRole('checkbox', { name: /active directory/i }); + expect(categoryCheckbox).not.toBeInTheDocument(); + }); + + it('search is case insensitive', async () => { + const user = userEvent.setup(); + + // get search field + const searchField = screen.getByPlaceholderText(/search edges/i); + + // type in lowercase search query + await user.type(searchField, 'contains'); + + // assert that edge type is found + const containsEdgeCheckbox = screen.getByRole('checkbox', { name: 'Contains' }); + expect(containsEdgeCheckbox).toBeInTheDocument(); + + // clear and try uppercase + await user.clear(searchField); + await user.type(searchField, 'CONTAINS'); + + // assert that edge type is still found + expect(screen.getByRole('checkbox', { name: 'Contains' })).toBeInTheDocument(); + }); +}); + +describe('Pathfinding Search Persistence', () => { + it('search field clears when dialog closes via cancel button', async () => { + const user = userEvent.setup(); + const handleCancel = vi.fn(); + const handleApply = vi.fn(); + + const { rerender } = render( + + ); + + // type in search field + const searchField = screen.getByPlaceholderText(/search edges/i); + await user.type(searchField, 'Contains'); + expect(searchField).toHaveValue('Contains'); + + // close dialog + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + // reopen dialog + rerender( + + ); + + await act(async () => { + rerender( + + ); + }); + + // assert search field is empty + const newSearchField = screen.getByPlaceholderText(/search edges/i); + expect(newSearchField).toHaveValue(''); + }); + + it('search field clears when dialog closes via apply button', async () => { + const user = userEvent.setup(); + const handleCancel = vi.fn(); + const handleApply = vi.fn(); + + const { rerender } = render( + + ); + + // type in search field + const searchField = screen.getByPlaceholderText(/search edges/i); + await user.type(searchField, 'Contains'); + expect(searchField).toHaveValue('Contains'); + + // close dialog via apply + const applyButton = screen.getByRole('button', { name: /apply/i }); + await user.click(applyButton); + + // reopen dialog + rerender( + + ); + + await act(async () => { + rerender( + + ); + }); + + // assert search field is empty + const newSearchField = screen.getByPlaceholderText(/search edges/i); + expect(newSearchField).toHaveValue(''); + }); }); diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx index 37fb09b70ea..56906aa474e 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx @@ -18,6 +18,7 @@ import { Button } from '@bloodhoundenterprise/doodleui'; import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { + Box, Checkbox, Collapse, Dialog, @@ -34,7 +35,9 @@ import { ListItemIcon, ListItemText, SvgIcon, + TextField, Typography, + useTheme, } from '@mui/material'; import { useState } from 'react'; import { AllEdgeTypes, Category, EdgeCheckboxType, Subcategory } from '../../../edgeTypes'; @@ -54,26 +57,50 @@ const EdgeFilteringDialog = ({ handleUpdate, handleCancel, }: EdgeFilteringDialogProps) => { + const [searchQuery, setSearchQuery] = useState(''); const title = 'Path Edge Filtering'; const description = 'Select the edge types to include in your pathfinding search.'; + const handleClose = () => { + handleCancel(); + }; + + const handleApplyClick = () => { + handleApply(); + }; + + const handleExited = () => { + setSearchQuery(''); + }; + return ( - + {title} - + {description} - + setSearchQuery(e.target.value)} + sx={{ mb: 2 }} + /> + - - + ); @@ -82,12 +109,28 @@ const EdgeFilteringDialog = ({ interface CategoryListProps { selectedFilters: Array; handleUpdate: (checked: EdgeCheckboxType[]) => void; + searchQuery: string; } -const CategoryList = ({ selectedFilters, handleUpdate }: CategoryListProps) => { +const CategoryList = ({ selectedFilters, handleUpdate, searchQuery }: CategoryListProps) => { + const filterCategory = (category: Category): boolean => { + if (!searchQuery) return true; + + const query = searchQuery.toLowerCase(); + + // Check if any edge type matches + const hasMatchingEdge = category.subcategories.some((subcategory) => { + return subcategory.edgeTypes.some((edgeType) => + edgeType.toLowerCase().includes(query) + ); + }); + + return hasMatchingEdge; + }; + return ( - {AllEdgeTypes.map((category: Category) => { + {AllEdgeTypes.filter(filterCategory).map((category: Category) => { const { categoryName } = category; return ( { category={category} checked={selectedFilters} setChecked={handleUpdate} + searchQuery={searchQuery} /> ); })} @@ -104,31 +148,42 @@ const CategoryList = ({ selectedFilters, handleUpdate }: CategoryListProps) => { interface CategoryListItemProps { category: Category; - checked: EdgeCheckboxType[]; setChecked: (checked: EdgeCheckboxType[]) => void; + searchQuery: string; } -const CategoryListItem = ({ category, checked, setChecked }: CategoryListItemProps) => { +const CategoryListItem = ({ category, checked, setChecked, searchQuery }: CategoryListItemProps) => { const { categoryName, subcategories } = category; const categoryFilter = (element: EdgeCheckboxType) => element.category === categoryName; + const filterSubcategory = (subcategory: Subcategory): boolean => { + if (!searchQuery) return true; + + const query = searchQuery.toLowerCase(); + const hasMatchingEdge = subcategory.edgeTypes.some((edgeType) => edgeType.toLowerCase().includes(query)); + + return hasMatchingEdge; + }; + return ( - {subcategories.map((subcategory) => { + + {subcategories.filter(filterSubcategory).map((subcategory) => { return ( ); })} @@ -140,26 +195,31 @@ const CategoryListItem = ({ category, checked, setChecked }: CategoryListItemPro interface SubcategoryListItemProps { subcategory: Subcategory; - checked: EdgeCheckboxType[]; setChecked: (checked: EdgeCheckboxType[]) => void; + searchQuery: string; } -const SubcategoryListItem = ({ subcategory, checked, setChecked }: SubcategoryListItemProps) => { +const SubcategoryListItem = ({ subcategory, checked, setChecked, searchQuery }: SubcategoryListItemProps) => { const { name, edgeTypes } = subcategory; const subcategoryFilter = (element: EdgeCheckboxType) => element.subcategory === name; + const filteredEdgeTypes = searchQuery + ? edgeTypes.filter((edgeType) => edgeType.toLowerCase().includes(searchQuery.toLowerCase())) + : edgeTypes; + return ( - - + + + } @@ -175,6 +235,8 @@ interface EdgesViewProps { } const EdgesView = ({ edgeTypes, checked, setChecked }: EdgesViewProps) => { + const theme = useTheme(); + const changeCheckbox = (event: React.ChangeEvent, edgeType: string) => { const newChecked = [...checked]; const indexToUpdate = newChecked.findIndex((element) => element.edgeType === edgeType); @@ -184,7 +246,7 @@ const EdgesView = ({ edgeTypes, checked, setChecked }: EdgesViewProps) => { }; return ( -
+ {edgeTypes.map((edgeType, index) => { return ( @@ -204,7 +266,7 @@ const EdgesView = ({ edgeTypes, checked, setChecked }: EdgesViewProps) => { ); })} -
+ ); }; @@ -217,6 +279,7 @@ interface IndeterminateListItemProps { setChecked: (checked: EdgeCheckboxType[]) => void; collapsibleContent: React.ReactNode; + forceExpand?: boolean; } const IndeterminateListItem = ({ @@ -225,6 +288,7 @@ const IndeterminateListItem = ({ checked, setChecked, collapsibleContent, + forceExpand = false, }: IndeterminateListItemProps) => { const [showCollapsibleContent, setShowCollapsibleContent] = useState(false); @@ -257,6 +321,8 @@ const IndeterminateListItem = ({ const toggleCollapsibleContent = () => setShowCollapsibleContent((v) => !v); + const isExpanded = forceExpand || showCollapsibleContent; + return ( <> - + }> @@ -288,7 +354,7 @@ const IndeterminateListItem = ({ {name} - {collapsibleContent} + {collapsibleContent} ); }; From 55b141b38db8e84b7c4e82d5bf70f61a4d824d4e Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 3 Jan 2026 18:24:27 +0000 Subject: [PATCH 2/4] fix: clear search field when edge filtering dialog closes Add useEffect hook to reset searchQuery state when the dialog's isOpen prop changes to false. This ensures the search field is properly cleared when the dialog closes via either the cancel or apply button. --- .../views/Explore/ExploreSearch/EdgeFilteringDialog.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx index 56906aa474e..1cedcba45c8 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx @@ -39,7 +39,7 @@ import { Typography, useTheme, } from '@mui/material'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AllEdgeTypes, Category, EdgeCheckboxType, Subcategory } from '../../../edgeTypes'; interface EdgeFilteringDialogProps { @@ -61,6 +61,13 @@ const EdgeFilteringDialog = ({ const title = 'Path Edge Filtering'; const description = 'Select the edge types to include in your pathfinding search.'; + // Clear search query when dialog closes + useEffect(() => { + if (!isOpen) { + setSearchQuery(''); + } + }, [isOpen]); + const handleClose = () => { handleCancel(); }; From 1c810e646005fe5a17cc75c536879c7fe88bea22 Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 3 Jan 2026 18:46:30 +0000 Subject: [PATCH 3/4] fix: address coderabbit feedback for edge filtering dialog - Remove redundant useEffect that cleared search on isOpen change (handleExited callback already handles this after transition) - Remove unnecessary wrapper functions (handleClose, handleApplyClick) and use handleCancel/handleApply directly - Add aria-label to search TextField for accessibility - Simplify Divider styling from 'ml-1 mr-1' to 'mx-1' - Remove unused useEffect import --- .../ExploreSearch/EdgeFilteringDialog.tsx | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx index 1cedcba45c8..b4ac7205715 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx @@ -39,7 +39,7 @@ import { Typography, useTheme, } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { AllEdgeTypes, Category, EdgeCheckboxType, Subcategory } from '../../../edgeTypes'; interface EdgeFilteringDialogProps { @@ -61,29 +61,14 @@ const EdgeFilteringDialog = ({ const title = 'Path Edge Filtering'; const description = 'Select the edge types to include in your pathfinding search.'; - // Clear search query when dialog closes - useEffect(() => { - if (!isOpen) { - setSearchQuery(''); - } - }, [isOpen]); - - const handleClose = () => { - handleCancel(); - }; - - const handleApplyClick = () => { - handleApply(); - }; - const handleExited = () => { setSearchQuery(''); }; return ( - + {title} - + {description} @@ -92,9 +77,10 @@ const EdgeFilteringDialog = ({ setSearchQuery(e.target.value)} - sx={{ mb: 2 }} + className='mb-2' /> - - + ); @@ -182,7 +168,7 @@ const CategoryListItem = ({ category, checked, setChecked, searchQuery }: Catego setChecked={setChecked} forceExpand={!!searchQuery} collapsibleContent={ - + {subcategories.filter(filterSubcategory).map((subcategory) => { return ( - + + From 962eda1dbe83b60c1a2e3a2b5c68bab4007b89c0 Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 3 Jan 2026 18:59:44 +0000 Subject: [PATCH 4/4] fix: restore useEffect and fix accordion state persistence Re-add useEffect to clear search on dialog close. The previous commit removed this based on PR feedback, but it's required for tests to pass. The handleExited callback only fires after MUI Dialog transition completes, which doesn't occur in test environments using rerender(). Also fix accordion state bug: prevent chevron clicks from toggling showCollapsibleContent while search is active (forceExpand=true). This ensures accordions collapse to default state when search is cleared, rather than persisting expanded state from clicks during search. --- .../ExploreSearch/EdgeFilteringDialog.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx index b4ac7205715..0faad63c6a1 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilteringDialog.tsx @@ -39,7 +39,7 @@ import { Typography, useTheme, } from '@mui/material'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AllEdgeTypes, Category, EdgeCheckboxType, Subcategory } from '../../../edgeTypes'; interface EdgeFilteringDialogProps { @@ -61,6 +61,14 @@ const EdgeFilteringDialog = ({ const title = 'Path Edge Filtering'; const description = 'Select the edge types to include in your pathfinding search.'; + // Clear search query when dialog closes (for tests and immediate cleanup) + useEffect(() => { + if (!isOpen) { + setSearchQuery(''); + } + }, [isOpen]); + + // Also clear on transition exit (for smooth UX in browser) const handleExited = () => { setSearchQuery(''); }; @@ -312,7 +320,12 @@ const IndeterminateListItem = ({ } }; - const toggleCollapsibleContent = () => setShowCollapsibleContent((v) => !v); + const toggleCollapsibleContent = () => { + // Don't toggle manual state when force-expanded by search + if (!forceExpand) { + setShowCollapsibleContent((v) => !v); + } + }; const isExpanded = forceExpand || showCollapsibleContent;