fix alert history filter reset

This commit is contained in:
rcourtman
2026-08-03 12:22:19 +01:00
parent 2402858e3c
commit e3fdfc98b4
7 changed files with 100 additions and 33 deletions
@@ -1223,6 +1223,14 @@ Future alert-history control-flow work should extend the feature hook, new
grouping or trend semantics should extend the history model, and render-heavy
history surfaces should extend those section owners instead of putting fetch,
resource-incident state, or table rendering back into the shell.
Alert History toolbar state is one route-owned unit. Search, period, and
severity are read and written by `useAlertHistoryState.ts`; its active-filter
count includes search, and `AlertHistoryFiltersCard.tsx` delegates contextual
Clear filters to one composite hook mutation instead of letting `FilterBar`
issue sequential route writes. That reset removes all three query parameters
in one navigation and clears any transient chart-bucket selection, so a
search-only result set remains visibly resettable and an older URL write
cannot resurrect another filter.
That same history surface now also owns the canonical resource-incident
handoff. `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`
must treat the selected incident resource as a unified-resource consumer,
@@ -3926,6 +3926,12 @@ Its feature owner may clear incompatible hidden facets when the workspace
changes and may preserve that workspace during a composite reset, but the
shared `FilterBar` remains the sole owner of Saved, Clear, filter-menu, and
popover chrome across desktop and narrow layouts.
Alert History follows the same rule for its route-owned search, period, and
severity state. Its feature hook owns one composite reset and exposes a
search-aware active-state accessor to `FilterBar`; the shared shell owns where
the contextual Clear filters action appears, while the alerts feature must not
fall back to sequential per-control URL writes or hide that action for a
search-only result set.
Because that popover combines view application, default selection, removal,
and an inline naming form, it is a labelled non-modal dialog rather than an
ARIA menu. Its trigger exposes the dialog relationship, Escape returns focus
+19 -20
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"base_sha": "59eb6dd68f3f1f7ac58639c2b82099eb076c14bc",
"verified_at": "2026-08-03T10:53:05Z",
"base_sha": "2402858e3c80b29d898e551c82969566c6a649b8",
"verified_at": "2026-08-03T11:15:05Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
"frontend-modern/src/routing/resourceLinks.ts"
"frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx",
"frontend-modern/src/features/alerts/useAlertHistoryState.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "6c838d09098ea243c8368753ef0f00c80cf11b59c7cbcd5f62750bba75fbafb2",
"frontend-modern/src/routing/resourceLinks.ts": "c57c920355766f401923340b746614e02b6e6a40fbd871bfd3be47611d952e93"
"frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx": "768d706dad29c92c8a205059e48f16bd4fc8f652ad093501759f7f50c72d849c",
"frontend-modern/src/features/alerts/useAlertHistoryState.ts": "e190111f1b89a806b7bd07cf2b10a5ba4f8e5d204c0cebea871964252c5ccebd"
},
"routes": ["/proxmox/backups"],
"routes": ["/alerts/history"],
"viewports": [
{
"width": 1280,
@@ -23,21 +23,20 @@
}
],
"states": [
"Desktop Proxmox Backups default Coverage workspace with Posture filtering",
"Desktop By date workspace with URL-backed search and PBS snapshots source filtering",
"Desktop Saved views empty, named-view, restored-view, and removed-view states",
"Phone-width filter shell collapsed and expanded in Coverage and By date workspaces",
"Desktop default, search-only, combined period/severity/search, and chart-bucket states",
"Desktop Saved views empty, named-view, restored-view, removed-view, and deep-linked states",
"Phone-width filter shell collapsed and expanded with search-only and combined filter state",
"Phone-width Saved views dialog bounded within the viewport with active filter state"
],
"interactions": [
"Switched from Coverage to By date and confirmed the incompatible posture parameter was removed in the same navigation",
"Entered backup-vault search text, selected PBS snapshots, and confirmed the route became ?view=date&q=backup-vault&source=pbs",
"Saved the filtered slice as Backup URL audit, cleared it, reapplied it, and confirmed the exact query string and visible controls were restored",
"Reloaded the explicit deep link and confirmed search and source state remained selected",
"Cleared the combined filter state and confirmed one navigation retained ?view=date while removing every narrowing parameter",
"Removed the temporary saved view and confirmed the Saved count returned to zero",
"Expanded the phone-width filter shell, selected Unknown posture, switched to By date, and confirmed hidden posture state did not survive",
"Opened and dismissed the phone-width Saved dialog with Escape and confirmed it stayed viewport-bounded without document overflow",
"Read both browser consoles after the final responsive pass and confirmed no warnings or errors"
"Entered search text alone and confirmed Clear filters appeared, then cleared the URL and visible search state in one action",
"Selected Last 30d and Critical with search text, confirmed all three query parameters, and cleared them atomically back to defaults",
"Saved the combined slice as Alert reset audit, cleared it, reapplied it, and confirmed the exact query string and visible controls were restored",
"Reloaded the explicit deep link and confirmed search, period, and severity remained selected",
"Removed the temporary saved view and confirmed it no longer appeared",
"Selected an alert-frequency bucket, added a toolbar filter, and confirmed Clear filters removed both the route-owned filters and transient chart selection",
"Expanded the phone-width filter shell and confirmed Saved and Clear filters remained aligned without overflow",
"Opened and dismissed the phone-width Saved dialog with Escape and confirmed it stayed viewport-bounded",
"Read the browser console after the final responsive pass and confirmed no warnings or errors"
]
}
@@ -69,6 +69,8 @@ export function AlertHistoryFiltersCard(props: AlertHistoryFiltersCardProps) {
clearOnEscape: true,
}}
filters={buildFilters()}
onClearAll={props.state.clearFilters}
showClearAll={() => props.state.activeFilterCount() > 0}
/>
);
}
@@ -146,4 +146,34 @@ describe('useAlertHistoryState', () => {
expect(AlertsAPI.clearHistory).toHaveBeenCalledTimes(1);
expect(result.alertHistory()).toEqual([]);
});
it('clears search, period, and severity in one route write', async () => {
const [activeAlerts] = createSignal({});
vi.mocked(AlertsAPI.getHistory).mockResolvedValue([] as any);
setMockLocation('?q=backup+failed&period=30d&severity=critical');
const { result } = renderHook(() =>
useAlertHistoryState({
activeAlerts,
getResource: () => undefined,
allResources: () => [],
}),
);
await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(1));
expect(result.activeFilterCount()).toBe(3);
expect(result.searchTerm()).toBe('backup failed');
expect(result.timeFilter()).toBe('30d');
expect(result.severityFilter()).toBe('critical');
navigateSpy.mockClear();
result.clearFilters();
expect(navigateSpy).toHaveBeenCalledTimes(1);
expect(navigateSpy).toHaveBeenCalledWith('/alerts/history', { replace: true });
expect(result.activeFilterCount()).toBe(0);
expect(result.searchTerm()).toBe('');
expect(result.timeFilter()).toBe('7d');
expect(result.severityFilter()).toBe('all');
});
});
@@ -42,6 +42,11 @@ export interface UseAlertHistoryStateProps {
const DEFAULT_TIME_FILTER: AlertHistoryRange = '7d';
const DEFAULT_SEVERITY_FILTER: AlertSeverityFilter = 'all';
const ALERT_HISTORY_QUERY_PARAMS = {
query: 'q',
period: 'period',
severity: 'severity',
} as const;
const parsePeriod = (raw: string | null | undefined): AlertHistoryRange =>
raw === '24h' || raw === '7d' || raw === '30d' || raw === 'all' ? raw : DEFAULT_TIME_FILTER;
@@ -54,10 +59,11 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const navigate = useNavigate();
const timeFilter: Accessor<AlertHistoryRange> = () =>
parsePeriod(new URLSearchParams(location.search).get('period'));
parsePeriod(new URLSearchParams(location.search).get(ALERT_HISTORY_QUERY_PARAMS.period));
const severityFilter: Accessor<AlertSeverityFilter> = () =>
parseSeverity(new URLSearchParams(location.search).get('severity'));
const searchTerm: Accessor<string> = () => new URLSearchParams(location.search).get('q') ?? '';
parseSeverity(new URLSearchParams(location.search).get(ALERT_HISTORY_QUERY_PARAMS.severity));
const searchTerm: Accessor<string> = () =>
new URLSearchParams(location.search).get(ALERT_HISTORY_QUERY_PARAMS.query) ?? '';
const updateSearchParam = (mutate: (params: URLSearchParams) => void): void => {
const params = new URLSearchParams(location.search);
@@ -69,9 +75,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const setTimeFilter = (value: AlertHistoryRange): void => {
updateSearchParam((params) => {
if (value === DEFAULT_TIME_FILTER) {
params.delete('period');
params.delete(ALERT_HISTORY_QUERY_PARAMS.period);
} else {
params.set('period', value);
params.set(ALERT_HISTORY_QUERY_PARAMS.period, value);
}
});
};
@@ -79,9 +85,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const setSeverityFilter = (value: AlertSeverityFilter): void => {
updateSearchParam((params) => {
if (value === DEFAULT_SEVERITY_FILTER) {
params.delete('severity');
params.delete(ALERT_HISTORY_QUERY_PARAMS.severity);
} else {
params.set('severity', value);
params.set(ALERT_HISTORY_QUERY_PARAMS.severity, value);
}
});
};
@@ -89,9 +95,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const setSearchTerm = (value: string): void => {
updateSearchParam((params) => {
if (value === '') {
params.delete('q');
params.delete(ALERT_HISTORY_QUERY_PARAMS.query);
} else {
params.set('q', value);
params.set(ALERT_HISTORY_QUERY_PARAMS.query, value);
}
});
};
@@ -119,11 +125,21 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const activeFilterCount = createMemo(() => {
let count = 0;
if (searchTerm().trim()) count++;
if (timeFilter() !== '7d') count++;
if (severityFilter() !== 'all') count++;
return count;
});
const clearFilters = (): void => {
updateSearchParam((params) => {
params.delete(ALERT_HISTORY_QUERY_PARAMS.query);
params.delete(ALERT_HISTORY_QUERY_PARAMS.period);
params.delete(ALERT_HISTORY_QUERY_PARAMS.severity);
});
setSelectedBarIndex(null);
};
const userLocale =
Intl.DateTimeFormat().resolvedOptions().locale ||
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
@@ -176,20 +192,20 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
const params = new URLSearchParams(window.location.search);
let mutated = false;
if (!params.has('period')) {
if (!params.has(ALERT_HISTORY_QUERY_PARAMS.period)) {
const legacy = window.localStorage.getItem('alertHistoryTimeFilter');
const parsed = parsePeriod(legacy);
if (parsed !== DEFAULT_TIME_FILTER && legacy === parsed) {
params.set('period', parsed);
params.set(ALERT_HISTORY_QUERY_PARAMS.period, parsed);
mutated = true;
}
}
if (!params.has('severity')) {
if (!params.has(ALERT_HISTORY_QUERY_PARAMS.severity)) {
const legacy = window.localStorage.getItem('alertHistorySeverityFilter');
const parsed = parseSeverity(legacy);
if (parsed !== DEFAULT_SEVERITY_FILTER && legacy === parsed) {
params.set('severity', parsed);
params.set(ALERT_HISTORY_QUERY_PARAMS.severity, parsed);
mutated = true;
}
}
@@ -303,6 +319,7 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) {
resourceIncidentEventFilters: resourceIncidentsState.resourceIncidentEventFilters,
setResourceIncidentEventFilters: resourceIncidentsState.setResourceIncidentEventFilters,
activeFilterCount,
clearFilters,
incidentTimelines,
incidentLoading,
incidentErrors,
@@ -466,6 +466,10 @@ describe('tab path helpers', () => {
expect(alertHistoryFiltersCardSource).toContain('export function AlertHistoryFiltersCard');
expect(alertHistoryFiltersCardSource).toContain('getAlertHistorySearchPlaceholder');
expect(alertHistoryFiltersCardSource).toContain('filterChipStatusDot');
expect(alertHistoryFiltersCardSource).toContain('onClearAll={props.state.clearFilters}');
expect(alertHistoryFiltersCardSource).toContain(
'showClearAll={() => props.state.activeFilterCount() > 0}',
);
expect(alertHistoryFiltersCardSource).not.toContain('severityDot');
expect(alertHistoryFiltersCardSource).not.toContain('h-2 w-2 rounded-full ${className}');
expect(alertResourceIncidentsPanelSource).toContain(
@@ -526,6 +530,7 @@ describe('tab path helpers', () => {
);
expect(alertHistoryStateSource).toContain('export function useAlertHistoryState');
expect(alertHistoryStateSource).toContain('export type AlertHistoryState');
expect(alertHistoryStateSource).toContain('const clearFilters = (): void =>');
expect(alertHistoryStateSource).toContain('AlertsAPI.getHistory');
expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory');
expect(alertHistoryStateSource).toContain('useAlertResourceIncidentsState');