diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index 7d15f353a7..fe153629a5 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -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 = { 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 ( { > {rows.map((row, index) => { const isVirtual = - index > scrollOffset + scrollIndex || - index + scrollOffset < scrollIndex; + index < firstRenderedIndex || + index > lastRenderedIndex; if (isVirtual) { return null; diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index fad7f7e4a2..9dbe5acbc2 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -137,7 +137,7 @@ const Project = () => { show={ diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx index 97ab342709..e063751971 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx @@ -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 = ({ projectId, row }) => { +export const ActionsCell: VFC = ({ + projectId, + row, + onOpenArchiveDialog, + onOpenStaleDialog, +}) => { const [anchorEl, setAnchorEl] = useState(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 = ({ projectId, row }) => { { - setOpenArchiveDialog(true); + onOpenArchiveDialog(featureId); handleClose(); }} disabled={!hasAccess} @@ -145,7 +146,10 @@ export const ActionsCell: VFC = ({ projectId, row }) => { className={classes.item} onClick={() => { handleClose(); - setOpenStaleDialog(true); + onOpenStaleDialog({ + featureId, + stale: stale === true, + }); }} disabled={!hasAccess} > @@ -162,25 +166,6 @@ export const ActionsCell: VFC = ({ projectId, row }) => { - { - setOpenStaleDialog(false); - refetch(); - }} - featureId={featureId} - projectId={projectId} - /> - { - refetch(); - }} - onClose={() => setOpenArchiveDialog(false)} - featureId={featureId} - projectId={projectId} - /> ); }; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx index 8bd91e312d..eed2093976 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx @@ -18,7 +18,6 @@ interface IFeatureToggleSwitchProps { ) => Promise; } -// TODO: check React.memo performance export const FeatureToggleSwitch: VFC = ({ projectId, featureName, diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts index 1d681909e5..e32f35f7fa 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts @@ -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, + }, + }, })); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 448fec240a..24890108fe 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -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(() => { 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 } }) => ( - + ), 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 ( limit - ? `first ${rows.length} of ${features.length}` - : data.length - })`} + title={`Project feature toggles (${rows.length})`} actions={ <> - +
- - {rows.map(row => { + + {rows.map((row, index) => { + const isVirtual = + index < firstRenderedIndex || + index > lastRenderedIndex; + + if (isVirtual) { + return null; + } + prepareRow(row); return ( - + {row.cells.map(cell => ( - + {cell.render('Cell')} ))} @@ -460,6 +511,27 @@ export const ProjectFeatureToggles = ({ projectId={projectId} {...strategiesDialogState} /> + { + setFeatureStaleDialogState({}); + refetch(); + }} + featureId={featureStaleDialogState.featureId || ''} + projectId={projectId} + /> + { + refetch(); + }} + onClose={() => { + setFeatureArchiveState(undefined); + }} + featureId={featureArchiveState || ''} + projectId={projectId} + /> ); }; diff --git a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts index de253c67ba..9873ac0d26 100644 --- a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts +++ b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts @@ -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', }, }, diff --git a/frontend/src/hooks/useVirtualizedRange.ts b/frontend/src/hooks/useVirtualizedRange.ts new file mode 100644 index 0000000000..82f38dec77 --- /dev/null +++ b/frontend/src/hooks/useVirtualizedRange.ts @@ -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; +};