1
0
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:
Tymoteusz Czech 2023-12-04 17:04:28 +01:00 committed by GitHub
parent 5c889df9be
commit ddca82213a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 79 additions and 548 deletions

View File

@ -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>

View File

@ -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
&ldquo;
{tableState.search}
{tableState.query}
&rdquo;
</TablePlaceholder>
</Box>

View File

@ -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>

View File

@ -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' });
});
});

View File

@ -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;
};