mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-11 00:08:30 +01:00
refactor: project overview table state (#5530)
Use new table state management on project overview and on project/features
This commit is contained in:
parent
5c889df9be
commit
ddca82213a
@ -9,15 +9,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import {
|
||||
DEFAULT_PAGE_LIMIT,
|
||||
useFeatureSearch,
|
||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||
import {
|
||||
PaginatedProjectFeatureToggles,
|
||||
ProjectTableState,
|
||||
} from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
||||
import { useTableState } from 'hooks/useTableState';
|
||||
import { PaginatedProjectFeatureToggles } from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
||||
|
||||
const refreshInterval = 15 * 1000;
|
||||
|
||||
@ -46,37 +38,6 @@ const PaginatedProjectOverview = () => {
|
||||
refreshInterval,
|
||||
});
|
||||
|
||||
const [tableState, setTableState] = useTableState<ProjectTableState>(
|
||||
{},
|
||||
`project-features-${projectId}`,
|
||||
);
|
||||
|
||||
const page = parseInt(tableState.page || '1', 10);
|
||||
const pageSize = tableState?.pageSize
|
||||
? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT
|
||||
: DEFAULT_PAGE_LIMIT;
|
||||
|
||||
const {
|
||||
features: searchFeatures,
|
||||
total,
|
||||
refetch,
|
||||
loading,
|
||||
initialLoad,
|
||||
} = useFeatureSearch(
|
||||
{
|
||||
offset: `${(page - 1) * pageSize}`,
|
||||
limit: `${pageSize}`,
|
||||
sortBy: tableState.sortBy || 'createdAt',
|
||||
sortOrder: tableState.sortOrder === 'desc' ? 'desc' : 'asc',
|
||||
favoritesFirst: tableState.favorites,
|
||||
project: projectId ? `IS:${projectId}` : '',
|
||||
query: tableState.search,
|
||||
},
|
||||
{
|
||||
refreshInterval,
|
||||
},
|
||||
);
|
||||
|
||||
const { environments } = project;
|
||||
|
||||
return (
|
||||
@ -84,21 +45,9 @@ const PaginatedProjectOverview = () => {
|
||||
<StyledContentContainer>
|
||||
<StyledProjectToggles>
|
||||
<PaginatedProjectFeatureToggles
|
||||
key={
|
||||
(loading || projectLoading) &&
|
||||
searchFeatures.length === 0
|
||||
? 'loading'
|
||||
: 'ready'
|
||||
}
|
||||
style={{ width: '100%', margin: 0 }}
|
||||
features={searchFeatures || []}
|
||||
environments={environments}
|
||||
initialLoad={initialLoad && searchFeatures.length === 0}
|
||||
loading={loading && searchFeatures.length === 0}
|
||||
onChange={refetch}
|
||||
total={total}
|
||||
tableState={tableState}
|
||||
setTableState={setTableState}
|
||||
storageKey='project-features'
|
||||
/>
|
||||
</StyledProjectToggles>
|
||||
</StyledContentContainer>
|
||||
|
@ -64,47 +64,63 @@ import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureTogg
|
||||
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
|
||||
import useLoading from 'hooks/useLoading';
|
||||
import { StickyPaginationBar } from '../../../common/Table/StickyPaginationBar/StickyPaginationBar';
|
||||
import { DEFAULT_PAGE_LIMIT } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||
import {
|
||||
DEFAULT_PAGE_LIMIT,
|
||||
useFeatureSearch,
|
||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||
import mapValues from 'lodash.mapvalues';
|
||||
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
||||
import { BooleansStringParam } from 'utils/serializeQueryParams';
|
||||
import {
|
||||
NumberParam,
|
||||
StringParam,
|
||||
ArrayParam,
|
||||
withDefault,
|
||||
} from 'use-query-params';
|
||||
|
||||
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
||||
whiteSpace: 'nowrap',
|
||||
}));
|
||||
|
||||
export type ProjectTableState = {
|
||||
page?: string;
|
||||
sortBy?: string;
|
||||
pageSize?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
favorites?: 'true' | 'false';
|
||||
columns?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
interface IPaginatedProjectFeatureTogglesProps {
|
||||
features: SearchFeaturesSchema['features'];
|
||||
environments: IProject['environments'];
|
||||
loading: boolean;
|
||||
onChange: () => void;
|
||||
total?: number;
|
||||
initialLoad: boolean;
|
||||
tableState: ProjectTableState;
|
||||
setTableState: (state: Partial<ProjectTableState>, quiet?: boolean) => void;
|
||||
style?: CSSProperties;
|
||||
refreshInterval?: number;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
||||
|
||||
export const PaginatedProjectFeatureToggles = ({
|
||||
features,
|
||||
loading,
|
||||
initialLoad,
|
||||
environments,
|
||||
onChange,
|
||||
total,
|
||||
tableState,
|
||||
setTableState,
|
||||
style,
|
||||
refreshInterval = 15 * 1000,
|
||||
storageKey = 'project-feature-toggles',
|
||||
}: IPaginatedProjectFeatureTogglesProps) => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const [tableState, setTableState] = usePersistentTableState(
|
||||
`${storageKey}-${projectId}`,
|
||||
{
|
||||
offset: withDefault(NumberParam, 0),
|
||||
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
|
||||
query: StringParam,
|
||||
favoritesFirst: withDefault(BooleansStringParam, true),
|
||||
sortBy: withDefault(StringParam, 'createdAt'),
|
||||
sortOrder: withDefault(StringParam, 'desc'),
|
||||
columns: ArrayParam,
|
||||
},
|
||||
);
|
||||
|
||||
const { features, total, refetch, loading, initialLoad } = useFeatureSearch(
|
||||
mapValues({ ...tableState, projectId }, (value) =>
|
||||
value ? `${value}` : undefined,
|
||||
),
|
||||
{
|
||||
refreshInterval,
|
||||
},
|
||||
);
|
||||
const onChange = refetch;
|
||||
|
||||
const { classes: styles } = useStyles();
|
||||
const bodyLoadingRef = useLoading(loading);
|
||||
const headerLoadingRef = useLoading(initialLoad);
|
||||
@ -120,7 +136,6 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
const [isCustomColumns, setIsCustomColumns] = useState(
|
||||
Boolean(tableState.columns),
|
||||
);
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
|
||||
useFeatureToggleSwitch(projectId);
|
||||
|
||||
@ -170,13 +185,10 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
id: 'favorite',
|
||||
Header: (
|
||||
<FavoriteIconHeader
|
||||
isActive={tableState.favorites === 'true'}
|
||||
isActive={tableState.favoritesFirst}
|
||||
onClick={() =>
|
||||
setTableState({
|
||||
favorites:
|
||||
tableState.favorites === 'true'
|
||||
? undefined
|
||||
: 'true',
|
||||
favoritesFirst: !tableState.favoritesFirst,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@ -323,7 +335,7 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
},
|
||||
},
|
||||
],
|
||||
[projectId, environments, loading, tableState.favorites, onChange],
|
||||
[projectId, environments, loading, tableState.favoritesFirst, onChange],
|
||||
);
|
||||
|
||||
const [showTitle, setShowTitle] = useState(true);
|
||||
@ -366,14 +378,10 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
|
||||
const { getSearchText, getSearchContext } = useSearch(
|
||||
columns,
|
||||
tableState.search || '',
|
||||
tableState.query || '',
|
||||
featuresData,
|
||||
);
|
||||
|
||||
const initialPageSize = tableState.pageSize
|
||||
? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT
|
||||
: DEFAULT_PAGE_LIMIT;
|
||||
|
||||
const allColumnIds = columns
|
||||
.map(
|
||||
(column: any) =>
|
||||
@ -396,14 +404,13 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
? {
|
||||
hiddenColumns: allColumnIds.filter(
|
||||
(id) =>
|
||||
!(tableState.columns?.split(',') || [])?.includes(
|
||||
id,
|
||||
) && !staticColumns.includes(id),
|
||||
!tableState.columns?.includes(id) &&
|
||||
!staticColumns.includes(id),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
pageSize: initialPageSize,
|
||||
pageIndex: tableState.page ? parseInt(tableState.page, 10) - 1 : 0,
|
||||
pageSize: tableState.limit,
|
||||
pageIndex: tableState.offset * tableState.limit,
|
||||
selectedRowIds: {},
|
||||
}),
|
||||
[initialLoad],
|
||||
@ -411,9 +418,7 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (initialLoad || loading) {
|
||||
const loadingData = Array(
|
||||
parseInt(tableState.pageSize || `${initialPageSize}`, 10),
|
||||
)
|
||||
const loadingData = Array(tableState.limit)
|
||||
.fill(null)
|
||||
.map((_, index) => ({
|
||||
id: index, // Assuming `id` is a required property
|
||||
@ -434,11 +439,8 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
}, [loading, featuresData]);
|
||||
|
||||
const pageCount = useMemo(
|
||||
() =>
|
||||
tableState.pageSize
|
||||
? Math.ceil((total || 0) / parseInt(tableState.pageSize))
|
||||
: 0,
|
||||
[total, tableState.pageSize],
|
||||
() => Math.ceil((total || 0) / tableState.limit),
|
||||
[total, tableState.limit],
|
||||
);
|
||||
const getRowId = useCallback((row: any) => row.name, []);
|
||||
|
||||
@ -478,30 +480,26 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
// Refetching - https://github.com/TanStack/table/blob/v7/docs/src/pages/docs/faq.md#how-can-i-use-the-table-state-to-fetch-new-data
|
||||
useEffect(() => {
|
||||
setTableState({
|
||||
page: `${pageIndex + 1}`,
|
||||
pageSize: `${pageSize}`,
|
||||
offset: pageIndex * pageSize,
|
||||
limit: pageSize,
|
||||
sortBy: sortBy[0]?.id || 'createdAt',
|
||||
sortOrder: sortBy[0]?.desc ? 'desc' : 'asc',
|
||||
});
|
||||
}, [pageIndex, pageSize, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
// FIXME: refactor column visibility logic when switching to react-table v8
|
||||
if (!loading && isCustomColumns) {
|
||||
setTableState(
|
||||
{
|
||||
columns:
|
||||
hiddenColumns !== undefined
|
||||
? allColumnIds
|
||||
.filter(
|
||||
(id) =>
|
||||
!hiddenColumns.includes(id) &&
|
||||
!staticColumns.includes(id),
|
||||
)
|
||||
.join(',')
|
||||
: undefined,
|
||||
},
|
||||
true, // Columns state is controllable by react-table - update only URL and storage, not state
|
||||
);
|
||||
setTableState({
|
||||
columns:
|
||||
hiddenColumns !== undefined
|
||||
? allColumnIds.filter(
|
||||
(id) =>
|
||||
!hiddenColumns.includes(id) &&
|
||||
!staticColumns.includes(id),
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}, [loading, isCustomColumns, hiddenColumns]);
|
||||
|
||||
@ -548,10 +546,12 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
data-loading
|
||||
placeholder='Search and Filter'
|
||||
expandable
|
||||
initialValue={tableState.search}
|
||||
initialValue={
|
||||
tableState.query || ''
|
||||
}
|
||||
onChange={(value) => {
|
||||
setTableState({
|
||||
search: value,
|
||||
query: value,
|
||||
});
|
||||
}}
|
||||
onFocus={() =>
|
||||
@ -630,11 +630,9 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={tableState.search}
|
||||
initialValue={tableState.query || ''}
|
||||
onChange={(value) => {
|
||||
setTableState({
|
||||
search: value,
|
||||
});
|
||||
setTableState({ query: value });
|
||||
}}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
@ -652,7 +650,7 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
aria-live='polite'
|
||||
>
|
||||
<SearchHighlightProvider
|
||||
value={getSearchText(tableState.search || '')}
|
||||
value={getSearchText(tableState.query || '')}
|
||||
>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
@ -665,15 +663,13 @@ export const PaginatedProjectFeatureToggles = ({
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
(tableState.search || '')?.length > 0
|
||||
}
|
||||
condition={(tableState.query || '')?.length > 0}
|
||||
show={
|
||||
<Box sx={{ padding: theme.spacing(3) }}>
|
||||
<TablePlaceholder>
|
||||
No feature toggles found matching
|
||||
“
|
||||
{tableState.search}
|
||||
{tableState.query}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
</Box>
|
||||
|
@ -15,11 +15,10 @@ import {
|
||||
useFeatureSearch,
|
||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||
import {
|
||||
ProjectTableState,
|
||||
// ProjectTableState,
|
||||
PaginatedProjectFeatureToggles,
|
||||
} from './ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
||||
|
||||
import { useTableState } from 'hooks/useTableState';
|
||||
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
|
||||
import { FeatureTypeCount } from '../../../interfaces/project';
|
||||
|
||||
@ -55,37 +54,6 @@ const PaginatedProjectOverview: FC<{
|
||||
refreshInterval,
|
||||
});
|
||||
|
||||
const [tableState, setTableState] = useTableState<ProjectTableState>(
|
||||
{},
|
||||
`${storageKey}-${projectId}`,
|
||||
);
|
||||
|
||||
const page = parseInt(tableState.page || '1', 10);
|
||||
const pageSize = tableState?.pageSize
|
||||
? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT
|
||||
: DEFAULT_PAGE_LIMIT;
|
||||
|
||||
const {
|
||||
features: searchFeatures,
|
||||
total,
|
||||
refetch,
|
||||
loading,
|
||||
initialLoad,
|
||||
} = useFeatureSearch(
|
||||
{
|
||||
offset: `${(page - 1) * pageSize}`,
|
||||
limit: `${pageSize}`,
|
||||
sortBy: tableState.sortBy || 'createdAt',
|
||||
sortOrder: tableState.sortOrder === 'desc' ? 'desc' : 'asc',
|
||||
favoritesFirst: tableState.favorites,
|
||||
project: projectId ? `IS:${projectId}` : '',
|
||||
query: tableState.search,
|
||||
},
|
||||
{
|
||||
refreshInterval,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
members,
|
||||
featureTypeCounts,
|
||||
@ -112,14 +80,9 @@ const PaginatedProjectOverview: FC<{
|
||||
style={
|
||||
fullWidth ? { width: '100%', margin: 0 } : undefined
|
||||
}
|
||||
features={searchFeatures || []}
|
||||
environments={environments}
|
||||
initialLoad={initialLoad && searchFeatures.length === 0}
|
||||
loading={loading && searchFeatures.length === 0}
|
||||
onChange={refetch}
|
||||
total={total}
|
||||
tableState={tableState}
|
||||
setTableState={setTableState}
|
||||
refreshInterval={refreshInterval}
|
||||
storageKey={storageKey}
|
||||
/>
|
||||
</StyledProjectToggles>
|
||||
</StyledContentContainer>
|
||||
|
@ -1,268 +0,0 @@
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useTableState } from './useTableState';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { act } from 'react-test-renderer';
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
|
||||
}));
|
||||
vi.mock('../utils/createLocalStorage', () => ({
|
||||
createLocalStorage: vi.fn(() => ({
|
||||
value: {},
|
||||
setValue: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockStorage = createLocalStorage as Mock;
|
||||
const mockQuery = useSearchParams as Mock;
|
||||
|
||||
describe('useTableState', () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.mockRestore();
|
||||
mockQuery.mockRestore();
|
||||
});
|
||||
|
||||
it('should return default params', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
page: '0';
|
||||
pageSize: '10';
|
||||
}>({ page: '0', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
expect(result.current[0]).toEqual({ page: '0', pageSize: '10' });
|
||||
});
|
||||
|
||||
it('should return params from local storage', () => {
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: 25 },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ pageSize: 25 });
|
||||
});
|
||||
|
||||
it('should return params from url', () => {
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=1'), vi.fn()]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '0' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
});
|
||||
|
||||
it('should use params from url over local storage', () => {
|
||||
mockQuery.mockReturnValue([
|
||||
new URLSearchParams('page=2&pageSize=25'),
|
||||
vi.fn(),
|
||||
]);
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: '10', sortOrder: 'desc' },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '5' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '2',
|
||||
pageSize: '25',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets local state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2' });
|
||||
});
|
||||
|
||||
it('keeps previous state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2', pageSize: '10' });
|
||||
});
|
||||
|
||||
it('removes params from previous state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
|
||||
// ensure that there are no keys with undefined values
|
||||
expect(Object.keys(result.current[0])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.skip('removes params from url', () => {
|
||||
const querySetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=2'), querySetter]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState(
|
||||
{ page: '1', pageSize: '10' },
|
||||
'test',
|
||||
['page', 'pageSize'],
|
||||
[],
|
||||
),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2', pageSize: '10' });
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '10', pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '10' });
|
||||
|
||||
expect(querySetter).toHaveBeenCalledWith({
|
||||
page: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes params from local storage', () => {
|
||||
const storageSetter = vi.fn();
|
||||
mockStorage.mockReturnValue({
|
||||
value: { sortBy: 'type' },
|
||||
setValue: storageSetter,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState(
|
||||
{ sortBy: 'createdAt', pageSize: '10' },
|
||||
'test',
|
||||
[],
|
||||
['sortBy', 'pageSize'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ sortBy: 'type', pageSize: '10' });
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ sortBy: 'type' });
|
||||
|
||||
expect(storageSetter).toHaveBeenCalledWith({
|
||||
sortBy: 'type',
|
||||
});
|
||||
});
|
||||
|
||||
test.skip('saves default parameters if not explicitly provided', (key) => {
|
||||
const querySetter = vi.fn();
|
||||
const storageSetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams(), querySetter]);
|
||||
mockStorage.mockReturnValue({
|
||||
value: {},
|
||||
setValue: storageSetter,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTableState({}, 'test'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]({
|
||||
unspecified: 'test',
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(storageSetter).toHaveBeenCalledTimes(1);
|
||||
expect(storageSetter).toHaveBeenCalledWith({
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
expect(querySetter).toHaveBeenCalledTimes(1);
|
||||
expect(querySetter).toHaveBeenCalledWith({
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't save default params if explicitly specified", () => {
|
||||
const storageSetter = vi.fn();
|
||||
mockStorage.mockReturnValue({
|
||||
value: {},
|
||||
setValue: storageSetter,
|
||||
});
|
||||
const querySetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams(), querySetter]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
[key: string]: string;
|
||||
}>({}, 'test', ['saveOnlyThisToUrl'], ['page']),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({
|
||||
saveOnlyThisToUrl: 'test',
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: 'test,id',
|
||||
});
|
||||
});
|
||||
|
||||
expect(querySetter).toHaveBeenCalledWith({ saveOnlyThisToUrl: 'test' });
|
||||
expect(storageSetter).toHaveBeenCalledWith({ page: '2' });
|
||||
});
|
||||
|
||||
it('can update query and storage without triggering a rerender', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' }, true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
});
|
||||
});
|
@ -1,109 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { createLocalStorage } from '../utils/createLocalStorage';
|
||||
|
||||
const filterObjectKeys = <T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
keys: Array<keyof T>,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(obj).filter(([key]) => keys.includes(key as keyof T)),
|
||||
) as T;
|
||||
|
||||
export const defaultStoredKeys = [
|
||||
'pageSize',
|
||||
'sortBy',
|
||||
'sortOrder',
|
||||
'favorites',
|
||||
'columns',
|
||||
];
|
||||
export const defaultQueryKeys = [
|
||||
...defaultStoredKeys,
|
||||
'search',
|
||||
'query',
|
||||
'page',
|
||||
];
|
||||
|
||||
/**
|
||||
* There are 3 sources of params, in order of priority:
|
||||
* 1. local state
|
||||
* 2. search params from the url
|
||||
* 3. stored params in local storage
|
||||
* 4. default parameters
|
||||
*
|
||||
* `queryKeys` will be saved in the url
|
||||
* `storedKeys` will be saved in local storage
|
||||
*
|
||||
* @deprecated
|
||||
*
|
||||
* @param defaultParams initial state
|
||||
* @param storageId identifier for the local storage
|
||||
* @param queryKeys array of elements to be saved in the url
|
||||
* @param storageKeys array of elements to be saved in local storage
|
||||
*/
|
||||
export const useTableState = <Params extends Record<string, string>>(
|
||||
defaultParams: Params,
|
||||
storageId: string,
|
||||
queryKeys?: Array<keyof Params | string>,
|
||||
storageKeys?: Array<keyof Params | string>,
|
||||
) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { value: storedParams, setValue: setStoredParams } =
|
||||
createLocalStorage(`${storageId}:tableQuery`, defaultParams);
|
||||
|
||||
const searchQuery = Object.fromEntries(searchParams.entries());
|
||||
const hasQuery = Object.keys(searchQuery).length > 0;
|
||||
const [state, setState] = useState({
|
||||
...defaultParams,
|
||||
});
|
||||
const params = useMemo(
|
||||
() =>
|
||||
({
|
||||
...state,
|
||||
...(hasQuery ? {} : storedParams),
|
||||
...searchQuery,
|
||||
}) as Params,
|
||||
[hasQuery, storedParams, searchQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = filterObjectKeys(
|
||||
params,
|
||||
queryKeys || defaultQueryKeys,
|
||||
);
|
||||
if (!hasQuery && Object.keys(urlParams).length > 0) {
|
||||
setSearchParams(urlParams, { replace: true });
|
||||
}
|
||||
}, [params, hasQuery, setSearchParams, queryKeys]);
|
||||
|
||||
const updateParams = useCallback(
|
||||
(value: Partial<Params>, quiet = false) => {
|
||||
const newState: Params = {
|
||||
...params,
|
||||
...value,
|
||||
};
|
||||
|
||||
// remove keys with undefined values
|
||||
Object.keys(newState).forEach((key) => {
|
||||
if (newState[key] === undefined) {
|
||||
delete newState[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (!quiet) {
|
||||
setState(newState);
|
||||
}
|
||||
setSearchParams(
|
||||
filterObjectKeys(newState, queryKeys || defaultQueryKeys),
|
||||
);
|
||||
setStoredParams(
|
||||
filterObjectKeys(newState, storageKeys || defaultStoredKeys),
|
||||
);
|
||||
|
||||
return params;
|
||||
},
|
||||
[setState, setSearchParams, setStoredParams],
|
||||
);
|
||||
|
||||
return [params, updateParams] as const;
|
||||
};
|
Loading…
Reference in New Issue
Block a user