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 (
+ <>
+
+ }
+ onClick={e => setAnchorEl(e.currentTarget)}
+ >
+ Saved Searches
+
+
+
+
+
+
+
+ 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;"
/>
+
+
+
+