1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-17 01:17:29 +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 { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
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 { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import { FeatureSchema } from 'openapi';
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
import { useStyles } from './styles';
const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
@ -100,8 +101,6 @@ const columns = [
},
];
const scrollOffset = 50;
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: false };
export const FeatureToggleListTable: VFC = () => {
@ -194,20 +193,8 @@ export const FeatureToggleListTable: VFC = () => {
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [sortBy, globalFilter, setSearchParams, setStoredParams]);
const [scrollIndex, setScrollIndex] = useState(0);
useEffect(() => {
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]);
const [firstRenderedIndex, lastRenderedIndex] =
useVirtualizedRange(rowHeight);
return (
<PageContent
@ -256,8 +243,8 @@ export const FeatureToggleListTable: VFC = () => {
>
{rows.map((row, index) => {
const isVirtual =
index > scrollOffset + scrollIndex ||
index + scrollOffset < scrollIndex;
index < firstRenderedIndex ||
index > lastRenderedIndex;
if (isVirtual) {
return null;

View File

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

View File

@ -22,9 +22,6 @@ import {
DELETE_FEATURE,
UPDATE_FEATURE,
} 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 {
projectId: string;
@ -34,13 +31,17 @@ interface IActionsCellProps {
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 [openStaleDialog, setOpenStaleDialog] = useState(false);
const [openArchiveDialog, setOpenArchiveDialog] = useState(false);
const { refetch } = useProject(projectId);
const { classes } = useStyles();
const {
original: { name: featureId, stale },
@ -120,7 +121,7 @@ export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => {
<MenuItem
className={classes.item}
onClick={() => {
setOpenArchiveDialog(true);
onOpenArchiveDialog(featureId);
handleClose();
}}
disabled={!hasAccess}
@ -145,7 +146,10 @@ export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => {
className={classes.item}
onClick={() => {
handleClose();
setOpenStaleDialog(true);
onOpenStaleDialog({
featureId,
stale: stale === true,
});
}}
disabled={!hasAccess}
>
@ -162,25 +166,6 @@ export const ActionsCell: VFC<IActionsCellProps> = ({ projectId, row }) => {
</PermissionHOC>
</MenuList>
</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>
);
};

View File

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

View File

@ -17,11 +17,6 @@ export const useStyles = makeStyles()(theme => ({
'& th': {
fontSize: theme.fontSizes.smallerBody,
lineHeight: '1rem',
// fix for padding with different font size in hovered column header
'span[data-tooltip] span': {
padding: '4px 0',
display: 'block',
},
},
},
bodyClass: {
@ -65,4 +60,16 @@ export const useStyles = makeStyles()(theme => ({
button: {
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 { useTheme } from '@mui/system';
import { Add } from '@mui/icons-material';
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 { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent';
@ -29,6 +35,7 @@ import {
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import useProject from 'hooks/api/getters/useProject/useProject';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
import useToast from 'hooks/useToast';
import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
@ -38,6 +45,8 @@ import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch';
import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
interface IProjectFeatureTogglesProps {
features: IProject['features'];
@ -58,8 +67,6 @@ type ListItemType = Pick<
};
const staticColumns = ['Actions', 'name'];
const limit = 300; // if above limit, render only `pageSize` of items
const pageSize = 100;
export const ProjectFeatureToggles = ({
features,
@ -72,6 +79,13 @@ export const ProjectFeatureToggles = ({
featureId: '',
environmentName: '',
});
const [featureStaleDialogState, setFeatureStaleDialogState] = useState<{
featureId?: string;
stale?: boolean;
}>({});
const [featureArchiveState, setFeatureArchiveState] = useState<
string | undefined
>();
const projectId = useRequiredPathParam('projectId');
const navigate = useNavigate();
const { uiConfig } = useUiConfig();
@ -80,6 +94,8 @@ export const ProjectFeatureToggles = ({
);
const { refetch } = useProject(projectId);
const { setToastData, setToastApiError } = useToast();
const theme = useTheme();
const rowHeight = theme.shape.tableRowHeight;
const data = useMemo<ListItemType[]>(() => {
if (loading) {
@ -93,36 +109,34 @@ export const ProjectFeatureToggles = ({
}) as ListItemType[];
}
return features
.slice(0, features.length > limit ? pageSize : limit)
.map(
({
name,
lastSeenAt,
createdAt,
type,
stale,
environments: featureEnvironments,
}) => ({
name,
lastSeenAt,
createdAt,
type,
stale,
environments: Object.fromEntries(
environments.map(env => [
env,
{
name: env,
enabled:
featureEnvironments?.find(
feature => feature?.name === env
)?.enabled || false,
},
])
),
})
);
return features.map(
({
name,
lastSeenAt,
createdAt,
type,
stale,
environments: featureEnvironments,
}) => ({
name,
lastSeenAt,
createdAt,
type,
stale,
environments: Object.fromEntries(
environments.map(env => [
env,
{
name: env,
enabled:
featureEnvironments?.find(
feature => feature?.name === env
)?.enabled || false,
},
])
),
})
);
}, [features, loading]); // eslint-disable-line react-hooks/exhaustive-deps
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
@ -181,12 +195,14 @@ export const ProjectFeatureToggles = ({
Cell: FeatureSeenCell,
sortType: 'date',
align: 'center',
maxWidth: 80,
},
{
Header: 'Type',
accessor: 'type',
Cell: FeatureTypeCell,
align: 'center',
maxWidth: 80,
},
{
Header: 'Feature toggle name',
@ -197,9 +213,7 @@ export const ProjectFeatureToggles = ({
to={`/projects/${projectId}/features/${value}`}
/>
),
width: '99%',
minWidth: 100,
maxWidth: 200,
sortType: 'alphanumeric',
disableGlobalFilter: false,
},
@ -213,7 +227,6 @@ export const ProjectFeatureToggles = ({
...environments.map(name => ({
Header: loading ? () => '' : name,
maxWidth: 90,
minWidth: 90,
accessor: `environments.${name}`,
align: 'center',
Cell: ({
@ -242,7 +255,12 @@ export const ProjectFeatureToggles = ({
maxWidth: 56,
width: 56,
Cell: (props: { row: { original: ListItemType } }) => (
<ActionsCell projectId={projectId} {...props} />
<ActionsCell
projectId={projectId}
onOpenArchiveDialog={setFeatureArchiveState}
onOpenStaleDialog={setFeatureStaleDialogState}
{...props}
/>
),
disableSortBy: true,
},
@ -319,6 +337,7 @@ export const ProjectFeatureToggles = ({
disableGlobalFilter: true,
},
},
useFlexLayout,
useGlobalFilter,
useSortBy
);
@ -357,6 +376,10 @@ export const ProjectFeatureToggles = ({
},
[setStoredParams]
);
const [firstRenderedIndex, lastRenderedIndex] = useVirtualizedRange(
rowHeight,
20
);
return (
<PageContent
@ -366,11 +389,7 @@ export const ProjectFeatureToggles = ({
header={
<PageHeader
className={styles.title}
title={`Project feature toggles (${
features?.length > limit
? `first ${rows.length} of ${features.length}`
: data.length
})`}
title={`Project feature toggles (${rows.length})`}
actions={
<>
<TableSearch
@ -410,19 +429,51 @@ export const ProjectFeatureToggles = ({
}
>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<Table {...getTableProps()} rowHeight={rowHeight}>
<SortableTableHeader
// @ts-expect-error -- verify after `react-table` v8
headerGroups={headerGroups}
className={styles.headerClass}
flex
/>
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
<TableBody
{...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);
return (
<TableRow hover {...row.getRowProps()}>
<TableRow
hover
{...row.getRowProps()}
className={styles.row}
style={{
top: `${index * rowHeight}px`,
display: 'flex',
}}
>
{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')}
</TableCell>
))}
@ -460,6 +511,27 @@ export const ProjectFeatureToggles = ({
projectId={projectId}
{...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>
);
};

View File

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