diff --git a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx index afb1dece6d..aa048678d1 100644 --- a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx +++ b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx @@ -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( - {}, - `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 = () => { diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx index b39fa14995..e3b383104e 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx @@ -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, 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: ( 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={ { - setTableState({ - search: value, - }); + setTableState({ query: value }); }} hasFilters getSearchContext={getSearchContext} @@ -652,7 +650,7 @@ export const PaginatedProjectFeatureToggles = ({ aria-live='polite' > 0 - } + condition={(tableState.query || '')?.length > 0} show={ No feature toggles found matching “ - {tableState.search} + {tableState.query} ” diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index d2debe4d86..22bb2da18b 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -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( - {}, - `${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} /> diff --git a/frontend/src/hooks/useTableState.test.ts b/frontend/src/hooks/useTableState.test.ts deleted file mode 100644 index 6c44d52172..0000000000 --- a/frontend/src/hooks/useTableState.test.ts +++ /dev/null @@ -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' }); - }); -}); diff --git a/frontend/src/hooks/useTableState.ts b/frontend/src/hooks/useTableState.ts deleted file mode 100644 index 442ba547f5..0000000000 --- a/frontend/src/hooks/useTableState.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { createLocalStorage } from '../utils/createLocalStorage'; - -const filterObjectKeys = >( - obj: T, - keys: Array, -) => - 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 = >( - defaultParams: Params, - storageId: string, - queryKeys?: Array, - storageKeys?: Array, -) => { - 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, 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; -};