1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

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
This commit is contained in:
Tymoteusz Czech 2022-05-13 14:51:22 +02:00 committed by GitHub
parent a66168a348
commit b1166bb2f4
45 changed files with 1444 additions and 286 deletions

View File

@ -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)]: {

View File

@ -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',
},
},

View File

@ -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<IFeatureArchiveDialogProps> = ({
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 (
<Dialogue
onClick={() => archiveToggle()}
open={isOpen}
onClose={onClose}
primaryButtonText="Archive toggle"
secondaryButtonText="Cancel"
title="Archive feature toggle"
>
Are you sure you want to archive this feature toggle?
</Dialogue>
);
};

View File

@ -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<React.SetStateAction<boolean>>;
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 = (
<DialogContentText>
@ -32,21 +35,20 @@ const StaleDialog = ({ open, setOpen, stale }: IStaleDialogProps) => {
</DialogContentText>
);
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 (
<>
<Dialogue
open={open}
secondaryButtonText={'Cancel'}
primaryButtonText={`Flip to ${toggleActionText}`}
title={`Set feature status to ${toggleActionText}`}
onClick={onSubmit}
onClose={onCancel}
>
<>
<ConditionallyRender
condition={stale}
show={toggleToActiveContent}
elseShow={toggleToStaleContent}
/>
</>
</Dialogue>
</>
<Dialogue
open={isOpen}
secondaryButtonText={'Cancel'}
primaryButtonText={`Flip to ${toggleActionText}`}
title={`Set feature status to ${toggleActionText}`}
onClick={onSubmit}
onClose={onClose}
>
<>
<ConditionallyRender
condition={isStale}
show={toggleToActiveContent}
elseShow={toggleToStaleContent}
/>
</>
</Dialogue>
);
};
export default StaleDialog;

View File

@ -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<ITooltipResolverProps, 'children' | 'title'>;
children: ({ hasAccess }: { hasAccess?: boolean }) => ReactElement;
};
export const PermissionHOC: FC<IPermissionHOCProps> = ({
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 (
<TooltipResolver
{...tooltipProps}
title={formatAccessText(access, tooltip)}
>
{children({ hasAccess: access })}
</TooltipResolver>
);
};

View File

@ -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',
},
}));

View File

@ -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<HTMLButtonElement>;
}
@ -19,12 +30,17 @@ export const CellSortable: FC<ICellSortableProps> = ({
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<HTMLSpanElement>(null);
const { classes: styles } = useStyles();
const ariaSort = isSorted
@ -42,28 +58,68 @@ export const CellSortable: FC<ICellSortableProps> = ({
);
};
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 (
<TableCell
component="th"
aria-sort={ariaSort}
className={classnames(
styles.tableCellHeaderSortable,
isGrow && 'grow'
)}
className={classnames(styles.header, isSortable && styles.sortable)}
style={{ width, minWidth, maxWidth }}
>
<ConditionallyRender
condition={isSortable}
show={
<button
className={classnames(
styles.sortButton,
isSorted && styles.sorted
)}
onClick={onSortClick}
>
{children}
<SortArrow isSorted={isSorted} isDesc={isDescending} />
</button>
<Tooltip title={title} arrow>
<button
className={classnames(
isSorted && styles.sortedButton,
styles.sortButton
)}
onClick={onSortClick}
style={{ justifyContent }}
>
<span
className={styles.label}
ref={ref}
tabIndex={-1}
>
{children}
</span>
<SortArrow
isSorted={isSorted}
isDesc={isDescending}
/>
</button>
</Tooltip>
}
elseShow={children}
/>

View File

@ -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',

View File

@ -12,10 +12,6 @@ export const useStyles = makeStyles()(theme => ({
borderTopRightRadius: theme.shape.borderRadiusMedium,
borderBottomRightRadius: theme.shape.borderRadiusMedium,
},
':not(.grow)': {
width: '0.1%',
whiteSpace: 'nowrap',
},
},
},
}));

View File

@ -4,26 +4,25 @@ import { HeaderGroup } from 'react-table';
import { useStyles } from './SortableTableHeader.styles';
import { CellSortable } from './CellSortable/CellSortable';
interface IHeaderGroupColumn extends HeaderGroup<object> {
isGrow?: boolean;
}
interface ISortableTableHeaderProps {
headerGroups: HeaderGroup<object>[];
className?: string;
}
export const SortableTableHeader: VFC<ISortableTableHeaderProps> = ({
headerGroups,
className,
}) => {
const { classes: styles } = useStyles();
return (
<TableHead>
<TableHead className={className}>
{headerGroups.map(headerGroup => (
<TableRow
{...headerGroup.getHeaderGroupProps()}
className={styles.tableHeader}
>
{headerGroup.headers.map((column: IHeaderGroupColumn) => {
{headerGroup.headers.map((column: HeaderGroup) => {
const content = column.render('Header');
return (
@ -39,7 +38,11 @@ export const SortableTableHeader: VFC<ISortableTableHeaderProps> = ({
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}
</CellSortable>

View File

@ -8,6 +8,5 @@ export const useStyles = makeStyles()(theme => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
margin: theme.spacing(4),
},
}));

View File

@ -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<IDateCellProps> = ({ value }) => {
const { locationSettings } = useLocationSettings();
if (!value) {
return <Box sx={{ py: 1.5, px: 2 }} />;
}
const date = value instanceof Date ? value : parseISO(value);
return (
<Box sx={{ py: 1.5, px: 2 }}>
<Tooltip
title={formatDateYMDHMS(date, locationSettings.locale)}
arrow
>
<span data-loading role="tooltip">
{formatDateYMD(date, locationSettings.locale)}
</span>
</Tooltip>
</Box>
);
};

View File

@ -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',
},
}));

View File

@ -28,7 +28,14 @@ export const FeatureLinkCell: FC<IFeatureLinkCellProps> = ({
className={styles.wrapper}
>
<div className={styles.container}>
<span data-loading>
<span
data-loading
className={styles.title}
style={{
WebkitLineClamp: Boolean(subtitle) ? 1 : 2,
lineClamp: Boolean(subtitle) ? 1 : 2,
}}
>
<Highlighter search={search}>{title}</Highlighter>
</span>
<ConditionallyRender

View File

@ -5,4 +5,4 @@ export const RBAC = 'RBAC';
export const EEA = 'EEA';
export const RE = 'RE';
export const SE = 'SE';
export const PROJECTFILTERING = false;
export const NEW_PROJECT_OVERVIEW = 'NEW_PROJECT_OVERVIEW';

View File

@ -1,37 +0,0 @@
import { VFC } from 'react';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMD, formatDateYMDHMS } from 'utils/formatDate';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Box, Tooltip } from '@mui/material';
interface IDateCellProps {
value?: Date | null;
}
export const DateCell: VFC<IDateCellProps> = ({ value }) => {
const { locationSettings } = useLocationSettings();
return (
<Box sx={{ py: 1.5, px: 2 }}>
<ConditionallyRender
condition={Boolean(value)}
show={
<Tooltip
title={formatDateYMDHMS(
value as Date,
locationSettings.locale
)}
arrow
>
<span data-loading role="tooltip">
{formatDateYMD(
value as Date,
locationSettings.locale
)}
</span>
</Tooltip>
}
/>
</Box>
);
};

View File

@ -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<string, any>[];
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',

View File

@ -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 = () => {
<Route path="settings" element={<FeatureSettings />} />
<Route path="*" element={<FeatureOverview />} />
</Routes>
<Dialogue
onClick={() => archiveToggle()}
open={showDelDialog}
onClose={handleCancel}
primaryButtonText="Archive toggle"
secondaryButtonText="Cancel"
title="Archive feature toggle"
>
Are you sure you want to archive this feature toggle?
</Dialogue>
<StaleDialog
stale={feature.stale}
open={openStaleDialog}
setOpen={setOpenStaleDialog}
<FeatureArchiveDialog
isOpen={showDelDialog}
onConfirm={() => {
projectRefetch();
navigate(`/projects/${projectId}`);
}}
onClose={() => setShowDelDialog(false)}
projectId={projectId}
featureId={featureId}
/>
<FeatureStaleDialog
isStale={feature.stale}
isOpen={openStaleDialog}
onClose={() => {
setOpenStaleDialog(false);
refetchFeature();
}}
featureId={featureId}
projectId={projectId}
/>
<AddTagDialog
open={openTagDialog}

View File

@ -14,7 +14,7 @@ exports[`should render DrawerMenu 1`] = `
}
>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-4 mui-1lj5egr-MuiGrid-root"
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-4 mui-g8shpt-MuiGrid-root"
>
<section
title="API details"
@ -33,7 +33,7 @@ exports[`should render DrawerMenu 1`] = `
</section>
</div>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-auto mui-lvo4zz-MuiGrid-root"
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-auto mui-16ccgsq-MuiGrid-root"
>
<div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-7 mui-3f6ufr-MuiGrid-root"
@ -459,7 +459,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
}
>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-4 mui-1lj5egr-MuiGrid-root"
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-4 mui-g8shpt-MuiGrid-root"
>
<section
title="API details"
@ -478,7 +478,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
</section>
</div>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-auto mui-lvo4zz-MuiGrid-root"
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-auto mui-16ccgsq-MuiGrid-root"
>
<div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-7 mui-3f6ufr-MuiGrid-root"

View File

@ -0,0 +1,14 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
menuContainer: {
borderRadius: theme.shape.borderRadiusLarge,
padding: theme.spacing(1),
},
item: {
borderRadius: theme.shape.borderRadius,
},
text: {
fontSize: theme.fontSizes.smallBody,
},
}));

View File

@ -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<IActionsCellProps> = ({ projectId, row }) => {
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 },
} = row;
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const id = `feature-${featureId}-actions`;
const menuId = `${id}-menu`;
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Tooltip
title="Feature toggle actions"
arrow
placement="bottom-end"
describeChild
enterDelay={1000}
>
<IconButton
id={id}
aria-controls={open ? menuId : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
type="button"
>
<MoreVertIcon />
</IconButton>
</Tooltip>
<Popover
id={menuId}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
disableScrollLock={true}
PaperProps={{
className: classes.menuContainer,
}}
>
<MenuList aria-labelledby={id}>
<PermissionHOC
projectId={projectId}
permission={CREATE_FEATURE}
>
{({ hasAccess }) => (
<MenuItem
className={classes.item}
onClick={handleClose}
disabled={!hasAccess}
component={RouterLink}
to={`/projects/${projectId}/features/${featureId}/strategies/copy`}
>
<ListItemIcon>
<FileCopyIcon />
</ListItemIcon>
<ListItemText>
<Typography variant="body2">
Copy
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
<PermissionHOC
projectId={projectId}
permission={DELETE_FEATURE}
>
{({ hasAccess }) => (
<MenuItem
className={classes.item}
onClick={() => {
setOpenArchiveDialog(true);
handleClose();
}}
disabled={!hasAccess}
>
<ListItemIcon>
<ArchiveIcon />
</ListItemIcon>
<ListItemText>
<Typography variant="body2">
Archive
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
<PermissionHOC
projectId={projectId}
permission={UPDATE_FEATURE}
>
{({ hasAccess }) => (
<MenuItem
className={classes.item}
onClick={() => {
handleClose();
setOpenStaleDialog(true);
}}
disabled={!hasAccess}
>
<ListItemIcon>
<WatchLaterIcon />
</ListItemIcon>
<ListItemText>
<Typography variant="body2">
{stale ? 'Un-mark' : 'Mark'} as stale
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
</MenuList>
</Popover>
<FeatureStaleDialog
isStale={stale === true}
isOpen={openStaleDialog}
onClose={() => {
setOpenStaleDialog(false);
refetch();
}}
featureId={featureId}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={openArchiveDialog}
onConfirm={() => {
refetch();
}}
onClose={() => setOpenArchiveDialog(false)}
featureId={featureId}
projectId={projectId}
/>
</Box>
);
};

View File

@ -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),
},
},
}));

View File

@ -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<IColumnsMenuProps> = ({
allColumns,
staticColumns = [],
dividerBefore = [],
dividerAfter = [],
setHiddenColumns,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(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<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const isOpen = Boolean(anchorEl);
const id = `columns-menu`;
const menuId = `columns-menu-list-${id}`;
return (
<Box className={classes.container}>
<Tooltip title="Select columns" arrow describeChild>
<IconButton
id={id}
aria-controls={isOpen ? menuId : undefined}
aria-haspopup="true"
aria-expanded={isOpen ? 'true' : undefined}
onClick={handleClick}
type="button"
className={classes.button}
>
<ViewColumnIcon />
</IconButton>
</Tooltip>
<Popover
id={menuId}
open={isOpen}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
disableScrollLock={true}
PaperProps={{
className: classes.menuContainer,
}}
>
<Box className={classes.menuHeader}>
<Typography variant="body2">
<strong>Columns</strong>
</Typography>
<IconButton onClick={handleClose}>
<CloseIcon />
</IconButton>
</Box>
<MenuList>
{allColumns.map(column => [
<ConditionallyRender
condition={dividerBefore.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
<MenuItem
onClick={() => {
column.toggleHidden(column.isVisible);
}}
disabled={staticColumns.includes(column.id)}
className={classes.menuItem}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={column.isVisible}
disableRipple
inputProps={{
'aria-labelledby': column.id,
}}
size="medium"
className={classes.checkbox}
/>
</ListItemIcon>
<ListItemText
id={column.id}
primary={
<Typography variant="body2">
<ConditionallyRender
condition={
typeof column.Header ===
'string'
}
show={() => <>{column.Header}</>}
elseShow={() =>
capitalize(column.id)
}
/>
</Typography>
}
/>
</MenuItem>,
<ConditionallyRender
condition={dividerAfter.includes(column.id)}
show={<Divider className={classes.divider} />}
/>,
])}
</MenuList>
</Popover>
</Box>
);
};

View File

@ -0,0 +1,9 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
mx: 'auto',
display: 'flex',
justifyContent: 'center',
},
}));

View File

@ -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<void>;
}
// TODO: check React.memo performance
export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
projectId,
featureName,
environmentName,
value,
onToggle,
}) => {
const { classes } = useStyles();
const [isChecked, setIsChecked, rollbackIsChecked] =
useOptimisticUpdate<boolean>(value);
const onClick = () => {
setIsChecked(!isChecked);
onToggle(projectId, featureName, environmentName, !isChecked).catch(
rollbackIsChecked
);
};
return (
<Box
className={classes.container}
key={`${featureName}-${environmentName}`} // Prevent animation when archiving rows
>
<PermissionSwitch
checked={isChecked}
environmentId={environmentName}
projectId={projectId}
permission={UPDATE_FEATURE_ENVIRONMENT}
inputProps={{ 'aria-label': environmentName }}
onClick={onClick}
/>
</Box>
);
};

View File

@ -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');
});
});

View File

@ -0,0 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
export const useOptimisticUpdate = <T>(state: T) => {
const [value, setValue] = useState(state);
const rollback = useCallback(() => setValue(state), [state]);
useEffect(() => {
setValue(state);
}, [state]);
return [value, setValue, rollback] as const;
};

View File

@ -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 (
<PageContent
className={styles.container}
bodyClass={styles.bodyClass}
header={
<PageHeader
className={styles.title}
title={`Project feature toggles (${filteredFeatures.length})`}
actions={
<div className={styles.actionsContainer}>
<SearchField
initialValue={filter}
updateValue={setFilter}
className={classnames(styles.search, {
skeleton: loading,
})}
/>
<ResponsiveButton
onClick={() =>
navigate(
getCreateTogglePath(
projectId,
uiConfig.flags.E
)
)
}
maxWidth="700px"
Icon={Add}
projectId={projectId}
permission={CREATE_FEATURE}
className={styles.button}
>
New feature toggle
</ResponsiveButton>
</div>
}
/>
}
>
<ConditionallyRender
condition={filteredFeatures.length > 0}
show={
<FeatureToggleListNew
features={filteredFeatures}
loading={loading}
projectId={projectId}
/>
}
elseShow={
<>
<p data-loading className={styles.noTogglesFound}>
No feature toggles added yet.
</p>
<ConditionallyRender
condition={hasAccess(CREATE_FEATURE, projectId)}
show={
<Link
to={getCreateTogglePath(
projectId,
uiConfig.flags.E
)}
className={styles.link}
data-loading
>
Add your first toggle
</Link>
}
/>
</>
}
/>
</PageContent>
);
};

View File

@ -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',

View File

@ -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<ListItemType[]>(() => {
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 }) => (
<FeatureLinkCell
title={value}
to={`/projects/${projectId}/features/${value}`}
/>
),
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 };
}) => (
<FeatureToggleSwitch
value={value?.enabled || false}
projectId={projectId}
featureName={feature?.name}
environmentName={value?.name}
onToggle={onToggle}
/>
),
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) => (
<ColumnsMenu
allColumns={allColumns}
staticColumns={['actions', 'name']}
dividerAfter={['createdAt']}
dividerBefore={['actions']}
setHiddenColumns={setHiddenColumns}
/>
),
maxWidth: 60,
width: 60,
id: 'actions',
Cell: (props: { row: { original: ListItemType } }) => (
<ActionsCell projectId={projectId} {...props} />
),
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 (
<PageContent
isLoading={loading}
className={styles.container}
bodyClass={styles.bodyClass}
header={
<PageHeader
className={styles.title}
title={`Project feature toggles (${filteredFeatures.length})`}
title={`Project feature toggles (${rows.length})`}
actions={
<div className={styles.actionsContainer}>
<SearchField
<>
<TableSearch
initialValue={filter}
updateValue={setFilter}
className={classnames(styles.search, {
skeleton: loading,
})}
onChange={value => setFilter('name', value)}
/>
<ConditionallyRender
condition={PROJECTFILTERING}
show={
<IconButton
className={styles.iconButton}
data-loading
size="large"
>
<FilterListIcon
className={styles.icon}
/>
</IconButton>
}
/>
<PageHeader.Divider />
<ResponsiveButton
onClick={() =>
navigate(
@ -91,42 +309,61 @@ export const ProjectFeatureToggles = ({
>
New feature toggle
</ResponsiveButton>
</div>
</>
}
/>
}
>
<SearchHighlightProvider value={filter}>
<Table {...getTableProps()}>
<SortableTableHeader
// @ts-expect-error -- verify after `react-table` v8
headerGroups={headerGroups}
className={styles.headerClass}
/>
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</SearchHighlightProvider>
<ConditionallyRender
condition={filteredFeatures.length > 0}
condition={rows.length === 0}
show={
<FeatureToggleListNew
features={filteredFeatures}
loading={loading}
projectId={projectId}
<ConditionallyRender
condition={filter?.length > 0}
show={
<TablePlaceholder>
No features found matching &ldquo;
{filter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No features available. Get started by adding a
new feature toggle.
</TablePlaceholder>
}
/>
}
elseShow={
<>
<p data-loading className={styles.noTogglesFound}>
No feature toggles added yet.
</p>
<ConditionallyRender
condition={hasAccess(CREATE_FEATURE, projectId)}
show={
<Link
to={getCreateTogglePath(
projectId,
uiConfig.flags.E
)}
className={styles.link}
data-loading
>
Add your first toggle
</Link>
}
/>
</>
/>
<EnvironmentStrategyDialog
onClose={() =>
setStrategiesDialogState(prev => ({ ...prev, open: false }))
}
projectId={projectId}
{...strategiesDialogState}
/>
</PageContent>
);

View File

@ -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<string[]>(environments);
if (environments?.join('') !== ref.current?.join('')) {
ref.current = environments;
}
return ref.current;
};

View File

@ -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 };
};

View File

@ -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 (
<div>
@ -25,9 +29,21 @@ const ProjectOverview = ({ projectId }: IProjectOverviewProps) => {
featureCount={features?.length}
/>
<div className={styles.projectToggles}>
<ProjectFeatureToggles
features={features}
loading={loading}
<ConditionallyRender
condition={uiConfig.flags.NEW_PROJECT_OVERVIEW}
show={() => (
<ProjectFeatureToggles
features={features}
environments={environments}
loading={loading}
/>
)}
elseShow={() => (
<LegacyProjectFeatureToggles
features={features}
loading={loading}
/>
)}
/>
</div>
</div>

View File

@ -7,7 +7,7 @@ exports[`renders correctly 1`] = `
className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root"
>
<div
className="tss-1yjkq3l-headerContainer"
className="tss-1ywhhai-headerContainer"
>
<div
className="tss-1ylehva-headerContainer"
@ -66,7 +66,7 @@ exports[`renders correctly 1`] = `
</div>
</div>
<div
className="tss-kiw3z8-bodyContainer"
className="tss-f35ha2-bodyContainer"
>
<ul
className="MuiList-root MuiList-padding mui-h4y409-MuiList-root"

View File

@ -7,7 +7,7 @@ exports[`renders an empty list correctly 1`] = `
className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root"
>
<div
className="tss-1yjkq3l-headerContainer"
className="tss-1ywhhai-headerContainer"
>
<div
className="tss-1ylehva-headerContainer"
@ -54,7 +54,7 @@ exports[`renders an empty list correctly 1`] = `
</div>
</div>
<div
className="tss-kiw3z8-bodyContainer"
className="tss-f35ha2-bodyContainer"
>
<ul
className="MuiList-root MuiList-padding mui-h4y409-MuiList-root"

View File

@ -1,9 +0,0 @@
export const fallbackProject = {
features: [],
environments: [],
name: '',
health: 0,
members: 0,
version: '1',
description: 'Default',
};

View File

@ -2,12 +2,19 @@ import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { getProjectFetcher } from './getProjectFetcher';
import { IProject } from 'interfaces/project';
import { fallbackProject } from './fallbackProject';
import useSort from 'hooks/useSort';
const fallbackProject: IProject = {
features: [],
environments: [],
name: '',
health: 0,
members: 0,
version: '1',
description: 'Default',
};
const useProject = (id: string, options: SWRConfiguration = {}) => {
const { KEY, fetcher } = getProjectFetcher(id);
const [sort] = useSort();
const { data, error } = useSWR<IProject>(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,

View File

@ -14,6 +14,7 @@ export const defaultValue = {
CO: false,
SE: false,
T: false,
NEW_PROJECT_OVERVIEW: false,
},
links: [
{

View File

@ -29,6 +29,7 @@ export interface IFlags {
CO?: boolean;
SE?: boolean;
T?: boolean;
NEW_PROJECT_OVERVIEW: boolean;
}
export interface IVersionInfo {

View File

@ -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],
},
},
},
},

View File

@ -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()),
};