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:
parent
99dce16149
commit
bee9fadbc9
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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';
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
deleteParagraph: {
|
||||
marginTop: '2rem',
|
||||
},
|
||||
deleteInput: {
|
||||
marginTop: '1rem',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
181
frontend/src/component/segments/SegmentList/SegmentList.tsx
Normal file
181
frontend/src/component/segments/SegmentList/SegmentList.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
};
|
39
frontend/src/hooks/api/getters/useSegments/useSegments.ts
Normal file
39
frontend/src/hooks/api/getters/useSegments/useSegments.ts
Normal 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());
|
||||
};
|
16
frontend/src/interfaces/segment.ts
Normal file
16
frontend/src/interfaces/segment.ts
Normal 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[];
|
||||
}
|
@ -27,6 +27,7 @@ export interface IFlags {
|
||||
EEA?: boolean;
|
||||
OIDC?: boolean;
|
||||
CO?: boolean;
|
||||
SE?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
Loading…
Reference in New Issue
Block a user