diff --git a/frontend/src/component/common/flags.js b/frontend/src/component/common/flags.js index 0ecfaba1e3..36180d0619 100644 --- a/frontend/src/component/common/flags.js +++ b/frontend/src/component/common/flags.js @@ -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; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap index b067b095c7..2e2f0bce48 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -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", diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 101c4674ba..a352a18d82 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -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', diff --git a/frontend/src/component/providers/AccessProvider/permissions.ts b/frontend/src/component/providers/AccessProvider/permissions.ts index 32660dd9bf..2b69a2b1ee 100644 --- a/frontend/src/component/providers/AccessProvider/permissions.ts +++ b/frontend/src/component/providers/AccessProvider/permissions.ts @@ -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'; diff --git a/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts b/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts new file mode 100644 index 0000000000..6c4bba514d --- /dev/null +++ b/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + deleteParagraph: { + marginTop: '2rem', + }, + deleteInput: { + marginTop: '1rem', + }, +})); diff --git a/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx b/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx new file mode 100644 index 0000000000..ecfe5a61da --- /dev/null +++ b/frontend/src/component/segments/SegmentDeleteConfirm/SegmentDeleteConfirm.tsx @@ -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>; + handleDeleteSegment: (id: number) => Promise; + confirmName: string; + setConfirmName: React.Dispatch>; +} + +export const SegmentDeleteConfirm = ({ + segment, + open, + setDeldialogue, + handleDeleteSegment, + confirmName, + setConfirmName, +}: ISegmentDeleteConfirmProps) => { + const styles = useStyles(); + + const handleChange = (e: React.ChangeEvent) => + setConfirmName(e.currentTarget.value); + + const handleCancel = () => { + setDeldialogue(false); + setConfirmName(''); + }; + const formId = 'delete-segment-confirmation-form'; + return ( + handleDeleteSegment(segment.id)} + disabledPrimaryButton={segment?.name !== confirmName} + onClose={handleCancel} + formId={formId} + > +

+ In order to delete this segment, please enter the name of the + segment in the textfield below: {segment?.name} +

+ +
+ +
+
+ ); +}; diff --git a/frontend/src/component/segments/SegmentList/SegmentList.styles.ts b/frontend/src/component/segments/SegmentList/SegmentList.styles.ts new file mode 100644 index 0000000000..1aa31752ff --- /dev/null +++ b/frontend/src/component/segments/SegmentList/SegmentList.styles.ts @@ -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', + }, + }, +})); diff --git a/frontend/src/component/segments/SegmentList/SegmentList.tsx b/frontend/src/component/segments/SegmentList/SegmentList.tsx new file mode 100644 index 0000000000..5a67dab74a --- /dev/null +++ b/frontend/src/component/segments/SegmentList/SegmentList.tsx @@ -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(); + 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 ( + + ); + }); + }; + + const renderNoSegments = () => { + return ( +
+ + There are no segments created yet. + +

+ 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. +

+ + Create your first segment + +
+ ); + }; + + return ( + history.push('/segments/create')} + permission={CREATE_SEGMENT} + > + New Segment + + } + /> + } + > +
+ + + + + Name + + + Description + + + Created on + + + Created By + + + {hasAccess(UPDATE_SEGMENT) ? 'Actions' : ''} + + + + + 0} + show={renderSegments()} + /> + + + +
+ + {currentSegment && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.styles.ts b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.styles.ts new file mode 100644 index 0000000000..50f25da905 --- /dev/null +++ b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx new file mode 100644 index 0000000000..1e2ea9e7fd --- /dev/null +++ b/frontend/src/component/segments/SegmentList/SegmentListItem/SegmentListItem.tsx @@ -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 + >; + setDelDialog: React.Dispatch>; +} + +export const SegmentListItem = ({ + id, + name, + description, + createdAt, + createdBy, + setCurrentSegment, + setDelDialog, +}: ISegmentListItemProps) => { + const styles = useStyles(); + + return ( + + + + {name} + + + + + {description} + + + + + + + + + + {createdBy} + + + + + {}} + permission={ADMIN} + > + + + { + setCurrentSegment({ + id, + name, + description, + createdAt, + createdBy, + constraints: [], + }); + setDelDialog(true); + }} + permission={ADMIN} + > + + + + + ); +}; diff --git a/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts b/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts new file mode 100644 index 0000000000..ec4d5888e0 --- /dev/null +++ b/frontend/src/hooks/api/actions/useSegmentsApi/useSegmentsApi.ts @@ -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 }; +}; diff --git a/frontend/src/hooks/api/getters/useSegments/useSegments.ts b/frontend/src/hooks/api/getters/useSegments/useSegments.ts new file mode 100644 index 0000000000..087719da5e --- /dev/null +++ b/frontend/src/hooks/api/getters/useSegments/useSegments.ts @@ -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()); +}; diff --git a/frontend/src/interfaces/segment.ts b/frontend/src/interfaces/segment.ts new file mode 100644 index 0000000000..32bc4f2ac6 --- /dev/null +++ b/frontend/src/interfaces/segment.ts @@ -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[]; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index d380d4817e..94921882bd 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -27,6 +27,7 @@ export interface IFlags { EEA?: boolean; OIDC?: boolean; CO?: boolean; + SE?: boolean; } export interface IVersionInfo {