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:
parent
c6f0a20fd6
commit
24cb1b21ef
@ -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;
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -18,7 +18,6 @@ interface IFeatureToggleSwitchProps {
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// TODO: check React.memo performance
|
||||
export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
|
||||
projectId,
|
||||
featureName,
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
38
frontend/src/hooks/useVirtualizedRange.ts
Normal file
38
frontend/src/hooks/useVirtualizedRange.ts
Normal 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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user