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:
parent
a66168a348
commit
b1166bb2f4
@ -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)]: {
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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',
|
||||
|
@ -12,10 +12,6 @@ export const useStyles = makeStyles()(theme => ({
|
||||
borderTopRightRadius: theme.shape.borderRadiusMedium,
|
||||
borderBottomRightRadius: theme.shape.borderRadiusMedium,
|
||||
},
|
||||
':not(.grow)': {
|
||||
width: '0.1%',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
|
@ -8,6 +8,5 @@ export const useStyles = makeStyles()(theme => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
margin: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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),
|
||||
},
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
container: {
|
||||
mx: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
|
@ -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 “
|
||||
{filter}
|
||||
”
|
||||
</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>
|
||||
);
|
||||
|
@ -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;
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -1,9 +0,0 @@
|
||||
export const fallbackProject = {
|
||||
features: [],
|
||||
environments: [],
|
||||
name: '',
|
||||
health: 0,
|
||||
members: 0,
|
||||
version: '1',
|
||||
description: 'Default',
|
||||
};
|
@ -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,
|
||||
|
@ -14,6 +14,7 @@ export const defaultValue = {
|
||||
CO: false,
|
||||
SE: false,
|
||||
T: false,
|
||||
NEW_PROJECT_OVERVIEW: false,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
|
@ -29,6 +29,7 @@ export interface IFlags {
|
||||
CO?: boolean;
|
||||
SE?: boolean;
|
||||
T?: boolean;
|
||||
NEW_PROJECT_OVERVIEW: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
21
frontend/src/utils/sortTypes.ts
Normal file
21
frontend/src/utils/sortTypes.ts
Normal 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()),
|
||||
};
|
Loading…
Reference in New Issue
Block a user