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

Virtualize project toggles list (#1043)

* fix: virtualize project toggles list

* refactor: improve type for feature dialogs

* fix: formatting - prettier
This commit is contained in:
Tymoteusz Czech 2022-06-01 14:24:24 +02:00 committed by GitHub
parent c6f0a20fd6
commit 24cb1b21ef
8 changed files with 191 additions and 103 deletions

View File

@ -23,15 +23,16 @@ import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes'; import { sortTypes } from 'utils/sortTypes';
import { useLocalStorage } from 'hooks/useLocalStorage'; import { useLocalStorage } from 'hooks/useLocalStorage';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import { FeatureSchema } from 'openapi'; import { FeatureSchema } from 'openapi';
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
import { useStyles } from './styles'; import { useStyles } from './styles';
const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
@ -100,8 +101,6 @@ const columns = [
}, },
]; ];
const scrollOffset = 50;
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: false }; const defaultSort: SortingRule<string> = { id: 'createdAt', desc: false };
export const FeatureToggleListTable: VFC = () => { export const FeatureToggleListTable: VFC = () => {
@ -194,20 +193,8 @@ export const FeatureToggleListTable: VFC = () => {
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [sortBy, globalFilter, setSearchParams, setStoredParams]); }, [sortBy, globalFilter, setSearchParams, setStoredParams]);
const [scrollIndex, setScrollIndex] = useState(0); const [firstRenderedIndex, lastRenderedIndex] =
useEffect(() => { useVirtualizedRange(rowHeight);
const handleScroll = () => {
requestAnimationFrame(() => {
const position = window.pageYOffset;
setScrollIndex(Math.floor(position / (rowHeight * 5)) * 5);
});
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [rowHeight]);
return ( return (
<PageContent <PageContent
@ -256,8 +243,8 @@ export const FeatureToggleListTable: VFC = () => {
> >
{rows.map((row, index) => { {rows.map((row, index) => {
const isVirtual = const isVirtual =
index > scrollOffset + scrollIndex || index < firstRenderedIndex ||
index + scrollOffset < scrollIndex; index > lastRenderedIndex;
if (isVirtual) { if (isVirtual) {
return null; return null;

View File

@ -137,7 +137,7 @@ const Project = () => {
show={ show={
<ApiError <ApiError
data-loading data-loading
style={{ maxWidth: '400px', marginTop: '1rem' }} style={{ maxWidth: '400px', margin: '1rem' }}
onClick={refetch} onClick={refetch}
text="Could not fetch project" text="Could not fetch project"
/> />

View File

@ -22,9 +22,6 @@ import {
DELETE_FEATURE, DELETE_FEATURE,
UPDATE_FEATURE, UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions'; } from 'component/providers/AccessProvider/permissions';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import useProject from 'hooks/api/getters/useProject/useProject';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
interface IActionsCellProps { interface IActionsCellProps {
projectId: string; projectId: string;
@ -34,13 +31,17 @@ interface IActionsCellProps {
stale?: boolean; stale?: boolean;
}; };
}; };
onOpenArchiveDialog: (featureId: string) => void;
onOpenStaleDialog: (props: { featureId: string; stale: boolean }) => void;
} }
export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => { export const ActionsCell: VFC<IActionsCellProps> = ({
projectId,
row,
onOpenArchiveDialog,
onOpenStaleDialog,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [openStaleDialog, setOpenStaleDialog] = useState(false);
const [openArchiveDialog, setOpenArchiveDialog] = useState(false);
const { refetch } = useProject(projectId);
const { classes } = useStyles(); const { classes } = useStyles();
const { const {
original: { name: featureId, stale }, original: { name: featureId, stale },
@ -120,7 +121,7 @@ export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => {
<MenuItem <MenuItem
className={classes.item} className={classes.item}
onClick={() => { onClick={() => {
setOpenArchiveDialog(true); onOpenArchiveDialog(featureId);
handleClose(); handleClose();
}} }}
disabled={!hasAccess} disabled={!hasAccess}
@ -145,7 +146,10 @@ export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => {
className={classes.item} className={classes.item}
onClick={() => { onClick={() => {
handleClose(); handleClose();
setOpenStaleDialog(true); onOpenStaleDialog({
featureId,
stale: stale === true,
});
}} }}
disabled={!hasAccess} disabled={!hasAccess}
> >
@ -162,25 +166,6 @@ export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => {
</PermissionHOC> </PermissionHOC>
</MenuList> </MenuList>
</Popover> </Popover>
<FeatureStaleDialog
isStale={stale === true}
isOpen={openStaleDialog}
onClose={() => {
setOpenStaleDialog(false);
refetch();
}}
featureId={featureId}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={openArchiveDialog}
onConfirm={() => {
refetch();
}}
onClose={() => setOpenArchiveDialog(false)}
featureId={featureId}
projectId={projectId}
/>
</Box> </Box>
); );
}; };

View File

@ -18,7 +18,6 @@ interface IFeatureToggleSwitchProps {
) => Promise<void>; ) => Promise<void>;
} }
// TODO: check React.memo performance
export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({ export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
projectId, projectId,
featureName, featureName,

View File

@ -17,11 +17,6 @@ export const useStyles = makeStyles()(theme => ({
'& th': { '& th': {
fontSize: theme.fontSizes.smallerBody, fontSize: theme.fontSizes.smallerBody,
lineHeight: '1rem', lineHeight: '1rem',
// fix for padding with different font size in hovered column header
'span[data-tooltip] span': {
padding: '4px 0',
display: 'block',
},
}, },
}, },
bodyClass: { bodyClass: {
@ -65,4 +60,16 @@ export const useStyles = makeStyles()(theme => ({
button: { button: {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}, },
row: {
position: 'absolute',
width: '100%',
},
cell: {
alignItems: 'center',
display: 'flex',
flexShrink: 0,
'& > *': {
flexGrow: 1,
},
},
})); }));

View File

@ -1,7 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTheme } from '@mui/system';
import { Add } from '@mui/icons-material'; import { Add } from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useGlobalFilter, useSortBy, useTable } from 'react-table'; import {
useGlobalFilter,
useFlexLayout,
useSortBy,
useTable,
} from 'react-table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
@ -29,6 +35,7 @@ import {
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import useProject from 'hooks/api/getters/useProject/useProject'; import useProject from 'hooks/api/getters/useProject/useProject';
import { useLocalStorage } from 'hooks/useLocalStorage'; import { useLocalStorage } from 'hooks/useLocalStorage';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
@ -38,6 +45,8 @@ import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch';
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 { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
interface IProjectFeatureTogglesProps { interface IProjectFeatureTogglesProps {
features: IProject['features']; features: IProject['features'];
@ -58,8 +67,6 @@ type ListItemType = Pick<
}; };
const staticColumns = ['Actions', 'name']; const staticColumns = ['Actions', 'name'];
const limit = 300; // if above limit, render only `pageSize` of items
const pageSize = 100;
export const ProjectFeatureToggles = ({ export const ProjectFeatureToggles = ({
features, features,
@ -72,6 +79,13 @@ export const ProjectFeatureToggles = ({
featureId: '', featureId: '',
environmentName: '', environmentName: '',
}); });
const [featureStaleDialogState, setFeatureStaleDialogState] = useState<{
featureId?: string;
stale?: boolean;
}>({});
const [featureArchiveState, setFeatureArchiveState] = useState<
string | undefined
>();
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const navigate = useNavigate(); const navigate = useNavigate();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -80,6 +94,8 @@ export const ProjectFeatureToggles = ({
); );
const { refetch } = useProject(projectId); const { refetch } = useProject(projectId);
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const theme = useTheme();
const rowHeight = theme.shape.tableRowHeight;
const data = useMemo<ListItemType[]>(() => { const data = useMemo<ListItemType[]>(() => {
if (loading) { if (loading) {
@ -93,9 +109,7 @@ export const ProjectFeatureToggles = ({
}) as ListItemType[]; }) as ListItemType[];
} }
return features return features.map(
.slice(0, features.length > limit ? pageSize : limit)
.map(
({ ({
name, name,
lastSeenAt, lastSeenAt,
@ -181,12 +195,14 @@ export const ProjectFeatureToggles = ({
Cell: FeatureSeenCell, Cell: FeatureSeenCell,
sortType: 'date', sortType: 'date',
align: 'center', align: 'center',
maxWidth: 80,
}, },
{ {
Header: 'Type', Header: 'Type',
accessor: 'type', accessor: 'type',
Cell: FeatureTypeCell, Cell: FeatureTypeCell,
align: 'center', align: 'center',
maxWidth: 80,
}, },
{ {
Header: 'Feature toggle name', Header: 'Feature toggle name',
@ -197,9 +213,7 @@ export const ProjectFeatureToggles = ({
to={`/projects/${projectId}/features/${value}`} to={`/projects/${projectId}/features/${value}`}
/> />
), ),
width: '99%',
minWidth: 100, minWidth: 100,
maxWidth: 200,
sortType: 'alphanumeric', sortType: 'alphanumeric',
disableGlobalFilter: false, disableGlobalFilter: false,
}, },
@ -213,7 +227,6 @@ export const ProjectFeatureToggles = ({
...environments.map(name => ({ ...environments.map(name => ({
Header: loading ? () => '' : name, Header: loading ? () => '' : name,
maxWidth: 90, maxWidth: 90,
minWidth: 90,
accessor: `environments.${name}`, accessor: `environments.${name}`,
align: 'center', align: 'center',
Cell: ({ Cell: ({
@ -242,7 +255,12 @@ export const ProjectFeatureToggles = ({
maxWidth: 56, maxWidth: 56,
width: 56, width: 56,
Cell: (props: { row: { original: ListItemType } }) => ( Cell: (props: { row: { original: ListItemType } }) => (
<ActionsCell projectId={projectId} {...props} /> <ActionsCell
projectId={projectId}
onOpenArchiveDialog={setFeatureArchiveState}
onOpenStaleDialog={setFeatureStaleDialogState}
{...props}
/>
), ),
disableSortBy: true, disableSortBy: true,
}, },
@ -319,6 +337,7 @@ export const ProjectFeatureToggles = ({
disableGlobalFilter: true, disableGlobalFilter: true,
}, },
}, },
useFlexLayout,
useGlobalFilter, useGlobalFilter,
useSortBy useSortBy
); );
@ -357,6 +376,10 @@ export const ProjectFeatureToggles = ({
}, },
[setStoredParams] [setStoredParams]
); );
const [firstRenderedIndex, lastRenderedIndex] = useVirtualizedRange(
rowHeight,
20
);
return ( return (
<PageContent <PageContent
@ -366,11 +389,7 @@ export const ProjectFeatureToggles = ({
header={ header={
<PageHeader <PageHeader
className={styles.title} className={styles.title}
title={`Project feature toggles (${ title={`Project feature toggles (${rows.length})`}
features?.length > limit
? `first ${rows.length} of ${features.length}`
: data.length
})`}
actions={ actions={
<> <>
<TableSearch <TableSearch
@ -410,19 +429,51 @@ export const ProjectFeatureToggles = ({
} }
> >
<SearchHighlightProvider value={globalFilter}> <SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}> <Table {...getTableProps()} rowHeight={rowHeight}>
<SortableTableHeader <SortableTableHeader
// @ts-expect-error -- verify after `react-table` v8 // @ts-expect-error -- verify after `react-table` v8
headerGroups={headerGroups} headerGroups={headerGroups}
className={styles.headerClass} className={styles.headerClass}
flex
/> />
<TableBody {...getTableBodyProps()}> <TableBody
{rows.map(row => { {...getTableBodyProps()}
style={{
height: `${rowHeight * rows.length}px`,
position: 'relative',
}}
>
{rows.map((row, index) => {
const isVirtual =
index < firstRenderedIndex ||
index > lastRenderedIndex;
if (isVirtual) {
return null;
}
prepareRow(row); prepareRow(row);
return ( return (
<TableRow hover {...row.getRowProps()}> <TableRow
hover
{...row.getRowProps()}
className={styles.row}
style={{
top: `${index * rowHeight}px`,
display: 'flex',
}}
>
{row.cells.map(cell => ( {row.cells.map(cell => (
<TableCell {...cell.getCellProps()}> <TableCell
{...cell.getCellProps({
style: {
flex: cell.column.minWidth
? '1 0 auto'
: undefined,
},
})}
className={styles.cell}
>
{cell.render('Cell')} {cell.render('Cell')}
</TableCell> </TableCell>
))} ))}
@ -460,6 +511,27 @@ export const ProjectFeatureToggles = ({
projectId={projectId} projectId={projectId}
{...strategiesDialogState} {...strategiesDialogState}
/> />
<FeatureStaleDialog
isStale={featureStaleDialogState.stale === true}
isOpen={Boolean(featureStaleDialogState.featureId)}
onClose={() => {
setFeatureStaleDialogState({});
refetch();
}}
featureId={featureStaleDialogState.featureId || ''}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={Boolean(featureArchiveState)}
onConfirm={() => {
refetch();
}}
onClose={() => {
setFeatureArchiveState(undefined);
}}
featureId={featureArchiveState || ''}
projectId={projectId}
/>
</PageContent> </PageContent>
); );
}; };

View File

@ -68,10 +68,10 @@ export const useStyles = makeStyles()(theme => ({
fontSize: '0.8rem', fontSize: '0.8rem',
position: 'relative', position: 'relative',
padding: '0.8rem', padding: '0.8rem',
['&:first-child']: { ['&:first-of-type']: {
marginLeft: '0', marginLeft: '0',
}, },
['&:last-child']: { ['&:last-of-type']: {
marginRight: '0', marginRight: '0',
}, },
}, },

View File

@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
/**
* Get index of first and last displayed item in current window scroll offset.
* This is done to optimize performance for large lists.
*
* @param rowHeight height of single item in pixels
* @param scrollOffset how many items above and below to render -- TODO: calculate from window height
* @param dampening cause less re-renders -- only after jumping this x of elements, "staircase" effect
* @returns [firstIndex, lastIndex]
*/
export const useVirtualizedRange = (
rowHeight: number,
scrollOffset = 40,
dampening = 5
) => {
const [scrollIndex, setScrollIndex] = useState(
Math.floor(window.pageYOffset / rowHeight)
);
useEffect(() => {
const handleScroll = () => {
requestAnimationFrame(() => {
setScrollIndex(
Math.floor(window.pageYOffset / (rowHeight * dampening)) *
dampening
);
});
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [rowHeight, dampening]);
return [scrollIndex - scrollOffset, scrollIndex + scrollOffset] as const;
};