Retire legacy Storage and Workloads filter helpers after FilterBar migration

Deletes files that no runtime path imports after the migration to
chip-based FilterBar:

  Storage's three-layer filter indirection:
  - StorageFilter.tsx (legacy filter shell)
  - StorageControls.tsx (subtab + filter pass-through wrapper)
  - useStorageFilterToolbarModel.ts (legacy active-filter / reset hook)
  - useStoragePageControlsModel.ts (sortDisabled + groupBy gating)
  - useStorageControlsModel.ts (subtab + node-filter wiring)
  - their tests

  Workloads filter state hook:
  - useWorkloadsFilterState.ts (replaced by inline FilterBar wiring;
    countActiveWorkloadsFilters / hasActiveWorkloadsFilters stay in
    workloadsFilterModel.ts)
  - its test

The StorageStatusFilter and StorageGroupByFilter type aliases that
StorageFilter.tsx exported fold into the existing canonical types in
storagePageState.ts (StorageStatusFilterValue) and storageModelCore.ts
(StorageGroupKey), keeping useStorageFilterState.ts alive without the
deleted shell.

PageControls.tsx and its companion FilterToolbar primitives stay in
the tree because the alert-history filter card and the Kubernetes
deployments drawer still consume them. The canonical claim shifts:
FilterBar is the chip-based shell for catalog-driven page filters
(Infrastructure, Workloads, Storage, Recovery Protected items, Recovery
events). PageControls remains for non-migrated surfaces.

Subsystem contracts and registry updated:
- frontend-primitives.md: adds FilterBar files to Canonical Files;
  describes FilterBar as the canonical page-level filter shell for
  catalog-driven resource lists; PageControls described as the legacy
  fallback for non-migrated surfaces.
- performance-and-scalability.md: drops useWorkloadsFilterState
  references; describes Workloads' FilterBar / viewOptionsTrailing
  composition.
- storage-recovery.md: replaces "PageControls toolbar rail" prose with
  FilterBar / viewOptionsTrailing for both Storage and Recovery events;
  notes the legacy three-layer indirection retired.
- registry.json: drops deleted file paths from owned_files,
  verification.exact_files, and verification.path_policies entries.
- canonical_completion_guard_test.py + subsystem_lookup_test.py: drop
  deleted file paths from hard-coded fixtures so the governance helper
  tests track the registry.
- SharedPrimitives.guardrails.test.ts: adds regression assertions that
  StoragePageControls no longer imports the deleted shells.
This commit is contained in:
rcourtman 2026-05-01 10:51:22 +01:00
parent 2a9234f2b0
commit 4e2b62e89b
22 changed files with 94 additions and 1184 deletions

View file

@ -78,7 +78,11 @@ work extends shared components instead of creating new local variants.
52. `frontend-modern/src/utils/updatesPresentation.ts`
53. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
54. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
55. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
55. `frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`
56. `frontend-modern/src/components/shared/FilterBar/FilterChip.tsx`
57. `frontend-modern/src/components/shared/FilterBar/AddFilterMenu.tsx`
58. `frontend-modern/src/components/shared/FilterBar/filterCatalog.ts`
59. `frontend-modern/src/components/shared/FilterBar/index.ts`
56. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
57. `frontend-modern/src/features/`
58. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
@ -1637,6 +1641,22 @@ search-row, filter-row, and inline-leading-slot layout surface. Monitoring
pages that need workspace tabs or count chips next to search should route that
through the shared `searchLeading` slot instead of recreating a second local
header strip above the control bar.
Pages that filter a list-of-resources surface (Infrastructure, Workloads,
Storage, Recovery Protected items, Recovery events) compose the chip-based
`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx` shell instead
of `PageControls`. Each page declares a `FilterDef[]` catalog (label, options,
value, defaultValue, group); `FilterBar` renders chips for active filters and
exposes the rest behind a "+ Filter" menu, with type-ahead at both the menu
and chip popovers (`AddFilterMenu` and `FilterChip`). View options
(grouping segmented control, charts toggle, columns picker, sort key) sit in
the `viewOptionsTrailing` slot and are not chips. Subtabs that switch the
rendered dataset (Recovery's Protected items vs Recovery events, Storage's
Pools vs Physical Disks) sit above the bar; they are navigation, not filters.
Pages that have not yet migrated (the alert-history filter card,
Kubernetes deployments drawer) keep using `PageControls` and
`LabeledFilterSelect`, but new resource-list filter surfaces should reach for
`FilterBar` with a catalog rather than reintroducing a per-page select row.
That same shared filter-toolbar boundary also owns controlled select continuity
when filter options materialize asynchronously. `LabeledFilterSelect` must keep
the caller-owned `value` visibly selected after option children arrive so

View file

@ -40,7 +40,6 @@ regression protection.
18. `frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts`
19. `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`
20. `frontend-modern/src/components/Workloads/workloadsFilterModel.ts`
21. `frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts`
22. `frontend-modern/src/components/Workloads/ThresholdSlider.tsx`
23. `frontend-modern/src/components/Workloads/thresholdSliderModel.ts`
24. `frontend-modern/src/components/Workloads/useThresholdSliderState.ts`
@ -97,7 +96,6 @@ regression protection.
75. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
76. `frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx`
77. `frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx`
78. `frontend-modern/src/components/Workloads/__tests__/useWorkloadsFilterState.test.ts`
79. `frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts`
80. `frontend-modern/src/components/Workloads/MetricBar.test.tsx`
81. `frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx`
@ -198,14 +196,7 @@ regression protection.
duplicate hot-path type matching.
16. Extend grouped workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
17. Extend workload control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts` and `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` and `frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`
Workloads filter presentation may consume frontend-primitives responsive
toggle controls, the shared `PageControls` structured control deck, and the
shared filter-section chrome for its semantic primary and secondary filter
groups, but the workload-owned state model must remain the source of truth
for active filter counting, reset behavior, and mobile collapse so changing
between wide toggle and narrow select presentation does not add a second
filter state path or layout-measurement path on the workload hot path.
18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` (defaults, `countActiveWorkloadsFilters`, `hasActiveWorkloadsFilters`) rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`. Workloads filter presentation now composes the chip-based shared `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a per-page `FilterDef[]` catalog rather than the legacy `PageControls` structured control deck. The xl segmented↔select swap retired with the migration; type-ahead in the "+ Filter" menu and chip popovers covers the power-user speed that the segmented controls used to give. View options (grouped/list, charts, columns) sit in the shared `viewOptionsTrailing` slot.
19. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Workloads/thresholdSliderModel.ts` and `frontend-modern/src/components/Workloads/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Workloads/ThresholdSlider.tsx`
20. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedDiskBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Workloads/StackedDiskBar.tsx`
21. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`
@ -834,21 +825,24 @@ presentation derivations and fallback tooltip/runtime wiring live in
usage math, threshold-color routing, and fallback handling must extend
through those owners instead of accreting back into the shell.
The Workloads filter now follows that same ownership rule: the shell
stays in `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`, while
toolbar defaults, active-filter counting, and reset semantics live in
`frontend-modern/src/components/Workloads/workloadsFilterModel.ts` and
`frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts`.
stays in `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`, which
composes the shared `FilterBar` chip primitive, while toolbar defaults,
active-filter counting, and reset semantics live in
`frontend-modern/src/components/Workloads/workloadsFilterModel.ts`. The legacy
`useWorkloadsFilterState.ts` hook retired with the FilterBar migration; its
`countActiveWorkloadsFilters` and `hasActiveWorkloadsFilters` helpers stay on
`workloadsFilterModel.ts`.
Workloads table-mode controls must also keep their accessible group name aligned
with the shared table presentation contract by using `Group by` for the
grouped/list selector instead of reintroducing local `Group By` casing or
platform-specific cluster wording. Dense workload toolbar variants must keep
that same row wrap-capable so optional runtime, chart, column, and reset
controls remain reachable on desktop instead of forcing a single no-wrap row
that clips trailing actions. The workload shell must route table display
actions such as grouped/list mode and chart visibility through
`PageControls.toolbarTrailing`, leaving route/filter selects as primary toolbar
children so the display-action cluster wraps together with Columns/Reset across
narrow desktop widths. Workload chart visibility is a display preference, not
that clips trailing actions. The workload shell now routes table display
actions such as grouped/list mode and chart visibility through the shared
`FilterBar.viewOptionsTrailing` slot, leaving filter chips as primary toolbar
children so the display-action cluster wraps together with Columns and the
"+ Filter" menu across narrow desktop widths. Workload chart visibility is a display preference, not
an in-summary collapse affordance: the toolbar action must expose explicit
`Show charts` / `Hide charts` pressed state, and hiding charts must remove the
summary section rather than leaving an empty collapsed summary band on screen.

View file

@ -4208,7 +4208,6 @@
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts",
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts",
@ -4252,7 +4251,6 @@
"frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/useThresholdSliderState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadsFilterState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/workloadFilterConfigModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts",
@ -4357,7 +4355,6 @@
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts",
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts",
@ -4398,7 +4395,6 @@
"frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/useThresholdSliderState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadsFilterState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/workloadFilterConfigModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts",
@ -4875,7 +4871,6 @@
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx",
"frontend-modern/src/components/Storage/__tests__/Storage.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StorageControls.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx",
"frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts",

View file

@ -79,16 +79,20 @@ state.
`overflow-x-auto` div around the shared table. Storage pools and physical
disks inherit the surrounding `StorageContentCard` frame and must not add
a nested `Card` or second scroll wrapper.
Recovery event filters must also keep page-level search, advanced filter,
column visibility, and reset controls on the shared `PageControls` toolbar
rail. Recovery must not reintroduce local no-wrap toolbar overrides or
right-aligned utility wrappers that prevent the frontend-primitives action
rail and structured control deck from owning responsive collapse behavior
and visible filter/action section boundaries.
Compact stable recovery-event filters, such as event outcome/status, may use
the frontend-primitives responsive toggle/select control, but Recovery must
keep route-backed event filter state and query semantics in its recovery
owner rather than introducing a page-local presentation state path.
Recovery event filters now compose the shared chip-based `FilterBar`
(`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a
`FilterDef[]` catalog. The legacy "advanced filter popover" retired: scope,
method, verification, cluster, node, and namespace fold into the same chip
catalog as Item Type, Platform, and Status. Page-level search, column
visibility, and the rollup item filter live in the shared search row; the
chip row appears below it only when filters are active. Recovery must not
reintroduce a parallel `PageControls` toolbar or local no-wrap overrides
that bypass the FilterBar shell.
Compact stable recovery-event filters, such as event outcome/status, ride
the same chip popover affordance the rest of the catalog uses, with
type-ahead in the "+ Filter" menu and chip popovers, but Recovery must keep
route-backed event filter state and query semantics in its recovery owner
rather than introducing a page-local presentation state path.
Recovery inventory protection posture and recovery-event outcome filtering
must stay separate in that owner: protected inventory uses the route-backed
`state` query for health, stale, failed, warning, running, unknown, and
@ -103,17 +107,20 @@ state.
protected-item, and recovery-outcome readiness claims belong on the Storage
and Recovery surfaces or their shared summary components, not in a restored
dashboard panel cluster or Assistant brief.
Storage summary chart visibility is a page-level display preference owned
by the Storage page model and exposed through the shared `PageControls`
trailing action rail. Storage must keep the charts toggle, column picker,
and reset affordance on that shared rail so the Workloads and Storage
filter sections collapse through the same primitive contract. The charts
toggle must read as an explicit `Show charts` / `Hide charts` pressed
display action, and the off state must remove the summary section fully
instead of leaving a collapsed summary shell in the interface. Storage
filters inherit the shared `PageControls` structured deck and must not
duplicate page-local control-deck, action-rail, border, or background class
strings.
Storage filters now compose the shared chip-based `FilterBar` shell
through `frontend-modern/src/components/Storage/StoragePageControls.tsx`.
The legacy three-layer indirection
(`StoragePageControls``StorageControls``StorageFilter`) and the
bare unlabelled Node `<select>` retired with the migration; Node, Source,
Status, Group by, and the disk-role / disk-group filters all enter the
`FilterDef[]` catalog. Subtabs (Pools / Physical Disks) sit above the bar
as navigation, not filters. Storage summary chart visibility remains a
page-level display preference, but it now rides the shared
`FilterBar.viewOptionsTrailing` slot together with the sort key/direction
controls. The charts toggle must read as an explicit `Show charts` /
`Hide charts` pressed display action, and the off state must remove the
summary section fully instead of leaving a collapsed summary shell in the
interface.
Ceph table shells on the storage route share the same frontend-primitives
table contract: `frontend-modern/src/pages/Ceph.tsx` may own Ceph-specific
columns and rows, but horizontal overflow and scrollbar hiding must route

View file

@ -1,119 +0,0 @@
import { Component, For, JSX } from 'solid-js';
import { Subtabs } from '@/components/shared/Subtabs';
import {
STORAGE_CONTROLS_NODE_DIVIDER_CLASS,
STORAGE_CONTROLS_NODE_SELECT_CLASS,
} from '@/features/storageBackups/storagePagePresentation';
import {
StorageFilter,
type StorageGroupByFilter,
type StorageStatusFilter,
} from './StorageFilter';
import type { StorageView } from './storagePageState';
import type { StorageSortKey } from './useStorageModel';
import { DEFAULT_STORAGE_SORT_OPTIONS } from './storagePageState';
import { useStorageControlsModel } from './useStorageControlsModel';
import type { StorageSourceOption } from '@/utils/storageSources';
import type { PhysicalDiskFilterOption } from '@/features/storageBackups/diskPresentation';
type StorageControlsProps = {
view: StorageView;
onViewChange: (value: StorageView) => void;
search: () => string;
setSearch: (value: string) => void;
searchTrailing?: JSX.Element;
groupBy?: () => StorageGroupByFilter;
setGroupBy?: (value: StorageGroupByFilter) => void;
sortKey: () => StorageSortKey;
setSortKey: (value: StorageSortKey) => void;
sortDirection: () => 'asc' | 'desc';
setSortDirection: (value: 'asc' | 'desc') => void;
sortDisabled?: boolean;
statusFilter: () => StorageStatusFilter;
setStatusFilter: (value: StorageStatusFilter) => void;
sourceFilter: () => string;
setSourceFilter: (value: string) => void;
sourceOptions: () => StorageSourceOption[];
diskRoleFilter?: () => string;
setDiskRoleFilter?: (value: string) => void;
diskRoleOptions?: () => PhysicalDiskFilterOption[];
diskGroupFilter?: () => string;
setDiskGroupFilter?: (value: string) => void;
diskGroupOptions?: () => PhysicalDiskFilterOption[];
nodeFilterOptions: Array<{ value: string; label: string }>;
selectedNodeId: () => string;
setSelectedNodeId: (value: string) => void;
chartsCollapsed?: () => boolean;
onChartsToggle?: () => void;
mobileTrailing?: JSX.Element;
utilityActions?: JSX.Element;
};
export const StorageControls: Component<StorageControlsProps> = (props) => {
const model = useStorageControlsModel({
selectedNodeId: props.selectedNodeId,
setSelectedNodeId: props.setSelectedNodeId,
onViewChange: props.onViewChange,
});
const leadingFilters = (): JSX.Element => (
<>
<select
value={props.selectedNodeId()}
onChange={(event) => model.handleNodeFilterChange(event.currentTarget.value)}
class={STORAGE_CONTROLS_NODE_SELECT_CLASS}
aria-label="Node"
>
<For each={props.nodeFilterOptions}>
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</select>
<div class={STORAGE_CONTROLS_NODE_DIVIDER_CLASS}></div>
</>
);
return (
<>
<Subtabs
value={props.view}
onChange={model.handleViewChange}
ariaLabel="Storage view"
tabs={model.viewTabs}
/>
<StorageFilter
search={props.search}
setSearch={props.setSearch}
searchTrailing={props.searchTrailing}
groupBy={props.groupBy}
setGroupBy={props.setGroupBy}
sortKey={props.sortKey}
setSortKey={(value) => props.setSortKey(value as StorageSortKey)}
sortDirection={props.sortDirection}
setSortDirection={props.setSortDirection}
sortOptions={DEFAULT_STORAGE_SORT_OPTIONS}
sortDisabled={props.sortDisabled}
statusFilter={props.statusFilter}
setStatusFilter={props.setStatusFilter}
sourceFilter={props.sourceFilter}
setSourceFilter={props.setSourceFilter}
sourceOptions={props.sourceOptions}
diskRoleFilter={props.diskRoleFilter}
setDiskRoleFilter={props.setDiskRoleFilter}
diskRoleOptions={props.diskRoleOptions}
diskGroupFilter={props.diskGroupFilter}
setDiskGroupFilter={props.setDiskGroupFilter}
diskGroupOptions={props.diskGroupOptions}
selectedNodeId={props.selectedNodeId}
setSelectedNodeId={props.setSelectedNodeId}
leadingFilters={leadingFilters()}
chartsCollapsed={props.chartsCollapsed}
onChartsToggle={props.onChartsToggle}
mobileTrailing={props.mobileTrailing}
utilityActions={props.utilityActions}
/>
</>
);
};
export default StorageControls;

View file

@ -1,310 +0,0 @@
import { Component, For, Show, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import {
ChartVisibilityToggleButton,
FilterDivider,
FilterSegmentedControl,
LabeledFilterSelect,
} from '@/components/shared/FilterToolbar';
import { PageControls } from '@/components/shared/PageControls';
import { SearchInput } from '@/components/shared/SearchInput';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { STORAGE_KEYS } from '@/utils/localStorage';
import type { StorageSourceOption } from '@/utils/storageSources';
import type { PhysicalDiskFilterOption } from '@/features/storageBackups/diskPresentation';
import {
STORAGE_FILTER_COMPACT_SELECT_CLASS,
STORAGE_FILTER_RESET_ACTION_CLASS,
STORAGE_FILTER_SEGMENTED_WRAP_CLASS,
STORAGE_FILTER_SORT_DIRECTION_BUTTON_CLASS,
STORAGE_FILTER_SORT_ICON_CLASS,
STORAGE_FILTER_SORT_WRAP_CLASS,
STORAGE_FILTER_SORT_SELECT_CLASS,
} from '@/features/storageBackups/storageFilterPresentation';
import {
DEFAULT_STORAGE_SORT_OPTIONS,
STORAGE_GROUP_BY_OPTIONS,
STORAGE_STATUS_FILTER_OPTIONS,
} from './storagePageState';
import { useStorageFilterToolbarModel } from './useStorageFilterToolbarModel';
export type StorageStatusFilter =
| 'all'
| 'attention'
| 'available'
| 'warning'
| 'critical'
| 'offline'
| 'unknown';
export type StorageGroupByFilter = 'node' | 'type' | 'status' | 'none';
interface StorageFilterProps {
search: () => string;
setSearch: (value: string) => void;
searchTrailing?: JSX.Element;
groupBy?: () => StorageGroupByFilter;
setGroupBy?: (value: StorageGroupByFilter) => void;
sortKey: () => string;
setSortKey: (value: string) => void;
sortDirection: () => 'asc' | 'desc';
setSortDirection: (value: 'asc' | 'desc') => void;
sortOptions?: { value: string; label: string }[];
sortDisabled?: boolean;
statusFilter?: () => StorageStatusFilter;
setStatusFilter?: (value: StorageStatusFilter) => void;
sourceFilter?: () => string;
setSourceFilter?: (value: string) => void;
sourceOptions?: () => StorageSourceOption[];
diskRoleFilter?: () => string;
setDiskRoleFilter?: (value: string) => void;
diskRoleOptions?: () => PhysicalDiskFilterOption[];
diskGroupFilter?: () => string;
setDiskGroupFilter?: (value: string) => void;
diskGroupOptions?: () => PhysicalDiskFilterOption[];
selectedNodeId?: () => string;
setSelectedNodeId?: (value: string) => void;
chartsCollapsed?: () => boolean;
onChartsToggle?: () => void;
// Slot for page-specific filters (e.g., view toggle, node selector).
leadingFilters?: JSX.Element;
// Column visibility (optional)
columnVisibility?: {
availableToggles: () => ColumnDef[];
isHiddenByUser: (id: string) => boolean;
toggle: (id: string) => void;
resetToDefaults: () => void;
};
mobileTrailing?: JSX.Element;
utilityActions?: JSX.Element;
}
export const StorageFilter: Component<StorageFilterProps> = (props) => {
const { isMobile } = useBreakpoint();
const {
filtersOpen,
setFiltersOpen,
activeFilterCount,
showReset,
sortOptions,
sourceOptions,
sortDirectionTitle,
sortDirectionIconClass,
toggleSortDirection,
resetFilters,
} = useStorageFilterToolbarModel({
search: props.search,
setSearch: props.setSearch,
groupBy: props.groupBy,
setGroupBy: props.setGroupBy,
sortKey: props.sortKey,
setSortKey: props.setSortKey,
sortDirection: props.sortDirection,
setSortDirection: props.setSortDirection,
statusFilter: props.statusFilter,
setStatusFilter: props.setStatusFilter,
sourceFilter: props.sourceFilter,
setSourceFilter: props.setSourceFilter,
diskRoleFilter: props.diskRoleFilter,
setDiskRoleFilter: props.setDiskRoleFilter,
diskGroupFilter: props.diskGroupFilter,
setDiskGroupFilter: props.setDiskGroupFilter,
selectedNodeId: props.selectedNodeId,
setSelectedNodeId: props.setSelectedNodeId,
sortOptions: props.sortOptions ?? DEFAULT_STORAGE_SORT_OPTIONS,
sourceOptions: props.sourceOptions,
});
const chartsToolbarAction = () =>
props.onChartsToggle ? (
<ChartVisibilityToggleButton
collapsed={props.chartsCollapsed?.() ?? false}
onToggle={() => props.onChartsToggle?.()}
/>
) : undefined;
return (
<Card class="storage-filter mb-3" padding="sm">
<PageControls
contentClass="gap-3"
search={
<SearchInput
value={props.search}
onChange={props.setSearch}
placeholder="Search storage... (e.g., local, nfs, node:pve1)"
title="Search storage by name or filter by node"
typeToSearch
history={{
storageKey: STORAGE_KEYS.STORAGE_SEARCH_HISTORY,
emptyMessage: 'Your recent storage searches will show here.',
}}
tips={{
popoverId: 'storage-search-help',
intro: 'Quick examples',
tips: [
{ code: 'local', description: 'Storage with "local" in the name' },
{ code: 'node:pve1', description: 'Show storage on a specific node' },
{ code: 'nfs', description: 'Find NFS storage' },
],
footerHighlight: 'node:pve1 nfs',
footerText: 'Combine filters to zero in on exactly what you need.',
}}
/>
}
searchTrailing={props.searchTrailing}
mobileFilters={{
enabled: isMobile(),
onToggle: () => setFiltersOpen((o) => !o),
count: activeFilterCount(),
}}
mobileTrailing={props.mobileTrailing}
columnVisibility={props.columnVisibility}
resetAction={{
show: showReset(),
onClick: resetFilters,
title: 'Reset all filters',
class: STORAGE_FILTER_RESET_ACTION_CLASS,
icon: (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
),
}}
showFilters={!isMobile() || filtersOpen()}
toolbarClass="gap-x-1.5 gap-y-2 sm:gap-x-2"
toolbarTrailing={chartsToolbarAction()}
utilityActions={props.utilityActions}
>
{props.leadingFilters}
<Show when={props.groupBy && props.setGroupBy}>
<div class={STORAGE_FILTER_SEGMENTED_WRAP_CLASS}>
<FilterSegmentedControl
value={props.groupBy!()}
onChange={(value) => props.setGroupBy!(value as StorageGroupByFilter)}
aria-label="Group by"
options={STORAGE_GROUP_BY_OPTIONS}
/>
</div>
<FilterDivider />
</Show>
<Show when={props.sourceFilter && props.setSourceFilter}>
<LabeledFilterSelect
id="storage-source-filter"
label="Source"
value={props.sourceFilter!()}
onChange={(e) => props.setSourceFilter!(e.currentTarget.value)}
selectClass={STORAGE_FILTER_COMPACT_SELECT_CLASS}
>
<For each={sourceOptions()}>
{(option: StorageSourceOption) => <option value={option.key}>{option.label}</option>}
</For>
</LabeledFilterSelect>
<FilterDivider />
</Show>
<Show
when={
props.diskRoleFilter &&
props.setDiskRoleFilter &&
(props.diskRoleOptions?.().length ?? 0) > 1
}
>
<LabeledFilterSelect
id="storage-disk-role-filter"
label="Role"
value={props.diskRoleFilter!()}
onChange={(e) => props.setDiskRoleFilter!(e.currentTarget.value)}
selectClass={STORAGE_FILTER_COMPACT_SELECT_CLASS}
>
<For each={props.diskRoleOptions!()}>
{(option: PhysicalDiskFilterOption) => (
<option value={option.value}>{option.label}</option>
)}
</For>
</LabeledFilterSelect>
<FilterDivider />
</Show>
<Show
when={
props.diskGroupFilter &&
props.setDiskGroupFilter &&
(props.diskGroupOptions?.().length ?? 0) > 1
}
>
<LabeledFilterSelect
id="storage-disk-group-filter"
label="Group"
value={props.diskGroupFilter!()}
onChange={(e) => props.setDiskGroupFilter!(e.currentTarget.value)}
selectClass={STORAGE_FILTER_COMPACT_SELECT_CLASS}
>
<For each={props.diskGroupOptions!()}>
{(option: PhysicalDiskFilterOption) => (
<option value={option.value}>{option.label}</option>
)}
</For>
</LabeledFilterSelect>
<FilterDivider />
</Show>
<LabeledFilterSelect
id="storage-status-filter"
label="Status"
value={props.statusFilter?.() ?? 'all'}
onChange={(e) => props.setStatusFilter?.(e.currentTarget.value as StorageStatusFilter)}
selectClass={STORAGE_FILTER_COMPACT_SELECT_CLASS}
>
{STORAGE_STATUS_FILTER_OPTIONS.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</LabeledFilterSelect>
<FilterDivider />
<div class={STORAGE_FILTER_SORT_WRAP_CLASS}>
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
disabled={props.sortDisabled}
aria-label="Sort by"
class={STORAGE_FILTER_SORT_SELECT_CLASS}
>
{sortOptions().map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
<button
type="button"
title={sortDirectionTitle()}
onClick={toggleSortDirection}
disabled={props.sortDisabled}
aria-label="Sort direction"
class={STORAGE_FILTER_SORT_DIRECTION_BUTTON_CLASS}
>
<svg
class={`${STORAGE_FILTER_SORT_ICON_CLASS} ${sortDirectionIconClass()}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</button>
</div>
</PageControls>
</Card>
);
};

View file

@ -30,10 +30,10 @@ import {
normalizeStorageSortKey,
STORAGE_GROUP_BY_OPTIONS,
STORAGE_STATUS_FILTER_OPTIONS,
type StorageStatusFilterValue,
type StorageView,
} from './storagePageState';
import type { StorageGroupKey, StorageSortKey } from './useStorageModel';
import type { StorageGroupByFilter, StorageStatusFilter } from './StorageFilter';
import type { StorageSourceOption } from '@/utils/storageSources';
type StoragePageControlsProps = {
@ -49,8 +49,8 @@ type StoragePageControlsProps = {
setSortKey: (value: StorageSortKey) => void;
sortDirection: () => 'asc' | 'desc';
setSortDirection: (value: 'asc' | 'desc') => void;
statusFilter: () => StorageStatusFilter;
setStatusFilter: (value: StorageStatusFilter) => void;
statusFilter: () => StorageStatusFilterValue;
setStatusFilter: (value: StorageStatusFilterValue) => void;
sourceFilter: () => string;
setSourceFilter: (value: string) => void;
sourceOptions: () => StorageSourceOption[];
@ -63,7 +63,7 @@ type StoragePageControlsProps = {
nodeFilterOptions: Array<{ value: string; label: string }>;
selectedNodeId: () => string;
setSelectedNodeId: (value: string) => void;
storageFilterGroupBy: () => StorageGroupByFilter;
storageFilterGroupBy: () => StorageGroupKey;
chartsCollapsed?: () => boolean;
onChartsToggle?: () => void;
};
@ -96,7 +96,7 @@ export const StoragePageControls: Component<StoragePageControlsProps> = (props)
props.setSortKey(DEFAULT_STORAGE_SORT_KEY);
props.setSortDirection(DEFAULT_STORAGE_SORT_DIRECTION);
props.setGroupBy(DEFAULT_STORAGE_GROUP_KEY);
props.setStatusFilter(DEFAULT_STORAGE_STATUS_FILTER as StorageStatusFilter);
props.setStatusFilter(DEFAULT_STORAGE_STATUS_FILTER as StorageStatusFilterValue);
props.setSourceFilter(DEFAULT_STORAGE_SOURCE_FILTER);
props.setDiskRoleFilter?.(DEFAULT_PHYSICAL_DISK_FACET_FILTER);
props.setDiskGroupFilter?.(DEFAULT_PHYSICAL_DISK_FACET_FILTER);
@ -155,8 +155,8 @@ export const StoragePageControls: Component<StoragePageControlsProps> = (props)
group: 'status',
value: () => props.statusFilter(),
setValue: (value: string) =>
props.setStatusFilter(value as StorageStatusFilter),
defaultValue: DEFAULT_STORAGE_STATUS_FILTER as StorageStatusFilter,
props.setStatusFilter(value as StorageStatusFilterValue),
defaultValue: DEFAULT_STORAGE_STATUS_FILTER as StorageStatusFilterValue,
options: () =>
STORAGE_STATUS_FILTER_OPTIONS.map((option) => ({
value: option.value as string,

View file

@ -1,164 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { StorageControls } from '@/components/Storage/StorageControls';
import type { StorageSourceOption } from '@/utils/storageSources';
describe('StorageControls', () => {
it('renders the shared storage controls and node filter', () => {
const [view, setView] = createSignal<'pools' | 'disks'>('pools');
const [search, setSearch] = createSignal('');
const [groupBy, setGroupBy] = createSignal<'none' | 'node'>('node');
const [sortKey, setSortKey] = createSignal<'priority' | 'name' | 'usage' | 'type'>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'warning' | 'critical'>('all');
const [sourceFilter, setSourceFilter] = createSignal('all');
const [sourceOptions] = createSignal<StorageSourceOption[]>([
{ key: 'all', label: 'All sources', tone: 'slate' as const },
{ key: 'proxmox-pve', label: 'PVE', tone: 'orange' as const },
]);
const [selectedNodeId, setSelectedNodeId] = createSignal('all');
render(() => (
<StorageControls
view={view()}
onViewChange={setView}
search={search}
setSearch={setSearch}
groupBy={groupBy}
setGroupBy={setGroupBy}
sortKey={sortKey}
setSortKey={setSortKey}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
sourceOptions={sourceOptions}
nodeFilterOptions={[
{ value: 'all', label: 'All nodes' },
{ value: 'node-1', label: 'pve1' },
]}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId}
/>
));
expect(screen.getByLabelText('Storage view')).toBeInTheDocument();
expect(screen.getByLabelText('Node')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Node'), {
target: { value: 'node-1' },
});
expect(selectedNodeId()).toBe('node-1');
});
it('updates source filter options when storage data arrives after first render', async () => {
const [view, setView] = createSignal<'pools' | 'disks'>('pools');
const [search, setSearch] = createSignal('');
const [groupBy, setGroupBy] = createSignal<'none' | 'node'>('node');
const [sortKey, setSortKey] = createSignal<'priority' | 'name' | 'usage' | 'type'>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'warning' | 'critical'>('all');
const [sourceFilter, setSourceFilter] = createSignal('truenas');
const [sourceOptions, setSourceOptions] = createSignal<StorageSourceOption[]>([
{ key: 'all', label: 'All sources', tone: 'slate' as const },
]);
const [selectedNodeId, setSelectedNodeId] = createSignal('all');
render(() => (
<StorageControls
view={view()}
onViewChange={setView}
search={search}
setSearch={setSearch}
groupBy={groupBy}
setGroupBy={setGroupBy}
sortKey={sortKey}
setSortKey={setSortKey}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
sourceOptions={sourceOptions}
nodeFilterOptions={[{ value: 'all', label: 'All nodes' }]}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId}
/>
));
setSourceOptions([
{ key: 'all', label: 'All sources', tone: 'slate' as const },
{ key: 'proxmox-pve', label: 'PVE', tone: 'orange' as const },
{ key: 'truenas', label: 'TrueNAS', tone: 'blue' as const },
]);
expect(
Array.from(screen.getByLabelText('Source').querySelectorAll('option')).map((option) => ({
value: option.value,
label: option.textContent,
})),
).toEqual([
{ value: 'all', label: 'All sources' },
{ value: 'proxmox-pve', label: 'PVE' },
{ value: 'truenas', label: 'TrueNAS' },
]);
await waitFor(() => {
expect(screen.getByLabelText('Source')).toHaveValue('truenas');
});
});
it('routes the charts toggle through the shared toolbar action rail', () => {
const [view, setView] = createSignal<'pools' | 'disks'>('pools');
const [search, setSearch] = createSignal('');
const [groupBy, setGroupBy] = createSignal<'none' | 'node'>('node');
const [sortKey, setSortKey] = createSignal<'priority' | 'name' | 'usage' | 'type'>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'warning' | 'critical'>('all');
const [sourceFilter, setSourceFilter] = createSignal('all');
const [sourceOptions] = createSignal<StorageSourceOption[]>([
{ key: 'all', label: 'All sources', tone: 'slate' as const },
]);
const [selectedNodeId, setSelectedNodeId] = createSignal('all');
const [chartsCollapsed] = createSignal(false);
const onChartsToggle = vi.fn();
render(() => (
<StorageControls
view={view()}
onViewChange={setView}
search={search}
setSearch={setSearch}
groupBy={groupBy}
setGroupBy={setGroupBy}
sortKey={sortKey}
setSortKey={setSortKey}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
sourceOptions={sourceOptions}
nodeFilterOptions={[{ value: 'all', label: 'All nodes' }]}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId}
chartsCollapsed={chartsCollapsed}
onChartsToggle={onChartsToggle}
/>
));
const chartsButton = screen.getByRole('button', { name: 'Hide charts' });
expect(chartsButton.closest('.page-controls-toolbar-actions')).not.toBeNull();
expect(chartsButton).toHaveTextContent('Charts');
expect(chartsButton).toHaveAttribute('aria-pressed', 'true');
expect(chartsButton).toHaveAttribute('title', 'Hide charts');
fireEvent.click(chartsButton);
expect(onChartsToggle).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,30 +0,0 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { useStorageControlsModel } from '@/components/Storage/useStorageControlsModel';
describe('useStorageControlsModel', () => {
it('centralizes storage controls view and node handlers', () => {
const [selectedNodeId, setSelectedNodeId] = createSignal('all');
const onViewChange = vi.fn();
const { result } = renderHook(() =>
useStorageControlsModel({
selectedNodeId,
setSelectedNodeId,
onViewChange,
}),
);
expect(result.viewTabs).toEqual([
{ value: 'pools', label: 'Pools' },
{ value: 'disks', label: 'Physical Disks' },
]);
result.handleNodeFilterChange('node-1');
expect(selectedNodeId()).toBe('node-1');
result.handleViewChange('disks');
expect(onViewChange).toHaveBeenCalledWith('disks');
});
});

View file

@ -1,99 +0,0 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it } from 'vitest';
import { useStorageFilterToolbarModel } from '@/components/Storage/useStorageFilterToolbarModel';
import type { StorageSourceOption } from '@/utils/storageSources';
describe('useStorageFilterToolbarModel', () => {
it('centralizes storage filter toolbar state and reset behavior', () => {
const [search, setSearch] = createSignal('tank');
const [groupBy, setGroupBy] = createSignal<'none' | 'node'>('node');
const [sortKey, setSortKey] = createSignal('usage');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'warning'>('warning');
const [sourceFilter, setSourceFilter] = createSignal('agent');
const [diskRoleFilter, setDiskRoleFilter] = createSignal('nvme-disk');
const [diskGroupFilter, setDiskGroupFilter] = createSignal('data');
const [selectedNodeId, setSelectedNodeId] = createSignal('node-1');
const { result } = renderHook(() =>
useStorageFilterToolbarModel({
search,
setSearch,
groupBy,
setGroupBy,
sortKey,
setSortKey,
sortDirection,
setSortDirection,
statusFilter,
setStatusFilter,
sourceFilter,
setSourceFilter,
diskRoleFilter,
setDiskRoleFilter,
diskGroupFilter,
setDiskGroupFilter,
selectedNodeId,
setSelectedNodeId,
sortOptions: [{ value: 'usage', label: 'Usage' }],
}),
);
expect(result.activeFilterCount()).toBeGreaterThan(0);
expect(result.showReset()).toBe(true);
expect(result.sortOptions()).toEqual([{ value: 'usage', label: 'Usage' }]);
expect(result.sortDirectionTitle()).toBe('Sort descending');
expect(result.sortDirectionIconClass()).toBe('rotate-180');
result.toggleSortDirection();
expect(sortDirection()).toBe('desc');
result.resetFilters();
expect(search()).toBe('');
expect(groupBy()).toBe('none');
expect(sortKey()).toBe('priority');
expect(sortDirection()).toBe('desc');
expect(statusFilter()).toBe('all');
expect(sourceFilter()).toBe('all');
expect(diskRoleFilter()).toBe('all');
expect(diskGroupFilter()).toBe('all');
expect(selectedNodeId()).toBe('all');
});
it('keeps storage source options reactive for restored route state', () => {
const [search, setSearch] = createSignal('');
const [sortKey, setSortKey] = createSignal('priority');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('desc');
const [sourceOptions, setSourceOptions] = createSignal<StorageSourceOption[]>([
{ key: 'all', label: 'All sources', tone: 'slate' as const },
]);
const { result } = renderHook(() =>
useStorageFilterToolbarModel({
search,
setSearch,
sortKey,
setSortKey,
sortDirection,
setSortDirection,
sourceOptions,
}),
);
expect(result.sourceOptions()).toEqual([{ key: 'all', label: 'All sources', tone: 'slate' }]);
setSourceOptions([
{ key: 'all', label: 'All sources', tone: 'slate' },
{ key: 'proxmox-pve', label: 'PVE', tone: 'orange' },
{ key: 'truenas', label: 'TrueNAS', tone: 'blue' },
]);
expect(result.sourceOptions()).toEqual([
{ key: 'all', label: 'All sources', tone: 'slate' },
{ key: 'proxmox-pve', label: 'PVE', tone: 'orange' },
{ key: 'truenas', label: 'TrueNAS', tone: 'blue' },
]);
});
});

View file

@ -1,34 +0,0 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { useStoragePageControlsModel } from '@/components/Storage/useStoragePageControlsModel';
describe('useStoragePageControlsModel', () => {
it('derives canonical controls wiring from page state', () => {
const [kioskMode] = createSignal(false);
const [view] = createSignal<'pools' | 'disks'>('pools');
const setGroupBy = vi.fn();
const setSortKey = vi.fn();
const [storageFilterGroupBy] = createSignal<'none' | 'node'>('node');
const { result } = renderHook(() =>
useStoragePageControlsModel({
kioskMode,
view,
setGroupBy,
setSortKey,
storageFilterGroupBy,
}),
);
expect(result.showControls()).toBe(true);
expect(result.sortDisabled()).toBe(false);
expect(result.groupBy()?.()).toBe('node');
result.setGroupBy()?.('node');
expect(setGroupBy).toHaveBeenCalledWith('node');
result.setNormalizedSortKey('type');
expect(setSortKey).toHaveBeenCalledWith('type');
});
});

View file

@ -1,24 +0,0 @@
import { STORAGE_VIEW_OPTIONS } from '@/features/storageBackups/storagePagePresentation';
import type { StorageView } from './storagePageState';
type StorageControlsModelOptions = {
selectedNodeId: () => string;
setSelectedNodeId: (value: string) => void;
onViewChange: (value: StorageView) => void;
};
export const useStorageControlsModel = (options: StorageControlsModelOptions) => {
const handleNodeFilterChange = (value: string) => {
options.setSelectedNodeId(value);
};
const handleViewChange = (value: string) => {
options.onViewChange(value as StorageView);
};
return {
viewTabs: STORAGE_VIEW_OPTIONS as { value: string; label: string }[],
handleNodeFilterChange,
handleViewChange,
};
};

View file

@ -18,10 +18,10 @@ import {
getStorageFilterGroupBy,
getStorageStatusFilterValue,
toStorageHealthFilterValue,
type StorageStatusFilterValue,
type StorageView,
} from './storagePageState';
import type { StorageNodeOption, StorageGroupKey } from './useStorageModel';
import type { StorageGroupByFilter, StorageStatusFilter } from './StorageFilter';
type UseStorageFilterStateOptions = {
view: Accessor<StorageView>;
@ -80,15 +80,15 @@ export const useStorageFilterState = (options: UseStorageFilterStateOptions) =>
],
);
const storageFilterGroupBy = (): StorageGroupByFilter => {
const storageFilterGroupBy = (): StorageGroupKey => {
return getStorageFilterGroupBy(options.groupBy());
};
const storageFilterStatus = (): StorageStatusFilter => {
const storageFilterStatus = (): StorageStatusFilterValue => {
return getStorageStatusFilterValue(options.healthFilter());
};
const setStorageFilterStatus = (value: StorageStatusFilter) => {
const setStorageFilterStatus = (value: StorageStatusFilterValue) => {
options.setHealthFilter(toStorageHealthFilterValue(value));
};

View file

@ -1,122 +0,0 @@
import { createMemo, createSignal, type Accessor } from 'solid-js';
import { DEFAULT_STORAGE_SOURCE_OPTIONS, type StorageSourceOption } from '@/utils/storageSources';
import {
getNextStorageSortDirection,
getStorageSortDirectionIconClass,
getStorageSortDirectionTitle,
} from '@/features/storageBackups/storageFilterPresentation';
import {
countActiveStorageFilters,
DEFAULT_STORAGE_GROUP_KEY,
DEFAULT_STORAGE_DISK_GROUP_FILTER,
DEFAULT_STORAGE_DISK_ROLE_FILTER,
DEFAULT_STORAGE_SORT_DIRECTION,
DEFAULT_STORAGE_SORT_KEY,
DEFAULT_STORAGE_SOURCE_FILTER,
DEFAULT_STORAGE_SELECTED_NODE_ID,
DEFAULT_STORAGE_STATUS_FILTER,
hasActiveStorageFilters,
type StorageOption,
} from './storagePageState';
import type { StorageGroupByFilter, StorageStatusFilter } from './StorageFilter';
type UseStorageFilterToolbarModelOptions = {
search: Accessor<string>;
setSearch: (value: string) => void;
groupBy?: Accessor<StorageGroupByFilter | undefined>;
setGroupBy?: (value: StorageGroupByFilter) => void;
sortKey: Accessor<string>;
setSortKey: (value: string) => void;
sortDirection: Accessor<'asc' | 'desc'>;
setSortDirection: (value: 'asc' | 'desc') => void;
statusFilter?: Accessor<StorageStatusFilter | undefined>;
setStatusFilter?: (value: StorageStatusFilter) => void;
sourceFilter?: Accessor<string | undefined>;
setSourceFilter?: (value: string) => void;
diskRoleFilter?: Accessor<string | undefined>;
setDiskRoleFilter?: (value: string) => void;
diskGroupFilter?: Accessor<string | undefined>;
setDiskGroupFilter?: (value: string) => void;
selectedNodeId?: Accessor<string | undefined>;
setSelectedNodeId?: (value: string) => void;
sortOptions?: StorageOption[];
sourceOptions?: Accessor<StorageSourceOption[] | undefined>;
};
export const useStorageFilterToolbarModel = (options: UseStorageFilterToolbarModelOptions) => {
const [filtersOpen, setFiltersOpen] = createSignal(false);
const activeFilterCount = createMemo(() => {
const nodeActive =
(options.selectedNodeId?.() || DEFAULT_STORAGE_SELECTED_NODE_ID) !==
DEFAULT_STORAGE_SELECTED_NODE_ID;
return (
countActiveStorageFilters({
search: options.search(),
sortKey: options.sortKey(),
sortDirection: options.sortDirection(),
groupBy: options.groupBy?.(),
statusFilter: options.statusFilter?.(),
sourceFilter: options.sourceFilter?.(),
diskRoleFilter: options.diskRoleFilter?.(),
diskGroupFilter: options.diskGroupFilter?.(),
}) + (nodeActive ? 1 : 0)
);
});
const showReset = createMemo(
() =>
hasActiveStorageFilters({
search: options.search(),
sortKey: options.sortKey(),
sortDirection: options.sortDirection(),
groupBy: options.groupBy?.(),
statusFilter: options.statusFilter?.(),
sourceFilter: options.sourceFilter?.(),
diskRoleFilter: options.diskRoleFilter?.(),
diskGroupFilter: options.diskGroupFilter?.(),
}) ||
(options.selectedNodeId?.() || DEFAULT_STORAGE_SELECTED_NODE_ID) !==
DEFAULT_STORAGE_SELECTED_NODE_ID,
);
const sortOptions = createMemo(() => options.sortOptions ?? []);
const sourceOptions = createMemo(
() => options.sourceOptions?.() ?? DEFAULT_STORAGE_SOURCE_OPTIONS,
);
const sortDirectionTitle = createMemo(() =>
getStorageSortDirectionTitle(options.sortDirection()),
);
const sortDirectionIconClass = createMemo(() =>
getStorageSortDirectionIconClass(options.sortDirection()),
);
const toggleSortDirection = () => {
options.setSortDirection(getNextStorageSortDirection(options.sortDirection()));
};
const resetFilters = () => {
options.setSearch('');
options.setSortKey(DEFAULT_STORAGE_SORT_KEY);
options.setSortDirection(DEFAULT_STORAGE_SORT_DIRECTION);
if (options.setGroupBy) options.setGroupBy(DEFAULT_STORAGE_GROUP_KEY);
if (options.setStatusFilter) options.setStatusFilter(DEFAULT_STORAGE_STATUS_FILTER);
if (options.setSourceFilter) options.setSourceFilter(DEFAULT_STORAGE_SOURCE_FILTER);
if (options.setDiskRoleFilter) options.setDiskRoleFilter(DEFAULT_STORAGE_DISK_ROLE_FILTER);
if (options.setDiskGroupFilter) options.setDiskGroupFilter(DEFAULT_STORAGE_DISK_GROUP_FILTER);
if (options.setSelectedNodeId) options.setSelectedNodeId(DEFAULT_STORAGE_SELECTED_NODE_ID);
};
return {
filtersOpen,
setFiltersOpen,
activeFilterCount,
showReset,
sortOptions,
sourceOptions,
sortDirectionTitle,
sortDirectionIconClass,
toggleSortDirection,
resetFilters,
};
};

View file

@ -1,34 +0,0 @@
import { createMemo } from 'solid-js';
import { normalizeStorageSortKey, type StorageView } from './storagePageState';
import type { StorageGroupKey, StorageSortKey } from './useStorageModel';
import type { StorageGroupByFilter } from './StorageFilter';
type UseStoragePageControlsModelOptions = {
kioskMode: () => boolean;
view: () => StorageView;
setGroupBy: (value: StorageGroupKey) => void;
setSortKey: (value: StorageSortKey) => void;
storageFilterGroupBy: () => StorageGroupByFilter;
};
export const useStoragePageControlsModel = (options: UseStoragePageControlsModelOptions) => {
const showControls = createMemo(() => !options.kioskMode());
const sortDisabled = createMemo(() => options.view() !== 'pools');
const groupBy = () => (options.view() === 'pools' ? options.storageFilterGroupBy : undefined);
const setGroupBy = () =>
options.view() === 'pools'
? ((value: StorageGroupByFilter) => options.setGroupBy(value as StorageGroupKey))
: undefined;
const setNormalizedSortKey = (value: StorageSortKey) => {
options.setSortKey(normalizeStorageSortKey(value));
};
return {
showControls,
sortDisabled,
groupBy,
setGroupBy,
setNormalizedSortKey,
};
};

View file

@ -25,7 +25,6 @@ import workloadsWorkloadViewportSyncSource from '../useWorkloadViewportSync.ts?r
import workloadsWorkloadRouteStateSource from '../useWorkloadRouteState.ts?raw';
import workloadsWorkloadUrlSyncSource from '../useWorkloadUrlSync.ts?raw';
import workloadsStateSource from '../useWorkloadsState.ts?raw';
import workloadsFilterStateSource from '../useWorkloadsFilterState.ts?raw';
import groupedTableWindowingSource from '../useGroupedTableWindowing.ts?raw';
import thresholdSliderSource from '../ThresholdSlider.tsx?raw';
import thresholdSliderModelSource from '../thresholdSliderModel.ts?raw';
@ -811,10 +810,8 @@ describe('Workloads performance contract', () => {
expect(workloadsFilterSource).not.toContain('const [filtersOpen, setFiltersOpen] =');
expect(workloadsFilterSource).not.toContain("props.setSortKey('name')");
expect(workloadsFilterSource).toContain('searchTrailing={props.searchTrailing}');
expect(workloadsFilterStateSource).toContain('countActiveWorkloadsFilters');
expect(workloadsFilterStateSource).not.toContain('props.containerRuntimeFilter?.onChange');
expect(workloadsFilterStateSource).toContain('useBreakpoint');
expect(workloadsFilterStateSource).toContain('DEFAULT_WORKLOADS_SORT_KEY');
// useWorkloadsFilterState hook retired in the cleanup commit; the
// canonical defaults / activity helpers now live in workloadsFilterModel.
expect(workloadsFilterModelSource).toContain('export const countActiveWorkloadsFilters');
expect(workloadsFilterModelSource).toContain('export const hasActiveWorkloadsFilters');
expect(workloadsFilterModelSource).toContain(

View file

@ -1,87 +0,0 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { useWorkloadsFilterState } from '@/components/Workloads/useWorkloadsFilterState';
vi.mock('@/hooks/useBreakpoint', () => ({
useBreakpoint: () => ({
isMobile: () => false,
}),
}));
describe('useWorkloadsFilterState', () => {
it('centralizes workloads filter state and reset behavior', () => {
const [search, setSearch] = createSignal('query');
const [viewMode, setViewMode] = createSignal<
'all' | 'vm' | 'container' | 'system-container' | 'app-container' | 'pod'
>('vm');
const [statusMode, setStatusMode] = createSignal<'all' | 'running' | 'degraded' | 'stopped'>(
'running',
);
const [groupingMode, setGroupingMode] = createSignal<'grouped' | 'flat'>('flat');
const [sortKey, setSortKey] = createSignal('cpu');
const [sortDirection, setSortDirection] = createSignal('desc');
const hostOnChange = vi.fn();
const platformOnChange = vi.fn();
const namespaceOnChange = vi.fn();
const runtimeOnChange = vi.fn();
const { result } = renderHook(() =>
useWorkloadsFilterState({
search,
setSearch,
viewMode,
setViewMode,
statusMode,
setStatusMode,
groupingMode,
setGroupingMode,
setSortKey,
setSortDirection,
hostFilter: {
value: 'host-1',
options: [{ value: 'host-1', label: 'Host 1' }],
onChange: hostOnChange,
},
platformFilter: {
value: 'truenas',
options: [{ value: 'truenas', label: 'TrueNAS' }],
onChange: platformOnChange,
},
namespaceFilter: {
value: 'ns-1',
options: [{ value: 'ns-1', label: 'NS 1' }],
onChange: namespaceOnChange,
},
containerRuntimeFilter: {
value: 'docker',
options: [{ value: 'docker', label: 'Docker' }],
onChange: runtimeOnChange,
},
}),
);
expect(result.activeFilterCount()).toBe(6);
expect(result.showReset()).toBe(true);
expect(result.showToolbarFilters()).toBe(true);
expect(result.filtersOpen()).toBe(false);
expect(result.isMobile()).toBe(false);
result.setFiltersOpen(true);
expect(result.filtersOpen()).toBe(true);
expect(result.showToolbarFilters()).toBe(true);
result.resetFilters();
expect(search()).toBe('');
expect(viewMode()).toBe('all');
expect(statusMode()).toBe('all');
expect(groupingMode()).toBe('grouped');
expect(sortKey()).toBe('type');
expect(sortDirection()).toBe('asc');
expect(hostOnChange).toHaveBeenCalledWith('');
expect(platformOnChange).toHaveBeenCalledWith('');
expect(namespaceOnChange).toHaveBeenCalledWith('');
expect(runtimeOnChange).not.toHaveBeenCalled();
});
});

View file

@ -1,82 +0,0 @@
import { createMemo, createSignal } from 'solid-js';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import {
countActiveWorkloadsFilters,
DEFAULT_WORKLOADS_GROUPING_MODE,
DEFAULT_WORKLOADS_SORT_DIRECTION,
DEFAULT_WORKLOADS_SORT_KEY,
DEFAULT_WORKLOADS_STATUS_MODE,
DEFAULT_WORKLOADS_VIEW_MODE,
hasActiveWorkloadsFilters,
type WorkloadsFilterProps,
} from './workloadsFilterModel';
export const useWorkloadsFilterState = (props: WorkloadsFilterProps) => {
const { isMobile } = useBreakpoint();
const [filtersOpen, setFiltersOpen] = createSignal(false);
const activeFilterCount = createMemo(() =>
countActiveWorkloadsFilters({
search: props.search(),
viewMode: props.viewMode(),
statusMode: props.statusMode(),
hostFilterValue: props.hostFilter?.value,
platformFilterValue: props.platformFilter?.value,
namespaceFilterValue: props.namespaceFilter?.value,
}),
);
const showReset = createMemo(() =>
hasActiveWorkloadsFilters({
search: props.search(),
viewMode: props.viewMode(),
statusMode: props.statusMode(),
groupingMode: props.groupingMode(),
hostFilterValue: props.hostFilter?.value,
platformFilterValue: props.platformFilter?.value,
namespaceFilterValue: props.namespaceFilter?.value,
}),
);
const pageControlsColumnVisibility = createMemo(() =>
props.columnVisibility
? {
availableToggles: () => props.columnVisibility!.availableColumns,
isHiddenByUser: props.columnVisibility!.isColumnHidden,
toggle: props.columnVisibility!.onColumnToggle,
resetToDefaults: props.columnVisibility!.onColumnReset ?? (() => undefined),
}
: undefined,
);
const toggleFilters = () => {
setFiltersOpen((open) => !open);
};
const resetFilters = () => {
props.setSearch('');
props.setSortKey(DEFAULT_WORKLOADS_SORT_KEY);
props.setSortDirection(DEFAULT_WORKLOADS_SORT_DIRECTION);
props.setViewMode(DEFAULT_WORKLOADS_VIEW_MODE);
props.setStatusMode(DEFAULT_WORKLOADS_STATUS_MODE);
props.setGroupingMode(DEFAULT_WORKLOADS_GROUPING_MODE);
props.hostFilter?.onChange('');
props.platformFilter?.onChange('');
props.namespaceFilter?.onChange('');
};
return {
activeFilterCount,
filtersOpen,
isMobile,
pageControlsColumnVisibility,
resetFilters,
setFiltersOpen,
showReset,
showToolbarFilters: createMemo(() => !isMobile() || filtersOpen()),
toggleFilters,
};
};

View file

@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import pageControlsSource from '@/components/shared/PageControls.tsx?raw';
import filterToolbarSource from '@/components/shared/FilterToolbar.tsx?raw';
import workloadsFilterSource from '@/components/Workloads/WorkloadsFilter.tsx?raw';
import storageFilterSource from '@/components/Storage/StorageFilter.tsx?raw';
import recoveryPageSource from '@/components/Recovery/Recovery.tsx?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
@ -47,9 +46,8 @@ describe('page controls guardrails', () => {
// FilterHeader check.
expect(workloadsFilterSource).not.toContain('<FilterHeader');
expect(storageFilterSource).toContain('PageControls');
expect(storageFilterSource).not.toContain('<FilterHeader');
expect(storageFilterSource).not.toContain('<ColumnPicker');
// StorageFilter retired (legacy 3-layer indirection deleted); Storage's
// page-level shell is StoragePageControls + FilterBar now.
// RecoveryProtectedInventorySection migrated to FilterBar; defensive
// assertions remain to catch any regression that reintroduces FilterHeader
@ -142,15 +140,13 @@ describe('page controls guardrails', () => {
});
it('keeps display controls and utility actions on the shared toolbar rail', () => {
// WorkloadsFilter migrated to FilterBar's viewOptionsTrailing slot; the
// toolbarTrailing PageControls prop is no longer used. ChartVisibilityToggleButton
// and the no-aria-label-Charts regression guard still apply.
expect(storageFilterSource).toContain('toolbarTrailing={');
// Workloads / Infrastructure / Storage migrated to FilterBar's
// viewOptionsTrailing slot; the toolbarTrailing PageControls prop is no
// longer used. ChartVisibilityToggleButton and the
// no-aria-label-Charts regression guard still apply.
expect(workloadsFilterSource).toContain('ChartVisibilityToggleButton');
expect(storageFilterSource).toContain('ChartVisibilityToggleButton');
expect(infrastructurePageSurfaceSource).toContain('ChartVisibilityToggleButton');
expect(workloadsFilterSource).not.toContain('aria-label="Charts"');
expect(storageFilterSource).not.toContain('aria-label="Charts"');
expect(infrastructurePageSurfaceSource).not.toContain('aria-label="Charts"');
expect(pageControlsSource).toContain('page-controls-filter-controls');
expect(pageControlsSource).toContain('page-controls-toolbar-actions ml-auto');
@ -192,9 +188,6 @@ describe('page controls guardrails', () => {
// / LabeledFilterSelect no longer rendered.
expect(recoveryHistorySectionSource).not.toContain('LabeledFilterToggleGroup');
expect(recoveryHistorySectionSource).not.toContain('LabeledFilterSelect');
expect(storageFilterSource).toContain(
'<LabeledFilterSelect\n id="storage-status-filter"',
);
// Infrastructure migrated to FilterBar; chip popover replaces the labelled
// select. Regression guard ensures the legacy primitive stays out.
expect(infrastructurePageSurfaceSource).not.toContain('<LabeledFilterSelect');

View file

@ -1708,5 +1708,18 @@ describe('shared primitive guardrails', () => {
expect(storagePageControlsSource).not.toContain('<StorageFilter');
expect(storagePageControlsSource).not.toContain('LabeledFilterSelect');
expect(storagePageControlsSource).not.toContain('FilterSegmentedControl');
// Storage's three-layer indirection retired — StoragePageControls no
// longer imports the deleted StorageFilter / StorageControls modules,
// and reads the canonical Storage types directly from storagePageState
// and useStorageModel rather than re-exporting them from the deleted
// shell.
expect(storagePageControlsSource).not.toContain("from './StorageFilter'");
expect(storagePageControlsSource).not.toContain("from './StorageControls'");
expect(storagePageControlsSource).not.toContain('useStoragePageControlsModel');
expect(storagePageControlsSource).not.toContain('useStorageControlsModel');
expect(storagePageControlsSource).toContain("type StorageStatusFilterValue,");
expect(storagePageControlsSource).toContain(
"import type { StorageGroupKey, StorageSortKey } from './useStorageModel';",
);
});
});

View file

@ -1275,7 +1275,6 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"exact_files": [
"frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx",
"frontend-modern/src/components/Storage/__tests__/Storage.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StorageControls.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx",
"frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts",
@ -3062,7 +3061,6 @@ index 1111111..2222222 100644
"frontend-modern/src/components/Workloads/__tests__/useThresholdSliderState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/useWorkloadsFilterState.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadFilterConfigModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts",

View file

@ -890,7 +890,6 @@ class SubsystemLookupTest(unittest.TestCase):
[
"frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx",
"frontend-modern/src/components/Storage/__tests__/Storage.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StorageControls.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx",
"frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx",
"frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts",
@ -2597,7 +2596,6 @@ class SubsystemLookupTest(unittest.TestCase):
[
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
"frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts",
]
)
self.assertEqual(result["unowned_runtime_files"], [])