From b1166bb2f423077da004742f8831432a20466e59 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Fri, 13 May 2022 14:51:22 +0200 Subject: [PATCH] Project overview feature toggles list (#971) * refactor: page container * refactor: table page header * feat: new feature toggles list in project overview * feat: sortable enviromnents in project overview * feat: project overview toggles search * feat: project overview features column actions * project overview table column sizing * project overview feature actions permissions * project overview archive feature action * project overview toggle state strategy fallback * remove previous project overview implementation * fix: remove additional prop in sortable table * fix: stale feature refetch * improvements after review * feat: manage visible columns in project overview * improve project overview columns selection * fix: simplify columns * Revert "remove previous project overview implementation" This reverts commit 98b051ff6a5a4fb8a9a0921b661514e15a00249a. * restore legacy project overview table --- frontend/src/component/App.styles.ts | 2 +- .../ConstraintAccordion.styles.ts | 2 +- .../FeatureArchiveDialog.tsx | 53 +++ .../FeatureStaleDialog.tsx} | 72 ++-- .../common/PermissionHOC/PermissionHOC.tsx | 45 +++ .../CellSortable/CellSortable.styles.ts | 22 +- .../CellSortable/CellSortable.tsx | 92 ++++- .../SortArrow/SortArrow.styles.ts | 3 +- .../SortableTableHeader.styles.ts | 4 - .../SortableTableHeader.tsx | 17 +- .../TablePlaceholder.styles.ts | 1 - .../common/Table/cells/DateCell/DateCell.tsx | 32 ++ .../FeatureLinkCell/FeatureLinkCell.styles.ts | 15 +- .../FeatureLinkCell/FeatureLinkCell.tsx | 9 +- .../FeatureSeenCell/FeatureSeenCell.styles.ts | 0 .../FeatureSeenCell/FeatureSeenCell.tsx | 0 .../FeatureTypeCell/FeatureTypeCell.styles.ts | 0 .../FeatureTypeCell/FeatureTypeCell.tsx | 0 frontend/src/component/common/flags.ts | 2 +- .../DateCell/DateCell.tsx | 37 -- .../FeatureToggleListTable.tsx | 30 +- .../feature/FeatureView/FeatureView.tsx | 64 ++- .../Footer/__snapshots__/Footer.test.tsx.snap | 8 +- .../ActionsCell/ActionsCell.styles.ts | 14 + .../ActionsCell/ActionsCell.tsx | 186 +++++++++ .../ColumnsMenu/ColumnsMenu.styles.ts | 35 ++ .../ColumnsMenu/ColumnsMenu.tsx | 190 +++++++++ .../FeatureToggleSwitch.styles.ts | 9 + .../FeatureToggleSwitch.tsx | 55 +++ .../hooks/useOptimisticUpdate.test.ts | 61 +++ .../hooks/useOptimisticUpdate.ts | 13 + .../LegacyProjectFeatureToggles.tsx | 119 ++++++ .../ProjectFeatureToggles.styles.ts | 12 +- .../ProjectFeatureToggles.tsx | 375 ++++++++++++++---- .../hooks/useEnvironmentsRef.ts | 15 + .../hooks/useSetFeatureState.ts | 31 ++ .../project/Project/ProjectOverview.tsx | 24 +- .../StrategiesList.test.tsx.snap | 4 +- .../__snapshots__/TagTypeList.test.tsx.snap | 4 +- .../api/getters/useProject/fallbackProject.ts | 9 - .../api/getters/useProject/useProject.ts | 23 +- .../api/getters/useUiConfig/defaultValue.ts | 1 + frontend/src/interfaces/uiConfig.ts | 1 + frontend/src/themes/theme.ts | 18 +- frontend/src/utils/sortTypes.ts | 21 + 45 files changed, 1444 insertions(+), 286 deletions(-) create mode 100644 frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx rename frontend/src/component/{feature/FeatureView/FeatureOverview/StaleDialog/StaleDialog.tsx => common/FeatureStaleDialog/FeatureStaleDialog.tsx} (54%) create mode 100644 frontend/src/component/common/PermissionHOC/PermissionHOC.tsx create mode 100644 frontend/src/component/common/Table/cells/DateCell/DateCell.tsx rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleListTable => common/Table/cells}/FeatureLinkCell/FeatureLinkCell.styles.ts (67%) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleListTable => common/Table/cells}/FeatureLinkCell/FeatureLinkCell.tsx (85%) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleListTable => common/Table/cells}/FeatureSeenCell/FeatureSeenCell.styles.ts (100%) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleListTable => common/Table/cells}/FeatureSeenCell/FeatureSeenCell.tsx (100%) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleListTable => common/Table/cells}/FeatureTypeCell/FeatureTypeCell.styles.ts (100%) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleListTable => common/Table/cells}/FeatureTypeCell/FeatureTypeCell.tsx (100%) delete mode 100644 frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/DateCell/DateCell.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.styles.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.styles.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.styles.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.test.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts delete mode 100644 frontend/src/hooks/api/getters/useProject/fallbackProject.ts create mode 100644 frontend/src/utils/sortTypes.ts diff --git a/frontend/src/component/App.styles.ts b/frontend/src/component/App.styles.ts index 08f095baad..e8861046cc 100644 --- a/frontend/src/component/App.styles.ts +++ b/frontend/src/component/App.styles.ts @@ -23,7 +23,7 @@ export const useStyles = makeStyles()(theme => ({ content: { width: '1250px', margin: '16px auto', - [theme.breakpoints.down(1260)]: { + [theme.breakpoints.down('lg')]: { width: '1024px', }, [theme.breakpoints.down(1024)]: { diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts index e7f053eea7..c4e0accb0a 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts @@ -96,7 +96,7 @@ export const useStyles = makeStyles()(theme => ({ headerText: { maxWidth: '400px', fontSize: theme.fontSizes.smallBody, - [theme.breakpoints.down(1260)]: { + [theme.breakpoints.down('xl')]: { display: 'none', }, }, diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx new file mode 100644 index 0000000000..25e3c5c972 --- /dev/null +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx @@ -0,0 +1,53 @@ +import { VFC } from 'react'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IFeatureArchiveDialogProps { + isOpen: boolean; + onConfirm: () => void; + onClose: () => void; + projectId: string; + featureId: string; +} + +export const FeatureArchiveDialog: VFC = ({ + isOpen, + onClose, + onConfirm, + projectId, + featureId, +}) => { + const { archiveFeatureToggle } = useFeatureApi(); + const { setToastData, setToastApiError } = useToast(); + + const archiveToggle = async () => { + try { + await archiveFeatureToggle(projectId, featureId); + setToastData({ + text: 'Your feature toggle has been archived', + type: 'success', + title: 'Feature archived', + }); + onConfirm(); + onClose(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + onClose(); + } + }; + + return ( + archiveToggle()} + open={isOpen} + onClose={onClose} + primaryButtonText="Archive toggle" + secondaryButtonText="Cancel" + title="Archive feature toggle" + > + Are you sure you want to archive this feature toggle? + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/StaleDialog/StaleDialog.tsx b/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx similarity index 54% rename from frontend/src/component/feature/FeatureView/FeatureOverview/StaleDialog/StaleDialog.tsx rename to frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx index e980c260f7..1897eacf57 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/StaleDialog/StaleDialog.tsx +++ b/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx @@ -2,24 +2,27 @@ import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import { DialogContentText } from '@mui/material'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { Dialogue } from 'component/common/Dialogue/Dialogue'; -import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import React from 'react'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -interface IStaleDialogProps { - open: boolean; - setOpen: React.Dispatch>; - stale: boolean; +interface IFeatureStaleDialogProps { + isStale: boolean; + isOpen: boolean; + projectId: string; + featureId: string; + onClose: () => void; } -const StaleDialog = ({ open, setOpen, stale }: IStaleDialogProps) => { +export const FeatureStaleDialog = ({ + isStale, + isOpen, + projectId, + featureId, + onClose, +}: IFeatureStaleDialogProps) => { const { setToastData, setToastApiError } = useToast(); - const projectId = useRequiredPathParam('projectId'); - const featureId = useRequiredPathParam('featureId'); const { patchFeatureToggle } = useFeatureApi(); - const { refetchFeature } = useFeature(projectId, featureId); const toggleToStaleContent = ( @@ -32,21 +35,20 @@ const StaleDialog = ({ open, setOpen, stale }: IStaleDialogProps) => { ); - const toggleActionText = stale ? 'active' : 'stale'; + const toggleActionText = isStale ? 'active' : 'stale'; const onSubmit = async (event: React.SyntheticEvent) => { event.stopPropagation(); try { - const patch = [{ op: 'replace', path: '/stale', value: !stale }]; + const patch = [{ op: 'replace', path: '/stale', value: !isStale }]; await patchFeatureToggle(projectId, featureId, patch); - refetchFeature(); - setOpen(false); + onClose(); } catch (err: unknown) { setToastApiError(formatUnknownError(err)); } - if (stale) { + if (isStale) { setToastData({ type: 'success', title: "And we're back!", @@ -61,30 +63,22 @@ const StaleDialog = ({ open, setOpen, stale }: IStaleDialogProps) => { } }; - const onCancel = () => { - setOpen(false); - }; - return ( - <> - - <> - - - - + + <> + + + ); }; - -export default StaleDialog; diff --git a/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx b/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx new file mode 100644 index 0000000000..25a833b0cc --- /dev/null +++ b/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx @@ -0,0 +1,45 @@ +import { useContext, FC, ReactElement } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { + ITooltipResolverProps, + TooltipResolver, +} from 'component/common/TooltipResolver/TooltipResolver'; +import { formatAccessText } from 'utils/formatAccessText'; + +type IPermissionHOCProps = { + permission: string; + projectId?: string; + environmentId?: string; + tooltip?: string; + tooltipProps?: Omit; + children: ({ hasAccess }: { hasAccess?: boolean }) => ReactElement; +}; + +export const PermissionHOC: FC = ({ + permission, + projectId, + children, + environmentId, + tooltip, + tooltipProps, +}) => { + const { hasAccess } = useContext(AccessContext); + let access; + + if (projectId && environmentId) { + access = hasAccess(permission, projectId, environmentId); + } else if (projectId) { + access = hasAccess(permission, projectId); + } else { + access = hasAccess(permission); + } + + return ( + + {children({ hasAccess: access })} + + ); +}; diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts index bfcc415fa4..7a79701c49 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts @@ -1,9 +1,12 @@ import { makeStyles } from 'tss-react/mui'; export const useStyles = makeStyles()(theme => ({ - tableCellHeaderSortable: { - padding: 0, + header: { position: 'relative', + fontWeight: theme.fontWeight.medium, + }, + sortable: { + padding: 0, '&:hover, &:focus': { backgroundColor: theme.palette.tableHeaderHover, '& svg': { @@ -14,17 +17,26 @@ export const useStyles = makeStyles()(theme => ({ sortButton: { all: 'unset', padding: theme.spacing(2), - fontWeight: theme.fontWeight.medium, + whiteSpace: 'nowrap', width: '100%', - '&:focus-visible, &:active': { + ':hover, :focus, &:focus-visible, &:active': { outline: 'revert', + '& svg': { + color: 'inherit', + }, }, display: 'flex', alignItems: 'center', boxSizing: 'inherit', cursor: 'pointer', }, - sorted: { + sortedButton: { fontWeight: theme.fontWeight.bold, }, + label: { + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflowX: 'hidden', + overflowY: 'visible', + }, })); diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx index 56cd4b1156..89737a8870 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx @@ -1,5 +1,13 @@ -import React, { FC, MouseEventHandler, useContext } from 'react'; -import { TableCell } from '@mui/material'; +import { + FC, + MouseEventHandler, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { TableCell, Tooltip } from '@mui/material'; import classnames from 'classnames'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { useStyles } from './CellSortable.styles'; @@ -9,9 +17,12 @@ import { SortArrow } from './SortArrow/SortArrow'; interface ICellSortableProps { isSortable?: boolean; isSorted?: boolean; - isGrow?: boolean; isDescending?: boolean; ariaTitle?: string; + width?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + align?: 'left' | 'center' | 'right'; onClick?: MouseEventHandler; } @@ -19,12 +30,17 @@ export const CellSortable: FC = ({ children, isSortable = true, isSorted = false, - isGrow = false, isDescending, + width, + minWidth, + maxWidth, + align, ariaTitle, onClick = () => {}, }) => { const { setAnnouncement } = useContext(AnnouncerContext); + const [title, setTitle] = useState(''); + const ref = useRef(null); const { classes: styles } = useStyles(); const ariaSort = isSorted @@ -42,28 +58,68 @@ export const CellSortable: FC = ({ ); }; + const justifyContent = useMemo(() => { + switch (align) { + case 'left': + return 'flex-start'; + case 'center': + return 'center'; + case 'right': + return 'flex-end'; + default: + return undefined; + } + }, [align]); + + useEffect(() => { + const updateTitle = () => { + const newTitle = + ariaTitle && + ref.current && + ref?.current?.offsetWidth < ref?.current?.scrollWidth + ? `${children}` + : ''; + + if (newTitle !== title) { + setTitle(newTitle); + } + }; + + updateTitle(); + }, [setTitle, ariaTitle]); // eslint-disable-line react-hooks/exhaustive-deps + return ( - {children} - - + + + } elseShow={children} /> diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts index ebb0a4d279..d1d8aa1654 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts @@ -2,7 +2,8 @@ import { makeStyles } from 'tss-react/mui'; export const useStyles = makeStyles()(theme => ({ icon: { - marginLeft: theme.spacing(0.5), + marginLeft: theme.spacing(0.25), + marginRight: -theme.spacing(0.5), color: theme.palette.grey[700], fontSize: theme.fontSizes.mainHeader, verticalAlign: 'middle', diff --git a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts index b9c7b66a8f..32f9ed3452 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts +++ b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts @@ -12,10 +12,6 @@ export const useStyles = makeStyles()(theme => ({ borderTopRightRadius: theme.shape.borderRadiusMedium, borderBottomRightRadius: theme.shape.borderRadiusMedium, }, - ':not(.grow)': { - width: '0.1%', - whiteSpace: 'nowrap', - }, }, }, })); diff --git a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx index 31a8797514..96957da85d 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx +++ b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx @@ -4,26 +4,25 @@ import { HeaderGroup } from 'react-table'; import { useStyles } from './SortableTableHeader.styles'; import { CellSortable } from './CellSortable/CellSortable'; -interface IHeaderGroupColumn extends HeaderGroup { - isGrow?: boolean; -} - interface ISortableTableHeaderProps { headerGroups: HeaderGroup[]; + className?: string; } export const SortableTableHeader: VFC = ({ headerGroups, + className, }) => { const { classes: styles } = useStyles(); + return ( - + {headerGroups.map(headerGroup => ( - {headerGroup.headers.map((column: IHeaderGroupColumn) => { + {headerGroup.headers.map((column: HeaderGroup) => { const content = column.render('Header'); return ( @@ -39,7 +38,11 @@ export const SortableTableHeader: VFC = ({ isSortable={column.canSort} isSorted={column.isSorted} isDescending={column.isSortedDesc} - isGrow={column.isGrow} + maxWidth={column.maxWidth} + minWidth={column.minWidth} + width={column.width} + // @ts-expect-error -- check after `react-table` v8 + align={column.align} > {content} diff --git a/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts index 4c66d989ef..ab3feae978 100644 --- a/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts +++ b/frontend/src/component/common/Table/TablePlaceholder/TablePlaceholder.styles.ts @@ -8,6 +8,5 @@ export const useStyles = makeStyles()(theme => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - margin: theme.spacing(4), }, })); diff --git a/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx b/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx new file mode 100644 index 0000000000..3d00f2a78d --- /dev/null +++ b/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx @@ -0,0 +1,32 @@ +import { VFC } from 'react'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { formatDateYMD, formatDateYMDHMS } from 'utils/formatDate'; +import { Box, Tooltip } from '@mui/material'; +import { parseISO } from 'date-fns'; + +interface IDateCellProps { + value?: Date | string | null; +} + +export const DateCell: VFC = ({ value }) => { + const { locationSettings } = useLocationSettings(); + + if (!value) { + return ; + } + + const date = value instanceof Date ? value : parseISO(value); + + return ( + + + + {formatDateYMD(date, locationSettings.locale)} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureLinkCell/FeatureLinkCell.styles.ts b/frontend/src/component/common/Table/cells/FeatureLinkCell/FeatureLinkCell.styles.ts similarity index 67% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureLinkCell/FeatureLinkCell.styles.ts rename to frontend/src/component/common/Table/cells/FeatureLinkCell/FeatureLinkCell.styles.ts index 0954a27d2f..6d521392ec 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureLinkCell/FeatureLinkCell.styles.ts +++ b/frontend/src/component/common/Table/cells/FeatureLinkCell/FeatureLinkCell.styles.ts @@ -22,10 +22,21 @@ export const useStyles = makeStyles()(theme => ({ justifyContent: 'center', wordBreak: 'break-all', }, + title: { + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + }, description: { - color: theme.palette.grey[800], + color: theme.palette.text.secondary, textDecoration: 'none', fontSize: 'inherit', - display: 'inline-block', + WebkitLineClamp: 1, + lineClamp: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', }, })); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureLinkCell/FeatureLinkCell.tsx b/frontend/src/component/common/Table/cells/FeatureLinkCell/FeatureLinkCell.tsx similarity index 85% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureLinkCell/FeatureLinkCell.tsx rename to frontend/src/component/common/Table/cells/FeatureLinkCell/FeatureLinkCell.tsx index c80badcc93..03a6a9793f 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureLinkCell/FeatureLinkCell.tsx +++ b/frontend/src/component/common/Table/cells/FeatureLinkCell/FeatureLinkCell.tsx @@ -28,7 +28,14 @@ export const FeatureLinkCell: FC = ({ className={styles.wrapper} >
- + {title} = ({ value }) => { - const { locationSettings } = useLocationSettings(); - - return ( - - - - {formatDateYMD( - value as Date, - locationSettings.locale - )} - - - } - /> - - ); -}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx index 8ec212d42d..0ee8269942 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable/FeatureToggleListTable.tsx @@ -12,52 +12,41 @@ import { TableSearch, } from 'component/common/Table'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; -import { DateCell } from './DateCell/DateCell'; -import { FeatureLinkCell } from './FeatureLinkCell/FeatureLinkCell'; -import { FeatureSeenCell } from './FeatureSeenCell/FeatureSeenCell'; +import { DateCell } from '../../../common/Table/cells/DateCell/DateCell'; +import { FeatureLinkCell } from 'component/common/Table/cells/FeatureLinkCell/FeatureLinkCell'; +import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; +import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; -import { FeatureTypeCell } from './FeatureTypeCell/FeatureTypeCell'; import { CreateFeatureButton } from '../../CreateFeatureButton/CreateFeatureButton'; 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'; interface IExperimentProps { data: Record[]; isLoading?: boolean; } -const sortTypes = { - date: (a: any, b: any, id: string) => - b?.values?.[id]?.getTime() - a?.values?.[id]?.getTime(), - boolean: (v1: any, v2: any, id: string) => { - const a = v1?.values?.[id]; - const b = v2?.values?.[id]; - return a === b ? 0 : a ? 1 : -1; - }, - alphanumeric: (a: any, b: any, id: string) => - a?.values?.[id] - ?.toLowerCase() - .localeCompare(b?.values?.[id]?.toLowerCase()), -}; - const columns = [ { Header: 'Seen', accessor: 'lastSeenAt', Cell: FeatureSeenCell, sortType: 'date', - totalWidth: 120, + align: 'center', }, { Header: 'Type', accessor: 'type', Cell: FeatureTypeCell, - totalWidth: 120, + align: 'center', }, { Header: 'Feature toggle name', accessor: 'name', + maxWidth: 300, + width: '67%', Cell: ({ row: { // @ts-expect-error -- props type @@ -71,7 +60,6 @@ const columns = [ /> ), sortType: 'alphanumeric', - isGrow: true, }, { Header: 'Created on', diff --git a/frontend/src/component/feature/FeatureView/FeatureView.tsx b/frontend/src/component/feature/FeatureView/FeatureView.tsx index 11d282ff49..a1da3d7c56 100644 --- a/frontend/src/component/feature/FeatureView/FeatureView.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureView.tsx @@ -8,16 +8,13 @@ import { Routes, useLocation, } from 'react-router-dom'; -import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import useProject from 'hooks/api/getters/useProject/useProject'; -import useToast from 'hooks/useToast'; import { CREATE_FEATURE, DELETE_FEATURE, UPDATE_FEATURE, } from 'component/providers/AccessProvider/permissions'; -import { Dialogue } from 'component/common/Dialogue/Dialogue'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import FeatureLog from './FeatureLog/FeatureLog'; import FeatureOverview from './FeatureOverview/FeatureOverview'; @@ -27,21 +24,20 @@ import { useStyles } from './FeatureView.styles'; import { FeatureSettings } from './FeatureSettings/FeatureSettings'; import useLoading from 'hooks/useLoading'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import StaleDialog from './FeatureOverview/StaleDialog/StaleDialog'; +import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog'; import StatusChip from 'component/common/StatusChip/StatusChip'; -import { formatUnknownError } from 'utils/formatUnknownError'; import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureArchiveDialog } from '../../common/FeatureArchiveDialog/FeatureArchiveDialog'; export const FeatureView = () => { const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); - const { refetch: projectRefetch } = useProject(projectId); + const { refetchFeature } = useFeature(projectId, featureId); + const [openTagDialog, setOpenTagDialog] = useState(false); - const { archiveFeatureToggle } = useFeatureApi(); - const { setToastData, setToastApiError } = useToast(); const [showDelDialog, setShowDelDialog] = useState(false); const [openStaleDialog, setOpenStaleDialog] = useState(false); const smallScreen = useMediaQuery(`(max-width:${500}px)`); @@ -58,25 +54,6 @@ export const FeatureView = () => { const basePath = `/projects/${projectId}/features/${featureId}`; - const archiveToggle = async () => { - try { - await archiveFeatureToggle(projectId, featureId); - setToastData({ - text: 'Your feature toggle has been archived', - type: 'success', - title: 'Feature archived', - }); - setShowDelDialog(false); - projectRefetch(); - navigate(`/projects/${projectId}`); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - setShowDelDialog(false); - } - }; - - const handleCancel = () => setShowDelDialog(false); - const tabData = [ { title: 'Overview', @@ -202,20 +179,25 @@ export const FeatureView = () => { } /> } /> - archiveToggle()} - open={showDelDialog} - onClose={handleCancel} - primaryButtonText="Archive toggle" - secondaryButtonText="Cancel" - title="Archive feature toggle" - > - Are you sure you want to archive this feature toggle? - - { + projectRefetch(); + navigate(`/projects/${projectId}`); + }} + onClose={() => setShowDelDialog(false)} + projectId={projectId} + featureId={featureId} + /> + { + setOpenStaleDialog(false); + refetchFeature(); + }} + featureId={featureId} + projectId={projectId} />
({ + menuContainer: { + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(1), + }, + item: { + borderRadius: theme.shape.borderRadius, + }, + text: { + fontSize: theme.fontSizes.smallBody, + }, +})); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx new file mode 100644 index 0000000000..9d8610dc9c --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ActionsCell/ActionsCell.tsx @@ -0,0 +1,186 @@ +import { useState, VFC } from 'react'; +import { + Box, + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + Tooltip, + Typography, +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import FileCopyIcon from '@mui/icons-material/FileCopy'; +import ArchiveIcon from '@mui/icons-material/Archive'; +import WatchLaterIcon from '@mui/icons-material/WatchLater'; +import { useStyles } from './ActionsCell.styles'; +import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC'; +import { + CREATE_FEATURE, + 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; + row: { + original: { + name: string; + stale?: boolean; + }; + }; +} + +export const ActionsCell: VFC = ({ projectId, row }) => { + 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 }, + } = row; + + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const id = `feature-${featureId}-actions`; + const menuId = `${id}-menu`; + + return ( + + + + + + + + + + {({ hasAccess }) => ( + + + + + + + Copy + + + + )} + + + {({ hasAccess }) => ( + { + setOpenArchiveDialog(true); + handleClose(); + }} + disabled={!hasAccess} + > + + + + + + Archive + + + + )} + + + {({ hasAccess }) => ( + { + handleClose(); + setOpenStaleDialog(true); + }} + disabled={!hasAccess} + > + + + + + + {stale ? 'Un-mark' : 'Mark'} as stale + + + + )} + + + + { + setOpenStaleDialog(false); + refetch(); + }} + featureId={featureId} + projectId={projectId} + /> + { + refetch(); + }} + onClose={() => setOpenArchiveDialog(false)} + featureId={featureId} + projectId={projectId} + /> + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.styles.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.styles.ts new file mode 100644 index 0000000000..94ab8ddebb --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + button: { + margin: theme.spacing(-1, 0), + }, + menuContainer: { + borderRadius: theme.shape.borderRadiusLarge, + paddingBottom: theme.spacing(2), + }, + menuHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1, 1, 0, 4), + }, + menuItem: { + padding: theme.spacing(0, 2), + margin: theme.spacing(0, 2), + borderRadius: theme.shape.borderRadius, + }, + checkbox: { + padding: theme.spacing(0.75, 1), + }, + divider: { + '&.MuiDivider-root.MuiDivider-fullWidth': { + margin: theme.spacing(0.75, 0), + }, + }, +})); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx new file mode 100644 index 0000000000..635b287efa --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ColumnsMenu/ColumnsMenu.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState, VFC } from 'react'; +import { + Box, + Checkbox, + Divider, + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + Tooltip, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import ViewColumnIcon from '@mui/icons-material/ViewColumn'; +import CloseIcon from '@mui/icons-material/Close'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { capitalize } from 'lodash'; +import { useStyles } from './ColumnsMenu.styles'; + +interface IColumnsMenuProps { + allColumns: { + Header: string | any; + id: string; + isVisible: boolean; + toggleHidden: (state: boolean) => void; + }[]; + staticColumns?: string[]; + dividerBefore?: string[]; + dividerAfter?: string[]; + setHiddenColumns: ( + hiddenColumns: + | string[] + | ((previousHiddenColumns: string[]) => string[]) + ) => void; +} + +export const ColumnsMenu: VFC = ({ + allColumns, + staticColumns = [], + dividerBefore = [], + dividerAfter = [], + setHiddenColumns, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const { classes } = useStyles(); + const theme = useTheme(); + const isTinyScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); + + useEffect(() => { + const setVisibleColumns = ( + columns: string[], + environmentsToShow: number = 0 + ) => { + const visibleEnvColumns = allColumns + .filter(({ id }) => id.startsWith('environments.') !== false) + .map(({ id }) => id) + .slice(0, environmentsToShow); + const hiddenColumns = allColumns + .map(({ id }) => id) + .filter(id => !columns.includes(id)) + .filter(id => !staticColumns.includes(id)) + .filter(id => !visibleEnvColumns.includes(id)); + setHiddenColumns(hiddenColumns); + }; + + if (isTinyScreen) { + return setVisibleColumns(['createdAt']); + } + if (isSmallScreen) { + return setVisibleColumns(['createdAt'], 1); + } + if (isMediumScreen) { + return setVisibleColumns(['type', 'createdAt'], 1); + } + setVisibleColumns(['lastSeenAt', 'type', 'createdAt'], 3); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTinyScreen, isSmallScreen, isMediumScreen]); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const isOpen = Boolean(anchorEl); + const id = `columns-menu`; + const menuId = `columns-menu-list-${id}`; + + return ( + + + + + + + + + + + Columns + + + + + + + {allColumns.map(column => [ + } + />, + { + column.toggleHidden(column.isVisible); + }} + disabled={staticColumns.includes(column.id)} + className={classes.menuItem} + > + + + + + <>{column.Header}} + elseShow={() => + capitalize(column.id) + } + /> + + } + /> + , + } + />, + ])} + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.styles.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.styles.ts new file mode 100644 index 0000000000..62f56ce55a --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()(theme => ({ + container: { + mx: 'auto', + display: 'flex', + justifyContent: 'center', + }, +})); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx new file mode 100644 index 0000000000..fd4e9866b6 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx @@ -0,0 +1,55 @@ +import { VFC } from 'react'; +import { Box } from '@mui/material'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import { useOptimisticUpdate } from './hooks/useOptimisticUpdate'; +import { useStyles } from './FeatureToggleSwitch.styles'; + +interface IFeatureToggleSwitchProps { + featureName: string; + environmentName: string; + projectId: string; + value: boolean; + onToggle: ( + projectId: string, + feature: string, + env: string, + state: boolean + ) => Promise; +} + +// TODO: check React.memo performance +export const FeatureToggleSwitch: VFC = ({ + projectId, + featureName, + environmentName, + value, + onToggle, +}) => { + const { classes } = useStyles(); + const [isChecked, setIsChecked, rollbackIsChecked] = + useOptimisticUpdate(value); + + const onClick = () => { + setIsChecked(!isChecked); + onToggle(projectId, featureName, environmentName, !isChecked).catch( + rollbackIsChecked + ); + }; + + return ( + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.test.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.test.ts new file mode 100644 index 0000000000..74bb82e66e --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.test.ts @@ -0,0 +1,61 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useOptimisticUpdate } from './useOptimisticUpdate'; + +describe('useOptimisticUpdate', () => { + it('should return state, setter, and rollback function', () => { + const { result } = renderHook(() => useOptimisticUpdate(true)); + + expect(result.current).toEqual([ + true, + expect.any(Function), + expect.any(Function), + ]); + }); + + it('should have working setter', () => { + const { result, rerender } = renderHook( + state => useOptimisticUpdate(state), + { + initialProps: 'initial', + } + ); + + act(() => { + result.current[1]('updated'); + }); + + expect(result.current[0]).toEqual('updated'); + }); + + it('should update reset state if input changed', () => { + const { result, rerender } = renderHook( + state => useOptimisticUpdate(state), + { + initialProps: 'A', + } + ); + + rerender('B'); + + expect(result.current[0]).toEqual('B'); + }); + + it('should have working rollback', () => { + const { result, rerender } = renderHook( + state => useOptimisticUpdate(state), + { + initialProps: 'initial', + } + ); + + act(() => { + result.current[1]('updated'); + }); + + act(() => { + result.current[2](); + }); + + expect(result.current[0]).toEqual('initial'); + }); +}); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.ts new file mode 100644 index 0000000000..60ed8beb01 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate.ts @@ -0,0 +1,13 @@ +import { useCallback, useEffect, useState } from 'react'; + +export const useOptimisticUpdate = (state: T) => { + const [value, setValue] = useState(state); + + const rollback = useCallback(() => setValue(state), [state]); + + useEffect(() => { + setValue(state); + }, [state]); + + return [value, setValue, rollback] as const; +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx new file mode 100644 index 0000000000..0f13b0ae20 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx @@ -0,0 +1,119 @@ +import { useContext, useMemo, useState } from 'react'; +import { Add } from '@mui/icons-material'; +import { Link, useNavigate } from 'react-router-dom'; +import AccessContext from 'contexts/AccessContext'; +import { SearchField } from 'component/common/SearchField/SearchField'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import FeatureToggleListNew from 'component/feature/FeatureToggleListNew/FeatureToggleListNew'; +import { IFeatureToggleListItem } from 'interfaces/featureToggle'; +import { getCreateTogglePath } from 'utils/routePathHelpers'; +import { useStyles } from './ProjectFeatureToggles.styles'; +import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import classnames from 'classnames'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +interface IProjectFeatureTogglesProps { + features: IFeatureToggleListItem[]; + loading: boolean; +} + +/** + * @deprecated + */ +export const ProjectFeatureToggles = ({ + features, + loading, +}: IProjectFeatureTogglesProps) => { + const { classes: styles } = useStyles(); + const projectId = useRequiredPathParam('projectId'); + const navigate = useNavigate(); + const { hasAccess } = useContext(AccessContext); + const { uiConfig } = useUiConfig(); + const [filter, setFilter] = useState(''); + + const filteredFeatures = useMemo(() => { + const regExp = new RegExp(filter, 'i'); + return filter + ? features.filter(feature => regExp.test(feature.name)) + : features; + }, [features, filter]); + + return ( + + + + + navigate( + getCreateTogglePath( + projectId, + uiConfig.flags.E + ) + ) + } + maxWidth="700px" + Icon={Add} + projectId={projectId} + permission={CREATE_FEATURE} + className={styles.button} + > + New feature toggle + +
+ } + /> + } + > + 0} + show={ + + } + elseShow={ + <> +

+ No feature toggles added yet. +

+ + Add your first toggle + + } + /> + + } + /> + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts index 0254ad4367..b6522227e2 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts @@ -14,7 +14,17 @@ export const useStyles = makeStyles()(theme => ({ width: 'inherit', }, }, - + headerClass: { + '& 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: { padding: '0.5rem 1rem' }, header: { padding: '1rem', diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 2998eccf76..6af8f3d493 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -1,79 +1,297 @@ -import { useContext, useMemo, useState } from 'react'; -import { IconButton } from '@mui/material'; +import { useCallback, useMemo, useState } from 'react'; import { Add } from '@mui/icons-material'; -import FilterListIcon from '@mui/icons-material/FilterList'; -import { Link, useNavigate } from 'react-router-dom'; -import AccessContext from 'contexts/AccessContext'; -import { SearchField } from 'component/common/SearchField/SearchField'; +import { useNavigate } from 'react-router-dom'; +import { useFilters, useSortBy, useTable } from 'react-table'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { PROJECTFILTERING } from 'component/common/flags'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageContent } from 'component/common/PageContent/PageContent'; import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; -import FeatureToggleListNew from 'component/feature/FeatureToggleListNew/FeatureToggleListNew'; -import { IFeatureToggleListItem } from 'interfaces/featureToggle'; import { getCreateTogglePath } from 'utils/routePathHelpers'; -import { useStyles } from './ProjectFeatureToggles.styles'; import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import classnames from 'classnames'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { FeatureLinkCell } from 'component/common/Table/cells/FeatureLinkCell/FeatureLinkCell'; +import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; +import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; +import { sortTypes } from 'utils/sortTypes'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { IProject } from 'interfaces/project'; +import { + Table, + SortableTableHeader, + TableBody, + TableCell, + TableRow, + TablePlaceholder, + TableSearch, +} from 'component/common/Table'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import useProject from 'hooks/api/getters/useProject/useProject'; +import useToast from 'hooks/useToast'; +import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; +import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; +import { useEnvironmentsRef } from './hooks/useEnvironmentsRef'; +import { useSetFeatureState } from './hooks/useSetFeatureState'; +import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch'; +import { ActionsCell } from './ActionsCell/ActionsCell'; +import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; +import { useStyles } from './ProjectFeatureToggles.styles'; interface IProjectFeatureTogglesProps { - features: IFeatureToggleListItem[]; + features: IProject['features']; + environments: IProject['environments']; loading: boolean; } +type ListItemType = Pick< + IProject['features'][number], + 'name' | 'lastSeenAt' | 'createdAt' | 'type' | 'stale' +> & { + environments: { + [key in string]: { + name: string; + enabled: boolean; + }; + }; +}; + export const ProjectFeatureToggles = ({ features, loading, + environments: newEnvironments = [], }: IProjectFeatureTogglesProps) => { const { classes: styles } = useStyles(); + const [strategiesDialogState, setStrategiesDialogState] = useState({ + open: false, + featureId: '', + environmentName: '', + }); const projectId = useRequiredPathParam('projectId'); const navigate = useNavigate(); - const { hasAccess } = useContext(AccessContext); const { uiConfig } = useUiConfig(); - const [filter, setFilter] = useState(''); + const environments = useEnvironmentsRef(newEnvironments); + const { refetch } = useProject(projectId); + const { setToastData, setToastApiError } = useToast(); - const filteredFeatures = useMemo(() => { - const regExp = new RegExp(filter, 'i'); - return filter - ? features.filter(feature => regExp.test(feature.name)) - : features; - }, [features, filter]); + const data = useMemo(() => { + if (loading) { + return Array(12).fill({ + type: '-', + name: 'Feature name', + createdAt: new Date(), + environments: { + production: { name: 'production', enabled: false }, + }, + }) as ListItemType[]; + } + + 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 { setFeatureState } = useSetFeatureState(); + const onToggle = useCallback( + async ( + projectId: string, + featureName: string, + environment: string, + enabled: boolean + ) => { + try { + await setFeatureState( + projectId, + featureName, + environment, + enabled + ); + } catch (error) { + const message = formatUnknownError(error); + if (message === ENVIRONMENT_STRATEGY_ERROR) { + setStrategiesDialogState({ + open: true, + featureId: featureName, + environmentName: environment, + }); + } else { + setToastApiError(message); + } + throw error; // caught when reverting optimistic update + } + + setToastData({ + type: 'success', + title: 'Updated toggle status', + text: 'Successfully updated toggle status.', + }); + refetch(); + }, + [setFeatureState] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const columns = useMemo( + () => [ + { + Header: 'Seen', + accessor: 'lastSeenAt', + Cell: FeatureSeenCell, + sortType: 'date', + align: 'center', + }, + { + Header: 'Type', + accessor: 'type', + Cell: FeatureTypeCell, + align: 'center', + }, + { + Header: 'Feature toggle name', + accessor: 'name', + Cell: ({ value }: { value: string }) => ( + + ), + width: '99%', + minWdith: 100, + sortType: 'alphanumeric', + }, + { + Header: 'Created on', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + align: 'center', + }, + ...environments.map(name => ({ + Header: name, + maxWidth: 103, + minWidth: 103, + accessor: `environments.${name}`, + align: 'center', + Cell: ({ + value, + row: { original: feature }, + }: { + value: { name: string; enabled: boolean }; + row: { original: ListItemType }; + }) => ( + + ), + sortType: (v1: any, v2: any, id: string) => { + const a = v1?.values?.[id]?.enabled; + const b = v2?.values?.[id]?.enabled; + return a === b ? 0 : a ? -1 : 1; + }, + })), + { + Header: ({ allColumns, setHiddenColumns }: any) => ( + + ), + maxWidth: 60, + width: 60, + id: 'actions', + Cell: (props: { row: { original: ListItemType } }) => ( + + ), + disableSortBy: true, + }, + ], + [projectId, environments, onToggle] + ); + + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'createdAt', desc: false }], + hiddenColumns: environments + .filter((_, index) => index >= 3) + .map(environment => `environments.${environment}`), + }), + [environments] + ); + + const { + state: { filters }, + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + setFilter, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + autoResetGlobalFilter: false, + disableSortRemove: true, + autoResetSortBy: false, + }, + useFilters, + useSortBy + ); + + const filter = useMemo( + () => filters?.find(filterRow => filterRow?.id === 'name')?.value || '', + [filters] + ); return ( - + setFilter('name', value)} /> - - - - } - /> - + navigate( @@ -91,42 +309,61 @@ export const ProjectFeatureToggles = ({ > New feature toggle -
+ } /> } > + + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
+
0} + condition={rows.length === 0} show={ - 0} + show={ + + No features found matching “ + {filter} + ” + + } + elseShow={ + + No features available. Get started by adding a + new feature toggle. + + } /> } - elseShow={ - <> -

- No feature toggles added yet. -

- - Add your first toggle - - } - /> - + /> + + setStrategiesDialogState(prev => ({ ...prev, open: false })) } + projectId={projectId} + {...strategiesDialogState} /> ); diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts new file mode 100644 index 0000000000..623418b3e2 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts @@ -0,0 +1,15 @@ +import { useRef } from 'react'; + +/** + * Don't revalidate if array content didn't change. + * Needed for `columns` memo optimization. + */ +export const useEnvironmentsRef = (environments: string[] = []) => { + const ref = useRef(environments); + + if (environments?.join('') !== ref.current?.join('')) { + ref.current = environments; + } + + return ref.current; +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts new file mode 100644 index 0000000000..fec5521ec9 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useSetFeatureState.ts @@ -0,0 +1,31 @@ +import useAPI from 'hooks/api/actions/useApi/useApi'; +import { useCallback } from 'react'; + +export const useSetFeatureState = () => { + const { makeRequest, createRequest, errors } = useAPI({ + propagateErrors: true, + }); + + const setFeatureState = useCallback( + async ( + projectId: string, + featureName: string, + environment: string, + enabled: boolean + ) => { + const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/${ + enabled ? 'on' : 'off' + }`; + const req = createRequest(path, { method: 'POST' }); + + try { + return makeRequest(req.caller, req.id); + } catch (e) { + throw e; + } + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return { setFeatureState, errors }; +}; diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index bf60d4b5ed..bb5dd4bdcf 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -1,5 +1,8 @@ +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import useProject from 'hooks/api/getters/useProject/useProject'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles'; +import { ProjectFeatureToggles as LegacyProjectFeatureToggles } from './ProjectFeatureToggles/LegacyProjectFeatureToggles'; import ProjectInfo from './ProjectInfo/ProjectInfo'; import { useStyles } from './Project.styles'; @@ -11,8 +14,9 @@ const ProjectOverview = ({ projectId }: IProjectOverviewProps) => { const { project, loading } = useProject(projectId, { refreshInterval: 10000, }); - const { members, features, health, description } = project; + const { members, features, health, description, environments } = project; const { classes: styles } = useStyles(); + const { uiConfig } = useUiConfig(); return (
@@ -25,9 +29,21 @@ const ProjectOverview = ({ projectId }: IProjectOverviewProps) => { featureCount={features?.length} />
- ( + + )} + elseShow={() => ( + + )} />
diff --git a/frontend/src/component/strategies/StrategiesList/__snapshots__/StrategiesList.test.tsx.snap b/frontend/src/component/strategies/StrategiesList/__snapshots__/StrategiesList.test.tsx.snap index 8de862ab2c..9928a1ffd4 100644 --- a/frontend/src/component/strategies/StrategiesList/__snapshots__/StrategiesList.test.tsx.snap +++ b/frontend/src/component/strategies/StrategiesList/__snapshots__/StrategiesList.test.tsx.snap @@ -7,7 +7,7 @@ exports[`renders correctly 1`] = ` className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root" >
      { const { KEY, fetcher } = getProjectFetcher(id); - const [sort] = useSort(); const { data, error } = useSWR(KEY, fetcher, options); const [loading, setLoading] = useState(!error && !data); @@ -20,16 +27,8 @@ const useProject = (id: string, options: SWRConfiguration = {}) => { setLoading(!error && !data); }, [data, error]); - const sortedData = (data: IProject | undefined): IProject => { - if (data) { - // @ts-expect-error - return { ...data, features: sort(data.features || []) }; - } - return fallbackProject; - }; - return { - project: sortedData(data), + project: data || fallbackProject, error, loading, refetch, diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts index d0cb77ed38..cccca101f2 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts @@ -14,6 +14,7 @@ export const defaultValue = { CO: false, SE: false, T: false, + NEW_PROJECT_OVERVIEW: false, }, links: [ { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 4cd9a3a613..6c7eb8ac85 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -29,6 +29,7 @@ export interface IFlags { CO?: boolean; SE?: boolean; T?: boolean; + NEW_PROJECT_OVERVIEW: boolean; } export interface IVersionInfo { diff --git a/frontend/src/themes/theme.ts b/frontend/src/themes/theme.ts index 6a63b33b98..924792f1ae 100644 --- a/frontend/src/themes/theme.ts +++ b/frontend/src/themes/theme.ts @@ -2,6 +2,15 @@ import { createTheme } from '@mui/material/styles'; import { colors } from './colors'; export default createTheme({ + breakpoints: { + values: { + xs: 0, + sm: 600, + md: 960, + lg: 1260, + xl: 1536, + }, + }, boxShadows: { main: '0px 2px 4px rgba(129, 122, 254, 0.2)', }, @@ -20,8 +29,8 @@ export default createTheme({ mainHeader: '1.25rem', subHeader: '1.1rem', bodySize: '1rem', - smallBody: '0.9rem', - smallerBody: '0.8rem', + smallBody: `${14 / 16}rem`, + smallerBody: `${12 / 16}rem`, }, fontWeight: { thin: 300, @@ -121,7 +130,10 @@ export default createTheme({ MuiTableHead: { styleOverrides: { root: { - background: colors.grey[200], + background: 'transparent', + '& th': { + background: colors.grey[200], + }, }, }, }, diff --git a/frontend/src/utils/sortTypes.ts b/frontend/src/utils/sortTypes.ts new file mode 100644 index 0000000000..db777b1039 --- /dev/null +++ b/frontend/src/utils/sortTypes.ts @@ -0,0 +1,21 @@ +/** + * For `react-table`. + * + * @see https://react-table.tanstack.com/docs/api/useSortBy#table-options + */ +export const sortTypes = { + date: (v1: any, v2: any, id: string) => { + const a = new Date(v1?.values?.[id] || 0); + const b = new Date(v2?.values?.[id] || 0); + return b?.getTime() - a?.getTime(); + }, + boolean: (v1: any, v2: any, id: string) => { + const a = v1?.values?.[id]; + const b = v2?.values?.[id]; + return a === b ? 0 : a ? 1 : -1; + }, + alphanumeric: (a: any, b: any, id: string) => + a?.values?.[id] + ?.toLowerCase() + .localeCompare(b?.values?.[id]?.toLowerCase()), +};