Files
portainer/app/react/components/datatables/useTableStateFromUrl.ts
T
bernard-portainer 76f525fd38 refactor(home): refactor Environment List to use SortableList component [C9S-131] (#2522)
- Migrate `EnvironmentList` from `GroupSortTable` to `SortableList`, removing ~1,700 lines of duplicated component code
- Move health sort ranking to the backend (`sort.go`), adding `Health` and `Id` sort keys
- Delete `GroupSortTable`, `GroupSortTableGroupRow`, `useGroupSortTableState`, and `store` — functionality absorbed by `SortableList`
- Add `useHomeViewState` hook to centralise home view URL state (`groupBy`, `groupFilter`, `order`, `page`, `search`)
- Update `useTableStateFromUrl` to support `groupBy` and `groupFilter` URL params with a `buildExtra` callback
- Rename URL param `filter` → `groupFilter` for clarity; add `search` and `order` to `/home` route definition
- Simplify `EnvironmentList` props — remove `headerFilter` / `onHeaderFilterChange`, leaving only `onClickBrowse`
- Add `computeSortDesc` pure utility to `SortableList` and cover all toggle/reset cases with unit tests
- Update `SortableListHeader` to use `activeKey` prop (renamed from `sortBy`); fix all callsites and stories
- Fix `SortableList` sort-key normalisation to be case-insensitive; update tests to reflect no-match behaviour
2026-05-08 16:55:40 +12:00

134 lines
3.6 KiB
TypeScript

import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { useParamsState } from '@/react/hooks/useParamState';
import { BasicTableSettings } from './types';
type CoreUrlState = {
search: string;
sort: string;
order: 'asc' | 'desc';
groupBy: string | null;
groupFilter: string | null;
page: number;
pageSize: number | null;
};
type Extra = {
search: string;
setSearch(value: string): void;
page: number;
setPage(page: number): void;
groupBy: string | null;
setGroupBy(group: string | null): void;
groupFilter: string | null;
setGroupFilter(group: string | null, filter: string | null): void;
};
export function useTableStateFromUrl<
TParsed extends Record<string, unknown> = Record<never, never>,
TExtra extends Record<string, unknown> = Record<never, never>,
>({
localStorageKey,
defaultSort = 'name',
defaultGroupBy,
parseExtra,
buildExtra,
}: {
localStorageKey: string;
defaultSort?: string;
defaultGroupBy?: string;
parseExtra?: (params: Record<string, string | undefined>) => TParsed;
buildExtra?: (
urlState: CoreUrlState & TParsed,
setUrlState: (s: Partial<CoreUrlState & TParsed>) => void
) => TExtra;
}): BasicTableSettings & TExtra & Extra {
const [storedPageSize, setStoredPageSize] = useLocalStorage(
`datatable_settings_${localStorageKey}_pageSize`,
10
);
const [urlState, setUrlState] = useParamsState((params) => ({
search: params.search ?? '',
sort: params.sort ?? defaultSort,
order: (params.order === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc',
groupBy: params.groupBy ?? defaultGroupBy ?? null,
groupFilter: params.groupFilter ?? null,
page: Math.max(0, parseIntOrDefault(params.page, 0)),
pageSize: parsePositiveIntOrNull(params.pageSize),
...(parseExtra ? parseExtra(params) : ({} as TParsed)),
}));
const pageSize = urlState.pageSize ?? storedPageSize;
const extra = buildExtra ? buildExtra(urlState, setUrlState) : ({} as TExtra);
return {
search: urlState.search,
setSearch: (search: string) => setCoreState({ search, page: 0 }),
sortBy: { id: urlState.sort, desc: urlState.order === 'desc' },
setSortBy: (id, desc) =>
setCoreState({
sort: id ?? defaultSort,
groupBy: null,
groupFilter: null,
order: desc ? 'desc' : 'asc',
page: 0,
}),
groupBy: urlState.groupBy,
setGroupBy: (group) =>
setCoreState({
groupBy: group,
groupFilter: null,
page: 0,
}),
groupFilter: urlState.groupFilter,
setGroupFilter: (group, filter) => {
setCoreState({
groupBy: group,
groupFilter: filter,
page: 0,
});
},
page: urlState.page,
setPage: (page) => setCoreState({ page }),
pageSize,
setPageSize: (size) => {
setStoredPageSize(size);
setCoreState({ pageSize: size, page: 0 });
},
...extra,
} satisfies BasicTableSettings & TExtra & Extra;
function setCoreState(partial: Partial<CoreUrlState>) {
return setUrlState(partial as Partial<CoreUrlState & TParsed>);
}
}
export function parseIntOrDefault<T>(
raw: string | undefined,
fallback: T
): number | T {
if (!raw) return fallback;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
export function parsePositiveIntOrNull(raw: string | undefined): number | null {
const n = parseIntOrDefault(raw, null);
return n !== null && n > 0 ? n : null;
}
export function asEnum<T>(
value: string | undefined,
allowed: Set<T>
): T | null {
return allowed.has(value as T) ? (value as T) : null;
}