mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-23 13:46:45 +02: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 { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||||
|
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import {
|
import { PaginatedProjectFeatureToggles } from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
||||||
DEFAULT_PAGE_LIMIT,
|
|
||||||
useFeatureSearch,
|
|
||||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
|
||||||
import {
|
|
||||||
PaginatedProjectFeatureToggles,
|
|
||||||
ProjectTableState,
|
|
||||||
} from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
|
||||||
import { useTableState } from 'hooks/useTableState';
|
|
||||||
|
|
||||||
const refreshInterval = 15 * 1000;
|
const refreshInterval = 15 * 1000;
|
||||||
|
|
||||||
@ -46,37 +38,6 @@ const PaginatedProjectOverview = () => {
|
|||||||
refreshInterval,
|
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;
|
const { environments } = project;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -84,21 +45,9 @@ const PaginatedProjectOverview = () => {
|
|||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<StyledProjectToggles>
|
<StyledProjectToggles>
|
||||||
<PaginatedProjectFeatureToggles
|
<PaginatedProjectFeatureToggles
|
||||||
key={
|
|
||||||
(loading || projectLoading) &&
|
|
||||||
searchFeatures.length === 0
|
|
||||||
? 'loading'
|
|
||||||
: 'ready'
|
|
||||||
}
|
|
||||||
style={{ width: '100%', margin: 0 }}
|
style={{ width: '100%', margin: 0 }}
|
||||||
features={searchFeatures || []}
|
|
||||||
environments={environments}
|
environments={environments}
|
||||||
initialLoad={initialLoad && searchFeatures.length === 0}
|
storageKey='project-features'
|
||||||
loading={loading && searchFeatures.length === 0}
|
|
||||||
onChange={refetch}
|
|
||||||
total={total}
|
|
||||||
tableState={tableState}
|
|
||||||
setTableState={setTableState}
|
|
||||||
/>
|
/>
|
||||||
</StyledProjectToggles>
|
</StyledProjectToggles>
|
||||||
</StyledContentContainer>
|
</StyledContentContainer>
|
||||||
|
@ -64,47 +64,63 @@ import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureTogg
|
|||||||
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
|
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
|
||||||
import useLoading from 'hooks/useLoading';
|
import useLoading from 'hooks/useLoading';
|
||||||
import { StickyPaginationBar } from '../../../common/Table/StickyPaginationBar/StickyPaginationBar';
|
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)(() => ({
|
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type ProjectTableState = {
|
|
||||||
page?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
pageSize?: string;
|
|
||||||
sortOrder?: 'asc' | 'desc';
|
|
||||||
favorites?: 'true' | 'false';
|
|
||||||
columns?: string;
|
|
||||||
search?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IPaginatedProjectFeatureTogglesProps {
|
interface IPaginatedProjectFeatureTogglesProps {
|
||||||
features: SearchFeaturesSchema['features'];
|
|
||||||
environments: IProject['environments'];
|
environments: IProject['environments'];
|
||||||
loading: boolean;
|
|
||||||
onChange: () => void;
|
|
||||||
total?: number;
|
|
||||||
initialLoad: boolean;
|
|
||||||
tableState: ProjectTableState;
|
|
||||||
setTableState: (state: Partial<ProjectTableState>, quiet?: boolean) => void;
|
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
refreshInterval?: number;
|
||||||
|
storageKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
||||||
|
|
||||||
export const PaginatedProjectFeatureToggles = ({
|
export const PaginatedProjectFeatureToggles = ({
|
||||||
features,
|
|
||||||
loading,
|
|
||||||
initialLoad,
|
|
||||||
environments,
|
environments,
|
||||||
onChange,
|
|
||||||
total,
|
|
||||||
tableState,
|
|
||||||
setTableState,
|
|
||||||
style,
|
style,
|
||||||
|
refreshInterval = 15 * 1000,
|
||||||
|
storageKey = 'project-feature-toggles',
|
||||||
}: IPaginatedProjectFeatureTogglesProps) => {
|
}: 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 { classes: styles } = useStyles();
|
||||||
const bodyLoadingRef = useLoading(loading);
|
const bodyLoadingRef = useLoading(loading);
|
||||||
const headerLoadingRef = useLoading(initialLoad);
|
const headerLoadingRef = useLoading(initialLoad);
|
||||||
@ -120,7 +136,6 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
const [isCustomColumns, setIsCustomColumns] = useState(
|
const [isCustomColumns, setIsCustomColumns] = useState(
|
||||||
Boolean(tableState.columns),
|
Boolean(tableState.columns),
|
||||||
);
|
);
|
||||||
const projectId = useRequiredPathParam('projectId');
|
|
||||||
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
|
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
|
||||||
useFeatureToggleSwitch(projectId);
|
useFeatureToggleSwitch(projectId);
|
||||||
|
|
||||||
@ -170,13 +185,10 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
id: 'favorite',
|
id: 'favorite',
|
||||||
Header: (
|
Header: (
|
||||||
<FavoriteIconHeader
|
<FavoriteIconHeader
|
||||||
isActive={tableState.favorites === 'true'}
|
isActive={tableState.favoritesFirst}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setTableState({
|
setTableState({
|
||||||
favorites:
|
favoritesFirst: !tableState.favoritesFirst,
|
||||||
tableState.favorites === 'true'
|
|
||||||
? undefined
|
|
||||||
: 'true',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -323,7 +335,7 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[projectId, environments, loading, tableState.favorites, onChange],
|
[projectId, environments, loading, tableState.favoritesFirst, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showTitle, setShowTitle] = useState(true);
|
const [showTitle, setShowTitle] = useState(true);
|
||||||
@ -366,14 +378,10 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
|
|
||||||
const { getSearchText, getSearchContext } = useSearch(
|
const { getSearchText, getSearchContext } = useSearch(
|
||||||
columns,
|
columns,
|
||||||
tableState.search || '',
|
tableState.query || '',
|
||||||
featuresData,
|
featuresData,
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialPageSize = tableState.pageSize
|
|
||||||
? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT
|
|
||||||
: DEFAULT_PAGE_LIMIT;
|
|
||||||
|
|
||||||
const allColumnIds = columns
|
const allColumnIds = columns
|
||||||
.map(
|
.map(
|
||||||
(column: any) =>
|
(column: any) =>
|
||||||
@ -396,14 +404,13 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
? {
|
? {
|
||||||
hiddenColumns: allColumnIds.filter(
|
hiddenColumns: allColumnIds.filter(
|
||||||
(id) =>
|
(id) =>
|
||||||
!(tableState.columns?.split(',') || [])?.includes(
|
!tableState.columns?.includes(id) &&
|
||||||
id,
|
!staticColumns.includes(id),
|
||||||
) && !staticColumns.includes(id),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
pageSize: initialPageSize,
|
pageSize: tableState.limit,
|
||||||
pageIndex: tableState.page ? parseInt(tableState.page, 10) - 1 : 0,
|
pageIndex: tableState.offset * tableState.limit,
|
||||||
selectedRowIds: {},
|
selectedRowIds: {},
|
||||||
}),
|
}),
|
||||||
[initialLoad],
|
[initialLoad],
|
||||||
@ -411,9 +418,7 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
|
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
if (initialLoad || loading) {
|
if (initialLoad || loading) {
|
||||||
const loadingData = Array(
|
const loadingData = Array(tableState.limit)
|
||||||
parseInt(tableState.pageSize || `${initialPageSize}`, 10),
|
|
||||||
)
|
|
||||||
.fill(null)
|
.fill(null)
|
||||||
.map((_, index) => ({
|
.map((_, index) => ({
|
||||||
id: index, // Assuming `id` is a required property
|
id: index, // Assuming `id` is a required property
|
||||||
@ -434,11 +439,8 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
}, [loading, featuresData]);
|
}, [loading, featuresData]);
|
||||||
|
|
||||||
const pageCount = useMemo(
|
const pageCount = useMemo(
|
||||||
() =>
|
() => Math.ceil((total || 0) / tableState.limit),
|
||||||
tableState.pageSize
|
[total, tableState.limit],
|
||||||
? Math.ceil((total || 0) / parseInt(tableState.pageSize))
|
|
||||||
: 0,
|
|
||||||
[total, tableState.pageSize],
|
|
||||||
);
|
);
|
||||||
const getRowId = useCallback((row: any) => row.name, []);
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
setTableState({
|
setTableState({
|
||||||
page: `${pageIndex + 1}`,
|
offset: pageIndex * pageSize,
|
||||||
pageSize: `${pageSize}`,
|
limit: pageSize,
|
||||||
sortBy: sortBy[0]?.id || 'createdAt',
|
sortBy: sortBy[0]?.id || 'createdAt',
|
||||||
sortOrder: sortBy[0]?.desc ? 'desc' : 'asc',
|
sortOrder: sortBy[0]?.desc ? 'desc' : 'asc',
|
||||||
});
|
});
|
||||||
}, [pageIndex, pageSize, sortBy]);
|
}, [pageIndex, pageSize, sortBy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// FIXME: refactor column visibility logic when switching to react-table v8
|
||||||
if (!loading && isCustomColumns) {
|
if (!loading && isCustomColumns) {
|
||||||
setTableState(
|
setTableState({
|
||||||
{
|
columns:
|
||||||
columns:
|
hiddenColumns !== undefined
|
||||||
hiddenColumns !== undefined
|
? allColumnIds.filter(
|
||||||
? allColumnIds
|
(id) =>
|
||||||
.filter(
|
!hiddenColumns.includes(id) &&
|
||||||
(id) =>
|
!staticColumns.includes(id),
|
||||||
!hiddenColumns.includes(id) &&
|
)
|
||||||
!staticColumns.includes(id),
|
: undefined,
|
||||||
)
|
});
|
||||||
.join(',')
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
true, // Columns state is controllable by react-table - update only URL and storage, not state
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [loading, isCustomColumns, hiddenColumns]);
|
}, [loading, isCustomColumns, hiddenColumns]);
|
||||||
|
|
||||||
@ -548,10 +546,12 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
data-loading
|
data-loading
|
||||||
placeholder='Search and Filter'
|
placeholder='Search and Filter'
|
||||||
expandable
|
expandable
|
||||||
initialValue={tableState.search}
|
initialValue={
|
||||||
|
tableState.query || ''
|
||||||
|
}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setTableState({
|
setTableState({
|
||||||
search: value,
|
query: value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onFocus={() =>
|
onFocus={() =>
|
||||||
@ -630,11 +630,9 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
condition={isSmallScreen}
|
condition={isSmallScreen}
|
||||||
show={
|
show={
|
||||||
<Search
|
<Search
|
||||||
initialValue={tableState.search}
|
initialValue={tableState.query || ''}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setTableState({
|
setTableState({ query: value });
|
||||||
search: value,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
hasFilters
|
hasFilters
|
||||||
getSearchContext={getSearchContext}
|
getSearchContext={getSearchContext}
|
||||||
@ -652,7 +650,7 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
aria-live='polite'
|
aria-live='polite'
|
||||||
>
|
>
|
||||||
<SearchHighlightProvider
|
<SearchHighlightProvider
|
||||||
value={getSearchText(tableState.search || '')}
|
value={getSearchText(tableState.query || '')}
|
||||||
>
|
>
|
||||||
<VirtualizedTable
|
<VirtualizedTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@ -665,15 +663,13 @@ export const PaginatedProjectFeatureToggles = ({
|
|||||||
condition={rows.length === 0}
|
condition={rows.length === 0}
|
||||||
show={
|
show={
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={
|
condition={(tableState.query || '')?.length > 0}
|
||||||
(tableState.search || '')?.length > 0
|
|
||||||
}
|
|
||||||
show={
|
show={
|
||||||
<Box sx={{ padding: theme.spacing(3) }}>
|
<Box sx={{ padding: theme.spacing(3) }}>
|
||||||
<TablePlaceholder>
|
<TablePlaceholder>
|
||||||
No feature toggles found matching
|
No feature toggles found matching
|
||||||
“
|
“
|
||||||
{tableState.search}
|
{tableState.query}
|
||||||
”
|
”
|
||||||
</TablePlaceholder>
|
</TablePlaceholder>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -15,11 +15,10 @@ import {
|
|||||||
useFeatureSearch,
|
useFeatureSearch,
|
||||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||||
import {
|
import {
|
||||||
ProjectTableState,
|
// ProjectTableState,
|
||||||
PaginatedProjectFeatureToggles,
|
PaginatedProjectFeatureToggles,
|
||||||
} from './ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
} from './ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
||||||
|
|
||||||
import { useTableState } from 'hooks/useTableState';
|
|
||||||
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
|
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
|
||||||
import { FeatureTypeCount } from '../../../interfaces/project';
|
import { FeatureTypeCount } from '../../../interfaces/project';
|
||||||
|
|
||||||
@ -55,37 +54,6 @@ const PaginatedProjectOverview: FC<{
|
|||||||
refreshInterval,
|
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 {
|
const {
|
||||||
members,
|
members,
|
||||||
featureTypeCounts,
|
featureTypeCounts,
|
||||||
@ -112,14 +80,9 @@ const PaginatedProjectOverview: FC<{
|
|||||||
style={
|
style={
|
||||||
fullWidth ? { width: '100%', margin: 0 } : undefined
|
fullWidth ? { width: '100%', margin: 0 } : undefined
|
||||||
}
|
}
|
||||||
features={searchFeatures || []}
|
|
||||||
environments={environments}
|
environments={environments}
|
||||||
initialLoad={initialLoad && searchFeatures.length === 0}
|
refreshInterval={refreshInterval}
|
||||||
loading={loading && searchFeatures.length === 0}
|
storageKey={storageKey}
|
||||||
onChange={refetch}
|
|
||||||
total={total}
|
|
||||||
tableState={tableState}
|
|
||||||
setTableState={setTableState}
|
|
||||||
/>
|
/>
|
||||||
</StyledProjectToggles>
|
</StyledProjectToggles>
|
||||||
</StyledContentContainer>
|
</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