1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

feat: segments (#776)

* feat: create segmentation structure and list

* feat: remove unused deps and change route

* feat: change header style and add renderNoSegments

* fix: style table header

* feat: create useSegments hook

* feat: add segmentApi hook

* fix: ts and style errors

* feat: update PR based on feedback

* feat: add flag

* fix: test and formating

* fix: update PR based on feedback

* fix: add correct permission

* fix: mobile view for segments

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Youssef Khedher 2022-03-16 13:19:27 +01:00 committed by GitHub
parent 99dce16149
commit bee9fadbc9
14 changed files with 547 additions and 1 deletions

View File

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

View File

@ -311,6 +311,19 @@ Array [
"title": "Addons",
"type": "protected",
},
Object {
"component": [Function],
"flag": "SE",
"hidden": false,
"layout": "main",
"menu": Object {
"advanced": true,
"mobile": true,
},
"path": "/segments",
"title": "Segments",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",

View File

@ -10,7 +10,7 @@ import AdminInvoice from '../admin/invoice/InvoiceAdminPage';
import AdminUsers from '../admin/users/UsersAdmin';
import { AuthSettings } from '../admin/auth/AuthSettings';
import Login from '../user/Login/Login';
import { C, E, EEA, P, RE } from '../common/flags';
import { C, E, EEA, P, RE, SE } from '../common/flags';
import { NewUser } from '../user/NewUser/NewUser';
import ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
@ -46,6 +46,7 @@ import { EventHistoryPage } from '../history/EventHistoryPage/EventHistoryPage';
import { FeatureEventHistoryPage } from '../history/FeatureEventHistoryPage/FeatureEventHistoryPage';
import { CreateStrategy } from '../strategies/CreateStrategy/CreateStrategy';
import { EditStrategy } from '../strategies/EditStrategy/EditStrategy';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
export const routes = [
// Project
@ -350,6 +351,19 @@ export const routes = [
menu: { mobile: true, advanced: true },
},
// Segments
{
path: '/segments',
title: 'Segments',
component: SegmentsList,
hidden: false,
type: 'protected',
layout: 'main',
menu: { mobile: true, advanced: true },
flag: SE,
},
// History
{
path: '/history/:toggleName',

View File

@ -26,3 +26,6 @@ export const DELETE_FEATURE_STRATEGY = 'DELETE_FEATURE_STRATEGY';
export const UPDATE_FEATURE_ENVIRONMENT = 'UPDATE_FEATURE_ENVIRONMENT';
export const UPDATE_FEATURE_VARIANTS = 'UPDATE_FEATURE_VARIANTS';
export const MOVE_FEATURE_TOGGLE = 'MOVE_FEATURE_TOGGLE';
export const CREATE_SEGMENT = 'CREATE_SEGMENT';
export const UPDATE_SEGMENT = 'UPDATE_SEGMENT';
export const DELETE_SEGMENT = 'DELETE_SEGMENT';

View File

@ -0,0 +1,10 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
deleteParagraph: {
marginTop: '2rem',
},
deleteInput: {
marginTop: '1rem',
},
}));

View File

@ -0,0 +1,61 @@
import React from 'react';
import Dialogue from 'component/common/Dialogue';
import Input from 'component/common/Input/Input';
import { useStyles } from './SegmentDeleteConfirm.styles';
import { ISegment } from 'interfaces/segment';
interface ISegmentDeleteConfirmProps {
segment: ISegment;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteSegment: (id: number) => Promise<void>;
confirmName: string;
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
}
export const SegmentDeleteConfirm = ({
segment,
open,
setDeldialogue,
handleDeleteSegment,
confirmName,
setConfirmName,
}: ISegmentDeleteConfirmProps) => {
const styles = useStyles();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmName(e.currentTarget.value);
const handleCancel = () => {
setDeldialogue(false);
setConfirmName('');
};
const formId = 'delete-segment-confirmation-form';
return (
<Dialogue
title="Are you sure you want to delete this segment?"
open={open}
primaryButtonText="Delete segment"
secondaryButtonText="Cancel"
onClick={() => handleDeleteSegment(segment.id)}
disabledPrimaryButton={segment?.name !== confirmName}
onClose={handleCancel}
formId={formId}
>
<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>
</p>
<form id={formId}>
<Input
autoFocus
onChange={handleChange}
value={confirmName}
label="Segment name"
className={styles.deleteInput}
/>
</form>
</Dialogue>
);
};

View File

@ -0,0 +1,54 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
main: {
paddingBottom: '2rem',
},
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '5rem',
},
title: {
fontSize: theme.fontSizes.mainHeader,
marginBottom: 12,
},
subtitle: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.grey[600],
maxWidth: 515,
marginBottom: 20,
wordBreak: 'break-all',
whiteSpace: 'normal',
},
tableRow: {
background: '#F6F6FA',
borderRadius: '8px',
},
paramButton: {
color: theme.palette.primary.dark,
},
cell: {
borderBottom: 'none',
display: 'table-cell',
},
firstHeader: {
borderTopLeftRadius: '5px',
borderBottomLeftRadius: '5px',
},
lastHeader: {
borderTopRightRadius: '5px',
borderBottomRightRadius: '5px',
},
hideSM: {
[theme.breakpoints.down('sm')]: {
display: 'none',
},
},
hideXS: {
[theme.breakpoints.down('xs')]: {
display: 'none',
},
},
}));

View File

@ -0,0 +1,181 @@
import { useContext, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@material-ui/core';
import AccessContext from 'contexts/AccessContext';
import usePagination from 'hooks/usePagination';
import {
CREATE_SEGMENT,
UPDATE_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 { SegmentDeleteConfirm } from '../SegmentDeleteConfirm/SegmentDeleteConfirm';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/format-unknown-error';
import { Link, useHistory } from 'react-router-dom';
import ConditionallyRender from 'component/common/ConditionallyRender';
import HeaderTitle from 'component/common/HeaderTitle';
import PageContent from 'component/common/PageContent';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
export const SegmentsList = () => {
const history = useHistory();
const { hasAccess } = useContext(AccessContext);
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 [confirmName, setConfirmName] = useState('');
const { setToastData, setToastApiError } = useToast();
const styles = useStyles();
const onDeleteSegment = async () => {
if (!currentSegment?.id) return;
try {
await deleteSegment(currentSegment?.id);
refetchSegments();
setToastData({
type: 'success',
title: 'Successfully deleted segment',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
setDelDialog(false);
setConfirmName('');
};
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.container}>
<Typography className={styles.title}>
There are no segments created 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
headerContent={
<HeaderTitle
title="Segments"
actions={
<PermissionButton
onClick={() => history.push('/segments/create')}
permission={CREATE_SEGMENT}
>
New Segment
</PermissionButton>
}
/>
}
>
<div className={styles.main}>
<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}
>
{hasAccess(UPDATE_SEGMENT) ? 'Actions' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ConditionallyRender
condition={segments.length > 0}
show={renderSegments()}
/>
</TableBody>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
/>
</Table>
<ConditionallyRender
condition={segments.length === 0}
show={renderNoSegments()}
/>
{currentSegment && (
<SegmentDeleteConfirm
segment={currentSegment}
open={delDialog}
setDeldialogue={setDelDialog}
handleDeleteSegment={onDeleteSegment}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
)}
</div>
</PageContent>
);
};

View File

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

View File

@ -0,0 +1,85 @@
import { useStyles } from './SegmentListItem.styles';
import { TableCell, TableRow, Typography } from '@material-ui/core';
import { Delete, Edit } from '@material-ui/icons';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import TimeAgo from 'react-timeago';
import { ISegment } from 'interfaces/segment';
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 styles = useStyles();
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">
<PermissionIconButton
data-loading
aria-label="Edit"
onClick={() => {}}
permission={ADMIN}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
aria-label="Remove segment"
onClick={() => {
setCurrentSegment({
id,
name,
description,
createdAt,
createdBy,
constraints: [],
});
setDelDialog(true);
}}
permission={ADMIN}
>
<Delete />
</PermissionIconButton>
</TableCell>
</TableRow>
);
};

View File

@ -0,0 +1,38 @@
import { ISegmentPayload } from 'interfaces/segment';
import useAPI from '../useApi/useApi';
export const useSegmentsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const PATH = 'api/admin/segments';
const createSegment = async (segment: ISegmentPayload, user: any) => {
const req = createRequest(PATH, {
method: 'POST',
body: JSON.stringify(segment),
});
return makeRequest(req.caller, req.id);
};
const deleteSegment = async (id: number) => {
const req = createRequest(`${PATH}/${id}`, {
method: 'DELETE',
});
return makeRequest(req.caller, req.id);
};
const updateSegment = async (segment: ISegmentPayload) => {
const req = createRequest(PATH, {
method: 'PUT',
body: JSON.stringify(segment),
});
return makeRequest(req.caller, req.id);
};
return { createSegment, deleteSegment, updateSegment, errors, loading };
};

View File

@ -0,0 +1,39 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from 'utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
import { ISegment } from 'interfaces/segment';
const PATH = formatApiPath('api/admin/segments');
export interface UseSegmentsOutput {
segments: ISegment[];
refetchSegments: () => void;
loading: boolean;
error?: Error;
}
export const useSegments = (options?: SWRConfiguration): UseSegmentsOutput => {
const { data, error } = useSWR<{ segments: ISegment[] }>(
PATH,
fetchSegments,
options
);
const refetchSegments = useCallback(() => {
mutate(PATH).catch(console.warn);
}, []);
return {
segments: data?.segments || [],
refetchSegments,
loading: !error && !data,
error,
};
};
const fetchSegments = () => {
return fetch(PATH, { method: 'GET' })
.then(handleErrorResponses('Segments'))
.then(res => res.json());
};

View File

@ -0,0 +1,16 @@
import { IConstraint } from './strategy';
export interface ISegment {
id: number;
name: string;
description: string;
createdAt: string;
createdBy: string;
constraints: IConstraint[];
}
export interface ISegmentPayload {
name: string;
description: string;
constraints: IConstraint[];
}

View File

@ -27,6 +27,7 @@ export interface IFlags {
EEA?: boolean;
OIDC?: boolean;
CO?: boolean;
SE?: boolean;
}
export interface IVersionInfo {