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

refactor: port segments list to react-table (#1024)

* refactor: extract SegmentEmpty component

* refactor: extract CreateSegmentButton component

* refactor: extract EditSegmentButton component

* refactor: extract RemoveSegmentButton component

* refactor: normalize Created table header text

* refactor: port segments list to react-table

* fix: improve row text height in table row

* fix: update test snapshots

* refactor table cell with search highlight

* fix: update after review

Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
Co-authored-by: Tymoteusz Czech <tymek+gpg@getunleash.ai>
This commit is contained in:
olav 2022-05-27 08:57:30 +02:00 committed by GitHub
parent 5bd840107e
commit 504a4af274
40 changed files with 538 additions and 544 deletions

View File

@ -1,30 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
tableRow: {
'&:hover': {
backgroundColor: theme.palette.grey[200],
},
},
leftTableCell: {
textAlign: 'left',
maxWidth: '300px',
},
description: {
textAlign: 'left',
maxWidth: '300px',
[theme.breakpoints.down('md')]: {
display: 'none',
},
},
hideSM: {
[theme.breakpoints.down('md')]: {
display: 'none',
},
},
hideXS: {
[theme.breakpoints.down('sm')]: {
display: 'none',
},
},
}));

View File

@ -1,81 +0,0 @@
import { useStyles } from './ProjectRoleListItem.styles';
import { TableCell, TableRow, Typography } from '@mui/material';
import { Delete, Edit } from '@mui/icons-material';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { SupervisedUserCircle } from '@mui/icons-material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { IProjectRole } from 'interfaces/role';
import { useNavigate } from 'react-router-dom';
import React from 'react';
interface IRoleListItemProps {
id: number;
name: string;
type: string;
description: string;
setCurrentRole: React.Dispatch<React.SetStateAction<IProjectRole>>;
setDelDialog: React.Dispatch<React.SetStateAction<boolean>>;
}
const BUILTIN_ROLE_TYPE = 'project';
const RoleListItem = ({
id,
name,
type,
description,
setCurrentRole,
setDelDialog,
}: IRoleListItemProps) => {
const navigate = useNavigate();
const { classes: styles } = useStyles();
return (
<>
<TableRow className={styles.tableRow}>
<TableCell className={styles.hideXS}>
<SupervisedUserCircle color="disabled" />
</TableCell>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
{name}
</Typography>
</TableCell>
<TableCell className={styles.description}>
<Typography variant="body2" data-loading>
{description}
</Typography>
</TableCell>
<TableCell align="right">
<PermissionIconButton
data-loading
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
navigate(`/admin/roles/${id}/edit`);
}}
permission={ADMIN}
tooltipProps={{ title: 'Edit role' }}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
// @ts-expect-error
setCurrentRole({ id, name, description });
setDelDialog(true);
}}
permission={ADMIN}
tooltipProps={{ title: 'Remove role' }}
>
<Delete />
</PermissionIconButton>
</TableCell>
</TableRow>
</>
);
};
export default RoleListItem;

View File

@ -179,7 +179,7 @@ const UsersList = ({ search }: IUsersListProps) => {
sort={sort}
setSort={setSort}
>
Created on
Created
</TableCellSortable>
<TableCell
align="center"

View File

@ -49,7 +49,7 @@ export const PageContent: FC<IPageContentProps> = ({
const paperProps = disableBorder ? { elevation: 0 } : {};
return (
<div ref={ref}>
<div ref={ref} aria-busy={isLoading} aria-live="polite">
<Paper
{...rest}
{...paperProps}

View File

@ -0,0 +1,17 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles<{
rowHeight: 'auto' | 'standard' | 'dense' | 'compact' | number;
}>()((theme, { rowHeight }) => ({
table: {
'& tbody tr': {
height:
{
auto: 'auto',
standard: theme.shape.tableRowHeight,
compact: theme.shape.tableRowHeightCompact,
dense: theme.shape.tableRowHeightDense,
}[rowHeight] ?? rowHeight,
},
},
}));

View File

@ -0,0 +1,16 @@
import { FC } from 'react';
import classnames from 'classnames';
import { Table as MUITable, TableProps } from '@mui/material';
import { useStyles } from './Table.styles';
export const Table: FC<
TableProps & {
rowHeight?: 'auto' | 'dense' | 'standard' | 'compact' | number;
}
> = ({ rowHeight = 'auto', className, ...props }) => {
const { classes } = useStyles({ rowHeight });
return (
<MUITable className={classnames(classes.table, className)} {...props} />
);
};

View File

@ -7,10 +7,7 @@ interface IContextActionsCellProps {
export const ActionCell = ({ children }: IContextActionsCellProps) => {
return (
<Box
data-loading
sx={{ display: 'flex', justifyContent: 'flex-end', px: 2 }}
>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 2 }}>
{children}
</Box>
);

View File

@ -17,5 +17,5 @@ export const DateCell: VFC<IDateCellProps> = ({ value }) => {
: formatDateYMD(parseISO(value), locationSettings.locale)
: undefined;
return <TextCell>{date}</TextCell>;
return <TextCell lineClamp={1}>{date}</TextCell>;
};

View File

@ -0,0 +1,29 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
wordBreak: 'break-word',
padding: theme.spacing(1, 2),
},
title: {
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: '1',
lineClamp: '1',
},
subtitle: {
color: theme.palette.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: 'inherit',
WebkitLineClamp: '1',
lineClamp: '1',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
},
}));

View File

@ -1,29 +1,48 @@
import { VFC } from 'react';
import { Box } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Box, Typography } from '@mui/material';
import { useStyles } from './HighlightCell.styles';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IHighlightCellProps {
value?: string | null;
children?: string | null;
value: string;
subtitle?: string;
}
export const HighlightCell: VFC<IHighlightCellProps> = ({
value,
children,
subtitle,
}) => {
const { searchQuery } = useSearchHighlightContext();
const text = children ?? value;
if (!text) {
return <Box sx={{ py: 1.5, px: 2 }} />;
}
const { classes } = useStyles();
return (
<Box sx={{ py: 1.5, px: 2 }}>
<span data-loading role="tooltip">
<Highlighter search={searchQuery}>{text}</Highlighter>
<Box className={classes.container}>
<span
className={classes.title}
style={{
WebkitLineClamp: Boolean(subtitle) ? 1 : 2,
lineClamp: Boolean(subtitle) ? 1 : 2,
}}
data-loading
>
<Highlighter search={searchQuery}>{value}</Highlighter>
</span>
<ConditionallyRender
condition={Boolean(subtitle)}
show={() => (
<Typography
component="span"
className={classes.subtitle}
data-loading
>
<Highlighter search={searchQuery}>
{subtitle}
</Highlighter>
</Typography>
)}
/>
</Box>
);
};

View File

@ -8,13 +8,11 @@ interface IIconCellProps {
export const IconCell = ({ icon }: IIconCellProps) => {
return (
<Box
data-loading
sx={{
pl: 2,
pr: 1,
display: 'flex',
alignItems: 'center',
minHeight: 60,
}}
>
{icon}

View File

@ -28,6 +28,7 @@ export const useStyles = makeStyles()(theme => ({
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
},
description: {
color: theme.palette.text.secondary,

View File

@ -0,0 +1,13 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles<{ lineClamp?: number }>()(
(theme, { lineClamp }) => ({
wrapper: {
padding: theme.spacing(1, 2),
display: '-webkit-box',
overflow: lineClamp ? 'hidden' : 'auto',
WebkitLineClamp: lineClamp ? lineClamp : 'none',
WebkitBoxOrient: 'vertical',
},
})
);

View File

@ -1,21 +1,22 @@
import { FC } from 'react';
import { Box } from '@mui/material';
import { useStyles } from './TextCell.styles';
interface ITextCellProps {
value?: string | null;
lineClamp?: number;
}
export const TextCell: FC<ITextCellProps> = ({ value, children }) => {
const text = children ?? value;
if (!text) {
return <Box sx={{ py: 1.5, px: 2 }} />;
}
export const TextCell: FC<ITextCellProps> = ({
value,
children,
lineClamp,
}) => {
const { classes } = useStyles({ lineClamp });
return (
<Box sx={{ py: 1.5, px: 2 }}>
<span data-loading role="tooltip">
{text}
</span>
<Box className={classes.wrapper}>
<span data-loading>{children ?? value}</span>
</Box>
);
};

View File

@ -1,5 +1,6 @@
export { TableSearch } from './TableSearch/TableSearch';
export { SortableTableHeader } from './SortableTableHeader/SortableTableHeader';
export { Table, TableBody, TableRow } from '@mui/material';
export { TableBody, TableRow } from '@mui/material';
export { Table } from './Table/Table';
export { TableCell } from './TableCell/TableCell';
export { TablePlaceholder } from './TablePlaceholder/TablePlaceholder';

View File

@ -112,8 +112,8 @@ const COLUMNS = [
accessor: 'name',
width: '100%',
canSort: false,
Cell: (props: any) => (
<EnvironmentNameCell environment={props.row.original} />
Cell: ({ row: { original } }: any) => (
<EnvironmentNameCell environment={original} />
),
},
{
@ -121,8 +121,8 @@ const COLUMNS = [
id: 'Actions',
align: 'center',
canSort: false,
Cell: (props: any) => (
<EnvironmentActionCell environment={props.row.original} />
Cell: ({ row: { original } }: any) => (
<EnvironmentActionCell environment={original} />
),
},
];

View File

@ -68,7 +68,7 @@ const columns = [
sortType: 'alphanumeric',
},
{
Header: 'Created on',
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
@ -236,7 +236,7 @@ export const FeatureToggleListTable: VFC = () => {
}
>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<Table {...getTableProps()} rowHeight={rowHeight}>
{/* @ts-expect-error -- fix in react-table v8 */}
<SortableTableHeader headerGroups={headerGroups} flex />
<TableBody

View File

@ -36,7 +36,6 @@ export const useStyles = makeStyles()(theme => ({
alignItems: 'center',
},
row: {
height: theme.shape.tableRowHeight,
position: 'absolute',
width: '100%',
},

View File

@ -49,9 +49,9 @@ import { SplashPage } from 'component/splash/SplashPage/SplashPage';
import { CreateUnleashContextPage } from 'component/context/CreateUnleashContext/CreateUnleashContextPage';
import { CreateSegment } from 'component/segments/CreateSegment/CreateSegment';
import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
import { IRoute } from 'interfaces/route';
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
import { SegmentTable } from 'component/segments/SegmentTable/SegmentTable';
import RedirectAdminInvoices from 'component/admin/billing/RedirectAdminInvoices/RedirectAdminInvoices';
export const routes: IRoute[] = [
@ -348,7 +348,7 @@ export const routes: IRoute[] = [
{
path: '/segments',
title: 'Segments',
component: SegmentsList,
component: SegmentTable,
hidden: false,
type: 'protected',
menu: { mobile: true, advanced: true },

View File

@ -203,7 +203,7 @@ export const ProjectFeatureToggles = ({
sortType: 'alphanumeric',
},
{
Header: 'Created on',
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',

View File

@ -142,7 +142,7 @@ export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
);
return (
<Table {...getTableProps()}>
<Table {...getTableProps()} rowHeight="compact">
{/* @ts-expect-error -- react-table */}
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>

View File

@ -2,6 +2,8 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
cell: {
padding: theme.spacing(1, 1.5),
padding: theme.spacing(0, 1.5),
display: 'flex',
alignItems: 'center',
},
}));

View File

@ -0,0 +1,18 @@
import { CREATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds';
import { useNavigate } from 'react-router-dom';
export const CreateSegmentButton = () => {
const navigate = useNavigate();
return (
<PermissionButton
onClick={() => navigate('/segments/create')}
permission={CREATE_SEGMENT}
data-testid={NAVIGATE_TO_CREATE_SEGMENT}
>
New segment
</PermissionButton>
);
};

View File

@ -0,0 +1,23 @@
import { ISegment } from 'interfaces/segment';
import { Edit } from '@mui/icons-material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import { useNavigate } from 'react-router-dom';
interface IEditSegmentButtonProps {
segment: ISegment;
}
export const EditSegmentButton = ({ segment }: IEditSegmentButtonProps) => {
const navigate = useNavigate();
return (
<PermissionIconButton
onClick={() => navigate(`/segments/edit/${segment.id}`)}
permission={UPDATE_SEGMENT}
tooltipProps={{ title: 'Edit segment' }}
>
<Edit data-loading />
</PermissionIconButton>
);
};

View File

@ -0,0 +1,62 @@
import { ISegment } from 'interfaces/segment';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { DELETE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import { Delete } from '@mui/icons-material';
import { SEGMENT_DELETE_BTN_ID } from 'utils/testIds';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useToast from 'hooks/useToast';
import { SegmentDelete } from 'component/segments/SegmentDelete/SegmentDelete';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useState } from 'react';
interface IRemoveSegmentButtonProps {
segment: ISegment;
}
export const RemoveSegmentButton = ({ segment }: IRemoveSegmentButtonProps) => {
const { refetchSegments } = useSegments();
const { deleteSegment } = useSegmentsApi();
const { setToastData, setToastApiError } = useToast();
const [showModal, toggleModal] = useState(false);
const onRemove = async () => {
try {
await deleteSegment(segment.id);
await refetchSegments();
setToastData({
type: 'success',
title: 'Successfully deleted segment',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
toggleModal(false);
}
};
return (
<>
<PermissionIconButton
onClick={() => toggleModal(true)}
permission={DELETE_SEGMENT}
tooltipProps={{ title: 'Remove segment' }}
data-testid={`${SEGMENT_DELETE_BTN_ID}_${segment.name}`}
>
<Delete data-loading />
</PermissionIconButton>
<ConditionallyRender
condition={showModal}
show={() => (
<SegmentDelete
segment={segment}
open={showModal}
onClose={() => toggleModal(false)}
onRemove={onRemove}
/>
)}
/>
</>
);
};

View File

@ -0,0 +1,17 @@
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { ISegment } from 'interfaces/segment';
import { RemoveSegmentButton } from 'component/segments/RemoveSegmentButton/RemoveSegmentButton';
import { EditSegmentButton } from 'component/segments/EditSegmentButton/EditSegmentButton';
interface ISegmentActionCellProps {
segment: ISegment;
}
export const SegmentActionCell = ({ segment }: ISegmentActionCellProps) => {
return (
<ActionCell>
<EditSegmentButton segment={segment} />
<RemoveSegmentButton segment={segment} />
</ActionCell>
);
};

View File

@ -8,14 +8,14 @@ import { SegmentDeleteUsedSegment } from './SegmentDeleteUsedSegment/SegmentDele
interface ISegmentDeleteProps {
segment: ISegment;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteSegment: (id: number) => Promise<void>;
onClose: () => void;
onRemove: () => void;
}
export const SegmentDelete = ({
segment,
open,
setDeldialogue,
handleDeleteSegment,
onClose,
onRemove,
}: ISegmentDeleteProps) => {
const { strategies } = useStrategiesBySegment(segment.id);
const canDeleteSegment = strategies?.length === 0;
@ -26,15 +26,15 @@ export const SegmentDelete = ({
<SegmentDeleteConfirm
segment={segment}
open={open}
setDeldialogue={setDeldialogue}
handleDeleteSegment={handleDeleteSegment}
onClose={onClose}
onRemove={onRemove}
/>
}
elseShow={
<SegmentDeleteUsedSegment
segment={segment}
open={open}
setDeldialogue={setDeldialogue}
onClose={onClose}
strategies={strategies}
/>
}

View File

@ -8,15 +8,15 @@ import { SEGMENT_DIALOG_NAME_ID } from 'utils/testIds';
interface ISegmentDeleteConfirmProps {
segment: ISegment;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteSegment: (id: number) => Promise<void>;
onClose: () => void;
onRemove: () => void;
}
export const SegmentDeleteConfirm = ({
segment,
open,
setDeldialogue,
handleDeleteSegment,
onClose,
onRemove,
}: ISegmentDeleteConfirmProps) => {
const { classes: styles } = useStyles();
const [confirmName, setConfirmName] = useState('');
@ -25,7 +25,7 @@ export const SegmentDeleteConfirm = ({
setConfirmName(e.currentTarget.value);
const handleCancel = () => {
setDeldialogue(false);
onClose();
setConfirmName('');
};
const formId = 'delete-segment-confirmation-form';
@ -36,7 +36,7 @@ export const SegmentDeleteConfirm = ({
primaryButtonText="Delete segment"
secondaryButtonText="Cancel"
onClick={() => {
handleDeleteSegment(segment.id);
onRemove();
setConfirmName('');
}}
disabledPrimaryButton={segment?.name !== confirmName}
@ -45,7 +45,7 @@ export const SegmentDeleteConfirm = ({
>
<p className={styles.deleteParagraph}>
In order to delete this segment, please enter the name of the
segment in the textfield below: <strong>{segment?.name}</strong>
segment in the field below: <strong>{segment?.name}</strong>
</p>
<form id={formId}>

View File

@ -10,28 +10,24 @@ import { formatStrategyName } from 'utils/strategyNames';
interface ISegmentDeleteUsedSegmentProps {
segment: ISegment;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
onClose: () => void;
strategies: IFeatureStrategy[] | undefined;
}
export const SegmentDeleteUsedSegment = ({
segment,
open,
setDeldialogue,
onClose,
strategies,
}: ISegmentDeleteUsedSegmentProps) => {
const { classes: styles } = useStyles();
const handleCancel = () => {
setDeldialogue(false);
};
return (
<Dialogue
title="You can't delete a segment that's currently in use"
open={open}
primaryButtonText="OK"
onClick={handleCancel}
onClick={onClose}
>
<p>
The following feature toggles are using the{' '}

View File

@ -0,0 +1,29 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
margin: theme.spacing(6),
marginLeft: 'auto',
marginRight: 'auto',
},
title: {
fontSize: theme.fontSizes.mainHeader,
marginBottom: theme.spacing(2.5),
},
subtitle: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
maxWidth: 515,
marginBottom: theme.spacing(2.5),
textAlign: 'center',
},
paramButton: {
textDecoration: 'none',
color: theme.palette.primary.main,
fontWeight: theme.fontWeight.bold,
},
}));

View File

@ -0,0 +1,21 @@
import { Typography } from '@mui/material';
import { useStyles } from 'component/segments/SegmentEmpty/SegmentEmpty.styles';
import { Link } from 'react-router-dom';
export const SegmentEmpty = () => {
const { classes } = useStyles();
return (
<div className={classes.empty}>
<Typography className={classes.title}>No segments yet!</Typography>
<p className={classes.subtitle}>
Segment makes it easy for you to define who should be exposed to
your feature. The segment is often a collection of constraints
and can be reused.
</p>
<Link to="/segments/create" className={classes.paramButton}>
Create your first segment
</Link>
</div>
);
};

View File

@ -1,55 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
docs: {
marginBottom: '2rem',
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBlock: '5rem',
},
title: {
fontSize: theme.fontSizes.mainHeader,
marginBottom: '1.25rem',
},
subtitle: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.grey[600],
maxWidth: 515,
marginBottom: 20,
textAlign: 'center',
},
tableRow: {
background: '#F6F6FA',
borderRadius: '8px',
},
paramButton: {
textDecoration: 'none',
color: theme.palette.primary.main,
fontWeight: theme.fontWeight.bold,
},
cell: {
borderBottom: 'none',
display: 'table-cell',
},
firstHeader: {
borderTopLeftRadius: '5px',
borderBottomLeftRadius: '5px',
},
lastHeader: {
borderTopRightRadius: '5px',
borderBottomRightRadius: '5px',
},
hideSM: {
[theme.breakpoints.down('md')]: {
display: 'none',
},
},
hideXS: {
[theme.breakpoints.down('sm')]: {
display: 'none',
},
},
}));

View File

@ -1,179 +0,0 @@
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import usePagination from 'hooks/usePagination';
import { CREATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import PaginateUI from 'component/common/PaginateUI/PaginateUI';
import { SegmentListItem } from './SegmentListItem/SegmentListItem';
import { ISegment } from 'interfaces/segment';
import { useStyles } from './SegmentList.styles';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { Link, useNavigate } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { SegmentDelete } from '../SegmentDelete/SegmentDelete';
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds';
export const SegmentsList = () => {
const navigate = useNavigate();
const { segments = [], refetchSegments } = useSegments();
const { deleteSegment } = useSegmentsApi();
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
usePagination(segments, 10);
const [currentSegment, setCurrentSegment] = useState<ISegment>();
const [delDialog, setDelDialog] = useState(false);
const { setToastData, setToastApiError } = useToast();
const { classes: styles } = useStyles();
const onDeleteSegment = async () => {
if (!currentSegment?.id) return;
try {
await deleteSegment(currentSegment?.id);
await refetchSegments();
setToastData({
type: 'success',
title: 'Successfully deleted segment',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
setDelDialog(false);
};
const renderSegments = () => {
return page.map((segment: ISegment) => {
return (
<SegmentListItem
key={segment.id}
id={segment.id}
name={segment.name}
description={segment.description}
createdAt={segment.createdAt}
createdBy={segment.createdBy}
setCurrentSegment={setCurrentSegment}
setDelDialog={setDelDialog}
/>
);
});
};
const renderNoSegments = () => {
return (
<div className={styles.empty}>
<Typography className={styles.title}>
No segments yet!
</Typography>
<p className={styles.subtitle}>
Segment makes it easy for you to define who should be
exposed to your feature. The segment is often a collection
of constraints and can be reused.
</p>
<Link to="/segments/create" className={styles.paramButton}>
Create your first segment
</Link>
</div>
);
};
return (
<PageContent
header={
<PageHeader
title="Segments"
actions={
<PermissionButton
onClick={() => navigate('/segments/create')}
permission={CREATE_SEGMENT}
data-testid={NAVIGATE_TO_CREATE_SEGMENT}
>
New Segment
</PermissionButton>
}
/>
}
>
<div className={styles.docs}>
<SegmentDocsWarning />
</div>
<Table>
<TableHead>
<TableRow className={styles.tableRow}>
<TableCell
className={styles.firstHeader}
classes={{ root: styles.cell }}
>
Name
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideSM}
>
Description
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideXS}
>
Created on
</TableCell>
<TableCell
classes={{ root: styles.cell }}
className={styles.hideXS}
>
Created By
</TableCell>
<TableCell
align="right"
classes={{ root: styles.cell }}
className={styles.lastHeader}
>
Action
</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ConditionallyRender
condition={segments.length > 0}
show={renderSegments()}
/>
</TableBody>
</Table>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
style={{ position: 'static', marginTop: '2rem' }}
/>
<ConditionallyRender
condition={segments.length === 0}
show={renderNoSegments()}
/>
<ConditionallyRender
condition={Boolean(currentSegment)}
show={() => (
<SegmentDelete
segment={currentSegment!}
open={delDialog}
setDeldialogue={setDelDialog}
handleDeleteSegment={onDeleteSegment}
/>
)}
/>
</PageContent>
);
};

View File

@ -1,30 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
tableRow: {
'&:hover': {
backgroundColor: theme.palette.grey[200],
},
},
leftTableCell: {
textAlign: 'left',
maxWidth: '300px',
},
icon: {
color: theme.palette.inactiveIcon,
},
descriptionCell: {
textAlign: 'left',
maxWidth: '300px',
[theme.breakpoints.down('md')]: {
display: 'none',
},
},
createdAtCell: {
[theme.breakpoints.down('sm')]: {
display: 'none',
},
textAlign: 'left',
maxWidth: '300px',
},
}));

View File

@ -1,97 +0,0 @@
import { useStyles } from './SegmentListItem.styles';
import { Box, TableCell, TableRow, Typography } from '@mui/material';
import { Delete, Edit } from '@mui/icons-material';
import {
UPDATE_SEGMENT,
DELETE_SEGMENT,
} from 'component/providers/AccessProvider/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import TimeAgo from 'react-timeago';
import { ISegment } from 'interfaces/segment';
import { useNavigate } from 'react-router-dom';
import { SEGMENT_DELETE_BTN_ID } from 'utils/testIds';
import React from 'react';
interface ISegmentListItemProps {
id: number;
name: string;
description: string;
createdAt: string;
createdBy: string;
setCurrentSegment: React.Dispatch<
React.SetStateAction<ISegment | undefined>
>;
setDelDialog: React.Dispatch<React.SetStateAction<boolean>>;
}
export const SegmentListItem = ({
id,
name,
description,
createdAt,
createdBy,
setCurrentSegment,
setDelDialog,
}: ISegmentListItemProps) => {
const { classes: styles } = useStyles();
const navigate = useNavigate();
return (
<TableRow className={styles.tableRow}>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
{name}
</Typography>
</TableCell>
<TableCell className={styles.descriptionCell}>
<Typography variant="body2" data-loading>
{description}
</Typography>
</TableCell>
<TableCell className={styles.createdAtCell}>
<Typography variant="body2" data-loading>
<TimeAgo date={createdAt} live={false} />
</Typography>
</TableCell>
<TableCell className={styles.createdAtCell}>
<Typography variant="body2" data-loading>
{createdBy}
</Typography>
</TableCell>
<TableCell align="right">
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<PermissionIconButton
data-loading
onClick={() => {
navigate(`/segments/edit/${id}`);
}}
permission={UPDATE_SEGMENT}
tooltipProps={{ title: 'Edit segment' }}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={() => {
setCurrentSegment({
id,
name,
description,
createdAt,
createdBy,
constraints: [],
});
setDelDialog(true);
}}
permission={DELETE_SEGMENT}
tooltipProps={{ title: 'Remove segment' }}
data-testid={`${SEGMENT_DELETE_BTN_ID}_${name}`}
>
<Delete />
</PermissionIconButton>
</Box>
</TableCell>
</TableRow>
);
};

View File

@ -0,0 +1,199 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import {
TableSearch,
SortableTableHeader,
TableCell,
TablePlaceholder,
Table,
TableBody,
TableRow,
} from 'component/common/Table';
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
import { CreateSegmentButton } from 'component/segments/CreateSegmentButton/CreateSegmentButton';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useMediaQuery, Box } from '@mui/material';
import { sortTypes } from 'utils/sortTypes';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useMemo, useEffect, useState } from 'react';
import { SegmentEmpty } from 'component/segments/SegmentEmpty/SegmentEmpty';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { DonutLarge } from '@mui/icons-material';
import { SegmentActionCell } from 'component/segments/SegmentActionCell/SegmentActionCell';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import theme from 'themes/theme';
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
export const SegmentTable = () => {
const { segments, loading } = useSegments();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [initialState] = useState({
sortBy: [{ id: 'createdAt', desc: false }],
hiddenColumns: ['description'],
});
const data = useMemo(() => {
return (
segments ??
Array(5).fill({
name: 'Segment name',
description: 'Segment descripton',
createdAt: new Date().toISOString(),
createdBy: 'user',
})
);
}, [segments]);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
setHiddenColumns,
} = useTable(
{
initialState,
columns: COLUMNS as any,
data: data as any,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
defaultColumn: {
Cell: HighlightCell,
},
},
useGlobalFilter,
useSortBy
);
useEffect(() => {
if (isSmallScreen) {
setHiddenColumns(['description', 'createdAt', 'createdBy']);
} else {
setHiddenColumns(['description']);
}
}, [setHiddenColumns, isSmallScreen]);
return (
<PageContent
header={
<PageHeader
title="Segments"
actions={
<>
<TableSearch
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
<PageHeader.Divider />
<CreateSegmentButton />
</>
}
/>
}
isLoading={loading}
>
<Box sx={{ mb: 4 }}>
<SegmentDocsWarning />
</Box>
<ConditionallyRender
condition={!loading && data.length === 0}
show={
<TablePlaceholder>
<SegmentEmpty />
</TablePlaceholder>
}
elseShow={() => (
<>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()} rowHeight="standard">
<SortableTableHeader
headerGroups={headerGroups as any}
/>
<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={
rows.length === 0 && globalFilter?.length > 0
}
show={
<TablePlaceholder>
No segments found matching &ldquo;
{globalFilter}&rdquo;
</TablePlaceholder>
}
/>
</>
)}
/>
</PageContent>
);
};
const COLUMNS = [
{
id: 'Icon',
width: '1%',
canSort: false,
Cell: () => <IconCell icon={<DonutLarge color="disabled" />} />,
disableGlobalFilter: true,
},
{
Header: 'Name',
accessor: 'name',
width: '80%',
Cell: ({ value, row: { original } }: any) => (
<HighlightCell value={value} subtitle={original.description} />
),
},
{
Header: 'Created at',
accessor: 'createdAt',
minWidth: 150,
Cell: DateCell,
disableGlobalFilter: true,
},
{
Header: 'Created by',
accessor: 'createdBy',
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
width: '1%',
canSort: false,
disableGlobalFilter: true,
Cell: ({ row: { original } }: any) => (
<SegmentActionCell segment={original} />
),
},
{
accessor: 'description',
},
];

View File

@ -2,7 +2,10 @@
exports[`renders an empty list correctly 1`] = `
[
<div>
<div
aria-busy={true}
aria-live="polite"
>
<div
className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root"
>
@ -109,7 +112,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-54jt3w-bodyContainer"
>
<table
className="MuiTable-root mui-133vm37-MuiTable-root"
className="MuiTable-root tss-z7cn64-table mui-133vm37-MuiTable-root"
role="table"
>
<thead
@ -268,7 +271,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-1sfnvgj-container"
>
<span
className="tss-7fbt0h-title"
className="tss-lcjmxf-title"
data-loading={true}
style={
{
@ -432,7 +435,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-1sfnvgj-container"
>
<span
className="tss-7fbt0h-title"
className="tss-lcjmxf-title"
data-loading={true}
style={
{
@ -596,7 +599,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-1sfnvgj-container"
>
<span
className="tss-7fbt0h-title"
className="tss-lcjmxf-title"
data-loading={true}
style={
{
@ -760,7 +763,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-1sfnvgj-container"
>
<span
className="tss-7fbt0h-title"
className="tss-lcjmxf-title"
data-loading={true}
style={
{
@ -924,7 +927,7 @@ exports[`renders an empty list correctly 1`] = `
className="tss-1sfnvgj-container"
>
<span
className="tss-7fbt0h-title"
className="tss-lcjmxf-title"
data-loading={true}
style={
{

View File

@ -18,7 +18,10 @@ export const useSegments = (strategyId?: string): IUseSegmentsOutput => {
const { data, error, mutate } = useSWR(
[strategyId, uiConfig.flags],
fetchSegments
fetchSegments,
{
refreshInterval: 15 * 1000,
}
);
const refetchSegments = useCallback(() => {

View File

@ -45,6 +45,7 @@ export default createTheme({
borderRadiusLarge: '12px',
borderRadiusExtraLarge: '20px',
tableRowHeight: 64,
tableRowHeightCompact: 56,
tableRowHeightDense: 48,
},
palette: {

View File

@ -109,6 +109,7 @@ declare module '@mui/system/createTheme/shape' {
borderRadiusLarge: string;
borderRadiusExtraLarge: string;
tableRowHeight: number;
tableRowHeightCompact: number;
tableRowHeightDense: number;
}
}