1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-31 01:16:01 +02:00

Feat/new paginated table (#5371)

## About the changes
This commit is contained in:
Tymoteusz Czech 2023-11-24 17:50:58 +01:00 committed by GitHub
parent f00eac0881
commit dbd897e3bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 369 additions and 462 deletions

View File

@ -9,7 +9,7 @@ interface IBatchSelectionActionsBarProps {
const StyledStickyContainer = styled('div')(({ theme }) => ({ const StyledStickyContainer = styled('div')(({ theme }) => ({
position: 'sticky', position: 'sticky',
bottom: 50, bottom: 50,
zIndex: theme.zIndex.mobileStepper, zIndex: theme.zIndex.fab,
pointerEvents: 'none', pointerEvents: 'none',
})); }));

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import { useEffect } from 'react';
import useProject, { import useProject, {
useProjectNameOrId, useProjectNameOrId,
} from 'hooks/api/getters/useProject/useProject'; } from 'hooks/api/getters/useProject/useProject';
@ -9,15 +9,15 @@ 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 { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import { import {
ISortingRules, DEFAULT_PAGE_LIMIT,
useFeatureSearch,
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import {
PaginatedProjectFeatureToggles, PaginatedProjectFeatureToggles,
ProjectTableState,
} from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles'; } from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles';
import { useSearchParams } from 'react-router-dom'; import { useTableState } from 'hooks/useTableState';
import { PaginationBar } from 'component/common/PaginationBar/PaginationBar';
import { SortingRule } from 'react-table';
const refreshInterval = 15 * 1000; const refreshInterval = 15 * 1000;
@ -40,26 +40,21 @@ const StyledContentContainer = styled(Box)(() => ({
minWidth: 0, minWidth: 0,
})); }));
export const DEFAULT_PAGE_LIMIT = 25;
const PaginatedProjectOverview = () => { const PaginatedProjectOverview = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const [searchParams, setSearchParams] = useSearchParams();
const { project, loading: projectLoading } = useProject(projectId, { const { project, loading: projectLoading } = useProject(projectId, {
refreshInterval, refreshInterval,
}); });
const [pageLimit, setPageLimit] = useState(DEFAULT_PAGE_LIMIT);
const [currentOffset, setCurrentOffset] = useState(0);
const [searchValue, setSearchValue] = useState( const [tableState, setTableState] = useTableState<ProjectTableState>(
searchParams.get('search') || '', {},
`project-features-${projectId}`,
); );
const [sortingRules, setSortingRules] = useState<ISortingRules>({ const page = parseInt(tableState.page || '1', 10);
sortBy: 'createdBy', const pageSize = tableState?.pageSize
sortOrder: 'desc', ? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT
isFavoritesPinned: false, : DEFAULT_PAGE_LIMIT;
});
const { const {
features: searchFeatures, features: searchFeatures,
@ -68,28 +63,21 @@ const PaginatedProjectOverview = () => {
loading, loading,
initialLoad, initialLoad,
} = useFeatureSearch( } = useFeatureSearch(
currentOffset, (page - 1) * pageSize,
pageLimit, pageSize,
sortingRules, {
sortBy: tableState.sortBy || 'createdAt',
sortOrder: tableState.sortOrder === 'desc' ? 'desc' : 'asc',
favoritesFirst: tableState.favorites === 'true',
},
projectId, projectId,
searchValue, tableState.search,
{ {
refreshInterval, refreshInterval,
}, },
); );
const { environments } = project; 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 ( return (
<StyledContainer> <StyledContainer>
@ -97,35 +85,20 @@ const PaginatedProjectOverview = () => {
<StyledProjectToggles> <StyledProjectToggles>
<PaginatedProjectFeatureToggles <PaginatedProjectFeatureToggles
key={ key={
loading && searchFeatures.length === 0 (loading || projectLoading) &&
searchFeatures.length === 0
? 'loading' ? 'loading'
: 'ready' : 'ready'
} }
features={searchFeatures}
style={{ width: '100%', margin: 0 }} style={{ width: '100%', margin: 0 }}
features={searchFeatures || []}
environments={environments} environments={environments}
initialLoad={initialLoad && searchFeatures.length === 0} initialLoad={initialLoad && searchFeatures.length === 0}
loading={loading && searchFeatures.length === 0} loading={loading && searchFeatures.length === 0}
onChange={refetch} onChange={refetch}
total={total} total={total}
searchValue={searchValue} tableState={tableState}
setSearchValue={setSearchValue} setTableState={setTableState}
sortingRules={sortingRules}
setSortingRules={setSortingRules}
paginationBar={
<StickyPaginationBar>
<PaginationBar
total={total}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
fetchNextPage={fetchNextPage}
fetchPrevPage={fetchPrevPage}
currentOffset={currentOffset}
pageLimit={pageLimit}
setPageLimit={setPageLimit}
/>
</StickyPaginationBar>
}
/> />
</StyledProjectToggles> </StyledProjectToggles>
</StyledContentContainer> </StyledContentContainer>
@ -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 (
<StyledStickyBar>
<StyledStickyBarContentContainer>
{children}
</StyledStickyBarContentContainer>
</StyledStickyBar>
);
};
/** /**
* @deprecated remove when flag `featureSearchFrontend` is removed * @deprecated remove when flag `featureSearchFrontend` is removed
*/ */

View File

@ -34,11 +34,8 @@ interface IColumnsMenuProps {
dividerBefore?: string[]; dividerBefore?: string[];
dividerAfter?: string[]; dividerAfter?: string[];
isCustomized?: boolean; isCustomized?: boolean;
setHiddenColumns: ( setHiddenColumns: (hiddenColumns: string[]) => void;
hiddenColumns: onCustomize?: () => void;
| string[]
| ((previousHiddenColumns: string[]) => string[]),
) => void;
} }
const columnNameMap: Record<string, string> = { const columnNameMap: Record<string, string> = {
@ -51,6 +48,7 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
dividerBefore = [], dividerBefore = [],
dividerAfter = [], dividerAfter = [],
isCustomized = false, isCustomized = false,
onCustomize,
setHiddenColumns, setHiddenColumns,
}) => { }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@ -69,7 +67,7 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
environmentsToShow: number = 0, environmentsToShow: number = 0,
) => { ) => {
const visibleEnvColumns = allColumns const visibleEnvColumns = allColumns
.filter(({ id }) => id.startsWith('environments.') !== false) .filter(({ id }) => id.startsWith('environment:') !== false)
.map(({ id }) => id) .map(({ id }) => id)
.slice(0, environmentsToShow); .slice(0, environmentsToShow);
const hiddenColumns = allColumns const hiddenColumns = allColumns
@ -160,9 +158,10 @@ export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
show={<StyledDivider />} show={<StyledDivider />}
/>, />,
<StyledMenuItem <StyledMenuItem
onClick={() => onClick={() => {
column.toggleHidden(column.isVisible) column.toggleHidden(column.isVisible);
} onCustomize?.();
}}
disabled={staticColumns.includes(column.id)} disabled={staticColumns.includes(column.id)}
> >
<ListItemIcon> <ListItemIcon>

View File

@ -1,4 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, {
type CSSProperties,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { import {
Checkbox, Checkbox,
IconButton, IconButton,
@ -9,10 +15,10 @@ import {
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
import { Add } from '@mui/icons-material'; import { Add } from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
SortingRule,
useFlexLayout, useFlexLayout,
usePagination,
useRowSelect, useRowSelect,
useSortBy, useSortBy,
useTable, useTable,
@ -32,7 +38,6 @@ import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/Fe
import { IProject } from 'interfaces/project'; import { IProject } from 'interfaces/project';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { createLocalStorage } from 'utils/createLocalStorage';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch'; import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch';
@ -40,17 +45,12 @@ import { Search } from 'component/common/Search/Search';
import { IFeatureToggleListItem } from 'interfaces/featureToggle'; import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import { import { ProjectEnvironmentType } from './hooks/useEnvironmentsRef';
ProjectEnvironmentType,
useEnvironmentsRef,
} from './hooks/useEnvironmentsRef';
import { ActionsCell } from './ActionsCell/ActionsCell'; import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles'; import { useStyles } from './ProjectFeatureToggles.styles';
import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi'; import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell'; import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import FileDownload from '@mui/icons-material/FileDownload'; import FileDownload from '@mui/icons-material/FileDownload';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog'; import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
@ -63,17 +63,22 @@ import { ListItemType } from './ProjectFeatureToggles.types';
import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell'; import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell';
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch'; import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
import useLoading from 'hooks/useLoading'; 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)(() => ({ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
})); }));
export interface ISortingRules { export type ProjectTableState = {
sortBy: string; page?: string;
sortOrder: 'asc' | 'desc'; sortBy?: string;
isFavoritesPinned: boolean; pageSize?: string;
} sortOrder?: 'asc' | 'desc';
favorites?: 'true' | 'false';
columns?: string;
search?: string;
};
interface IPaginatedProjectFeatureTogglesProps { interface IPaginatedProjectFeatureTogglesProps {
features: IProject['features']; features: IProject['features'];
@ -82,33 +87,23 @@ interface IPaginatedProjectFeatureTogglesProps {
onChange: () => void; onChange: () => void;
total?: number; total?: number;
initialLoad: boolean; initialLoad: boolean;
searchValue: string; tableState: ProjectTableState;
setSearchValue: React.Dispatch<React.SetStateAction<string>>; setTableState: (state: Partial<ProjectTableState>, quiet?: boolean) => void;
paginationBar: JSX.Element; style?: CSSProperties;
sortingRules: ISortingRules;
setSortingRules: (sortingRules: ISortingRules) => void;
style?: React.CSSProperties;
} }
const staticColumns = ['Select', 'Actions', 'name', 'favorite']; const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
const defaultSort: SortingRule<string> & {
columns?: string[];
} = { id: 'createdAt', desc: true };
export const PaginatedProjectFeatureToggles = ({ export const PaginatedProjectFeatureToggles = ({
features, features,
loading, loading,
initialLoad, initialLoad,
environments: newEnvironments = [], environments,
onChange, onChange,
total, total,
searchValue, tableState,
setSearchValue, setTableState,
paginationBar, style,
sortingRules,
setSortingRules,
style = {},
}: IPaginatedProjectFeatureTogglesProps) => { }: IPaginatedProjectFeatureTogglesProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const bodyLoadingRef = useLoading(loading); const bodyLoadingRef = useLoading(loading);
@ -122,30 +117,14 @@ export const PaginatedProjectFeatureToggles = ({
const [featureArchiveState, setFeatureArchiveState] = useState< const [featureArchiveState, setFeatureArchiveState] = useState<
string | undefined string | undefined
>(); >();
const [isCustomColumns, setIsCustomColumns] = useState(
Boolean(tableState.columns),
);
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { onToggle: onFeatureToggle, modals: featureToggleModals } = const { onToggle: onFeatureToggle, modals: featureToggleModals } =
useFeatureToggleSwitch(projectId); useFeatureToggleSwitch(projectId);
const { value: storedParams, setValue: setStoredParams } =
createLocalStorage(
`${projectId}:FeatureToggleListTable:v1`,
defaultSort,
);
const { value: globalStore, setValue: setGlobalStore } =
useGlobalLocalStorage();
const navigate = useNavigate(); 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 { favorite, unfavorite } = useFavoriteFeaturesApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const [showExportDialog, setShowExportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false);
@ -191,8 +170,15 @@ export const PaginatedProjectFeatureToggles = ({
id: 'favorite', id: 'favorite',
Header: ( Header: (
<FavoriteIconHeader <FavoriteIconHeader
isActive={isFavoritesPinned} isActive={tableState.favorites === 'true'}
onClick={onChangeIsFavoritePinned} onClick={() =>
setTableState({
favorites:
tableState.favorites === 'true'
? undefined
: 'true',
})
}
/> />
), ),
accessor: 'favorite', accessor: 'favorite',
@ -280,35 +266,40 @@ export const PaginatedProjectFeatureToggles = ({
Cell: DateCell, Cell: DateCell,
minWidth: 120, minWidth: 120,
}, },
...environments.map((value: ProjectEnvironmentType | string) => { ...environments.map(
const name = (projectEnvironment: ProjectEnvironmentType | string) => {
typeof value === 'string' const name =
? value typeof projectEnvironment === 'string'
: (value as ProjectEnvironmentType).environment; ? projectEnvironment
const isChangeRequestEnabled = isChangeRequestConfigured(name); : (projectEnvironment as ProjectEnvironmentType)
const FeatureToggleCell = createFeatureToggleCell( .environment;
projectId, const isChangeRequestEnabled =
name, isChangeRequestConfigured(name);
isChangeRequestEnabled, const FeatureToggleCell = createFeatureToggleCell(
onChange, projectId,
onFeatureToggle, name,
); isChangeRequestEnabled,
onChange,
return { onFeatureToggle,
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',
};
}),
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', id: 'Actions',
maxWidth: 56, maxWidth: 56,
@ -332,7 +323,7 @@ export const PaginatedProjectFeatureToggles = ({
}, },
}, },
], ],
[projectId, environments, loading], [projectId, environments, loading, tableState.favorites, onChange],
); );
const [showTitle, setShowTitle] = useState(true); const [showTitle, setShowTitle] = useState(true);
@ -345,10 +336,10 @@ export const PaginatedProjectFeatureToggles = ({
environments.map((env) => { environments.map((env) => {
const thisEnv = feature?.environments.find( const thisEnv = feature?.environments.find(
(featureEnvironment) => (featureEnvironment) =>
featureEnvironment?.name === env, featureEnvironment?.name === env.environment,
); );
return [ return [
env, typeof env === 'string' ? env : env.environment,
{ {
name: env, name: env,
enabled: thisEnv?.enabled || false, enabled: thisEnv?.enabled || false,
@ -374,89 +365,92 @@ export const PaginatedProjectFeatureToggles = ({
const { getSearchText, getSearchContext } = useSearch( const { getSearchText, getSearchContext } = useSearch(
columns, columns,
searchValue, tableState.search || '',
featuresData, 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(() => { const data = useMemo(() => {
if (initialLoad || loading) { if (initialLoad || loading) {
const loadingData = Array(15) const loadingData = Array(
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
type: '-', type: '-',
name: `Feature name ${index}`, name: `Feature name ${index}`,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
environments: { environments: [
production: { {
name: 'production', name: 'production',
enabled: false, enabled: false,
}, },
}, ],
})); }));
// Coerce loading data to FeatureSchema[] // Coerce loading data to FeatureSchema[]
return loadingData as unknown as FeatureSchema[]; return loadingData as unknown as typeof featuresData;
} }
return featuresData; return featuresData;
}, [loading, featuresData]); }, [loading, featuresData]);
const initialState = useMemo( const pageCount = useMemo(
() => { () =>
const allColumnIds = columns tableState.pageSize
.map( ? Math.ceil((total || 0) / parseInt(tableState.pageSize))
(column: any) => : 0,
(column?.id as string) || [total, tableState.pageSize],
(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 getRowId = useCallback((row: any) => row.name, []); const getRowId = useCallback((row: any) => row.name, []);
const { const {
allColumns, allColumns,
headerGroups, headerGroups,
rows, rows,
state: { selectedRowIds, sortBy, hiddenColumns }, state: { pageIndex, pageSize, hiddenColumns, selectedRowIds, sortBy },
canNextPage,
canPreviousPage,
previousPage,
nextPage,
setPageSize,
prepareRow, prepareRow,
setHiddenColumns, setHiddenColumns,
toggleAllRowsSelected, toggleAllRowsSelected,
@ -465,74 +459,52 @@ export const PaginatedProjectFeatureToggles = ({
columns: columns as any[], // TODO: fix after `react-table` v8 update columns: columns as any[], // TODO: fix after `react-table` v8 update
data, data,
initialState, initialState,
sortTypes,
autoResetHiddenColumns: false, autoResetHiddenColumns: false,
autoResetSelectedRows: false, autoResetSelectedRows: false,
disableSortRemove: true, disableSortRemove: true,
autoResetSortBy: false, autoResetSortBy: false,
manualSortBy: true,
manualPagination: true,
pageCount,
getRowId, getRowId,
}, },
useFlexLayout, useFlexLayout,
useSortBy, useSortBy,
usePagination,
useRowSelect, 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(() => { useEffect(() => {
if (loading) { setTableState({
return; page: `${pageIndex + 1}`,
} pageSize: `${pageSize}`,
const sortedByColumn = sortBy[0].id; sortBy: sortBy[0]?.id || 'createdAt',
const sortOrder = sortBy[0].desc ? 'desc' : 'asc'; sortOrder: sortBy[0]?.desc ? 'desc' : 'asc',
const tableState: Record<string, string> = {};
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,
}); });
}, [pageIndex, pageSize, sortBy]);
// eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => {
}, [ if (!loading && isCustomColumns) {
loading, setTableState(
sortBy, {
hiddenColumns, columns:
searchValue, hiddenColumns !== undefined
setSearchParams, ? allColumnIds
isFavoritesPinned, .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 const paginatedStyles = showPaginationBar
? { ? {
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
@ -546,7 +518,7 @@ export const PaginatedProjectFeatureToggles = ({
disableLoading disableLoading
disablePadding disablePadding
className={styles.container} className={styles.container}
style={{ ...style, ...paginatedStyles }} style={{ ...paginatedStyles, ...style }}
header={ header={
<Box <Box
ref={headerLoadingRef} ref={headerLoadingRef}
@ -575,8 +547,12 @@ export const PaginatedProjectFeatureToggles = ({
data-loading data-loading
placeholder='Search and Filter' placeholder='Search and Filter'
expandable expandable
initialValue={searchValue} initialValue={tableState.search}
onChange={setSearchValue} onChange={(value) => {
setTableState({
search: value,
});
}}
onFocus={() => onFocus={() =>
setShowTitle(false) setShowTitle(false)
} }
@ -596,10 +572,11 @@ export const PaginatedProjectFeatureToggles = ({
staticColumns={staticColumns} staticColumns={staticColumns}
dividerAfter={['createdAt']} dividerAfter={['createdAt']}
dividerBefore={['Actions']} dividerBefore={['Actions']}
isCustomized={Boolean( isCustomized={isCustomColumns}
storedParams.columns,
)}
setHiddenColumns={setHiddenColumns} setHiddenColumns={setHiddenColumns}
onCustomize={() =>
setIsCustomColumns(true)
}
/> />
<PageHeader.Divider <PageHeader.Divider
sx={{ marginLeft: 0 }} sx={{ marginLeft: 0 }}
@ -652,8 +629,12 @@ export const PaginatedProjectFeatureToggles = ({
condition={isSmallScreen} condition={isSmallScreen}
show={ show={
<Search <Search
initialValue={searchValue} initialValue={tableState.search}
onChange={setSearchValue} onChange={(value) => {
setTableState({
search: value,
});
}}
hasFilters hasFilters
getSearchContext={getSearchContext} getSearchContext={getSearchContext}
id='projectFeatureToggles' id='projectFeatureToggles'
@ -669,7 +650,9 @@ export const PaginatedProjectFeatureToggles = ({
aria-busy={loading} aria-busy={loading}
aria-live='polite' aria-live='polite'
> >
<SearchHighlightProvider value={getSearchText(searchValue)}> <SearchHighlightProvider
value={getSearchText(tableState.search || '')}
>
<VirtualizedTable <VirtualizedTable
rows={rows} rows={rows}
headerGroups={headerGroups} headerGroups={headerGroups}
@ -677,31 +660,35 @@ export const PaginatedProjectFeatureToggles = ({
/> />
</SearchHighlightProvider> </SearchHighlightProvider>
<Box sx={{ padding: theme.spacing(3) }}> <ConditionallyRender
<ConditionallyRender condition={rows.length === 0}
condition={rows.length === 0} show={
show={ <ConditionallyRender
<ConditionallyRender condition={
condition={searchValue?.length > 0} (tableState.search || '')?.length > 0
show={ }
show={
<Box sx={{ padding: theme.spacing(3) }}>
<TablePlaceholder> <TablePlaceholder>
No feature toggles found matching No feature toggles found matching
&ldquo; &ldquo;
{searchValue} {tableState.search}
&rdquo; &rdquo;
</TablePlaceholder> </TablePlaceholder>
} </Box>
elseShow={ }
elseShow={
<Box sx={{ padding: theme.spacing(3) }}>
<TablePlaceholder> <TablePlaceholder>
No feature toggles available. Get No feature toggles available. Get
started by adding a new feature started by adding a new feature
toggle. toggle.
</TablePlaceholder> </TablePlaceholder>
} </Box>
/> }
} />
/> }
</Box> />
<FeatureStaleDialog <FeatureStaleDialog
isStale={featureStaleDialogState.stale === true} isStale={featureStaleDialogState.stale === true}
isOpen={Boolean(featureStaleDialogState.featureId)} isOpen={Boolean(featureStaleDialogState.featureId)}
@ -731,7 +718,9 @@ export const PaginatedProjectFeatureToggles = ({
showExportDialog={showExportDialog} showExportDialog={showExportDialog}
data={data} data={data}
onClose={() => setShowExportDialog(false)} onClose={() => setShowExportDialog(false)}
environments={environments} environments={environments.map(
({ environment }) => environment,
)}
/> />
} }
/> />
@ -740,7 +729,18 @@ export const PaginatedProjectFeatureToggles = ({
</PageContent> </PageContent>
<ConditionallyRender <ConditionallyRender
condition={showPaginationBar} condition={showPaginationBar}
show={paginationBar} show={
<StickyPaginationBar
total={total || 0}
hasNextPage={canNextPage}
hasPreviousPage={canPreviousPage}
fetchNextPage={nextPage}
fetchPrevPage={previousPage}
currentOffset={pageIndex * pageSize}
pageLimit={pageSize}
setPageLimit={setPageSize}
/>
}
/> />
<BatchSelectionActionsBar <BatchSelectionActionsBar
count={Object.keys(selectedRowIds).length} count={Object.keys(selectedRowIds).length}

View File

@ -387,7 +387,7 @@ export const ProjectFeatureToggles = ({
.filter(Boolean); .filter(Boolean);
let hiddenColumns = environments let hiddenColumns = environments
.filter((_, index) => index >= 3) .filter((_, index) => index >= 3)
.map((environment) => `environments.${environment}`); .map((environment) => `environment:${environment}`);
if (searchParams.has('columns')) { if (searchParams.has('columns')) {
const columnsInParams = const columnsInParams =

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { FC, useEffect } from 'react';
import useProject, { import useProject, {
useProjectNameOrId, useProjectNameOrId,
} from 'hooks/api/getters/useProject/useProject'; } from 'hooks/api/getters/useProject/useProject';
@ -9,17 +9,17 @@ import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useLastViewedProject } from 'hooks/useLastViewedProject'; import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { ProjectStats } from './ProjectStats/ProjectStats'; import { ProjectStats } from './ProjectStats/ProjectStats';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import { import {
ISortingRules, DEFAULT_PAGE_LIMIT,
useFeatureSearch,
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import {
ProjectTableState,
PaginatedProjectFeatureToggles, PaginatedProjectFeatureToggles,
} from './ProjectFeatureToggles/PaginatedProjectFeatureToggles'; } from './ProjectFeatureToggles/PaginatedProjectFeatureToggles';
import { useSearchParams } from 'react-router-dom';
import { PaginationBar } from 'component/common/PaginationBar/PaginationBar'; import { useTableState } from 'hooks/useTableState';
import { SortingRule } from 'react-table';
const refreshInterval = 15 * 1000; const refreshInterval = 15 * 1000;
@ -42,26 +42,24 @@ const StyledContentContainer = styled(Box)(() => ({
minWidth: 0, minWidth: 0,
})); }));
export const DEFAULT_PAGE_LIMIT = 25; const PaginatedProjectOverview: FC<{
fullWidth?: boolean;
const PaginatedProjectOverview = () => { storageKey?: string;
}> = ({ fullWidth, storageKey = 'project-overview' }) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const [searchParams, setSearchParams] = useSearchParams();
const { project, loading: projectLoading } = useProject(projectId, { const { project, loading: projectLoading } = useProject(projectId, {
refreshInterval, refreshInterval,
}); });
const [pageLimit, setPageLimit] = useState(DEFAULT_PAGE_LIMIT);
const [currentOffset, setCurrentOffset] = useState(0);
const [searchValue, setSearchValue] = useState( const [tableState, setTableState] = useTableState<ProjectTableState>(
searchParams.get('search') || '', {},
`${storageKey}-${projectId}`,
); );
const [sortingRules, setSortingRules] = useState<ISortingRules>({ const page = parseInt(tableState.page || '1', 10);
sortBy: 'createdBy', const pageSize = tableState?.pageSize
sortOrder: 'desc', ? parseInt(tableState.pageSize, 10) || DEFAULT_PAGE_LIMIT
isFavoritesPinned: false, : DEFAULT_PAGE_LIMIT;
});
const { const {
features: searchFeatures, features: searchFeatures,
@ -70,11 +68,15 @@ const PaginatedProjectOverview = () => {
loading, loading,
initialLoad, initialLoad,
} = useFeatureSearch( } = useFeatureSearch(
currentOffset, (page - 1) * pageSize,
pageLimit, pageSize,
sortingRules, {
sortBy: tableState.sortBy || 'createdAt',
sortOrder: tableState.sortOrder === 'desc' ? 'desc' : 'asc',
favoritesFirst: tableState.favorites === 'true',
},
projectId, projectId,
searchValue, tableState.search,
{ {
refreshInterval, refreshInterval,
}, },
@ -82,20 +84,9 @@ const PaginatedProjectOverview = () => {
const { members, features, health, description, environments, stats } = const { members, features, health, description, environments, stats } =
project; 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 ( return (
<StyledContainer> <StyledContainer key={projectId}>
<ProjectInfo <ProjectInfo
id={projectId} id={projectId}
description={description} description={description}
@ -108,35 +99,17 @@ const PaginatedProjectOverview = () => {
<ProjectStats stats={project.stats} /> <ProjectStats stats={project.stats} />
<StyledProjectToggles> <StyledProjectToggles>
<PaginatedProjectFeatureToggles <PaginatedProjectFeatureToggles
key={ style={
loading && searchFeatures.length === 0 fullWidth ? { width: '100%', margin: 0 } : undefined
? 'loading'
: 'ready'
} }
features={searchFeatures} features={searchFeatures || []}
environments={environments} environments={environments}
initialLoad={initialLoad && searchFeatures.length === 0} initialLoad={initialLoad && searchFeatures.length === 0}
loading={loading && searchFeatures.length === 0} loading={loading && searchFeatures.length === 0}
onChange={refetch} onChange={refetch}
total={total} total={total}
searchValue={searchValue} tableState={tableState}
setSearchValue={setSearchValue} setTableState={setTableState}
sortingRules={sortingRules}
setSortingRules={setSortingRules}
paginationBar={
<StickyPaginationBar>
<PaginationBar
total={total}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
fetchNextPage={fetchNextPage}
fetchPrevPage={fetchPrevPage}
currentOffset={currentOffset}
pageLimit={pageLimit}
setPageLimit={setPageLimit}
/>
</StickyPaginationBar>
}
/> />
</StyledProjectToggles> </StyledProjectToggles>
</StyledContentContainer> </StyledContentContainer>
@ -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 (
<StyledStickyBar>
<StyledStickyBarContentContainer>
{children}
</StyledStickyBarContentContainer>
</StyledStickyBar>
);
};
/** /**
* @deprecated remove when flag `featureSearchFrontend` is removed * @deprecated remove when flag `featureSearchFrontend` is removed
*/ */

View File

@ -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<ComponentProps<typeof PaginationBar>> = ({
...props
}) => {
return (
<StyledStickyBar>
<StyledStickyBarContentContainer>
<PaginationBar {...props} />
</StyledStickyBarContentContainer>
</StyledStickyBar>
);
};

View File

@ -4,7 +4,12 @@ import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import { translateToQueryParams } from './searchToQueryParams'; import { translateToQueryParams } from './searchToQueryParams';
import { ISortingRules } from 'component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles';
type ISortingRules = {
sortBy: string;
sortOrder: string;
favoritesFirst: boolean;
};
type IFeatureSearchResponse = { type IFeatureSearchResponse = {
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
@ -109,6 +114,8 @@ const createFeatureSearch = () => {
}; };
}; };
export const DEFAULT_PAGE_LIMIT = 25;
export const useFeatureSearch = createFeatureSearch(); export const useFeatureSearch = createFeatureSearch();
const getFeatureSearchFetcher = ( const getFeatureSearchFetcher = (
@ -137,7 +144,7 @@ const getFeatureSearchFetcher = (
}; };
const translateToSortQueryParams = (sortingRules: ISortingRules) => { const translateToSortQueryParams = (sortingRules: ISortingRules) => {
const { sortBy, sortOrder, isFavoritesPinned } = sortingRules; const { sortBy, sortOrder, favoritesFirst } = sortingRules;
const sortQueryParams = `sortBy=${sortBy}&sortOrder=${sortOrder}&favoritesFirst=${isFavoritesPinned}`; const sortQueryParams = `sortBy=${sortBy}&sortOrder=${sortOrder}&favoritesFirst=${favoritesFirst}`;
return sortQueryParams; return sortQueryParams;
}; };

View File

@ -231,7 +231,7 @@ describe('useTableState', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useTableState<{ useTableState<{
[key: string]: string | string[]; [key: string]: string;
}>({}, 'test', ['saveOnlyThisToUrl'], ['page']), }>({}, 'test', ['saveOnlyThisToUrl'], ['page']),
); );
const setParams = result.current[1]; const setParams = result.current[1];
@ -245,7 +245,7 @@ describe('useTableState', () => {
sortBy: 'type', sortBy: 'type',
sortOrder: 'favorites', sortOrder: 'favorites',
favorites: 'false', favorites: 'false',
columns: ['test', 'id'], columns: 'test,id',
}); });
}); });
@ -253,39 +253,16 @@ describe('useTableState', () => {
expect(storageSetter).toHaveBeenCalledWith({ page: '2' }); expect(storageSetter).toHaveBeenCalledWith({ page: '2' });
}); });
it('can reset state to the default instead of overwriting', () => { it('can update query and storage without triggering a rerender', () => {
mockStorage.mockReturnValue({
value: { pageSize: 25 },
setValue: vi.fn(),
});
mockQuery.mockReturnValue([new URLSearchParams('page=4'), vi.fn()]);
const { result } = renderHook(() => const { result } = renderHook(() =>
useTableState<{ useTableState({ page: '1' }, 'test', [], []),
page: string;
pageSize?: string;
sortBy?: string;
}>({ page: '1', pageSize: '10' }, 'test'),
); );
const setParams = result.current[1]; const setParams = result.current[1];
act(() => { act(() => {
setParams({ sortBy: 'type' }); setParams({ page: '2' }, true);
});
expect(result.current[0]).toEqual({
page: '4',
pageSize: '10',
sortBy: 'type',
}); });
act(() => { expect(result.current[0]).toEqual({ page: '1' });
setParams({ pageSize: '50' }, true);
});
expect(result.current[0]).toEqual({
page: '1',
pageSize: '50',
});
}); });
}); });

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { createLocalStorage } from '../utils/createLocalStorage'; 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 queryKeys array of elements to be saved in the url
* @param storageKeys array of elements to be saved in local storage * @param storageKeys array of elements to be saved in local storage
*/ */
export const useTableState = <Params extends Record<string, string | string[]>>( export const useTableState = <Params extends Record<string, string>>(
defaultParams: Params, defaultParams: Params,
storageId: string, storageId: string,
queryKeys?: Array<keyof Params>, queryKeys?: Array<keyof Params>,
@ -52,31 +52,34 @@ export const useTableState = <Params extends Record<string, string | string[]>>(
...searchQuery, ...searchQuery,
} as Params); } as Params);
const updateParams = (value: Partial<Params>, reset = false) => { const updateParams = useCallback(
const newState: Params = reset (value: Partial<Params>, quiet = false) => {
? { ...defaultParams, ...value } const newState: Params = {
: { ...params,
...params, ...value,
...value, };
};
// remove keys with undefined values // remove keys with undefined values
Object.keys(newState).forEach((key) => { Object.keys(newState).forEach((key) => {
if (newState[key] === undefined) { if (newState[key] === undefined) {
delete newState[key]; delete newState[key];
}
});
if (!quiet) {
setParams(newState);
} }
}); setSearchParams(
filterObjectKeys(newState, queryKeys || defaultQueryKeys),
);
setStoredParams(
filterObjectKeys(newState, storageKeys || defaultStoredKeys),
);
setParams(newState); return params;
setSearchParams( },
filterObjectKeys(newState, queryKeys || defaultQueryKeys), [setParams, setSearchParams, setStoredParams],
); );
setStoredParams(
filterObjectKeys(newState, storageKeys || defaultStoredKeys),
);
return params;
};
return [params, updateParams] as const; return [params, updateParams] as const;
}; };