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..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 @@ -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,9 +35,11 @@ import { ListItemIcon, ListItemText, SvgIcon, + TextField, Typography, + useTheme, } from '@mui/material'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AllEdgeTypes, Category, EdgeCheckboxType, Subcategory } from '../../../edgeTypes'; interface EdgeFilteringDialogProps { @@ -54,19 +57,44 @@ 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.'; + // 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(''); + }; + return ( - + {title} - + {description} - + setSearchQuery(e.target.value)} + className='mb-2' + /> + @@ -82,12 +110,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 +149,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 +196,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 +236,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 +247,7 @@ const EdgesView = ({ edgeTypes, checked, setChecked }: EdgesViewProps) => { }; return ( -
+ {edgeTypes.map((edgeType, index) => { return ( @@ -204,7 +267,7 @@ const EdgesView = ({ edgeTypes, checked, setChecked }: EdgesViewProps) => { ); })} -
+ ); }; @@ -217,6 +280,7 @@ interface IndeterminateListItemProps { setChecked: (checked: EdgeCheckboxType[]) => void; collapsibleContent: React.ReactNode; + forceExpand?: boolean; } const IndeterminateListItem = ({ @@ -225,6 +289,7 @@ const IndeterminateListItem = ({ checked, setChecked, collapsibleContent, + forceExpand = false, }: IndeterminateListItemProps) => { const [showCollapsibleContent, setShowCollapsibleContent] = useState(false); @@ -255,7 +320,14 @@ 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; return ( <> @@ -264,10 +336,10 @@ const IndeterminateListItem = ({ dense secondaryAction={ - + }> @@ -288,7 +360,7 @@ const IndeterminateListItem = ({ {name} - {collapsibleContent} + {collapsibleContent} ); };