diff --git a/frontend/src/component/common/BatchSelectionActionsBar/BatchSelectionActionsBar.tsx b/frontend/src/component/common/BatchSelectionActionsBar/BatchSelectionActionsBar.tsx index 7fc34a9dca91..27d5cb56e42b 100644 --- a/frontend/src/component/common/BatchSelectionActionsBar/BatchSelectionActionsBar.tsx +++ b/frontend/src/component/common/BatchSelectionActionsBar/BatchSelectionActionsBar.tsx @@ -9,7 +9,7 @@ interface IBatchSelectionActionsBarProps { const StyledStickyContainer = styled('div')(({ theme }) => ({ position: 'sticky', bottom: 50, - zIndex: theme.zIndex.mobileStepper, + zIndex: theme.zIndex.fab, pointerEvents: 'none', })); diff --git a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx index 11ee148b3d3b..57e3047f871c 100644 --- a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx +++ b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import useProject, { useProjectNameOrId, } from 'hooks/api/getters/useProject/useProject'; @@ -9,15 +9,15 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; import { useUiFlag } from 'hooks/useUiFlag'; -import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; import { - ISortingRules, + DEFAULT_PAGE_LIMIT, + useFeatureSearch, +} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; +import { PaginatedProjectFeatureToggles, + ProjectTableState, } from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles'; -import { useSearchParams } from 'react-router-dom'; - -import { PaginationBar } from 'component/common/PaginationBar/PaginationBar'; -import { SortingRule } from 'react-table'; +import { useTableState } from 'hooks/useTableState'; const refreshInterval = 15 * 1000; @@ -40,26 +40,21 @@ const StyledContentContainer = styled(Box)(() => ({ minWidth: 0, })); -export const DEFAULT_PAGE_LIMIT = 25; - const PaginatedProjectOverview = () => { const projectId = useRequiredPathParam('projectId'); - const [searchParams, setSearchParams] = useSearchParams(); const { project, loading: projectLoading } = useProject(projectId, { refreshInterval, }); - const [pageLimit, setPageLimit] = useState(DEFAULT_PAGE_LIMIT); - const [currentOffset, setCurrentOffset] = useState(0); - const [searchValue, setSearchValue] = useState( - searchParams.get('search') || '', + const [tableState, setTableState] = useTableState( + {}, + `project-features-${projectId}`, ); - const [sortingRules, setSortingRules] = useState({ - sortBy: 'createdBy', - sortOrder: 'desc', - isFavoritesPinned: false, - }); + const page = parseInt(tableState.page || '1', 10); + const pageSize = tableState?.pageSize + ? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT + : DEFAULT_PAGE_LIMIT; const { features: searchFeatures, @@ -68,28 +63,21 @@ const PaginatedProjectOverview = () => { loading, initialLoad, } = useFeatureSearch( - currentOffset, - pageLimit, - sortingRules, + (page - 1) * pageSize, + pageSize, + { + sortBy: tableState.sortBy || 'createdAt', + sortOrder: tableState.sortOrder === 'desc' ? 'desc' : 'asc', + favoritesFirst: tableState.favorites === 'true', + }, projectId, - searchValue, + tableState.search, { refreshInterval, }, ); const { environments } = project; - const fetchNextPage = () => { - if (!loading) { - setCurrentOffset(Math.min(total, currentOffset + pageLimit)); - } - }; - const fetchPrevPage = () => { - setCurrentOffset(Math.max(0, currentOffset - pageLimit)); - }; - - const hasPreviousPage = currentOffset > 0; - const hasNextPage = currentOffset + pageLimit < total; return ( @@ -97,35 +85,20 @@ const PaginatedProjectOverview = () => { - - - } + tableState={tableState} + setTableState={setTableState} /> @@ -133,36 +106,6 @@ const PaginatedProjectOverview = () => { ); }; -const StyledStickyBar = styled('div')(({ theme }) => ({ - position: 'sticky', - bottom: 0, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(2), - zIndex: 9999, - borderBottomLeftRadius: theme.shape.borderRadiusMedium, - borderBottomRightRadius: theme.shape.borderRadiusMedium, - borderTop: `1px solid ${theme.palette.divider}`, - boxShadow: `0px -2px 8px 0px rgba(32, 32, 33, 0.06)`, - height: '52px', -})); - -const StyledStickyBarContentContainer = styled(Box)(({ theme }) => ({ - display: 'flex', - justifyContent: 'space-between', - width: '100%', - minWidth: 0, -})); - -const StickyPaginationBar: React.FC = ({ children }) => { - return ( - - - {children} - - - ); -}; - /** * @deprecated remove when flag `featureSearchFrontend` is removed */ diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx index 40b6f6c21840..44322c7f433b 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx @@ -34,11 +34,8 @@ interface IColumnsMenuProps { dividerBefore?: string[]; dividerAfter?: string[]; isCustomized?: boolean; - setHiddenColumns: ( - hiddenColumns: - | string[] - | ((previousHiddenColumns: string[]) => string[]), - ) => void; + setHiddenColumns: (hiddenColumns: string[]) => void; + onCustomize?: () => void; } const columnNameMap: Record = { @@ -51,6 +48,7 @@ export const ColumnsMenu: VFC = ({ dividerBefore = [], dividerAfter = [], isCustomized = false, + onCustomize, setHiddenColumns, }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -69,7 +67,7 @@ export const ColumnsMenu: VFC = ({ environmentsToShow: number = 0, ) => { const visibleEnvColumns = allColumns - .filter(({ id }) => id.startsWith('environments.') !== false) + .filter(({ id }) => id.startsWith('environment:') !== false) .map(({ id }) => id) .slice(0, environmentsToShow); const hiddenColumns = allColumns @@ -160,9 +158,10 @@ export const ColumnsMenu: VFC = ({ show={} />, - column.toggleHidden(column.isVisible) - } + onClick={() => { + column.toggleHidden(column.isVisible); + onCustomize?.(); + }} disabled={staticColumns.includes(column.id)} > diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx index d0078e2fe0a6..005420b2e973 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + type CSSProperties, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { Checkbox, IconButton, @@ -9,10 +15,10 @@ import { useTheme, } from '@mui/material'; import { Add } from '@mui/icons-material'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { - SortingRule, useFlexLayout, + usePagination, useRowSelect, useSortBy, useTable, @@ -32,7 +38,6 @@ import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/Fe import { IProject } from 'interfaces/project'; import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; -import { createLocalStorage } from 'utils/createLocalStorage'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch'; @@ -40,17 +45,12 @@ import { Search } from 'component/common/Search/Search'; import { IFeatureToggleListItem } from 'interfaces/featureToggle'; import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; -import { - ProjectEnvironmentType, - useEnvironmentsRef, -} from './hooks/useEnvironmentsRef'; +import { ProjectEnvironmentType } from './hooks/useEnvironmentsRef'; import { ActionsCell } from './ActionsCell/ActionsCell'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; import { useStyles } from './ProjectFeatureToggles.styles'; -import { usePinnedFavorites } from 'hooks/usePinnedFavorites'; import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi'; import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell'; -import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage'; import FileDownload from '@mui/icons-material/FileDownload'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog'; @@ -63,17 +63,22 @@ import { ListItemType } from './ProjectFeatureToggles.types'; import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell'; import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch'; import useLoading from 'hooks/useLoading'; -import { DEFAULT_PAGE_LIMIT } from '../ProjectOverview'; +import { StickyPaginationBar } from '../StickyPaginationBar/StickyPaginationBar'; +import { DEFAULT_PAGE_LIMIT } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ whiteSpace: 'nowrap', })); -export interface ISortingRules { - sortBy: string; - sortOrder: 'asc' | 'desc'; - isFavoritesPinned: boolean; -} +export type ProjectTableState = { + page?: string; + sortBy?: string; + pageSize?: string; + sortOrder?: 'asc' | 'desc'; + favorites?: 'true' | 'false'; + columns?: string; + search?: string; +}; interface IPaginatedProjectFeatureTogglesProps { features: IProject['features']; @@ -82,33 +87,23 @@ interface IPaginatedProjectFeatureTogglesProps { onChange: () => void; total?: number; initialLoad: boolean; - searchValue: string; - setSearchValue: React.Dispatch>; - paginationBar: JSX.Element; - sortingRules: ISortingRules; - setSortingRules: (sortingRules: ISortingRules) => void; - style?: React.CSSProperties; + tableState: ProjectTableState; + setTableState: (state: Partial, quiet?: boolean) => void; + style?: CSSProperties; } const staticColumns = ['Select', 'Actions', 'name', 'favorite']; -const defaultSort: SortingRule & { - columns?: string[]; -} = { id: 'createdAt', desc: true }; - export const PaginatedProjectFeatureToggles = ({ features, loading, initialLoad, - environments: newEnvironments = [], + environments, onChange, total, - searchValue, - setSearchValue, - paginationBar, - sortingRules, - setSortingRules, - style = {}, + tableState, + setTableState, + style, }: IPaginatedProjectFeatureTogglesProps) => { const { classes: styles } = useStyles(); const bodyLoadingRef = useLoading(loading); @@ -122,30 +117,14 @@ export const PaginatedProjectFeatureToggles = ({ const [featureArchiveState, setFeatureArchiveState] = useState< string | undefined >(); + const [isCustomColumns, setIsCustomColumns] = useState( + Boolean(tableState.columns), + ); const projectId = useRequiredPathParam('projectId'); const { onToggle: onFeatureToggle, modals: featureToggleModals } = useFeatureToggleSwitch(projectId); - const { value: storedParams, setValue: setStoredParams } = - createLocalStorage( - `${projectId}:FeatureToggleListTable:v1`, - defaultSort, - ); - const { value: globalStore, setValue: setGlobalStore } = - useGlobalLocalStorage(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const environments = useEnvironmentsRef( - loading - ? [{ environment: 'a' }, { environment: 'b' }, { environment: 'c' }] - : newEnvironments, - ); - const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } = - usePinnedFavorites( - searchParams.has('favorites') - ? searchParams.get('favorites') === 'true' - : globalStore.favorites, - ); const { favorite, unfavorite } = useFavoriteFeaturesApi(); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const [showExportDialog, setShowExportDialog] = useState(false); @@ -191,8 +170,15 @@ export const PaginatedProjectFeatureToggles = ({ id: 'favorite', Header: ( + setTableState({ + favorites: + tableState.favorites === 'true' + ? undefined + : 'true', + }) + } /> ), accessor: 'favorite', @@ -280,35 +266,40 @@ export const PaginatedProjectFeatureToggles = ({ Cell: DateCell, minWidth: 120, }, - ...environments.map((value: ProjectEnvironmentType | string) => { - const name = - typeof value === 'string' - ? value - : (value as ProjectEnvironmentType).environment; - const isChangeRequestEnabled = isChangeRequestConfigured(name); - const FeatureToggleCell = createFeatureToggleCell( - projectId, - name, - isChangeRequestEnabled, - onChange, - onFeatureToggle, - ); - - return { - Header: loading ? () => '' : name, - maxWidth: 90, - id: `environment:${name}`, - accessor: (row: ListItemType) => - row.environments[name]?.enabled, - align: 'center', - Cell: FeatureToggleCell, - sortType: 'boolean', - filterName: name, - filterParsing: (value: boolean) => - value ? 'enabled' : 'disabled', - }; - }), + ...environments.map( + (projectEnvironment: ProjectEnvironmentType | string) => { + const name = + typeof projectEnvironment === 'string' + ? projectEnvironment + : (projectEnvironment as ProjectEnvironmentType) + .environment; + const isChangeRequestEnabled = + isChangeRequestConfigured(name); + const FeatureToggleCell = createFeatureToggleCell( + projectId, + name, + isChangeRequestEnabled, + onChange, + onFeatureToggle, + ); + return { + Header: loading ? () => '' : name, + maxWidth: 90, + id: `environment:${name}`, + accessor: (row: ListItemType) => { + return row.environments?.[name]?.enabled; + }, + align: 'center', + Cell: FeatureToggleCell, + sortType: 'boolean', + sortDescFirst: true, + filterName: name, + filterParsing: (value: boolean) => + value ? 'enabled' : 'disabled', + }; + }, + ), { id: 'Actions', maxWidth: 56, @@ -332,7 +323,7 @@ export const PaginatedProjectFeatureToggles = ({ }, }, ], - [projectId, environments, loading], + [projectId, environments, loading, tableState.favorites, onChange], ); const [showTitle, setShowTitle] = useState(true); @@ -345,10 +336,10 @@ export const PaginatedProjectFeatureToggles = ({ environments.map((env) => { const thisEnv = feature?.environments.find( (featureEnvironment) => - featureEnvironment?.name === env, + featureEnvironment?.name === env.environment, ); return [ - env, + typeof env === 'string' ? env : env.environment, { name: env, enabled: thisEnv?.enabled || false, @@ -374,89 +365,92 @@ export const PaginatedProjectFeatureToggles = ({ const { getSearchText, getSearchContext } = useSearch( columns, - searchValue, + tableState.search || '', featuresData, ); + const initialPageSize = tableState.pageSize + ? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT + : DEFAULT_PAGE_LIMIT; + + const allColumnIds = columns + .map( + (column: any) => + (column?.id as string) || + (typeof column?.accessor === 'string' + ? (column?.accessor as string) + : ''), + ) + .filter(Boolean); + + const initialState = useMemo( + () => ({ + sortBy: [ + { + id: tableState.sortBy || 'createdAt', + desc: tableState.sortOrder === 'desc', + }, + ], + ...(tableState.columns + ? { + hiddenColumns: allColumnIds.filter( + (id) => + !(tableState.columns?.split(',') || [])?.includes( + id, + ) && !staticColumns.includes(id), + ), + } + : {}), + pageSize: initialPageSize, + pageIndex: tableState.page ? parseInt(tableState.page, 10) - 1 : 0, + selectedRowIds: {}, + }), + [initialLoad], + ); + const data = useMemo(() => { if (initialLoad || loading) { - const loadingData = Array(15) + const loadingData = Array( + parseInt(tableState.pageSize || `${initialPageSize}`, 10), + ) .fill(null) .map((_, index) => ({ id: index, // Assuming `id` is a required property type: '-', name: `Feature name ${index}`, createdAt: new Date().toISOString(), - environments: { - production: { + environments: [ + { name: 'production', enabled: false, }, - }, + ], })); // Coerce loading data to FeatureSchema[] - return loadingData as unknown as FeatureSchema[]; + return loadingData as unknown as typeof featuresData; } return featuresData; }, [loading, featuresData]); - const initialState = useMemo( - () => { - const allColumnIds = columns - .map( - (column: any) => - (column?.id as string) || - (typeof column?.accessor === 'string' - ? (column?.accessor as string) - : ''), - ) - .filter(Boolean); - let hiddenColumns = environments - .filter((_, index) => index >= 3) - .map((environment) => `environments.${environment}`); - - if (searchParams.has('columns')) { - const columnsInParams = - searchParams.get('columns')?.split(',') || []; - const visibleColumns = [...staticColumns, ...columnsInParams]; - hiddenColumns = allColumnIds.filter( - (columnId) => !visibleColumns.includes(columnId), - ); - } else if (storedParams.columns) { - const visibleColumns = [ - ...staticColumns, - ...storedParams.columns, - ]; - hiddenColumns = allColumnIds.filter( - (columnId) => !visibleColumns.includes(columnId), - ); - } - - return { - sortBy: [ - { - id: - searchParams.get('sort') || - storedParams.id || - sortingRules.sortBy, - desc: searchParams.has('order') - ? searchParams.get('order') === 'desc' - : storedParams.desc, - }, - ], - hiddenColumns, - selectedRowIds: {}, - }; - }, - [environments], // eslint-disable-line react-hooks/exhaustive-deps + const pageCount = useMemo( + () => + tableState.pageSize + ? Math.ceil((total || 0) / parseInt(tableState.pageSize)) + : 0, + [total, tableState.pageSize], ); - const getRowId = useCallback((row: any) => row.name, []); + const { allColumns, headerGroups, rows, - state: { selectedRowIds, sortBy, hiddenColumns }, + state: { pageIndex, pageSize, hiddenColumns, selectedRowIds, sortBy }, + canNextPage, + canPreviousPage, + previousPage, + nextPage, + setPageSize, prepareRow, setHiddenColumns, toggleAllRowsSelected, @@ -465,74 +459,52 @@ export const PaginatedProjectFeatureToggles = ({ columns: columns as any[], // TODO: fix after `react-table` v8 update data, initialState, - sortTypes, autoResetHiddenColumns: false, autoResetSelectedRows: false, disableSortRemove: true, autoResetSortBy: false, + manualSortBy: true, + manualPagination: true, + pageCount, getRowId, }, useFlexLayout, useSortBy, + usePagination, useRowSelect, ); + // 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(() => { - if (loading) { - return; - } - const sortedByColumn = sortBy[0].id; - const sortOrder = sortBy[0].desc ? 'desc' : 'asc'; - const tableState: Record = {}; - tableState.sort = sortedByColumn; - if (sortBy[0].desc) { - tableState.order = sortOrder; - } - if (searchValue) { - tableState.search = searchValue; - } - if (isFavoritesPinned) { - tableState.favorites = 'true'; - } - tableState.columns = allColumns - .map(({ id }) => id) - .filter( - (id) => - !staticColumns.includes(id) && !hiddenColumns?.includes(id), - ) - .join(','); - - setSearchParams(tableState, { - replace: true, - }); - setStoredParams((params) => ({ - ...params, - id: sortBy[0].id, - desc: sortBy[0].desc || false, - columns: tableState.columns.split(','), - })); - const favoritesPinned = Boolean(isFavoritesPinned); - setGlobalStore((params) => ({ - ...params, - favorites: favoritesPinned, - })); - setSortingRules({ - sortBy: sortedByColumn, - sortOrder, - isFavoritesPinned: favoritesPinned, + setTableState({ + page: `${pageIndex + 1}`, + pageSize: `${pageSize}`, + sortBy: sortBy[0]?.id || 'createdAt', + sortOrder: sortBy[0]?.desc ? 'desc' : 'asc', }); + }, [pageIndex, pageSize, sortBy]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - loading, - sortBy, - hiddenColumns, - searchValue, - setSearchParams, - isFavoritesPinned, - ]); + useEffect(() => { + 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 + ); + } + }, [loading, isCustomColumns, hiddenColumns]); - const showPaginationBar = Boolean(total && total > DEFAULT_PAGE_LIMIT); + const showPaginationBar = Boolean(total && total > pageSize); const paginatedStyles = showPaginationBar ? { borderBottomLeftRadius: 0, @@ -546,7 +518,7 @@ export const PaginatedProjectFeatureToggles = ({ disableLoading disablePadding className={styles.container} - style={{ ...style, ...paginatedStyles }} + style={{ ...paginatedStyles, ...style }} header={ { + setTableState({ + search: value, + }); + }} onFocus={() => setShowTitle(false) } @@ -596,10 +572,11 @@ export const PaginatedProjectFeatureToggles = ({ staticColumns={staticColumns} dividerAfter={['createdAt']} dividerBefore={['Actions']} - isCustomized={Boolean( - storedParams.columns, - )} + isCustomized={isCustomColumns} setHiddenColumns={setHiddenColumns} + onCustomize={() => + setIsCustomColumns(true) + } /> { + setTableState({ + search: value, + }); + }} hasFilters getSearchContext={getSearchContext} id='projectFeatureToggles' @@ -669,7 +650,9 @@ export const PaginatedProjectFeatureToggles = ({ aria-busy={loading} aria-live='polite' > - + - - 0} - show={ + 0 + } + show={ + No feature toggles found matching “ - {searchValue} + {tableState.search} ” - } - elseShow={ + + } + elseShow={ + No feature toggles available. Get started by adding a new feature toggle. - } - /> - } - /> - + + } + /> + } + /> setShowExportDialog(false)} - environments={environments} + environments={environments.map( + ({ environment }) => environment, + )} /> } /> @@ -740,7 +729,18 @@ export const PaginatedProjectFeatureToggles = ({ + } /> index >= 3) - .map((environment) => `environments.${environment}`); + .map((environment) => `environment:${environment}`); if (searchParams.has('columns')) { const columnsInParams = diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index 113e42c91025..2f26d7fbfc90 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { FC, useEffect } from 'react'; import useProject, { useProjectNameOrId, } from 'hooks/api/getters/useProject/useProject'; @@ -9,17 +9,17 @@ import { usePageTitle } from 'hooks/usePageTitle'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; import { ProjectStats } from './ProjectStats/ProjectStats'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { useUiFlag } from 'hooks/useUiFlag'; -import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; import { - ISortingRules, + DEFAULT_PAGE_LIMIT, + useFeatureSearch, +} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; +import { + ProjectTableState, PaginatedProjectFeatureToggles, } from './ProjectFeatureToggles/PaginatedProjectFeatureToggles'; -import { useSearchParams } from 'react-router-dom'; -import { PaginationBar } from 'component/common/PaginationBar/PaginationBar'; -import { SortingRule } from 'react-table'; +import { useTableState } from 'hooks/useTableState'; const refreshInterval = 15 * 1000; @@ -42,26 +42,24 @@ const StyledContentContainer = styled(Box)(() => ({ minWidth: 0, })); -export const DEFAULT_PAGE_LIMIT = 25; - -const PaginatedProjectOverview = () => { +const PaginatedProjectOverview: FC<{ + fullWidth?: boolean; + storageKey?: string; +}> = ({ fullWidth, storageKey = 'project-overview' }) => { const projectId = useRequiredPathParam('projectId'); - const [searchParams, setSearchParams] = useSearchParams(); const { project, loading: projectLoading } = useProject(projectId, { refreshInterval, }); - const [pageLimit, setPageLimit] = useState(DEFAULT_PAGE_LIMIT); - const [currentOffset, setCurrentOffset] = useState(0); - const [searchValue, setSearchValue] = useState( - searchParams.get('search') || '', + const [tableState, setTableState] = useTableState( + {}, + `${storageKey}-${projectId}`, ); - const [sortingRules, setSortingRules] = useState({ - sortBy: 'createdBy', - sortOrder: 'desc', - isFavoritesPinned: false, - }); + const page = parseInt(tableState.page || '1', 10); + const pageSize = tableState?.pageSize + ? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT + : DEFAULT_PAGE_LIMIT; const { features: searchFeatures, @@ -70,11 +68,15 @@ const PaginatedProjectOverview = () => { loading, initialLoad, } = useFeatureSearch( - currentOffset, - pageLimit, - sortingRules, + (page - 1) * pageSize, + pageSize, + { + sortBy: tableState.sortBy || 'createdAt', + sortOrder: tableState.sortOrder === 'desc' ? 'desc' : 'asc', + favoritesFirst: tableState.favorites === 'true', + }, projectId, - searchValue, + tableState.search, { refreshInterval, }, @@ -82,20 +84,9 @@ const PaginatedProjectOverview = () => { const { members, features, health, description, environments, stats } = project; - const fetchNextPage = () => { - if (!loading) { - setCurrentOffset(Math.min(total, currentOffset + pageLimit)); - } - }; - const fetchPrevPage = () => { - setCurrentOffset(Math.max(0, currentOffset - pageLimit)); - }; - - const hasPreviousPage = currentOffset > 0; - const hasNextPage = currentOffset + pageLimit < total; return ( - + { - - - } + tableState={tableState} + setTableState={setTableState} /> @@ -144,37 +117,6 @@ const PaginatedProjectOverview = () => { ); }; -const StyledStickyBar = styled('div')(({ theme }) => ({ - position: 'sticky', - bottom: 0, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(2), - marginLeft: theme.spacing(2), - zIndex: 9999, - borderBottomLeftRadius: theme.shape.borderRadiusMedium, - borderBottomRightRadius: theme.shape.borderRadiusMedium, - borderTop: `1px solid ${theme.palette.divider}`, - boxShadow: `0px -2px 8px 0px rgba(32, 32, 33, 0.06)`, - height: '52px', -})); - -const StyledStickyBarContentContainer = styled(Box)(({ theme }) => ({ - display: 'flex', - justifyContent: 'space-between', - width: '100%', - minWidth: 0, -})); - -const StickyPaginationBar: React.FC = ({ children }) => { - return ( - - - {children} - - - ); -}; - /** * @deprecated remove when flag `featureSearchFrontend` is removed */ diff --git a/frontend/src/component/project/Project/StickyPaginationBar/StickyPaginationBar.tsx b/frontend/src/component/project/Project/StickyPaginationBar/StickyPaginationBar.tsx new file mode 100644 index 000000000000..b753a4e14170 --- /dev/null +++ b/frontend/src/component/project/Project/StickyPaginationBar/StickyPaginationBar.tsx @@ -0,0 +1,36 @@ +import { Box, styled } from '@mui/material'; +import { PaginationBar } from 'component/common/PaginationBar/PaginationBar'; +import { ComponentProps, FC } from 'react'; + +const StyledStickyBar = styled('div')(({ theme }) => ({ + position: 'sticky', + bottom: 0, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2), + marginLeft: theme.spacing(2), + zIndex: theme.zIndex.fab, + borderBottomLeftRadius: theme.shape.borderRadiusMedium, + borderBottomRightRadius: theme.shape.borderRadiusMedium, + borderTop: `1px solid ${theme.palette.divider}`, + boxShadow: `0px -2px 8px 0px rgba(32, 32, 33, 0.06)`, + height: '52px', +})); + +const StyledStickyBarContentContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + width: '100%', + minWidth: 0, +})); + +export const StickyPaginationBar: FC> = ({ + ...props +}) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts b/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts index 0426ff9d9cdd..d4dd59cde965 100644 --- a/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts +++ b/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts @@ -4,7 +4,12 @@ import { IFeatureToggleListItem } from 'interfaces/featureToggle'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import { translateToQueryParams } from './searchToQueryParams'; -import { ISortingRules } from 'component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles'; + +type ISortingRules = { + sortBy: string; + sortOrder: string; + favoritesFirst: boolean; +}; type IFeatureSearchResponse = { features: IFeatureToggleListItem[]; @@ -109,6 +114,8 @@ const createFeatureSearch = () => { }; }; +export const DEFAULT_PAGE_LIMIT = 25; + export const useFeatureSearch = createFeatureSearch(); const getFeatureSearchFetcher = ( @@ -137,7 +144,7 @@ const getFeatureSearchFetcher = ( }; const translateToSortQueryParams = (sortingRules: ISortingRules) => { - const { sortBy, sortOrder, isFavoritesPinned } = sortingRules; - const sortQueryParams = `sortBy=${sortBy}&sortOrder=${sortOrder}&favoritesFirst=${isFavoritesPinned}`; + const { sortBy, sortOrder, favoritesFirst } = sortingRules; + const sortQueryParams = `sortBy=${sortBy}&sortOrder=${sortOrder}&favoritesFirst=${favoritesFirst}`; return sortQueryParams; }; diff --git a/frontend/src/hooks/useTableState.test.ts b/frontend/src/hooks/useTableState.test.ts index ce2f716d4fa3..9eb2ef1d3d41 100644 --- a/frontend/src/hooks/useTableState.test.ts +++ b/frontend/src/hooks/useTableState.test.ts @@ -231,7 +231,7 @@ describe('useTableState', () => { const { result } = renderHook(() => useTableState<{ - [key: string]: string | string[]; + [key: string]: string; }>({}, 'test', ['saveOnlyThisToUrl'], ['page']), ); const setParams = result.current[1]; @@ -245,7 +245,7 @@ describe('useTableState', () => { sortBy: 'type', sortOrder: 'favorites', favorites: 'false', - columns: ['test', 'id'], + columns: 'test,id', }); }); @@ -253,39 +253,16 @@ describe('useTableState', () => { expect(storageSetter).toHaveBeenCalledWith({ page: '2' }); }); - it('can reset state to the default instead of overwriting', () => { - mockStorage.mockReturnValue({ - value: { pageSize: 25 }, - setValue: vi.fn(), - }); - mockQuery.mockReturnValue([new URLSearchParams('page=4'), vi.fn()]); - + it('can update query and storage without triggering a rerender', () => { const { result } = renderHook(() => - useTableState<{ - page: string; - pageSize?: string; - sortBy?: string; - }>({ page: '1', pageSize: '10' }, 'test'), + useTableState({ page: '1' }, 'test', [], []), ); - const setParams = result.current[1]; act(() => { - setParams({ sortBy: 'type' }); - }); - expect(result.current[0]).toEqual({ - page: '4', - pageSize: '10', - sortBy: 'type', + setParams({ page: '2' }, true); }); - act(() => { - setParams({ pageSize: '50' }, true); - }); - - expect(result.current[0]).toEqual({ - page: '1', - pageSize: '50', - }); + expect(result.current[0]).toEqual({ page: '1' }); }); }); diff --git a/frontend/src/hooks/useTableState.ts b/frontend/src/hooks/useTableState.ts index 8e2ad79351cd..5c78b7a900fc 100644 --- a/frontend/src/hooks/useTableState.ts +++ b/frontend/src/hooks/useTableState.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { createLocalStorage } from '../utils/createLocalStorage'; @@ -35,7 +35,7 @@ const defaultQueryKeys = [...defaultStoredKeys, 'page']; * @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 = >( +export const useTableState = >( defaultParams: Params, storageId: string, queryKeys?: Array, @@ -52,31 +52,34 @@ export const useTableState = >( ...searchQuery, } as Params); - const updateParams = (value: Partial, reset = false) => { - const newState: Params = reset - ? { ...defaultParams, ...value } - : { - ...params, - ...value, - }; + 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]; - } - }); + // remove keys with undefined values + Object.keys(newState).forEach((key) => { + if (newState[key] === undefined) { + delete newState[key]; + } + }); - setParams(newState); - setSearchParams( - filterObjectKeys(newState, queryKeys || defaultQueryKeys), - ); - setStoredParams( - filterObjectKeys(newState, storageKeys || defaultStoredKeys), - ); + if (!quiet) { + setParams(newState); + } + setSearchParams( + filterObjectKeys(newState, queryKeys || defaultQueryKeys), + ); + setStoredParams( + filterObjectKeys(newState, storageKeys || defaultStoredKeys), + ); - return params; - }; + return params; + }, + [setParams, setSearchParams, setStoredParams], + ); return [params, updateParams] as const; };