diff --git a/frontend/src/components/advancedSearch/AdvancedSearch.tsx b/frontend/src/components/advancedSearch/AdvancedSearch.tsx index 678562f5aef..4cfb18d48fa 100644 --- a/frontend/src/components/advancedSearch/AdvancedSearch.tsx +++ b/frontend/src/components/advancedSearch/AdvancedSearch.tsx @@ -28,7 +28,10 @@ import { useLocalStorageState } from '../globalSearch/useLocalStorageState'; import { ApiResourcesView } from './ApiResourcePicker'; import { EmptyResults } from './EmptyResults'; import { ResourceSearch } from './ResourceSearch'; +import type { SavedAdvancedSearch } from './savedAdvancedSearches'; +import { SavedSearches } from './SavedSearches'; import { SearchSettings } from './SearchSettings'; +import { getSelectedResourcesValue } from './selectedResources'; const emptyList: [] = []; @@ -82,12 +85,9 @@ export function AdvancedSearch() { if (!selectedResources) return; setSelectedResourcesState(() => - selectedResources.size === resources?.length - ? 'all' - : selectedResources.size === 0 - ? '' - : [...selectedResources].join('+') + getSelectedResourcesValue(selectedResources, resources?.length) ); + // setSelectedResourcesState is not stable between renders. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedResources, resources]); @@ -96,6 +96,35 @@ export function AdvancedSearch() { [resources, selectedResources] ); + const selectedResourcesValue = useMemo(() => { + if (!selectedResources) { + return selectedResourcesState || ''; + } + + return getSelectedResourcesValue(selectedResources, resources?.length); + }, [resources, selectedResources, selectedResourcesState]); + + const restoreSavedSearch = useCallback( + (search: SavedAdvancedSearch) => { + const availableResourceIds = new Set(resources?.map(resource => apiResourceId(resource))); + + if (search.resources === 'all') { + setSelectedResources(availableResourceIds); + } else if (!search.resources) { + setSelectedResources(new Set()); + } else { + setSelectedResources( + new Set( + search.resources.split('+').filter(resourceId => availableResourceIds.has(resourceId)) + ) + ); + } + + setRawQuery(search.query); + }, + [resources, setRawQuery] + ); + if (isLoading) { return ; } @@ -128,6 +157,12 @@ export function AdvancedSearch() { setSelectedResources={setSelectedResources} /> + + { + afterEach(() => { + localStorage.clear(); + }); + + it('opens the saved searches popover when clicked', () => { + render( + + {}} /> + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Saved Searches' })); + + expect(screen.getByText('Save current search')).toBeInTheDocument(); + expect(screen.getByText('No saved searches yet.')).toBeInTheDocument(); + }); + + it('clears transient popover state when dismissed', async () => { + localStorage.setItem( + SAVED_ADVANCED_SEARCHES_KEY, + JSON.stringify([ + { + id: 'saved-1', + name: 'Existing', + query: 'true', + resources: 'all', + namespaces: [], + createdAt: 1, + }, + ]) + ); + + render( + + {}} /> + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Saved Searches' })); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Draft save name' } }); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + expect(screen.getByDisplayValue('Existing')).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole('presentation'), { code: 'Escape', key: 'Escape' }); + + await waitFor(() => { + expect(screen.queryByText('Save current search')).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Saved Searches' })); + + expect(screen.queryByDisplayValue('Existing')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Name')).toHaveValue(''); + }); +}); diff --git a/frontend/src/components/advancedSearch/SavedSearches.tsx b/frontend/src/components/advancedSearch/SavedSearches.tsx new file mode 100644 index 00000000000..a99c42c2c2c --- /dev/null +++ b/frontend/src/components/advancedSearch/SavedSearches.tsx @@ -0,0 +1,344 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Icon } from '@iconify/react'; +import { + Alert, + Badge, + Box, + Button, + Divider, + IconButton, + Popover, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { setNamespaceFilter } from '../../redux/filterSlice'; +import { useTypedSelector } from '../../redux/hooks'; +import { + addSavedAdvancedSearch, + deleteSavedAdvancedSearch, + readSavedAdvancedSearches, + renameSavedAdvancedSearch, + SavedAdvancedSearch, + writeSavedAdvancedSearches, +} from './savedAdvancedSearches'; + +export function SavedSearches({ + rawQuery, + resourcesValue, + onSearchSelected, +}: { + rawQuery: string; + resourcesValue: string | 'all'; + onSearchSelected: (search: SavedAdvancedSearch) => void; +}) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const history = useHistory(); + const location = useLocation(); + const selectedNamespaces = useTypedSelector(state => state.filter.namespaces); + const [anchorEl, setAnchorEl] = useState(null); + const [searches, setSearches] = useState(() => readSavedAdvancedSearches()); + const searchesRef = useRef(searches); + const [saveName, setSaveName] = useState(''); + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(''); + const [error, setError] = useState(''); + + const queryCanBeSaved = rawQuery.trim().length > 0; + + const closePopover = () => { + setEditId(null); + setEditName(''); + setSaveName(''); + setError(''); + setAnchorEl(null); + }; + + const updateSearches = (updater: (current: SavedAdvancedSearch[]) => SavedAdvancedSearch[]) => { + const current = searchesRef.current; + const next = updater(current); + if (next === current) { + return; + } + + try { + writeSavedAdvancedSearches(next); + searchesRef.current = next; + setSearches(next); + setError(''); + } catch { + setError(t('Could not save searches in local storage.')); + } + }; + + const updateNamespaceQuery = (nextNamespaces: string[]) => { + const searchParams = new URLSearchParams(location.search); + if (nextNamespaces.length > 0) { + searchParams.set('namespace', nextNamespaces.join(' ')); + } else { + searchParams.delete('namespace'); + } + + history.push({ + pathname: location.pathname, + search: searchParams.toString(), + }); + }; + + const restoreSearch = (search: SavedAdvancedSearch) => { + onSearchSelected(search); + dispatch(setNamespaceFilter(search.namespaces)); + updateNamespaceQuery(search.namespaces); + closePopover(); + }; + + return ( + <> + + + + + + + + + Save current search + + + setSaveName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && saveName.trim() && queryCanBeSaved) { + updateSearches(current => + addSavedAdvancedSearch(current, { + name: saveName, + query: rawQuery, + resources: resourcesValue, + namespaces: [...selectedNamespaces], + }) + ); + setSaveName(''); + } + }} + fullWidth + /> + + + {!queryCanBeSaved && ( + + Enter a query to save it. + + )} + + + + + {error && {error}} + + + {searches.length === 0 && ( + + No saved searches yet. + + )} + + {searches.map(search => ( + ({ + border: '1px solid', + borderColor: theme.palette.divider, + borderRadius: 1, + padding: 1, + display: 'flex', + gap: 1, + alignItems: 'flex-start', + })} + > + + {editId === search.id ? ( + setEditName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && editName.trim()) { + updateSearches(current => + renameSavedAdvancedSearch(current, search.id, editName) + ); + setEditId(null); + } + }} + fullWidth + /> + ) : ( + <> + + {search.name} + + + {search.query} + + + )} + + + {editId === search.id ? ( + <> + Save}> + + { + updateSearches(current => + renameSavedAdvancedSearch(current, search.id, editName) + ); + setEditId(null); + }} + > + + + + + Cancel}> + setEditId(null)} + > + + + + + ) : ( + <> + Restore}> + restoreSearch(search)} + > + + + + Rename}> + { + setEditId(search.id); + setEditName(search.name); + }} + > + + + + Delete}> + + updateSearches(current => deleteSavedAdvancedSearch(current, search.id)) + } + > + + + + + )} + + ))} + + + + + ); +} diff --git a/frontend/src/components/advancedSearch/__snapshots__/AdvancedSearch.Default.stories.storyshot b/frontend/src/components/advancedSearch/__snapshots__/AdvancedSearch.Default.stories.storyshot index c26cf4427b3..9db33b5f483 100644 --- a/frontend/src/components/advancedSearch/__snapshots__/AdvancedSearch.Default.stories.storyshot +++ b/frontend/src/components/advancedSearch/__snapshots__/AdvancedSearch.Default.stories.storyshot @@ -47,6 +47,27 @@ style="top: 4px; right: 4px;" /> + + + +