1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

feat/rbac roles (#562)

* feat: create screen

* fix: import accordion summary

* feat: add accordions

* fix: add codebox

* feat: select permissions

* fix: permission checker

* fix: update permission checker

* feat: wire up role list

* fix: change icon color in project roles list

* fix: add color to icon in project roles

* add confirm dialog on role deletion

* feat: add created screen

* fix: cleanup

* fix: update access permissions

* fix: update admin panel

* feat: add edit screen

* fix: use color from palette and show toast when fails

* fix: refactor

* feat: validation

* feat: implement checked all

* fix: experimental toast

* fix: error handling

* fix: toast

* feat: unique name validation

* fix: update toasts

* fix: remove toast

* fix: reset flag

* fix: remove unused vars

* fix: update tests

* feat: add error icon for toast

* fix: replace wrong import for setToastData

* feat: Patch keying on ui to handle uniqueness for permissions across multiple envs

* fix: hasAccess handles *

* fix: update permission switch

* fix: use flag for environments rbac

* fix: do not include check all keys in payload

* fix: filter roles

* fix: account for new permissions in variants list

* fix: use effect on length property

* fix: set polling interval on user

* 4.5.0-beta.0

* fix: set initial permissions correctly to avoid race condition

* fix: handle activeEnvironment when it is null

* fix: remove unused imports

* fix: unused imports

* fix: Include missing project in hasAccess for deleteinng a tag

* fix: Move add/delete tag to use update feature permissions

* fix: use rest parameter

* fix: remove sandbox from scripts

* 4.6.0-beta.1

* fix: remove loading deduping

* fix: disable editing on builtin roles

* fix: check all

* fix: feature overview environment

* fix: refetch user on project create

* fix: update snaphots

* fix: frontend permissions

* fix: delete create confirm

* fix: remove unused permission

* 4.6.0-beta.4

* fix: update permissions

* fix: permissions

* fix: set error to string

* 4.6.0-beta.5

* fix: add permissions for project view

* fix: add permissions to useEffect deps

* fix: update permission for move feature toggle

* fix: add permissions data to useEffect

* fix: move settings

* fix: key on confetti

* fix: refetch project permissions on environment create/delete

* fix: optional coalescing error object

* fix: remove logging error

* fix: reorder disable importance in permissionbutton

* fix: add project roles to menu

* fix: add disabled check to revive

* fix: update snapshots

* fix: change text to select all

* fix: change text to select

* 4.6.0-beta.6

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
Co-authored-by: sighphyre <liquidwicked64@gmail.com>
This commit is contained in:
Youssef Khedher 2022-01-14 15:50:02 +01:00 committed by GitHub
parent 8b538e4ded
commit 182d566895
94 changed files with 2755 additions and 1641 deletions

View File

@ -1,7 +1,7 @@
{
"name": "unleash-frontend",
"description": "unleash your features",
"version": "4.4.1",
"version": "4.6.0-beta.6",
"keywords": [
"unleash",
"feature toggle",

View File

@ -65,6 +65,19 @@ export const useCommonStyles = makeStyles(theme => ({
fontWeight: 'bold',
marginBottom: '0.5rem',
},
fadeInBottomStartNoPosition: {
transform: 'translateY(400px)',
opacity: '0',
boxShadow: `rgb(129 129 129 / 40%) 4px 5px 11px 4px`,
zIndex: 500,
width: '100%',
backgroundColor: '#fff',
right: 0,
bottom: 0,
left: 0,
height: '300px',
position: 'fixed',
},
fadeInBottomStart: {
opacity: '0',
position: 'fixed',

View File

@ -13,12 +13,12 @@ import IAuthStatus from '../interfaces/user';
import { useState, useEffect } from 'react';
import NotFound from './common/NotFound/NotFound';
import Feedback from './common/Feedback';
import useToast from '../hooks/useToast';
import SWRProvider from './providers/SWRProvider/SWRProvider';
import ConditionallyRender from './common/ConditionallyRender';
import EnvironmentSplash from './common/EnvironmentSplash/EnvironmentSplash';
import Loader from './common/Loader/Loader';
import useUser from '../hooks/api/getters/useUser/useUser';
import ToastRenderer from './common/ToastRenderer/ToastRenderer';
interface IAppProps extends RouteComponentProps {
user: IAuthStatus;
@ -26,9 +26,9 @@ interface IAppProps extends RouteComponentProps {
feedback: any;
}
const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
const { toast, setToastData } = useToast();
// because we need the userId when the component load.
const { splash, user: userFromUseUser, authDetails } = useUser();
const [showSplash, setShowSplash] = useState(false);
const [showLoader, setShowLoader] = useState(false);
useEffect(() => {
@ -101,7 +101,6 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
return (
<SWRProvider
setToastData={setToastData}
isUnauthorized={isUnauthorized}
setShowLoader={setShowLoader}
>
@ -141,7 +140,7 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
}
/>
{toast}
<ToastRenderer />
</div>
}
/>

View File

@ -1,63 +0,0 @@
import { useContext } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@material-ui/core';
import AccessContext from '../../../contexts/AccessContext';
import usePagination from '../../../hooks/usePagination';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import PaginateUI from '../../common/PaginateUI/PaginateUI';
import RoleListItem from './RolesListItem/RoleListItem';
import useProjectRoles from '../../../hooks/api/getters/useProjectRoles/useProjectRoles';
const RolesList = () => {
const { hasAccess } = useContext(AccessContext);
const { roles } = useProjectRoles();
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
usePagination(roles, 10);
const renderRoles = () => {
return page.map(role => {
return (
<RoleListItem
key={role.id}
name={role.name}
description={role.description}
/>
);
});
};
if (!roles) return null;
return (
<div>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell>Project Role</TableCell>
<TableCell>Description</TableCell>
<TableCell align="right">
{hasAccess(ADMIN) ? 'Action' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>{renderRoles()}</TableBody>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
/>
</Table>
<br />
</div>
);
};
export default RolesList;

View File

@ -1,61 +0,0 @@
import { useStyles } from './RoleListItem.styles';
import { TableRow, TableCell, Typography } from '@material-ui/core';
import { Edit, Delete } from '@material-ui/icons';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle';
import PermissionIconButton from '../../../common/PermissionIconButton/PermissionIconButton';
interface IRoleListItemProps {
key: number;
name: string;
description: string;
}
const RoleListItem = ({ key, name, description }: IRoleListItemProps) => {
const styles = useStyles();
return (
<TableRow key={key} className={styles.tableRow}>
<TableCell>
<SupervisedUserCircleIcon />
</TableCell>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
{name}
</Typography>
</TableCell>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
{description}
</Typography>
</TableCell>
<TableCell align="right">
<PermissionIconButton
data-loading
aria-label="Edit"
tooltip="Edit"
onClick={() => {
console.log('hi');
}}
permission={ADMIN}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
aria-label="Remove user"
tooltip="Remove role"
onClick={() => {
console.log('hi');
}}
permission={ADMIN}
>
<Delete />
</PermissionIconButton>
</TableCell>
</TableRow>
);
};
export default RoleListItem;

View File

@ -1,6 +1,7 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Paper, Tabs, Tab } from '@material-ui/core';
import useUiConfig from '../../hooks/api/getters/useUiConfig/useUiConfig';
const navLinkStyle = {
display: 'flex',
@ -19,12 +20,19 @@ const activeNavLinkStyle = {
};
function AdminMenu({ history }) {
const SHOW_PROJECT_ROLES = false;
const { uiConfig } = useUiConfig();
const { flags } = uiConfig;
const { location } = history;
const { pathname } = location;
return (
<Paper style={{ marginBottom: '1rem' }}>
<Paper
style={{
marginBottom: '1rem',
borderRadius: '12.5px',
boxShadow: 'none',
}}
>
<Tabs centered value={pathname}>
<Tab
value="/admin/users"
@ -38,7 +46,7 @@ function AdminMenu({ history }) {
</NavLink>
}
></Tab>
{SHOW_PROJECT_ROLES && (
{flags.RE && (
<Tab
value="/admin/roles"
label={

View File

@ -54,7 +54,7 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
const { uiConfig } = useUiConfig();
const [showDelete, setShowDelete] = useState(false);
const [delToken, setDeleteToken] = useState<IApiToken>();
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const { tokens, loading, refetch, error } = useApiTokens();
const { deleteToken, createToken } = useApiTokensApi();
const ref = useLoading(loading);
@ -80,19 +80,25 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
};
const onCreateToken = async (token: IApiTokenCreate) => {
await createToken(token);
refetch();
setToastData({
type: 'success',
show: true,
text: 'Successfully created API token.',
});
try {
await createToken(token);
refetch();
setToastData({
type: 'success',
title: 'Created token',
text: 'Successfully created API token',
confetti: true,
});
} catch (e) {
setToastApiError(e.message);
}
};
const copyToken = (value: string) => {
if (copy(value)) {
setToastData({
type: 'success',
show: true,
title: 'Token copied',
confetti: true,
text: `Token is copied to clipboard`,
});
}
@ -161,7 +167,10 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
<TableBody>
{tokens.map(item => {
return (
<TableRow key={item.secret} className={styles.tableRow}>
<TableRow
key={item.secret}
className={styles.tableRow}
>
<TableCell
align="left"
className={styles.hideSM}
@ -302,7 +311,7 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
elseShow={renderApiTokens(tokens)}
/>
</div>
{toast}
<ApiTokenCreate
showDialog={showDialog}
createToken={onCreateToken}

View File

@ -0,0 +1,101 @@
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
import useProjectRolesApi from '../../../../hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
import { useHistory } from 'react-router-dom';
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
import useProjectRoleForm from '../hooks/useProjectRoleForm';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast';
const CreateProjectRole = () => {
/* @ts-ignore */
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const history = useHistory();
const {
roleName,
roleDesc,
setRoleName,
setRoleDesc,
checkedPermissions,
handlePermissionChange,
checkAllProjectPermissions,
checkAllEnvironmentPermissions,
getProjectRolePayload,
validatePermissions,
validateName,
validateNameUniqueness,
errors,
clearErrors,
getRoleKey,
} = useProjectRoleForm();
const { createRole, loading } = useProjectRolesApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
const validName = validateName();
const validPermissions = validatePermissions();
if (validName && validPermissions) {
const payload = getProjectRolePayload();
try {
await createRole(payload);
history.push('/admin/roles');
setToastData({
title: 'Project role created',
text: 'Now you can start assigning your project roles to project members.',
confetti: true,
type: 'success',
});
} catch (e) {
setToastApiError(e.toString());
}
}
};
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/roles' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
};
const handleCancel = () => {
history.push('/admin/roles');
};
return (
<FormTemplate
loading={loading}
title="Create project role"
description="A project role can be
customised to limit access
to resources within a project"
documentationLink="https://docs.getunleash.io/"
formatApiCode={formatApiCode}
>
<ProjectRoleForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
roleName={roleName}
setRoleName={setRoleName}
roleDesc={roleDesc}
setRoleDesc={setRoleDesc}
checkedPermissions={checkedPermissions}
handlePermissionChange={handlePermissionChange}
checkAllProjectPermissions={checkAllProjectPermissions}
checkAllEnvironmentPermissions={checkAllEnvironmentPermissions}
submitButtonText="Create"
clearErrors={clearErrors}
validateNameUniqueness={validateNameUniqueness}
getRoleKey={getRoleKey}
/>
</FormTemplate>
);
};
export default CreateProjectRole;

View File

@ -0,0 +1,128 @@
import { useEffect } from 'react';
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
import useProjectRolesApi from '../../../../hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
import { useHistory, useParams } from 'react-router-dom';
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
import useProjectRoleForm from '../hooks/useProjectRoleForm';
import useProjectRole from '../../../../hooks/api/getters/useProjectRole/useProjectRole';
import { IPermission } from '../../../../interfaces/project';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast';
const EditProjectRole = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const { id } = useParams();
const { role } = useProjectRole(id);
const history = useHistory();
const {
roleName,
roleDesc,
setRoleName,
setRoleDesc,
checkedPermissions,
handlePermissionChange,
checkAllProjectPermissions,
checkAllEnvironmentPermissions,
getProjectRolePayload,
validatePermissions,
validateName,
errors,
clearErrors,
getRoleKey,
handleInitialCheckedPermissions,
permissions,
} = useProjectRoleForm(role.name, role.description);
useEffect(() => {
const initialCheckedPermissions = role?.permissions?.reduce(
(acc: { [key: string]: IPermission }, curr: IPermission) => {
acc[getRoleKey(curr)] = curr;
return acc;
},
{}
);
handleInitialCheckedPermissions(initialCheckedPermissions || {});
/* eslint-disable-next-line */
}, [
role?.permissions?.length,
permissions?.project?.length,
permissions?.environments?.length,
]);
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/roles/${role.id}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
};
const { refetch } = useProjectRole(id);
const { editRole, loading } = useProjectRolesApi();
const handleSubmit = async e => {
e.preventDefault();
const payload = getProjectRolePayload();
const validName = validateName();
const validPermissions = validatePermissions();
if (validName && validPermissions) {
try {
await editRole(id, payload);
refetch();
history.push('/admin/roles');
setToastData({
title: 'Project role updated',
text: 'Your role changes will automatically be applied to the users with this role.',
confetti: true,
});
} catch (e) {
setToastApiError(e.toString());
}
}
};
const handleCancel = () => {
history.push('/admin/roles');
};
return (
<FormTemplate
loading={loading}
title="Edit project role"
description="A project role can be
customised to limit access
to resources within a project"
documentationLink="https://docs.getunleash.io/"
formatApiCode={formatApiCode}
>
<ProjectRoleForm
handleSubmit={handleSubmit}
handleCancel={handleCancel}
roleName={roleName}
setRoleName={setRoleName}
roleDesc={roleDesc}
setRoleDesc={setRoleDesc}
checkedPermissions={checkedPermissions}
handlePermissionChange={handlePermissionChange}
checkAllProjectPermissions={checkAllProjectPermissions}
checkAllEnvironmentPermissions={checkAllEnvironmentPermissions}
submitButtonText="Edit"
errors={errors}
clearErrors={clearErrors}
getRoleKey={getRoleKey}
/>
</FormTemplate>
);
};
export default EditProjectRole;

View File

@ -0,0 +1,35 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
environmentPermissionContainer: {
marginBottom: '1.25rem',
},
accordionSummary: {
boxShadow: 'none',
padding: '0',
},
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
accordionHeader: {
display: 'flex',
alignItems: 'center',
[theme.breakpoints.down(500)]: {
flexDirection: 'column',
alignItems: 'flex-start',
},
},
accordionBody: {
padding: '0',
flexWrap: 'wrap',
},
header: {
color: theme.palette.primary.light,
},
icon: {
fill: theme.palette.primary.light,
},
}));

View File

@ -0,0 +1,147 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Checkbox,
FormControlLabel,
} from '@material-ui/core';
import { ExpandMore } from '@material-ui/icons';
import { useEffect, useState } from 'react';
import {
IPermission,
IProjectEnvironmentPermissions,
} from '../../../../../interfaces/project';
import StringTruncator from '../../../../common/StringTruncator/StringTruncator';
import { ICheckedPermission } from '../../hooks/useProjectRoleForm';
import { useStyles } from './EnvironmentPermissionAccordion.styles';
type PermissionMap = { [key: string]: boolean };
interface IEnvironmentPermissionAccordionProps {
environment: IProjectEnvironmentPermissions;
handlePermissionChange: (permission: IPermission, type: string) => void;
checkAllEnvironmentPermissions: (envName: string) => void;
checkedPermissions: ICheckedPermission;
getRoleKey: (permission: { id: number; environment?: string }) => string;
}
const EnvironmentPermissionAccordion = ({
environment,
handlePermissionChange,
checkAllEnvironmentPermissions,
checkedPermissions,
getRoleKey,
}: IEnvironmentPermissionAccordionProps) => {
const [permissionMap, setPermissionMap] = useState<PermissionMap>({});
const [permissionCount, setPermissionCount] = useState(0);
const styles = useStyles();
useEffect(() => {
const permissionMap = environment?.permissions?.reduce(
(acc: PermissionMap, curr: IPermission) => {
acc[getRoleKey(curr)] = true;
return acc;
},
{}
);
setPermissionMap(permissionMap);
/* eslint-disable-next-line */
}, [environment?.permissions?.length]);
useEffect(() => {
let count = 0;
Object.keys(checkedPermissions).forEach(key => {
if (permissionMap[key]) {
count = count + 1;
}
});
setPermissionCount(count);
/* eslint-disable-next-line */
}, [checkedPermissions]);
const renderPermissions = () => {
const envPermissions = environment?.permissions?.map(
(permission: IPermission) => {
return (
<FormControlLabel
classes={{ root: styles.label }}
key={getRoleKey(permission)}
control={
<Checkbox
checked={
checkedPermissions[getRoleKey(permission)]
? true
: false
}
onChange={() =>
handlePermissionChange(
permission,
environment.name
)
}
color="primary"
/>
}
label={permission.displayName || 'Dummy permission'}
/>
);
}
);
envPermissions.push(
<FormControlLabel
key={`check-all-environment-${environment?.name}`}
classes={{ root: styles.label }}
control={
<Checkbox
checked={
checkedPermissions[
`check-all-environment-${environment?.name}`
]
? true
: false
}
onChange={() =>
checkAllEnvironmentPermissions(environment?.name)
}
color="primary"
/>
}
label={'Select all permissions for this env'}
/>
);
return envPermissions;
};
return (
<div className={styles.environmentPermissionContainer}>
<Accordion style={{ boxShadow: 'none' }}>
<AccordionSummary
className={styles.accordionSummary}
expandIcon={<ExpandMore className={styles.icon} />}
>
<div className={styles.accordionHeader}>
<StringTruncator
text={environment.name}
className={styles.header}
maxWidth="120"
/>
&nbsp;
<p className={styles.header}>
({permissionCount} /{' '}
{environment?.permissions?.length} permissions)
</p>
</div>
</AccordionSummary>
<AccordionDetails className={styles.accordionBody}>
{renderPermissions()}
</AccordionDetails>
</Accordion>
</div>
);
};
export default EnvironmentPermissionAccordion;

View File

@ -0,0 +1,41 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
maxWidth: '400px',
},
input: { width: '100%', marginBottom: '1rem' },
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
buttonContainer: {
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
},
cancelButton: {
marginRight: '1.5rem',
},
inputDescription: {
marginBottom: '0.5rem',
},
formHeader: {
fontWeight: 'normal',
marginTop: '0',
},
header: {
fontWeight: 'normal',
},
permissionErrorContainer: {
position: 'relative',
},
errorMessage: {
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
}));

View File

@ -0,0 +1,190 @@
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import Input from '../../../common/Input/Input';
import EnvironmentPermissionAccordion from './EnvironmentPermissionAccordion/EnvironmentPermissionAccordion';
import {
Checkbox,
FormControlLabel,
TextField,
Button,
} from '@material-ui/core';
import useProjectRolePermissions from '../../../../hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import { useStyles } from './ProjectRoleForm.styles';
import ConditionallyRender from '../../../common/ConditionallyRender';
import React from 'react';
import { IPermission } from '../../../../interfaces/project';
import {
ICheckedPermission,
PROJECT_CHECK_ALL_KEY,
} from '../hooks/useProjectRoleForm';
interface IProjectRoleForm {
roleName: string;
roleDesc: string;
setRoleName: React.Dispatch<React.SetStateAction<string>>;
setRoleDesc: React.Dispatch<React.SetStateAction<string>>;
checkedPermissions: ICheckedPermission;
handlePermissionChange: (permission: IPermission, type: string) => void;
checkAllProjectPermissions: () => void;
checkAllEnvironmentPermissions: (envName: string) => void;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
submitButtonText: string;
clearErrors: () => void;
getRoleKey: (permission: { id: number; environment?: string }) => string;
}
const ProjectRoleForm = ({
handleSubmit,
handleCancel,
roleName,
roleDesc,
setRoleName,
setRoleDesc,
checkedPermissions,
handlePermissionChange,
checkAllProjectPermissions,
checkAllEnvironmentPermissions,
errors,
submitButtonText,
validateNameUniqueness,
clearErrors,
getRoleKey,
}: IProjectRoleForm) => {
const styles = useStyles();
const { permissions } = useProjectRolePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const { project, environments } = permissions;
const renderProjectPermissions = () => {
const projectPermissions = project.map(permission => {
return (
<FormControlLabel
key={getRoleKey(permission)}
classes={{ root: styles.label }}
control={
<Checkbox
checked={
checkedPermissions[getRoleKey(permission)]
? true
: false
}
onChange={() =>
handlePermissionChange(permission, 'project')
}
color="primary"
/>
}
label={permission.displayName}
/>
);
});
projectPermissions.push(
<FormControlLabel
key={PROJECT_CHECK_ALL_KEY}
classes={{ root: styles.label }}
control={
<Checkbox
checked={
checkedPermissions[PROJECT_CHECK_ALL_KEY]
? true
: false
}
onChange={() => checkAllProjectPermissions()}
color="primary"
/>
}
label={'Select all project permissions'}
/>
);
return projectPermissions;
};
const renderEnvironmentPermissions = () => {
return environments.map(environment => {
return (
<EnvironmentPermissionAccordion
environment={environment}
key={environment.name}
checkedPermissions={checkedPermissions}
handlePermissionChange={handlePermissionChange}
checkAllEnvironmentPermissions={
checkAllEnvironmentPermissions
}
getRoleKey={getRoleKey}
/>
);
});
};
return (
<form onSubmit={handleSubmit}>
<h3 className={styles.formHeader}>Role information</h3>
<div className={styles.container}>
<p className={styles.inputDescription}>
What is your role name?
</p>
<Input
className={styles.input}
label="Role name"
value={roleName}
onChange={e => setRoleName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
onBlur={validateNameUniqueness}
/>
<p className={styles.inputDescription}>
What is this role for?
</p>
<TextField
className={styles.input}
label="Role description"
variant="outlined"
multiline
maxRows={4}
value={roleDesc}
onChange={e => setRoleDesc(e.target.value)}
/>
</div>
<div className={styles.permissionErrorContainer}>
<ConditionallyRender
condition={Boolean(errors.permissions)}
show={
<span className={styles.errorMessage}>
You must select at least one permission for a role.
</span>
}
/>
</div>
<h3 className={styles.header}>Project permissions</h3>
<div>{renderProjectPermissions()}</div>
<h3 className={styles.header}>Environment permissions</h3>
<div>{renderEnvironmentPermissions()}</div>
<div className={styles.buttonContainer}>
<Button onClick={handleCancel} className={styles.cancelButton}>
Cancel
</Button>
<PermissionButton
onClick={handleSubmit}
permission={ADMIN}
type="submit"
>
{submitButtonText} role
</PermissionButton>
</div>
</form>
);
};
export default ProjectRoleForm;

View File

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

View File

@ -0,0 +1,70 @@
import { Alert } from '@material-ui/lab';
import React from 'react';
import { IProjectRole } from '../../../../../interfaces/role';
import Dialogue from '../../../../common/Dialogue';
import Input from '../../../../common/Input/Input';
import { useStyles } from './ProjectRoleDeleteConfirm.styles';
interface IProjectRoleDeleteConfirmProps {
role: IProjectRole;
open: boolean;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteRole: (id: number) => Promise<void>;
confirmName: string;
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
}
const ProjectRoleDeleteConfirm = ({
role,
open,
setDeldialogue,
handleDeleteRole,
confirmName,
setConfirmName,
}: IProjectRoleDeleteConfirmProps) => {
const styles = useStyles();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmName(e.currentTarget.value);
const handleCancel = () => {
setDeldialogue(false);
setConfirmName('');
};
const formId = 'delete-project-role-confirmation-form';
return (
<Dialogue
title="Are you sure you want to delete this role?"
open={open}
primaryButtonText="Delete project role"
secondaryButtonText="Cancel"
onClick={() => handleDeleteRole(role.id)}
disabledPrimaryButton={role?.name !== confirmName}
onClose={handleCancel}
formId={formId}
>
<Alert severity="error">
Danger. Deleting this role will result in removing all
permissions that are active in this environment across all
feature toggles.
</Alert>
<p className={styles.deleteParagraph}>
In order to delete this role, please enter the name of the role
in the textfield below: <strong>{role?.name}</strong>
</p>
<form id={formId}>
<Input
autoFocus
onChange={handleChange}
value={confirmName}
label="Role name"
className={styles.roleDeleteInput}
/>
</form>
</Dialogue>
);
};
export default ProjectRoleDeleteConfirm;

View File

@ -0,0 +1,106 @@
import { useContext, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@material-ui/core';
import AccessContext from '../../../../../contexts/AccessContext';
import usePagination from '../../../../../hooks/usePagination';
import { ADMIN } from '../../../../providers/AccessProvider/permissions';
import PaginateUI from '../../../../common/PaginateUI/PaginateUI';
import ProjectRoleListItem from './ProjectRoleListItem/ProjectRoleListItem';
import useProjectRoles from '../../../../../hooks/api/getters/useProjectRoles/useProjectRoles';
import { IProjectRole } from '../../../../../interfaces/role';
import useProjectRolesApi from '../../../../../hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
import useToast from '../../../../../hooks/useToast';
import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm';
const ROOTROLE = 'root';
const ProjectRoleList = () => {
const { hasAccess } = useContext(AccessContext);
const { roles } = useProjectRoles();
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
usePagination(roles, 10);
const { deleteRole } = useProjectRolesApi();
const { refetch } = useProjectRoles();
const [currentRole, setCurrentRole] = useState<IProjectRole | null>(null);
const [delDialog, setDelDialog] = useState(false);
const [confirmName, setConfirmName] = useState('');
const { setToastData, setToastApiError } = useToast();
const deleteProjectRole = async () => {
if (!currentRole?.id) return;
try {
await deleteRole(currentRole?.id);
refetch();
setToastData({
type: 'success',
title: 'Successfully deleted role',
text: 'Your role is now deleted',
});
} catch (e) {
setToastApiError(e.toString());
}
setDelDialog(false);
setConfirmName('');
};
const renderRoles = () => {
return page
.filter(role => role?.type !== ROOTROLE)
.map((role: IProjectRole) => {
return (
<ProjectRoleListItem
key={role.id}
id={role.id}
name={role.name}
type={role.type}
description={role.description}
setCurrentRole={setCurrentRole}
setDelDialog={setDelDialog}
/>
);
});
};
if (!roles) return null;
return (
<div>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell>Project Role</TableCell>
<TableCell>Description</TableCell>
<TableCell align="right">
{hasAccess(ADMIN) ? 'Action' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>{renderRoles()}</TableBody>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
/>
</Table>
<br />
<ProjectRoleDeleteConfirm
role={currentRole}
open={delDialog}
setDeldialogue={setDelDialog}
handleDeleteRole={deleteProjectRole}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
</div>
);
};
export default ProjectRoleList;

View File

@ -6,8 +6,11 @@ export const useStyles = makeStyles(theme => ({
backgroundColor: theme.palette.grey[200],
},
},
leftTableCell:{
leftTableCell: {
textAlign: 'left',
maxWidth: '300px'
}
maxWidth: '300px',
},
icon: {
color: theme.palette.grey[600],
},
}));

View File

@ -0,0 +1,81 @@
import { useStyles } from './ProjectRoleListItem.styles';
import { TableRow, TableCell, Typography } from '@material-ui/core';
import { Edit, Delete } from '@material-ui/icons';
import { ADMIN } from '../../../../../providers/AccessProvider/permissions';
import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle';
import PermissionIconButton from '../../../../../common/PermissionIconButton/PermissionIconButton';
import { IProjectRole } from '../../../../../../interfaces/role';
import { useHistory } from 'react-router-dom';
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 history = useHistory();
const styles = useStyles();
return (
<>
<TableRow className={styles.tableRow}>
<TableCell>
<SupervisedUserCircleIcon className={styles.icon} />
</TableCell>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
{name}
</Typography>
</TableCell>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
{description}
</Typography>
</TableCell>
<TableCell align="right">
<PermissionIconButton
data-loading
aria-label="Edit"
tooltip="Edit"
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
history.push(`/admin/roles/${id}/edit`);
}}
permission={ADMIN}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
aria-label="Remove role"
tooltip="Remove role"
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
setCurrentRole({ id, name, description });
setDelDialog(true);
}}
permission={ADMIN}
>
<Delete />
</PermissionIconButton>
</TableCell>
</TableRow>
</>
);
};
export default RoleListItem;

View File

@ -2,14 +2,14 @@ import { Button } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { useContext } from 'react';
import { useHistory } from 'react-router-dom';
import AccessContext from '../../../contexts/AccessContext';
import ConditionallyRender from '../../common/ConditionallyRender';
import HeaderTitle from '../../common/HeaderTitle';
import PageContent from '../../common/PageContent';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import AdminMenu from '../admin-menu';
import AccessContext from '../../../../contexts/AccessContext';
import ConditionallyRender from '../../../common/ConditionallyRender';
import HeaderTitle from '../../../common/HeaderTitle';
import PageContent from '../../../common/PageContent';
import { ADMIN } from '../../../providers/AccessProvider/permissions';
import AdminMenu from '../../admin-menu';
import { useStyles } from './ProjectRoles.styles';
import RolesList from './RolesList';
import ProjectRoleList from './ProjectRoleList/ProjectRoleList';
const ProjectRoles = () => {
const { hasAccess } = useContext(AccessContext);
@ -31,7 +31,11 @@ const ProjectRoles = () => {
<Button
variant="contained"
color="primary"
onClick={() => console.log('hi')}
onClick={() =>
history.push(
'/admin/create-project-role'
)
}
>
New Project role
</Button>
@ -48,7 +52,7 @@ const ProjectRoles = () => {
>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<RolesList location={location} />}
show={<ProjectRoleList />}
elseShow={
<Alert severity="error">
You need instance admin to access this section.

View File

@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import { IPermission } from '../../../../interfaces/project';
import cloneDeep from 'lodash.clonedeep';
import useProjectRolePermissions from '../../../../hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import useProjectRolesApi from '../../../../hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
export interface ICheckedPermission {
[key: string]: IPermission;
}
export const PROJECT_CHECK_ALL_KEY = 'check-all-project';
export const ENVIRONMENT_CHECK_ALL_KEY = 'check-all-environment';
const useProjectRoleForm = (
initialRoleName = '',
initialRoleDesc = '',
initialCheckedPermissions = {}
) => {
const { permissions } = useProjectRolePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const [roleName, setRoleName] = useState(initialRoleName);
const [roleDesc, setRoleDesc] = useState(initialRoleDesc);
const [checkedPermissions, setCheckedPermissions] =
useState<ICheckedPermission>(initialCheckedPermissions);
const [errors, setErrors] = useState({});
const { validateRole } = useProjectRolesApi();
useEffect(() => {
setRoleName(initialRoleName);
}, [initialRoleName]);
useEffect(() => {
setRoleDesc(initialRoleDesc);
}, [initialRoleDesc]);
const handleInitialCheckedPermissions = (
initialCheckedPermissions: ICheckedPermission
) => {
const formattedInitialCheckedPermissions =
isAllEnvironmentPermissionsChecked(
isAllProjectPermissionsChecked(initialCheckedPermissions)
);
setCheckedPermissions(formattedInitialCheckedPermissions || {});
};
const isAllProjectPermissionsChecked = (
initialCheckedPermissions: ICheckedPermission
) => {
const { project } = permissions;
if (!project || project.length === 0) return;
const isAllChecked = project.every((permission: IPermission) => {
return initialCheckedPermissions[getRoleKey(permission)];
});
if (isAllChecked) {
initialCheckedPermissions[PROJECT_CHECK_ALL_KEY] = true;
} else {
delete initialCheckedPermissions[PROJECT_CHECK_ALL_KEY];
}
return initialCheckedPermissions;
};
const isAllEnvironmentPermissionsChecked = (
initialCheckedPermissions: ICheckedPermission
) => {
const { environments } = permissions;
if (!environments || environments.length === 0) return;
environments.forEach(env => {
const isAllChecked = env.permissions.every(
(permission: IPermission) => {
return initialCheckedPermissions[getRoleKey(permission)];
}
);
const key = `${ENVIRONMENT_CHECK_ALL_KEY}-${env.name}`;
if (isAllChecked) {
initialCheckedPermissions[key] = true;
} else {
delete initialCheckedPermissions[key];
}
});
return initialCheckedPermissions;
};
const getCheckAllKeys = () => {
const { environments } = permissions;
const envKeys = environments.map(env => {
return `${ENVIRONMENT_CHECK_ALL_KEY}-${env.name}`;
});
return [...envKeys, PROJECT_CHECK_ALL_KEY];
};
const handlePermissionChange = (permission: IPermission, type: string) => {
let checkedPermissionsCopy = cloneDeep(checkedPermissions);
if (checkedPermissionsCopy[getRoleKey(permission)]) {
delete checkedPermissionsCopy[getRoleKey(permission)];
} else {
checkedPermissionsCopy[getRoleKey(permission)] = { ...permission };
}
if (type === 'project') {
checkedPermissionsCopy = isAllProjectPermissionsChecked(
checkedPermissionsCopy
);
} else {
checkedPermissionsCopy = isAllEnvironmentPermissionsChecked(
checkedPermissionsCopy
);
}
setCheckedPermissions(checkedPermissionsCopy);
};
const checkAllProjectPermissions = () => {
const { project } = permissions;
const checkedPermissionsCopy = cloneDeep(checkedPermissions);
const checkedAll = checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY];
project.forEach((permission: IPermission, index: number) => {
const lastItem = project.length - 1 === index;
if (checkedAll) {
if (checkedPermissionsCopy[getRoleKey(permission)]) {
delete checkedPermissionsCopy[getRoleKey(permission)];
}
if (lastItem) {
delete checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY];
}
} else {
checkedPermissionsCopy[getRoleKey(permission)] = {
...permission,
};
if (lastItem) {
checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY] = true;
}
}
});
setCheckedPermissions(checkedPermissionsCopy);
};
const checkAllEnvironmentPermissions = (envName: string) => {
const { environments } = permissions;
const checkedPermissionsCopy = cloneDeep(checkedPermissions);
const environmentCheckAllKey = `${ENVIRONMENT_CHECK_ALL_KEY}-${envName}`;
const env = environments.find(env => env.name === envName);
if (!env) return;
const checkedAll = checkedPermissionsCopy[environmentCheckAllKey];
env.permissions.forEach((permission: IPermission, index: number) => {
const lastItem = env.permissions.length - 1 === index;
if (checkedAll) {
if (checkedPermissionsCopy[getRoleKey(permission)]) {
delete checkedPermissionsCopy[getRoleKey(permission)];
}
if (lastItem) {
delete checkedPermissionsCopy[environmentCheckAllKey];
}
} else {
checkedPermissionsCopy[getRoleKey(permission)] = {
...permission,
};
if (lastItem) {
checkedPermissionsCopy[environmentCheckAllKey] = true;
}
}
});
setCheckedPermissions(checkedPermissionsCopy);
};
const getProjectRolePayload = () => {
const checkAllKeys = getCheckAllKeys();
const permissions = Object.keys(checkedPermissions)
.filter(key => {
return !checkAllKeys.includes(key);
})
.map(permission => {
return checkedPermissions[permission];
});
return {
name: roleName,
description: roleDesc,
permissions,
};
};
const NAME_EXISTS_ERROR =
'BadRequestError: There already exists a role with the name';
const validateNameUniqueness = async () => {
const payload = getProjectRolePayload();
try {
await validateRole(payload);
} catch (e) {
if (e.toString().includes(NAME_EXISTS_ERROR)) {
setErrors(prev => ({
...prev,
name: 'There already exists a role with this role name',
}));
}
}
};
const validateName = () => {
if (roleName.length === 0) {
setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
return false;
}
return true;
};
const validatePermissions = () => {
if (Object.keys(checkedPermissions).length === 0) {
setErrors(prev => ({
...prev,
permissions: 'You must include at least one permission.',
}));
return false;
}
return true;
};
const clearErrors = () => {
setErrors({});
};
const getRoleKey = (permission: {
id: number;
environment?: string;
}): string => {
return permission.environment
? `${permission.id}-${permission.environment}`
: `${permission.id}`;
};
return {
roleName,
roleDesc,
setRoleName,
setRoleDesc,
handlePermissionChange,
checkAllProjectPermissions,
checkAllEnvironmentPermissions,
checkedPermissions,
getProjectRolePayload,
validatePermissions,
validateName,
handleInitialCheckedPermissions,
clearErrors,
validateNameUniqueness,
errors,
getRoleKey,
permissions,
};
};
export default useProjectRoleForm;

View File

@ -107,31 +107,6 @@ exports[`renders correctly with permissions 1`] = `
</svg>
</span>
</a>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textSecondary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Delete application"
type="button"
>
<span
className="MuiButton-label"
>
Delete
</span>
</button>
</div>
</div>
</div>
@ -153,377 +128,6 @@ exports[`renders correctly with permissions 1`] = `
</strong>
</p>
</div>
<div>
<div
className="MuiPaper-root makeStyles-tabNav-10 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
>
<div
className="MuiTabs-scroller MuiTabs-fixed"
onScroll={[Function]}
style={
Object {
"marginBottom": null,
"overflow": "hidden",
}
}
>
<div
className="MuiTabs-flexContainer MuiTabs-centered"
onKeyDown={[Function]}
role="tablist"
>
<button
aria-controls="tabpanel-0"
aria-selected={true}
className="MuiButtonBase-root MuiTab-root MuiTab-textColorPrimary Mui-selected"
disabled={false}
id="tab-0"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="tab"
tabIndex={0}
type="button"
>
<span
className="MuiTab-wrapper"
>
Application overview
</span>
<span
className="PrivateTabIndicator-root-11 PrivateTabIndicator-colorPrimary-12 MuiTabs-indicator"
style={Object {}}
/>
</button>
<button
aria-controls="tabpanel-1"
aria-selected={false}
className="MuiButtonBase-root MuiTab-root MuiTab-textColorPrimary"
disabled={false}
id="tab-1"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="tab"
tabIndex={-1}
type="button"
>
<span
className="MuiTab-wrapper"
>
Edit application
</span>
</button>
</div>
</div>
</div>
</div>
<div>
<div
aria-labelledby="wrapped-tab-0"
hidden={false}
id="wrapped-tabpanel-0"
role="tabpanel"
>
<div
className="MuiGrid-root MuiGrid-container"
style={
Object {
"margin": 0,
}
}
>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-6 MuiGrid-grid-xl-6"
>
<h6
className="MuiTypography-root MuiTypography-subtitle1"
style={
Object {
"padding": "1rem 0",
}
}
>
Toggles
</h6>
<hr />
<ul
className="MuiList-root MuiList-padding"
>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M14.4 6l-.24-1.2c-.09-.46-.5-.8-.98-.8H6c-.55 0-1 .45-1 1v15c0 .55.45 1 1 1s1-.45 1-1v-6h5.6l.24 1.2c.09.47.5.8.98.8H19c.55 0 1-.45 1-1V7c0-.55-.45-1-1-1h-4.6z"
/>
</svg>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/projects/default/features/ToggleA/strategies/ToggleA"
onClick={[Function]}
>
ToggleA
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
this is A toggle
</p>
</div>
</li>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
style={
Object {
"color": "red",
}
}
viewBox="0 0 24 24"
>
<path
d="M15.73 3H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3zM12 17.3c-.72 0-1.3-.58-1.3-1.3 0-.72.58-1.3 1.3-1.3.72 0 1.3.58 1.3 1.3 0 .72-.58 1.3-1.3 1.3zm1-4.3h-2V7h2v6z"
/>
</svg>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/projects/default/create-toggle?name=ToggleB"
onClick={[Function]}
>
ToggleB
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
Missing, want to create?
</p>
</div>
</li>
</ul>
</div>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-6 MuiGrid-grid-xl-6"
>
<h6
className="MuiTypography-root MuiTypography-subtitle1"
style={
Object {
"padding": "1rem 0",
}
}
>
Implemented strategies
</h6>
<hr />
<ul
className="MuiList-root MuiList-padding"
>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"
/>
</svg>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/strategies/view/StrategyA"
onClick={[Function]}
>
StrategyA
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
A description
</p>
</div>
</li>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
style={
Object {
"color": "red",
}
}
viewBox="0 0 24 24"
>
<path
d="M15.73 3H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3zM12 17.3c-.72 0-1.3-.58-1.3-1.3 0-.72.58-1.3 1.3-1.3.72 0 1.3.58 1.3 1.3 0 .72-.58 1.3-1.3 1.3zm1-4.3h-2V7h2v6z"
/>
</svg>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/strategies/create"
onClick={[Function]}
>
StrategyB
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
Missing, want to create?
</p>
</div>
</li>
</ul>
</div>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-md-12 MuiGrid-grid-xl-12"
>
<h6
className="MuiTypography-root MuiTypography-subtitle1"
style={
Object {
"padding": "1rem 0",
}
}
>
1
Instances registered
</h6>
<hr />
<ul
className="MuiList-root MuiList-padding"
>
<li
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemAvatar-root"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M23 8c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2z"
/>
</svg>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
instance-1 (4.0)
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
<span>
123.123.123.123
last seen at
<small>
23/02/2017, 15:56:49
</small>
</span>
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
<div
aria-labelledby="wrapped-tab-1"
hidden={true}
id="wrapped-tabpanel-1"
role="tabpanel"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -1,11 +1,21 @@
import { Check } from '@material-ui/icons';
import { Check, Close } from '@material-ui/icons';
import { useStyles } from './CheckMarkBadge.styles';
import classnames from 'classnames';
const CheckMarkBadge = () => {
interface ICheckMarkBadgeProps {
className: string;
type?: string;
}
const CheckMarkBadge = ({ type, className }: ICheckMarkBadgeProps) => {
const styles = useStyles();
return (
<div className={styles.badge}>
<Check className={styles.check} />
<div className={classnames(styles.badge, className)}>
{type === 'error' ? (
<Close className={styles.check} />
) : (
<Check className={styles.check} />
)}
</div>
);
};

View File

@ -0,0 +1,19 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
backgroundColor: theme.palette.primary.main,
padding: '1rem',
borderRadius: '3px',
position: 'relative',
},
code: { wordBreak: 'break-all', color: '#fff', whiteSpace: 'pre-wrap' },
icon: {
fill: '#fff',
},
iconButton: {
position: 'absolute',
bottom: '10px',
right: '20px',
},
}));

View File

@ -0,0 +1,16 @@
import { useStyles } from './Codebox.styles';
interface ICodeboxProps {
text: string;
}
const Codebox = ({ text }: ICodeboxProps) => {
const styles = useStyles();
return (
<div className={styles.container}>
<pre className={styles.code}>{text}</pre>
</div>
);
};
export default Codebox;

View File

@ -0,0 +1,75 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
minHeight: '80vh',
width: '100%',
display: 'flex',
margin: '0 auto',
[theme.breakpoints.down(900)]: {
flexDirection: 'column',
},
},
sidebar: {
backgroundColor: theme.palette.primary.light,
padding: '2rem',
width: '35%',
borderTopLeftRadius: '12.5px',
borderBottomLeftRadius: '12.5px',
[theme.breakpoints.down(900)]: {
width: '100%',
borderBottomLeftRadius: '0',
borderTopRightRadius: '12.5px',
},
[theme.breakpoints.down(500)]: {
padding: '2rem 1rem',
},
},
title: {
color: '#fff',
marginBottom: '1rem',
fontWeight: 'normal',
},
subtitle: {
color: '#fff',
marginBottom: '1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontWeight: 'normal',
},
description: {
color: '#fff',
},
linkContainer: {
margin: '1.5rem 0',
display: 'flex',
alignItems: 'center',
},
linkIcon: {
marginRight: '0.5rem',
color: '#fff',
},
documentationLink: {
color: '#fff',
display: 'block',
},
formContent: {
backgroundColor: '#fff',
display: 'flex',
flexDirection: 'column',
padding: '2rem',
width: '65%',
borderTopRightRadius: '12.5px',
borderBottomRightRadius: '12.5px',
[theme.breakpoints.down(900)]: {
width: '100%',
borderBottomLeftRadius: '12.5px',
borderTopRightRadius: '0',
},
[theme.breakpoints.down(500)]: {
padding: '2rem 1rem',
},
},
icon: { fill: '#fff' },
}));

View File

@ -0,0 +1,98 @@
import { useStyles } from './FormTemplate.styles';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import Codebox from '../Codebox/Codebox';
import { IconButton, useMediaQuery } from '@material-ui/core';
import { FileCopy } from '@material-ui/icons';
import ConditionallyRender from '../ConditionallyRender';
import Loader from '../Loader/Loader';
import copy from 'copy-to-clipboard';
import useToast from '../../../hooks/useToast';
interface ICreateProps {
title: string;
description: string;
documentationLink: string;
loading?: boolean;
formatApiCode: () => string;
}
const FormTemplate: React.FC<ICreateProps> = ({
title,
description,
children,
documentationLink,
loading,
formatApiCode,
}) => {
// @ts-ignore-next-line
const { setToastData } = useToast();
const styles = useStyles();
const smallScreen = useMediaQuery(`(max-width:${900}px)`);
const copyCommand = () => {
if (copy(formatApiCode())) {
setToastData({
title: 'Successfully copied the command',
text: 'The command should now be automatically copied to your clipboard',
autoHideDuration: 6000,
type: 'success',
show: true,
});
} else {
setToastData({
title: 'Could not copy the command',
text: 'Sorry, but we could not copy the command.',
autoHideDuration: 6000,
type: 'error',
show: true,
});
}
};
return (
<section className={styles.container}>
<aside className={styles.sidebar}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.description}>{description}</p>
<div className={styles.linkContainer}>
<MenuBookIcon className={styles.linkIcon} />
<a
className={styles.documentationLink}
href={documentationLink}
rel="noopener noreferrer"
target="_blank"
>
Learn more
</a>
</div>
<ConditionallyRender
condition={!smallScreen}
show={
<>
<h3 className={styles.subtitle}>
API Command{' '}
<IconButton
className={styles.iconButton}
onClick={copyCommand}
>
<FileCopy className={styles.icon} />
</IconButton>
</h3>
<Codebox text={formatApiCode()} />
</>
}
/>
</aside>
<div className={styles.formContent}>
<ConditionallyRender
condition={loading || false}
show={<Loader />}
elseShow={<>{children}</>}
/>{' '}
</div>
</section>
);
};
export default FormTemplate;

View File

@ -1,4 +1,4 @@
import { UPDATE_FEATURE } from '../../../providers/AccessProvider/permissions';
import { CREATE_FEATURE_STRATEGY } from '../../../providers/AccessProvider/permissions';
import ConditionallyRender from '../../ConditionallyRender';
import PermissionButton from '../../PermissionButton/PermissionButton';
import StringTruncator from '../../StringTruncator/StringTruncator';
@ -49,8 +49,9 @@ const NoItemsStrategies = ({
show={
<PermissionButton
variant="contained"
permission={UPDATE_FEATURE}
permission={CREATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={envName}
color="primary"
onClick={onClick}
>

View File

@ -11,7 +11,7 @@ const PageContent = ({
headerContent,
disablePadding = false,
disableBorder = false,
bodyClass = undefined,
bodyClass = '',
...rest
}) => {
const styles = useStyles();

View File

@ -1,16 +1,17 @@
import { Button, Tooltip } from '@material-ui/core';
import { OverridableComponent } from '@material-ui/core/OverridableComponent';
import { Lock } from '@material-ui/icons';
import { useContext } from 'react';
import AccessContext from '../../../contexts/AccessContext';
import ConditionallyRender from '../ConditionallyRender';
interface IPermissionIconButtonProps extends OverridableComponent<any> {
permission: string;
tooltip: string;
interface IPermissionIconButtonProps
extends React.HTMLProps<HTMLButtonElement> {
permission: string | string[];
tooltip?: string;
onClick?: (e: any) => void;
disabled?: boolean;
projectId?: string;
environmentId?: string;
}
const PermissionButton: React.FC<IPermissionIconButtonProps> = ({
@ -20,13 +21,38 @@ const PermissionButton: React.FC<IPermissionIconButtonProps> = ({
children,
disabled,
projectId,
environmentId,
...rest
}) => {
const { hasAccess } = useContext(AccessContext);
let access;
const access = projectId
? hasAccess(permission, projectId)
: hasAccess(permission);
const handleAccess = () => {
let access;
if (Array.isArray(permission)) {
access = permission.some(permission => {
if (projectId && environmentId) {
return hasAccess(permission, projectId, environmentId);
} else if (projectId) {
return hasAccess(permission, projectId);
} else {
return hasAccess(permission);
}
});
} else {
if (projectId && environmentId) {
access = hasAccess(permission, projectId, environmentId);
} else if (projectId) {
access = hasAccess(permission, projectId);
} else {
access = hasAccess(permission);
}
}
return access;
};
access = handleAccess();
const tooltipText = access
? tooltip

View File

@ -1,14 +1,15 @@
import { IconButton, Tooltip } from '@material-ui/core';
import { OverridableComponent } from '@material-ui/core/OverridableComponent';
import { useContext } from 'react';
import AccessContext from '../../../contexts/AccessContext';
interface IPermissionIconButtonProps extends OverridableComponent<any> {
interface IPermissionIconButtonProps
extends React.HTMLProps<HTMLButtonElement> {
permission: string;
Icon?: React.ElementType;
tooltip: string;
onClick?: (e: any) => void;
projectId?: string;
environmentId?: string;
}
const PermissionIconButton: React.FC<IPermissionIconButtonProps> = ({
@ -18,13 +19,19 @@ const PermissionIconButton: React.FC<IPermissionIconButtonProps> = ({
onClick,
projectId,
children,
environmentId,
...rest
}) => {
const { hasAccess } = useContext(AccessContext);
let access;
const access = projectId
? hasAccess(permission, projectId)
: hasAccess(permission);
if (projectId && environmentId) {
access = hasAccess(permission, projectId, environmentId);
} else if (projectId) {
access = hasAccess(permission, projectId);
} else {
access = hasAccess(permission);
}
const tooltipText = access
? tooltip || ''

View File

@ -9,6 +9,7 @@ interface IPermissionSwitchProps extends OverridableComponent<any> {
onChange?: (e: any) => void;
disabled?: boolean;
projectId?: string;
environmentId?: string;
checked: boolean;
}
@ -17,14 +18,21 @@ const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
tooltip = '',
disabled,
projectId,
environmentId,
checked,
onChange,
...rest
}) => {
const { hasAccess } = useContext(AccessContext);
const access = projectId
? hasAccess(permission, projectId)
: hasAccess(permission);
let access;
if (projectId && environmentId) {
access = hasAccess(permission, projectId, environmentId);
} else if (projectId) {
access = hasAccess(permission, projectId);
} else {
access = hasAccess(permission);
}
const tooltipText = access
? tooltip

View File

@ -10,6 +10,7 @@ interface IResponsiveButtonProps {
disabled?: boolean;
permission?: string;
projectId?: string;
environmentId?: string;
maxWidth: string;
}
@ -21,6 +22,7 @@ const ResponsiveButton: React.FC<IResponsiveButtonProps> = ({
disabled = false,
children,
permission,
environmentId,
projectId,
...rest
}) => {
@ -35,6 +37,7 @@ const ResponsiveButton: React.FC<IResponsiveButtonProps> = ({
onClick={onClick}
permission={permission}
projectId={projectId}
environmentId={environmentId}
data-loading
{...rest}
>
@ -49,6 +52,7 @@ const ResponsiveButton: React.FC<IResponsiveButtonProps> = ({
color="primary"
variant="contained"
disabled={disabled}
environmentId={environmentId}
data-loading
{...rest}
>

View File

@ -1,45 +0,0 @@
import { Portal, Snackbar } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { useCommonStyles } from '../../../common.styles';
import { IToast } from '../../../hooks/useToast';
import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
interface IToastProps extends IToast {
onClose: () => void;
autoHideDuration?: number;
}
const Toast = ({
show,
onClose,
type,
text,
autoHideDuration = 6000,
}: IToastProps) => {
const styles = useCommonStyles();
return (
<Portal>
<AnimateOnMount
mounted={show}
start={styles.fadeInBottomStartWithoutFixed}
enter={styles.fadeInBottomEnter}
leave={styles.fadeInBottomLeave}
container={styles.fullWidth}
>
<Snackbar
open={show}
onClose={onClose}
autoHideDuration={autoHideDuration}
style={{ bottom: '40px' }}
>
<Alert variant="filled" severity={type} onClose={onClose}>
{text}
</Alert>
</Snackbar>
</AnimateOnMount>
</Portal>
);
};
export default Toast;

View File

@ -0,0 +1,66 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
width: '450px',
background: '#fff',
boxShadow: '2px 2px 4px rgba(0,0,0,0.4)',
zIndex: 500,
margin: '0 auto',
borderRadius: '12.5px',
padding: '2rem',
},
innerContainer: {
position: 'relative',
},
starting: {
opacity: 0,
},
headerContainer: {
display: 'flex',
alignItems: 'center',
},
confettiContainer: {
position: 'relative',
maxWidth: '600px',
margin: '0 auto',
display: 'flex',
},
textContainer: {
marginLeft: '1rem',
},
headerStyles: {
fontWeight: 'normal',
margin: 0,
marginBottom: '0.5rem',
},
createdContainer: {
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
},
anim: {
animation: `$drop 10s 3s`,
},
checkMark: {
width: '65px',
height: '65px',
},
buttonStyle: {
position: 'absolute',
top: '-33px',
right: '-29px',
},
'@keyframes drop': {
'0%': {
opacity: '0%',
top: '0%',
},
'10%': {
opacity: '100%',
},
'100%': {
top: '100%',
},
},
}));

View File

@ -0,0 +1,94 @@
import { useStyles } from './Toast.styles';
import classnames from 'classnames';
import { useContext } from 'react';
import { IconButton } from '@material-ui/core';
import CheckMarkBadge from '../../CheckmarkBadge/CheckMarkBadge';
import UIContext, { IToastData } from '../../../../contexts/UIContext';
import ConditionallyRender from '../../ConditionallyRender';
import Close from '@material-ui/icons/Close';
const Toast = ({ title, text, type, confetti }: IToastData) => {
// @ts-ignore
const { setToast } = useContext(UIContext);
const styles = useStyles();
const confettiColors = ['#d13447', '#ffbf00', '#263672'];
const confettiAmount = 200;
const getRandomNumber = (input: number) => {
return Math.floor(Math.random() * input) + 1;
};
const renderConfetti = () => {
const elements = new Array(confettiAmount).fill(1);
const styledElements = elements.map((el, index) => {
const width = getRandomNumber(8);
const length = getRandomNumber(100);
const style = {
position: 'absolute' as 'absolute',
width: `${width}px`,
height: `${width * 0.4}px`,
backgroundColor: confettiColors[getRandomNumber(2)],
left: `${length}%`,
transform: `rotate(${getRandomNumber(101)}deg)`,
animationDelay: `${getRandomNumber(5)}s`,
animationDuration: `${getRandomNumber(3)}s`,
animationEase: `${getRandomNumber(2)}s`,
};
return (
<div
key={index}
style={style}
className={classnames(styles.starting, styles.anim)}
/>
);
});
return styledElements;
};
const hide = () => {
setToast((prev: IToastData) => ({ ...prev, show: false }));
};
return (
<div className={styles.container}>
<div className={styles.innerContainer}>
<div className={styles.confettiContainer}>
{confetti && renderConfetti()}
<div className={styles.createdContainer}>
<div className={styles.headerContainer}>
<div>
<CheckMarkBadge
type={type}
className={styles.checkMark}
/>
</div>
<div className={styles.textContainer}>
<h3 className={styles.headerStyles}>{title}</h3>
<ConditionallyRender
condition={Boolean(text)}
show={<p>{text}</p>}
/>
</div>
</div>
<IconButton
color="primary"
onClick={hide}
className={styles.buttonStyle}
>
<Close />
</IconButton>
</div>
</div>
</div>
</div>
);
};
export default Toast;

View File

@ -0,0 +1,44 @@
import { Portal } from '@material-ui/core';
import { useContext, useEffect } from 'react';
import { useCommonStyles } from '../../../common.styles';
import UIContext, { IToastData } from '../../../contexts/UIContext';
import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
import Toast from './Toast/Toast';
const ToastRenderer = () => {
// @ts-ignore-next-line
const { toastData, setToast } = useContext(UIContext);
const styles = useCommonStyles();
const hide = () => {
setToast((prev: IToastData) => ({ ...prev, show: false }));
};
useEffect(() => {
if (!toastData.autoHideDuration) return;
let timeout = setTimeout(() => {
hide();
}, toastData.autoHideDuration);
return () => {
clearTimeout(timeout);
};
/* eslint-disable-next-line */
}, [toastData?.show]);
return (
<Portal>
<AnimateOnMount
mounted={toastData?.show}
start={styles.fadeInBottomStartWithoutFixed}
enter={styles.fadeInBottomEnter}
leave={styles.fadeInBottomLeave}
container={styles.fullWidth}
>
<Toast {...toastData} />
</AnimateOnMount>
</Portal>
);
};
export default ToastRenderer;

View File

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

View File

@ -14,6 +14,7 @@ import EnvironmentTypeSelector from '../form/EnvironmentTypeSelector/Environment
import Input from '../../common/Input/Input';
import useEnvironments from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import { Alert } from '@material-ui/lab';
import useProjectRolePermissions from '../../../hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
const NAME_EXISTS_ERROR = 'Error: Environment';
@ -27,7 +28,8 @@ const CreateEnvironment = () => {
const { validateEnvName, createEnvironment, loading } = useEnvironmentApi();
const { environments } = useEnvironments();
const ref = useLoading(loading);
const { toast, setToastData } = useToast();
const { setToastApiError } = useToast();
const { refetch } = useProjectRolePermissions();
const handleTypeChange = (event: React.FormEvent<HTMLInputElement>) => {
setType(event.currentTarget.value);
@ -72,9 +74,10 @@ const CreateEnvironment = () => {
try {
await createEnvironment(environment);
refetch();
setCreateSucceess(true);
} catch (e) {
setToastData({ show: true, type: 'error', text: e.toString() });
setToastApiError(e.toString());
}
}
};
@ -83,94 +86,107 @@ const CreateEnvironment = () => {
<PageContent headerContent={<HeaderTitle title="Create environment" />}>
<ConditionallyRender
condition={createSuccess}
show={
<CreateEnvironmentSuccess
name={envName}
type={type}
/>
}
show={<CreateEnvironmentSuccess name={envName} type={type} />}
elseShow={
<ConditionallyRender condition={canCreateMoreEnvs} show={
<div ref={ref}>
<p className={styles.helperText} data-loading>
Environments allow you to manage your product
lifecycle from local development through production.
Your projects and feature toggles are accessible in
all your environments, but they can take different
configurations per environment. This means that you
can enable a feature toggle in a development or test
environment without enabling the feature toggle in
the production environment.
</p>
<ConditionallyRender
condition={canCreateMoreEnvs}
show={
<div ref={ref}>
<p className={styles.helperText} data-loading>
Environments allow you to manage your
product lifecycle from local development
through production. Your projects and
feature toggles are accessible in all your
environments, but they can take different
configurations per environment. This means
that you can enable a feature toggle in a
development or test environment without
enabling the feature toggle in the
production environment.
</p>
<form onSubmit={handleSubmit}>
<FormControl component="fieldset">
<h3 className={styles.formHeader} data-loading>
Environment Id and name
</h3>
<form onSubmit={handleSubmit}>
<FormControl component="fieldset">
<h3
className={styles.formHeader}
data-loading
>
Environment Id and name
</h3>
<div
data-loading
className={
styles.environmentDetailsContainer
}
>
<div
data-loading
className={
styles.environmentDetailsContainer
}
>
<p>
Unique env name for SDK
configurations.
</p>
<Input
label="Environment Id"
onFocus={clearNameError}
placeholder="A unique name for your environment"
onBlur={validateEnvironmentName}
error={Boolean(nameError)}
errorText={nameError}
value={envName}
onChange={handleEnvNameChange}
className={styles.inputField}
/>
</div>
<EnvironmentTypeSelector
onChange={handleTypeChange}
value={type}
/>
</FormControl>
<div className={styles.btnContainer}>
<Button
className={styles.submitButton}
variant="contained"
color="primary"
type="submit"
data-loading
>
Submit
</Button>{' '}
<Button
className={styles.submitButton}
variant="outlined"
color="secondary"
onClick={goBack}
data-loading
>
Cancel
</Button>
</div>
</form>
</div>
}
elseShow={
<>
<Alert severity="error">
<p>
Unique env name for SDK configurations.
Currently Unleash does not support more
than 5 environments. If you need more
please reach out.
</p>
<Input
label="Environment Id"
onFocus={clearNameError}
placeholder="A unique name for your environment"
onBlur={validateEnvironmentName}
error={Boolean(nameError)}
errorText={nameError}
value={envName}
onChange={handleEnvNameChange}
className={styles.inputField}
/>
</div>
<EnvironmentTypeSelector
onChange={handleTypeChange}
value={type}
/>
</FormControl>
<div className={styles.btnContainer}>
</Alert>
<br />
<Button
className={styles.submitButton}
onClick={goBack}
variant="contained"
color="primary"
type="submit"
data-loading
>
Submit
</Button>{' '}
<Button
className={styles.submitButton}
variant="outlined"
color="secondary"
onClick={goBack}
data-loading
>
Cancel
Go back
</Button>
</div>
</form>
</div>
} elseShow={
<>
<Alert severity="error">
<p>Currently Unleash does not support more than 5 environments. If you need more please reach out.</p>
</Alert>
<br />
<Button onClick={goBack} variant="contained" color="primary">Go back</Button>
</>
} />
</>
}
/>
}
/>
{toast}
</PageContent>
);
};

View File

@ -19,6 +19,7 @@ import EnvironmentListItem from './EnvironmentListItem/EnvironmentListItem';
import { mutate } from 'swr';
import EditEnvironment from '../EditEnvironment/EditEnvironment';
import EnvironmentToggleConfirm from './EnvironmentToggleConfirm/EnvironmentToggleConfirm';
import useProjectRolePermissions from '../../../hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
const EnvironmentList = () => {
const defaultEnv = {
@ -31,6 +32,8 @@ const EnvironmentList = () => {
};
const { environments, refetch } = useEnvironments();
const [editEnvironment, setEditEnvironment] = useState(false);
const { refetch: refetchProjectRolePermissions } =
useProjectRolePermissions();
const [selectedEnv, setSelectedEnv] = useState(defaultEnv);
const [delDialog, setDeldialogue] = useState(false);
@ -38,7 +41,7 @@ const EnvironmentList = () => {
const [confirmName, setConfirmName] = useState('');
const history = useHistory();
const { toast, setToastData } = useToast();
const { setToastApiError, setToastData } = useToast();
const {
deleteEnvironment,
changeSortOrder,
@ -72,11 +75,7 @@ const EnvironmentList = () => {
await sortOrderAPICall(sortOrder);
refetch();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
}
};
@ -84,28 +83,21 @@ const EnvironmentList = () => {
try {
await changeSortOrder(sortOrder);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
}
};
const handleDeleteEnvironment = async () => {
try {
await deleteEnvironment(selectedEnv.name);
refetchProjectRolePermissions();
setToastData({
show: true,
type: 'success',
text: 'Successfully deleted environment.',
title: 'Project environment deleted',
text: 'You have successfully deleted the project environment.',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
} finally {
setDeldialogue(false);
setSelectedEnv(defaultEnv);
@ -125,17 +117,14 @@ const EnvironmentList = () => {
try {
await toggleEnvironmentOn(selectedEnv.name);
setToggleDialog(false);
setToastData({
show: true,
type: 'success',
text: 'Successfully enabled environment.',
title: 'Project environment enabled',
text: 'Your environment is enabled',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
} finally {
refetch();
}
@ -146,16 +135,12 @@ const EnvironmentList = () => {
await toggleEnvironmentOff(selectedEnv.name);
setToggleDialog(false);
setToastData({
show: true,
type: 'success',
text: 'Successfully disabled environment.',
title: 'Project environment disabled',
text: 'Your environment is disabled.',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
} finally {
refetch();
}
@ -223,7 +208,6 @@ const EnvironmentList = () => {
setToggleDialog={setToggleDialog}
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
/>
{toast}
</PageContent>
);
};

View File

@ -148,7 +148,10 @@ const FeatureToggleListItem = ({
<PermissionIconButton
permission={UPDATE_FEATURE}
projectId={project}
disabled={!projectExists()}
disabled={
!hasAccess(UPDATE_FEATURE, project) ||
!projectExists()
}
onClick={reviveFeature}
>
<Undo />

View File

@ -14,7 +14,7 @@ import FeatureType from '../../FeatureView2/FeatureType/FeatureType';
import classNames from 'classnames';
import CreatedAt from './CreatedAt';
import useProject from '../../../../hooks/api/getters/useProject/useProject';
import { UPDATE_FEATURE } from '../../../providers/AccessProvider/permissions';
import { UPDATE_FEATURE_ENVIRONMENT } from '../../../providers/AccessProvider/permissions';
import PermissionSwitch from '../../../common/PermissionSwitch/PermissionSwitch';
import { Link } from 'react-router-dom';
import { ENVIRONMENT_STRATEGY_ERROR } from '../../../../constants/apiErrors';
@ -37,7 +37,7 @@ const FeatureToggleListNewItem = ({
projectId,
createdAt,
}: IFeatureToggleListNewItemProps) => {
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const { toggleFeatureByEnvironment } = useToggleFeatureByEnv(
projectId,
name
@ -65,8 +65,8 @@ const FeatureToggleListNewItem = ({
toggleFeatureByEnvironment(env.name, env.enabled)
.then(() => {
setToastData({
show: true,
type: 'success',
title: 'Updated toggle status',
text: 'Successfully updated toggle status.',
});
refetch();
@ -75,11 +75,7 @@ const FeatureToggleListNewItem = ({
if (e.message === ENVIRONMENT_STRATEGY_ERROR) {
setShowInfoBox(true);
} else {
setToastData({
show: true,
type: 'error',
text: e.message,
});
setToastApiError(e.message);
}
});
};
@ -149,8 +145,9 @@ const FeatureToggleListNewItem = ({
<span data-loading style={{ display: 'block' }}>
<PermissionSwitch
checked={env.enabled}
environmentId={env.name}
projectId={projectId}
permission={UPDATE_FEATURE}
permission={UPDATE_FEATURE_ENVIRONMENT}
ref={ref}
onClick={() => {
handleToggle(env);
@ -162,7 +159,6 @@ const FeatureToggleListNewItem = ({
);
})}
</TableRow>
{toast}
<EnvironmentStrategyDialog
open={showInfoBox}
onClose={closeInfoBox}

View File

@ -69,7 +69,7 @@ const FeatureView = ({
const { hasAccess } = useContext(AccessContext);
const { project } = featureToggle || {};
const { changeFeatureProject } = useFeatureApi();
const { toast, setToastData } = useToast();
const { setToastApiError, setToastData } = useToast();
const archive = !Boolean(isFeatureView);
const { uiConfig } = useUiConfig();
@ -249,17 +249,13 @@ const FeatureView = ({
.then(() => {
fetchFeatureToggles();
setToastData({
show: true,
title: 'Updated toggle project',
type: 'success',
text: 'Successfully updated toggle project.',
});
})
.catch(e => {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
});
};
@ -435,7 +431,6 @@ const FeatureView = ({
}}
onClose={() => setDelDialog(false)}
/>
{toast}
</Paper>
);
};

View File

@ -30,7 +30,7 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
const { addTagToFeature, loading } = useFeatureApi();
const { refetch } = useTags(featureId);
const [errors, setErrors] = useState({ tagError: '' });
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const [tag, setTag] = useState(DEFAULT_TAG);
const onCancel = () => {
@ -58,10 +58,12 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
refetch();
setToastData({
type: 'success',
show: true,
text: 'Successfully created tag',
title: 'Added tag to toggle',
text: 'We successfully added a tag to your toggle',
confetti: true,
});
} catch (e) {
setToastApiError(e.message);
setErrors({ tagError: e.message });
}
};
@ -108,7 +110,6 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
</form>
</>
</Dialogue>
{toast}
</>
);
};

View File

@ -2,16 +2,15 @@ import { useParams } from 'react-router';
import { ENVIRONMENT_STRATEGY_ERROR } from '../../../../../../constants/apiErrors';
import useFeatureApi from '../../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useFeature from '../../../../../../hooks/api/getters/useFeature/useFeature';
import { TSetToastData } from '../../../../../../hooks/useToast';
import useToast from '../../../../../../hooks/useToast';
import { IFeatureEnvironment } from '../../../../../../interfaces/featureToggle';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
import PermissionSwitch from '../../../../../common/PermissionSwitch/PermissionSwitch';
import StringTruncator from '../../../../../common/StringTruncator/StringTruncator';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import { UPDATE_FEATURE_ENVIRONMENT } from '../../../../../providers/AccessProvider/permissions';
interface IFeatureOverviewEnvSwitchProps {
env: IFeatureEnvironment;
setToastData: TSetToastData;
callback?: () => void;
text?: string;
showInfoBox?: () => void;
@ -19,7 +18,6 @@ interface IFeatureOverviewEnvSwitchProps {
const FeatureOverviewEnvSwitch = ({
env,
setToastData,
callback,
text,
showInfoBox,
@ -28,14 +26,15 @@ const FeatureOverviewEnvSwitch = ({
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const { refetch } = useFeature(projectId, featureId);
const { setToastData, setToastApiError } = useToast();
const handleToggleEnvironmentOn = async () => {
try {
await toggleFeatureEnvironmentOn(projectId, featureId, env.name);
setToastData({
type: 'success',
show: true,
text: 'Successfully turned environment on.',
title: 'Environment turned on',
text: 'Successfully turned environment on. Strategies are executing in this environment.',
});
refetch();
if (callback) {
@ -45,11 +44,7 @@ const FeatureOverviewEnvSwitch = ({
if (e.message === ENVIRONMENT_STRATEGY_ERROR) {
showInfoBox(true);
} else {
setToastData({
show: true,
type: 'error',
text: e.message,
});
setToastApiError(e.message);
}
}
};
@ -59,19 +54,15 @@ const FeatureOverviewEnvSwitch = ({
await toggleFeatureEnvironmentOff(projectId, featureId, env.name);
setToastData({
type: 'success',
show: true,
text: 'Successfully turned environment off.',
title: 'Environment turned off',
text: 'Successfully turned environment off. Strategies are no longer executing in this environment.',
});
refetch();
if (callback) {
callback();
}
} catch (e: any) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
@ -97,10 +88,11 @@ const FeatureOverviewEnvSwitch = ({
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<PermissionSwitch
permission={UPDATE_FEATURE}
permission={UPDATE_FEATURE_ENVIRONMENT}
projectId={projectId}
checked={env.enabled}
onChange={toggleEnvironment}
environmentId={env.name}
tooltip={''}
/>
{content}

View File

@ -3,7 +3,6 @@ import { useState } from 'react';
import { useParams } from 'react-router';
import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import useToast from '../../../../../hooks/useToast';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import EnvironmentStrategyDialog from '../../../../common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
import FeatureOverviewEnvSwitch from './FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch';
@ -14,7 +13,7 @@ const FeatureOverviewEnvSwitches = () => {
const { featureId, projectId } = useParams<IFeatureViewParams>();
useFeatureApi();
const { feature } = useFeature(projectId, featureId);
const { toast, setToastData } = useToast();
const [showInfoBox, setShowInfoBox] = useState(false);
const [environmentName, setEnvironmentName] = useState('');
@ -28,7 +27,6 @@ const FeatureOverviewEnvSwitches = () => {
<FeatureOverviewEnvSwitch
key={env.name}
env={env}
setToastData={setToastData}
showInfoBox={() => {
setEnvironmentName(env.name);
setShowInfoBox(true);
@ -49,7 +47,6 @@ const FeatureOverviewEnvSwitches = () => {
</h3>
</Tooltip>
{renderEnvironmentSwitches()}
{toast}
<EnvironmentStrategyDialog
open={showInfoBox}
onClose={closeInfoBox}

View File

@ -22,7 +22,7 @@ import DisabledIndicator from '../../../../../common/DisabledIndicator/DisabledI
import EnvironmentIcon from '../../../../../common/EnvironmentIcon/EnvironmentIcon';
import PermissionButton from '../../../../../common/PermissionButton/PermissionButton';
import StringTruncator from '../../../../../common/StringTruncator/StringTruncator';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import { CREATE_FEATURE_STRATEGY } from '../../../../../providers/AccessProvider/permissions';
import { useStyles } from './FeatureOverviewEnvironment.styles';
import FeatureOverviewEnvironmentBody from './FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody';
@ -108,7 +108,9 @@ const FeatureOverviewEnvironment = ({
</div>
<div className={styles.container}>
<PermissionButton
permission={UPDATE_FEATURE}
permission={CREATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={env.name}
onClick={() => history.push(strategiesLink)}
className={styles.addStrategyButton}
>

View File

@ -1,14 +1,13 @@
import { useParams, useHistory } from 'react-router-dom';
import { useParams, useHistory, Link } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../../../../interfaces/params';
import ConditionallyRender from '../../../../../../common/ConditionallyRender';
import NoItemsStrategies from '../../../../../../common/NoItems/NoItemsStrategies/NoItemsStrategies';
import FeatureOverviewEnvironmentStrategies from '../FeatureOverviewEnvironmentStrategies/FeatureOverviewEnvironmentStrategies';
import { useStyles } from '../FeatureOverviewEnvironment.styles';
import { IFeatureEnvironment } from '../../../../../../../interfaces/featureToggle';
import { UPDATE_FEATURE } from '../../../../../../providers/AccessProvider/permissions';
import ResponsiveButton from '../../../../../../common/ResponsiveButton/ResponsiveButton';
import { Add } from '@material-ui/icons';
import { CREATE_FEATURE_STRATEGY } from '../../../../../../providers/AccessProvider/permissions';
import { useContext } from 'react';
import AccessContext from '../../../../../../../contexts/AccessContext';
interface IFeatureOverviewEnvironmentBodyProps {
getOverviewText: () => string;
@ -22,6 +21,7 @@ const FeatureOverviewEnvironmentBody = ({
const { projectId, featureId } = useParams<IFeatureViewParams>();
const styles = useStyles();
const history = useHistory();
const { hasAccess } = useContext(AccessContext);
const strategiesLink = `/projects/${projectId}/features2/${featureId}/strategies?environment=${featureEnvironment?.name}&addStrategy=true`;
if (!featureEnvironment) return null;
@ -41,16 +41,20 @@ const FeatureOverviewEnvironmentBody = ({
condition={featureEnvironment?.strategies.length > 0}
show={
<>
<div className={styles.linkContainer}>
<ResponsiveButton
Icon={Add}
onClick={() => history.push(strategiesLink)}
maxWidth="700px"
permission={UPDATE_FEATURE}
>
Add strategy
</ResponsiveButton>
</div>
<ConditionallyRender
condition={hasAccess(
CREATE_FEATURE_STRATEGY,
projectId,
featureEnvironment.name
)}
show={
<div className={styles.linkContainer}>
<Link to={strategiesLink}>
Edit strategies
</Link>
</div>
}
/>
<FeatureOverviewEnvironmentStrategies
strategies={featureEnvironment?.strategies}
environmentName={featureEnvironment.name}

View File

@ -78,7 +78,7 @@ const FeatureOverviewMetaData = () => {
condition={tags.length > 0}
show={
<div className={styles.paddingContainerBottom}>
<FeatureOverviewTags />
<FeatureOverviewTags projectId={projectId} />
</div>
}
/>

View File

@ -14,11 +14,18 @@ import useFeatureApi from '../../../../../../hooks/api/actions/useFeatureApi/use
import Dialogue from '../../../../../common/Dialogue';
import { ITag } from '../../../../../../interfaces/tags';
import useToast from '../../../../../../hooks/useToast';
import { DELETE_TAG } from '../../../../../providers/AccessProvider/permissions';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
import AccessContext from '../../../../../../contexts/AccessContext';
const FeatureOverviewTags = () => {
interface IFeatureOverviewTagsProps extends React.HTMLProps<HTMLButtonElement> {
projectId: string;
}
const FeatureOverviewTags: React.FC<IFeatureOverviewTagsProps> = ({
projectId,
...rest
}) => {
const [showDelDialog, setShowDelDialog] = useState(false);
const [selectedTag, setSelectedTag] = useState<ITag>({
value: '',
@ -29,9 +36,9 @@ const FeatureOverviewTags = () => {
const { tags, refetch } = useTags(featureId);
const { tagTypes } = useTagTypes();
const { deleteTagFromFeature } = useFeatureApi();
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const { hasAccess } = useContext(AccessContext);
const canDeleteTag = hasAccess(DELETE_TAG);
const canDeleteTag = hasAccess(UPDATE_FEATURE, projectId);
const handleDelete = async () => {
try {
@ -43,15 +50,11 @@ const FeatureOverviewTags = () => {
refetch();
setToastData({
type: 'success',
show: true,
title: 'Tag deleted',
text: 'Successfully deleted tag',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
@ -114,7 +117,7 @@ const FeatureOverviewTags = () => {
);
return (
<div className={styles.container}>
<div className={styles.container} {...rest}>
<Dialogue
open={showDelDialog}
onClose={() => {
@ -136,7 +139,6 @@ const FeatureOverviewTags = () => {
elseShow={<p data-loading>No tags to display</p>}
/>
</div>
{toast}
</div>
);
};

View File

@ -19,7 +19,7 @@ const FeatureSettingsMetadata = () => {
const [description, setDescription] = useState(feature.description);
const [type, setType] = useState(feature.type);
const editable = hasAccess(UPDATE_FEATURE, projectId);
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const [dirty, setDirty] = useState(false);
const { patchFeatureToggle } = useFeatureApi();
@ -48,18 +48,14 @@ const FeatureSettingsMetadata = () => {
const patch = createPatch();
await patchFeatureToggle(projectId, featureId, patch);
setToastData({
show: true,
title: 'Updated metadata',
type: 'success',
text: 'Successfully updated feature toggle metadata',
});
setDirty(false);
refetch();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
}
};
@ -95,8 +91,6 @@ const FeatureSettingsMetadata = () => {
</PermissionButton>
}
/>
{toast}
</>
);
};

View File

@ -6,15 +6,12 @@ import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import useUser from '../../../../../hooks/api/getters/useUser/useUser';
import useToast from '../../../../../hooks/useToast';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import { projectFilterGenerator } from '../../../../../utils/project-filter-generator';
import {
CREATE_FEATURE,
UPDATE_FEATURE,
} from '../../../../providers/AccessProvider/permissions';
import { MOVE_FEATURE_TOGGLE } from '../../../../providers/AccessProvider/permissions';
import ConditionallyRender from '../../../../common/ConditionallyRender';
import PermissionButton from '../../../../common/PermissionButton/PermissionButton';
import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect';
import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm';
import { IPermission } from '../../../../../interfaces/user';
const FeatureSettingsProject = () => {
const { hasAccess } = useContext(AccessContext);
@ -23,10 +20,10 @@ const FeatureSettingsProject = () => {
const [project, setProject] = useState(feature.project);
const [dirty, setDirty] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const editable = hasAccess(UPDATE_FEATURE, projectId);
const editable = hasAccess(MOVE_FEATURE_TOGGLE, projectId);
const { permissions } = useUser();
const { changeFeatureProject } = useFeatureApi();
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const history = useHistory();
useEffect(() => {
@ -38,13 +35,24 @@ const FeatureSettingsProject = () => {
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [project]);
useEffect(() => {
const movableTargets = createMoveTargets();
if (!movableTargets[project]) {
setDirty(false);
setProject(projectId);
}
/* eslint-disable-next-line */
}, [permissions?.length]);
const updateProject = async () => {
const newProject = project;
try {
await changeFeatureProject(projectId, featureId, newProject);
refetch();
setToastData({
show: true,
title: 'Updated project',
confetti: true,
type: 'success',
text: 'Successfully updated toggle project.',
});
@ -54,14 +62,31 @@ const FeatureSettingsProject = () => {
`/projects/${newProject}/features2/${featureId}/settings`
);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
const createMoveTargets = () => {
return permissions.reduce(
(acc: { [key: string]: boolean }, permission: IPermission) => {
if (permission.permission === MOVE_FEATURE_TOGGLE) {
acc[permission.project] = true;
}
return acc;
},
{}
);
};
const filterProjects = () => {
const validTargets = createMoveTargets();
return (project: string) => {
if (validTargets[project]) {
return project;
}
};
};
return (
<>
<FeatureProjectSelect
@ -69,13 +94,13 @@ const FeatureSettingsProject = () => {
onChange={e => setProject(e.target.value)}
label="Project"
enabled={editable}
filter={projectFilterGenerator({ permissions }, CREATE_FEATURE)}
filter={filterProjects()}
/>
<ConditionallyRender
condition={dirty}
show={
<PermissionButton
permission={UPDATE_FEATURE}
permission={MOVE_FEATURE_TOGGLE}
tooltip="Update feature"
onClick={() => setShowConfirmDialog(true)}
projectId={projectId}
@ -91,7 +116,6 @@ const FeatureSettingsProject = () => {
onClose={() => setShowConfirmDialog(false)}
onClick={updateProject}
/>
{toast}
</>
);
};

View File

@ -16,14 +16,11 @@ import { PRODUCTION } from '../../../../../../constants/environmentTypes';
import { ADD_NEW_STRATEGY_SAVE_ID } from '../../../../../../testIds';
import useFeature from '../../../../../../hooks/api/getters/useFeature/useFeature';
import { scrollToTop } from '../../../../../common/util';
import useToast from '../../../../../../hooks/useToast';
interface IFeatureStrategiesConfigure {
setToastData: React.Dispatch<React.SetStateAction<IToastType>>;
}
const FeatureStrategiesConfigure = ({
setToastData,
}: IFeatureStrategiesConfigure) => {
const FeatureStrategiesConfigure = () => {
const history = useHistory();
const { setToastData, setToastApiError } = useToast();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { refetch } = useFeature(projectId, featureId);
@ -89,19 +86,16 @@ const FeatureStrategiesConfigure = ({
setConfigureNewStrategy(null);
setExpandedSidebar(false);
setToastData({
show: true,
type: 'success',
text: 'Successfully added strategy.',
text: 'Successfully added strategy',
title: 'Added strategy',
confetti: true,
});
history.replace(history.location.pathname);
refetch();
scrollToTop();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};

View File

@ -34,7 +34,6 @@ const FeatureStrategiesEnvironmentList = ({
const {
activeEnvironmentsRef,
toast,
setToastData,
deleteStrategy,
updateStrategy,
@ -180,7 +179,6 @@ const FeatureStrategiesEnvironmentList = ({
/>
{dropboxMarkup}
{toast}
{delDialogueMarkup}
{productionGuardMarkup}
</div>

View File

@ -24,7 +24,7 @@ const useFeatureStrategiesEnvironmentList = () => {
featureCache,
} = useContext(FeatureStrategiesUIContext);
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const [delDialog, setDelDialog] = useState({ strategyId: '', show: false });
const [productionGuard, setProductionGuard] = useState({
show: false,
@ -65,7 +65,8 @@ const useFeatureStrategiesEnvironmentList = () => {
);
setToastData({
show: true,
title: 'Updates strategy',
confetti: true,
type: 'success',
text: `Successfully updated strategy`,
});
@ -85,11 +86,7 @@ const useFeatureStrategiesEnvironmentList = () => {
history.replace(history.location.pathname);
setFeatureCache(feature);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
@ -116,23 +113,18 @@ const useFeatureStrategiesEnvironmentList = () => {
setDelDialog({ strategyId: '', show: false });
setToastData({
show: true,
type: 'success',
title: 'Deleted strategy',
text: `Successfully deleted strategy from ${featureId}`,
});
history.replace(history.location.pathname);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
return {
activeEnvironmentsRef,
toast,
setToastData,
deleteStrategy,
updateStrategy,

View File

@ -7,10 +7,9 @@ import cloneDeep from 'lodash.clonedeep';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import { ADD_NEW_STRATEGY_ID } from '../../../../../testIds';
import { UPDATE_FEATURE } from '../../../../providers/AccessProvider/permissions';
import { CREATE_FEATURE_STRATEGY } from '../../../../providers/AccessProvider/permissions';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import useToast from '../../../../../hooks/useToast';
import useTabs from '../../../../../hooks/useTabs';
import useQueryParams from '../../../../../hooks/useQueryParams';
@ -34,7 +33,6 @@ const FeatureStrategiesEnvironments = () => {
const startingTabId = 0;
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { toast, setToastData } = useToast();
const [showRefreshPrompt, setShowRefreshPrompt] = useState(false);
const styles = useStyles();
@ -45,6 +43,7 @@ const FeatureStrategiesEnvironments = () => {
const { a11yProps, activeTabIdx, setActiveTab } = useTabs(startingTabId);
const {
setActiveEnvironment,
activeEnvironment,
configureNewStrategy,
expandedSidebar,
setExpandedSidebar,
@ -250,7 +249,11 @@ const FeatureStrategiesEnvironments = () => {
const listContainerClasses = classNames(styles.listContainer, {
[styles.listContainerFullWidth]: expandedSidebar,
[styles.listContainerWithoutSidebar]: !hasAccess(UPDATE_FEATURE),
[styles.listContainerWithoutSidebar]: !hasAccess(
CREATE_FEATURE_STRATEGY,
projectId,
activeEnvironment?.name
),
});
return featureCache?.environments?.map((env, index) => {
@ -276,7 +279,8 @@ const FeatureStrategiesEnvironments = () => {
Icon={Add}
maxWidth="700px"
projectId={projectId}
permission={UPDATE_FEATURE}
environmentId={activeEnvironment.name}
permission={CREATE_FEATURE_STRATEGY}
>
Add new strategy
</ResponsiveButton>
@ -376,14 +380,9 @@ const FeatureStrategiesEnvironments = () => {
{renderTabPanels()}
<ConditionallyRender
condition={configureNewStrategy}
show={
<FeatureStrategiesConfigure
setToastData={setToastData}
/>
}
show={<FeatureStrategiesConfigure />}
/>
</div>
{toast}
</>
}
/>

View File

@ -21,8 +21,10 @@ import {
STRATEGY_ACCORDION_ID,
UPDATE_STRATEGY_BUTTON_ID,
} from '../../../../../../testIds';
import AccessContext from '../../../../../../contexts/AccessContext';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import {
DELETE_FEATURE_STRATEGY,
UPDATE_FEATURE_STRATEGY,
} from '../../../../../providers/AccessProvider/permissions';
import useFeatureApi from '../../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import PermissionIconButton from '../../../../../common/PermissionIconButton/PermissionIconButton';
import PermissionButton from '../../../../../common/PermissionButton/PermissionButton';
@ -40,7 +42,6 @@ const FeatureStrategyEditable = ({
setDelDialog,
index,
}: IFeatureStrategyEditable) => {
const { hasAccess } = useContext(AccessContext);
const { loading } = useFeatureApi();
const { projectId, featureId } = useParams<IFeatureViewParams>();
@ -143,27 +144,23 @@ const FeatureStrategyEditable = ({
setStrategyConstraints={setStrategyConstraints}
dirty={dirty[strategy.id]}
actions={
<ConditionallyRender
condition={hasAccess(UPDATE_FEATURE)}
show={
<Tooltip title="Delete strategy">
<PermissionIconButton
permission={UPDATE_FEATURE}
projectId={projectId}
data-test={`${DELETE_STRATEGY_ID}-${strategy.name}`}
onClick={e => {
e.stopPropagation();
setDelDialog({
strategyId: strategy.id,
show: true,
});
}}
>
<Delete />
</PermissionIconButton>
</Tooltip>
}
/>
<Tooltip title="Delete strategy">
<PermissionIconButton
permission={DELETE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={activeEnvironment.name}
data-test={`${DELETE_STRATEGY_ID}-${strategy.name}`}
onClick={e => {
e.stopPropagation();
setDelDialog({
strategyId: strategy.id,
show: true,
});
}}
>
<Delete />
</PermissionIconButton>
</Tooltip>
}
>
<ConditionallyRender
@ -172,8 +169,9 @@ const FeatureStrategyEditable = ({
<>
<div className={styles.buttonContainer}>
<PermissionButton
permission={UPDATE_FEATURE}
permission={UPDATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={activeEnvironment?.name}
variant="contained"
color="primary"
className={styles.editButton}
@ -189,8 +187,9 @@ const FeatureStrategyEditable = ({
disabled={loading}
color="tertiary"
variant="text"
permission={UPDATE_FEATURE}
permission={UPDATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={activeEnvironment?.name}
>
Discard changes
</PermissionButton>

View File

@ -14,9 +14,10 @@ import {
getFeatureStrategyIcon,
getHumanReadableStrategyName,
} from '../../../../../../utils/strategy-names';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import { CREATE_FEATURE_STRATEGY } from '../../../../../providers/AccessProvider/permissions';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
import { useStyles } from './FeatureStrategyCard.styles';
import PermissionIconButton from '../../../../../common/PermissionIconButton/PermissionIconButton';
interface IFeatureStrategyCardProps {
name: string;
@ -33,15 +34,12 @@ const FeatureStrategyCard = ({
configureNewStrategy,
index,
}: IFeatureStrategyCardProps) => {
const { featureId } = useParams<IFeatureViewParams>();
const { featureId, projectId } = useParams<IFeatureViewParams>();
const { strategies } = useStrategies();
const { setConfigureNewStrategy, setExpandedSidebar } = useContext(
FeatureStrategiesUIContext
);
const { setConfigureNewStrategy, setExpandedSidebar, activeEnvironment } =
useContext(FeatureStrategiesUIContext);
const { hasAccess } = useContext(AccessContext);
const canUpdateFeature = hasAccess(UPDATE_FEATURE);
const handleClick = () => {
const strategy = getStrategyObject(strategies, name, featureId);
if (!strategy) return;
@ -54,7 +52,11 @@ const FeatureStrategyCard = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, drag] = useDrag({
type: FEATURE_STRATEGIES_DRAG_TYPE,
canDrag: canUpdateFeature,
canDrag: hasAccess(
CREATE_FEATURE_STRATEGY,
projectId,
activeEnvironment?.name
),
item: () => {
return { name };
},
@ -80,16 +82,18 @@ const FeatureStrategyCard = ({
</div>
</div>
<div className={styles.rightSection}>
<IconButton
<PermissionIconButton
className={styles.addButton}
onClick={handleClick}
data-test={`${ADD_NEW_STRATEGY_CARD_BUTTON_ID}-${
index + 1
}`}
disabled={!canUpdateFeature}
permission={CREATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={activeEnvironment?.name}
>
<Add />
</IconButton>
</PermissionIconButton>
<Tooltip title={readableName}>
<p className={styles.title}>{readableName}</p>
</Tooltip>

View File

@ -17,11 +17,15 @@ import Dialogue from '../../../../../common/Dialogue';
import DefaultStrategy from '../../common/DefaultStrategy/DefaultStrategy';
import { ADD_CONSTRAINT_ID } from '../../../../../../testIds';
import AccessContext from '../../../../../../contexts/AccessContext';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_STRATEGY,
} from '../../../../../providers/AccessProvider/permissions';
import Constraint from '../../../../../common/Constraint/Constraint';
import PermissionButton from '../../../../../common/PermissionButton/PermissionButton';
import { useParams } from 'react-router';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
import FeatureStrategiesUIContext from '../../../../../../contexts/FeatureStrategiesUIContext';
interface IFeatureStrategyAccordionBodyProps {
strategy: IFeatureStrategy;
@ -32,175 +36,190 @@ interface IFeatureStrategyAccordionBodyProps {
setStrategyConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
}
const FeatureStrategyAccordionBody: React.FC<IFeatureStrategyAccordionBodyProps> =
({
strategy,
updateParameters,
children,
constraints,
updateConstraints,
setStrategyConstraints,
}) => {
const styles = useStyles();
const { projectId } = useParams<IFeatureViewParams>();
const [constraintError, setConstraintError] = useState({});
const { strategies } = useStrategies();
const { uiConfig } = useUiConfig();
const [showConstraints, setShowConstraints] = useState(false);
const { hasAccess } = useContext(AccessContext);
const FeatureStrategyAccordionBody: React.FC<
IFeatureStrategyAccordionBodyProps
> = ({
strategy,
updateParameters,
children,
constraints,
updateConstraints,
setStrategyConstraints,
}) => {
const styles = useStyles();
const { projectId } = useParams<IFeatureViewParams>();
const [constraintError, setConstraintError] = useState({});
const { strategies } = useStrategies();
const { uiConfig } = useUiConfig();
const [showConstraints, setShowConstraints] = useState(false);
const { hasAccess } = useContext(AccessContext);
const { activeEnvironment } = useContext(FeatureStrategiesUIContext);
const { context } = useUnleashContext();
const { context } = useUnleashContext();
const resolveInputType = () => {
switch (strategy?.name) {
case 'default':
return DefaultStrategy;
case 'flexibleRollout':
return FlexibleStrategy;
case 'userWithId':
return UserWithIdStrategy;
default:
return GeneralStrategy;
}
};
const toggleConstraints = () => setShowConstraints(prev => !prev);
const resolveStrategyDefinition = () => {
const definition = strategies.find(
definition => definition.name === strategy.name
);
return definition;
};
const saveConstraintsLocally = () => {
let valid = true;
constraints.forEach((constraint, index) => {
const { values } = constraint;
if (values.length === 0) {
setConstraintError(prev => ({
...prev,
[`${constraint.contextName}-${index}`]:
'You need to specify at least one value',
}));
valid = false;
}
});
if (valid) {
setShowConstraints(false);
setStrategyConstraints(constraints);
}
};
const renderConstraints = () => {
if (constraints.length === 0) {
return (
<p className={styles.noConstraints}>
No constraints configured
</p>
);
}
return constraints.map((constraint, index) => {
return (
<Constraint
constraint={constraint}
editCallback={() => {
setShowConstraints(true);
}}
deleteCallback={() => {
removeConstraint(index);
}}
key={`${constraint.contextName}-${index}`}
/>
);
});
};
const removeConstraint = (index: number) => {
const updatedConstraints = [...constraints];
updatedConstraints.splice(index, 1);
updateConstraints(updatedConstraints);
};
const closeConstraintDialog = () => {
setShowConstraints(false);
const filteredConstraints = constraints.filter(constraint => {
return constraint.values.length > 0;
});
updateConstraints(filteredConstraints);
};
const Type = resolveInputType();
const definition = resolveStrategyDefinition();
const { parameters } = strategy;
const ON = uiConfig.flags[C];
return (
<div className={styles.accordionContainer}>
<ConditionallyRender
condition={ON}
show={
<>
<p className={styles.constraintHeader}>
Constraints
</p>
{renderConstraints()}
<PermissionButton
className={styles.addConstraintBtn}
onClick={toggleConstraints}
variant={'text'}
data-test={ADD_CONSTRAINT_ID}
permission={UPDATE_FEATURE}
projectId={projectId}
>
+ Add constraints
</PermissionButton>
</>
}
/>
<Dialogue
title="Define constraints"
open={showConstraints}
onClick={saveConstraintsLocally}
primaryButtonText="Update constraints"
secondaryButtonText="Cancel"
onClose={closeConstraintDialog}
fullWidth
maxWidth="md"
>
<StrategyConstraints
updateConstraints={updateConstraints}
constraints={constraints || []}
constraintError={constraintError}
setConstraintError={setConstraintError}
/>
</Dialogue>
<ConditionallyRender
condition={hasAccess(UPDATE_FEATURE)}
show={
<Type
parameters={parameters}
updateParameter={updateParameters}
strategyDefinition={definition}
context={context}
editable
/>
}
/>
{children}
</div>
);
const resolveInputType = () => {
switch (strategy?.name) {
case 'default':
return DefaultStrategy;
case 'flexibleRollout':
return FlexibleStrategy;
case 'userWithId':
return UserWithIdStrategy;
default:
return GeneralStrategy;
}
};
const toggleConstraints = () => setShowConstraints(prev => !prev);
const resolveStrategyDefinition = () => {
const definition = strategies.find(
definition => definition.name === strategy.name
);
return definition;
};
const saveConstraintsLocally = () => {
let valid = true;
constraints.forEach((constraint, index) => {
const { values } = constraint;
if (values.length === 0) {
setConstraintError(prev => ({
...prev,
[`${constraint.contextName}-${index}`]:
'You need to specify at least one value',
}));
valid = false;
}
});
if (valid) {
setShowConstraints(false);
setStrategyConstraints(constraints);
}
};
const renderConstraints = () => {
if (constraints.length === 0) {
return (
<p className={styles.noConstraints}>
No constraints configured
</p>
);
}
return constraints.map((constraint, index) => {
return (
<Constraint
constraint={constraint}
editCallback={() => {
setShowConstraints(true);
}}
deleteCallback={() => {
removeConstraint(index);
}}
key={`${constraint.contextName}-${index}`}
/>
);
});
};
const removeConstraint = (index: number) => {
const updatedConstraints = [...constraints];
updatedConstraints.splice(index, 1);
updateConstraints(updatedConstraints);
};
const closeConstraintDialog = () => {
setShowConstraints(false);
const filteredConstraints = constraints.filter(constraint => {
return constraint.values.length > 0;
});
updateConstraints(filteredConstraints);
};
const Type = resolveInputType();
const definition = resolveStrategyDefinition();
const { parameters } = strategy;
const ON = uiConfig.flags[C];
return (
<div className={styles.accordionContainer}>
<ConditionallyRender
condition={ON}
show={
<>
<p className={styles.constraintHeader}>Constraints</p>
{renderConstraints()}
<PermissionButton
className={styles.addConstraintBtn}
onClick={toggleConstraints}
variant={'text'}
data-test={ADD_CONSTRAINT_ID}
permission={[
UPDATE_FEATURE_STRATEGY,
CREATE_FEATURE_STRATEGY,
]}
environmentId={activeEnvironment.name}
projectId={projectId}
>
+ Add constraints
</PermissionButton>
</>
}
/>
<Dialogue
title="Define constraints"
open={showConstraints}
onClick={saveConstraintsLocally}
primaryButtonText="Update constraints"
secondaryButtonText="Cancel"
onClose={closeConstraintDialog}
fullWidth
maxWidth="md"
>
<StrategyConstraints
updateConstraints={updateConstraints}
constraints={constraints || []}
constraintError={constraintError}
setConstraintError={setConstraintError}
/>
</Dialogue>
<ConditionallyRender
condition={
hasAccess(
UPDATE_FEATURE_STRATEGY,
projectId,
activeEnvironment.name
) ||
hasAccess(
CREATE_FEATURE_STRATEGY,
projectId,
activeEnvironment.name
)
}
show={
<Type
parameters={parameters}
updateParameter={updateParameters}
strategyDefinition={definition}
context={context}
editable
/>
}
/>
{children}
</div>
);
};
export default FeatureStrategyAccordionBody;

View File

@ -20,7 +20,7 @@ import { useCommonStyles } from '../../../../../../common.styles';
import Dialogue from '../../../../../common/Dialogue';
import { trim, modalStyles } from '../../../../../common/util';
import PermissionSwitch from '../../../../../common/PermissionSwitch/PermissionSwitch';
import { UPDATE_FEATURE } from '../../../../../providers/AccessProvider/permissions';
import { UPDATE_FEATURE_VARIANTS } from '../../../../../providers/AccessProvider/permissions';
import useFeature from '../../../../../../hooks/api/getters/useFeature/useFeature';
import { useParams } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
@ -256,7 +256,10 @@ const AddVariant = ({
<Grid container>
{/* If we're editing, we need to have at least 2 existing variants, since we require at least 1 variable. If adding, we could be adding nr 2, and as such should be allowed to set weightType to variable */}
<ConditionallyRender
condition={(editing && variants.length > 1) || (!editing && variants.length > 0)}
condition={
(editing && variants.length > 1) ||
(!editing && variants.length > 0)
}
show={
<Grid
item
@ -267,7 +270,10 @@ const AddVariant = ({
<FormControlLabel
control={
<PermissionSwitch
permission={UPDATE_FEATURE}
permission={
UPDATE_FEATURE_VARIANTS
}
projectId={projectId}
name="weightType"
checked={isFixWeight}
data-test={

View File

@ -2,7 +2,14 @@ import classnames from 'classnames';
import * as jsonpatch from 'fast-json-patch';
import styles from './variants.module.scss';
import { Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@material-ui/core';
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@material-ui/core';
import AddVariant from './AddFeatureVariant/AddFeatureVariant';
import { useContext, useEffect, useState } from 'react';
@ -11,7 +18,7 @@ import { useParams } from 'react-router';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import AccessContext from '../../../../../contexts/AccessContext';
import FeatureVariantListItem from './FeatureVariantsListItem/FeatureVariantsListItem';
import { UPDATE_FEATURE } from '../../../../providers/AccessProvider/permissions';
import { UPDATE_FEATURE_VARIANTS } from '../../../../providers/AccessProvider/permissions';
import ConditionallyRender from '../../../../common/ConditionallyRender';
import useUnleashContext from '../../../../../hooks/api/getters/useUnleashContext/useUnleashContext';
import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect';
@ -31,7 +38,7 @@ const FeatureOverviewVariants = () => {
const [variants, setVariants] = useState<IFeatureVariant[]>([]);
const [editing, setEditing] = useState(false);
const { context } = useUnleashContext();
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const { patchFeatureVariants } = useFeatureApi();
const [editVariant, setEditVariant] = useState({});
const [showAddVariant, setShowAddVariant] = useState(false);
@ -54,7 +61,7 @@ const FeatureOverviewVariants = () => {
setStickinessOptions(options);
}, [context]);
const editable = hasAccess(UPDATE_FEATURE, projectId);
const editable = hasAccess(UPDATE_FEATURE_VARIANTS, projectId);
const setClonedVariants = clonedVariants =>
setVariants(cloneDeep(clonedVariants));
@ -104,7 +111,7 @@ const FeatureOverviewVariants = () => {
return (
<section style={{ paddingTop: '16px' }}>
<GeneralSelect
label='Stickiness'
label="Stickiness"
options={options}
value={value}
onChange={onChange}
@ -118,9 +125,9 @@ const FeatureOverviewVariants = () => {
is used to ensure consistent traffic allocation across
variants.{' '}
<a
href='https://docs.getunleash.io/advanced/toggle_variants'
target='_blank'
rel='noreferrer'
href="https://docs.getunleash.io/advanced/toggle_variants"
target="_blank"
rel="noreferrer"
>
Read more
</a>
@ -144,16 +151,13 @@ const FeatureOverviewVariants = () => {
const { variants } = await res.json();
mutate(FEATURE_CACHE_KEY, { ...feature, variants }, false);
setToastData({
show: true,
title: 'Updated variant',
confetti: true,
type: 'success',
text: 'Successfully updated variant stickiness',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
@ -162,20 +166,16 @@ const FeatureOverviewVariants = () => {
try {
await updateVariants(
updatedVariants,
'Successfully removed variant',
'Successfully removed variant'
);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
const updateVariant = async (variant: IFeatureVariant) => {
const updatedVariants = cloneDeep(variants);
const variantIdxToUpdate = updatedVariants.findIndex(
(v: IFeatureVariant) => v.name === variant.name,
(v: IFeatureVariant) => v.name === variant.name
);
updatedVariants[variantIdxToUpdate] = variant;
await updateVariants(updatedVariants, 'Successfully updated variant');
@ -190,13 +190,13 @@ const FeatureOverviewVariants = () => {
await updateVariants(
[...variants, variant],
'Successfully added a variant',
'Successfully added a variant'
);
};
const updateVariants = async (
variants: IFeatureVariant[],
successText: string,
successText: string
) => {
const newVariants = updateWeight(variants, 1000);
const patch = createPatch(newVariants);
@ -208,18 +208,13 @@ const FeatureOverviewVariants = () => {
const { variants } = await res.json();
mutate(FEATURE_CACHE_KEY, { ...feature, variants }, false);
setToastData({
show: true,
title: 'Updated variant',
type: 'success',
text: successText,
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
}
};
const validateName = (name: string) => {
@ -241,7 +236,7 @@ const FeatureOverviewVariants = () => {
removeVariant(delDialog.name);
setDelDialog({ name: '', show: false });
setToastData({
show: true,
title: 'Deleted variant',
type: 'success',
text: `Successfully deleted variant`,
});
@ -250,13 +245,12 @@ const FeatureOverviewVariants = () => {
});
const createPatch = (newVariants: IFeatureVariant[]) => {
return jsonpatch
.compare(feature.variants, newVariants);
return jsonpatch.compare(feature.variants, newVariants);
};
return (
<section style={{ padding: '16px' }}>
<Typography variant='body1'>
<Typography variant="body1">
Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using
variants you should use the{' '}
@ -298,12 +292,15 @@ const FeatureOverviewVariants = () => {
}}
className={styles.addVariantButton}
data-test={'ADD_VARIANT_BUTTON'}
permission={UPDATE_FEATURE}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
>
Add variant
</PermissionButton>
{renderStickiness()}
<ConditionallyRender
condition={editable}
show={renderStickiness()}
/>
</div>
<AddVariant
@ -323,7 +320,6 @@ const FeatureOverviewVariants = () => {
title={editing ? 'Edit variant' : 'Add variant'}
/>
{toast}
{delDialogueMarkup}
</section>
);

View File

@ -8,7 +8,11 @@ import useProject from '../../../hooks/api/getters/useProject/useProject';
import useTabs from '../../../hooks/useTabs';
import useToast from '../../../hooks/useToast';
import { IFeatureViewParams } from '../../../interfaces/params';
import { UPDATE_FEATURE } from '../../providers/AccessProvider/permissions';
import {
CREATE_FEATURE,
DELETE_FEATURE,
UPDATE_FEATURE,
} from '../../providers/AccessProvider/permissions';
import Dialogue from '../../common/Dialogue';
import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton';
import FeatureLog from './FeatureLog/FeatureLog';
@ -33,7 +37,7 @@ const FeatureView2 = () => {
const [openTagDialog, setOpenTagDialog] = useState(false);
const { a11yProps } = useTabs(0);
const { archiveFeatureToggle } = useFeatureApi();
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const [showDelDialog, setShowDelDialog] = useState(false);
const [openStaleDialog, setOpenStaleDialog] = useState(false);
const smallScreen = useMediaQuery(`(max-width:${500}px)`);
@ -49,19 +53,15 @@ const FeatureView2 = () => {
try {
await archiveFeatureToggle(projectId, featureId);
setToastData({
text: 'Feature archived',
text: 'Your feature toggle has been archived',
type: 'success',
show: true,
title: 'Feature archived',
});
setShowDelDialog(false);
projectRefetch();
history.push(`/projects/${projectId}`);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.toString());
setShowDelDialog(false);
}
};
@ -152,7 +152,7 @@ const FeatureView2 = () => {
<div className={styles.actions}>
<PermissionIconButton
permission={UPDATE_FEATURE}
permission={CREATE_FEATURE}
projectId={projectId}
tooltip="Copy"
data-loading
@ -162,7 +162,7 @@ const FeatureView2 = () => {
<FileCopy />
</PermissionIconButton>
<PermissionIconButton
permission={UPDATE_FEATURE}
permission={DELETE_FEATURE}
projectId={projectId}
tooltip="Archive feature toggle"
data-loading
@ -246,8 +246,6 @@ const FeatureView2 = () => {
open={openTagDialog}
setOpen={setOpenTagDialog}
/>
{toast}
</div>
}
elseShow={renderFeatureNotExist()}

View File

@ -31,7 +31,7 @@ const CopyFeature = props => {
const [newToggleName, setNewToggleName] = useState();
const { cloneFeatureToggle } = useFeatureApi();
const inputRef = useRef();
const { name: copyToggleName, id: projectId } = useParams();
const { name: copyToggleName, id: projectId } = useParams();
const { feature } = useFeature(projectId, copyToggleName);
const { uiConfig } = useUiConfig();
@ -66,16 +66,15 @@ const CopyFeature = props => {
}
try {
await cloneFeatureToggle(
projectId,
copyToggleName,
{ name: newToggleName, replaceGroupId }
);
await cloneFeatureToggle(projectId, copyToggleName, {
name: newToggleName,
replaceGroupId,
});
props.history.push(
getTogglePath(projectId, newToggleName, uiConfig.flags.E)
)
);
} catch (e) {
setApiError(e);
setApiError(e.toString());
}
};
@ -98,7 +97,11 @@ const CopyFeature = props => {
You are about to create a new feature toggle by cloning the
configuration of feature toggle&nbsp;
<Link
to={getTogglePath(projectId, copyToggleName, uiConfig.flags.E)}
to={getTogglePath(
projectId,
copyToggleName,
uiConfig.flags.E
)}
>
{copyToggleName}
</Link>

View File

@ -496,10 +496,10 @@ exports[`renders correctly with with variants 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-22 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-23 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-24 PrivateNotchedOutline-legendNotched-25"
className="PrivateNotchedOutline-legendLabelled-25 PrivateNotchedOutline-legendNotched-26"
>
<span>
Stickiness

View File

@ -29,7 +29,7 @@ exports[`renders correctly with one feature 1`] = `
}
>
<div
className="MuiChip-root makeStyles-chip-20 MuiChip-colorPrimary MuiChip-outlined MuiChip-outlinedPrimary"
className="MuiChip-root makeStyles-chip-21 MuiChip-colorPrimary MuiChip-outlined MuiChip-outlinedPrimary"
onKeyDown={[Function]}
onKeyUp={[Function]}
title="Feature toggle is active."
@ -52,44 +52,6 @@ exports[`renders correctly with one feature 1`] = `
className="MuiTypography-root MuiTypography-body1"
>
another's description
<a
aria-disabled={false}
aria-label="toggle description edit"
className="MuiButtonBase-root MuiIconButton-root"
href="/#edit"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
>
<span
className="MuiIconButton-label"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</span>
<span
className="MuiTouchRipple-root"
/>
</a>
</p>
</div>
<div
@ -105,18 +67,19 @@ exports[`renders correctly with one feature 1`] = `
Feature type
</label>
<div
className="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl MuiInputBase-marginDense MuiOutlinedInput-marginDense"
className="MuiInputBase-root MuiOutlinedInput-root Mui-disabled Mui-disabled MuiInputBase-formControl MuiInputBase-marginDense MuiOutlinedInput-marginDense"
onClick={[Function]}
>
<div
aria-disabled="true"
aria-haspopup="listbox"
className="MuiSelect-root MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputMarginDense MuiOutlinedInput-inputMarginDense"
className="MuiSelect-root MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input Mui-disabled Mui-disabled MuiInputBase-inputMarginDense MuiOutlinedInput-inputMarginDense Mui-disabled"
onBlur={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
onMouseDown={null}
role="button"
tabIndex={0}
tabIndex={null}
>
Release
</div>
@ -131,7 +94,7 @@ exports[`renders correctly with one feature 1`] = `
/>
<svg
aria-hidden={true}
className="MuiSvgIcon-root MuiSelect-icon MuiSelect-iconOutlined"
className="MuiSvgIcon-root MuiSelect-icon MuiSelect-iconOutlined Mui-disabled"
focusable="false"
viewBox="0 0 24 24"
>
@ -141,10 +104,10 @@ exports[`renders correctly with one feature 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-22 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-23 PrivateNotchedOutline-legendNotched-24"
className="PrivateNotchedOutline-legendLabelled-24 PrivateNotchedOutline-legendNotched-25"
>
<span>
Feature type
@ -200,10 +163,10 @@ exports[`renders correctly with one feature 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-21 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-22 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-23 PrivateNotchedOutline-legendNotched-24"
className="PrivateNotchedOutline-legendLabelled-24 PrivateNotchedOutline-legendNotched-25"
>
<span>
Project
@ -234,8 +197,8 @@ exports[`renders correctly with one feature 1`] = `
className="MuiSwitch-root"
>
<span
aria-disabled={false}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-25 MuiSwitch-switchBase MuiSwitch-colorSecondary"
aria-disabled={true}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-26 MuiSwitch-switchBase MuiSwitch-colorSecondary PrivateSwitchBase-disabled-28 Mui-disabled Mui-disabled Mui-disabled"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
@ -247,15 +210,15 @@ exports[`renders correctly with one feature 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={null}
tabIndex={-1}
>
<span
className="MuiIconButton-label"
>
<input
checked={false}
className="PrivateSwitchBase-input-28 MuiSwitch-input"
disabled={false}
className="PrivateSwitchBase-input-29 MuiSwitch-input"
disabled={true}
onChange={[Function]}
type="checkbox"
/>
@ -263,9 +226,6 @@ exports[`renders correctly with one feature 1`] = `
className="MuiSwitch-thumb"
/>
</span>
<span
className="MuiTouchRipple-root"
/>
</span>
<span
className="MuiSwitch-track"
@ -280,8 +240,8 @@ exports[`renders correctly with one feature 1`] = `
featureToggleName="Another"
/>
<a
aria-disabled={false}
className="MuiButtonBase-root MuiButton-root MuiButton-text"
aria-disabled={true}
className="MuiButtonBase-root MuiButton-root MuiButton-text Mui-disabled Mui-disabled"
href="/projects/default/features/Another/strategies/copy"
onBlur={[Function]}
onClick={[Function]}
@ -296,7 +256,7 @@ exports[`renders correctly with one feature 1`] = `
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
tabIndex={-1}
title="Create new feature toggle by cloning configuration"
>
<span
@ -304,9 +264,6 @@ exports[`renders correctly with one feature 1`] = `
>
Clone
</span>
<span
className="MuiTouchRipple-root"
/>
</a>
<button
aria-controls="feature-stale-dropdown"
@ -358,13 +315,10 @@ exports[`renders correctly with one feature 1`] = `
</span>
</span>
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
className="MuiButtonBase-root MuiButton-root MuiButton-text Mui-disabled Mui-disabled"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
@ -382,7 +336,7 @@ exports[`renders correctly with one feature 1`] = `
"flexShrink": 0,
}
}
tabIndex={0}
tabIndex={-1}
title="Archive feature toggle"
type="button"
>
@ -391,15 +345,12 @@ exports[`renders correctly with one feature 1`] = `
>
Archive
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</div>
</div>
<hr />
<div
className="MuiPaper-root makeStyles-tabNav-29 MuiPaper-elevation1 MuiPaper-rounded"
className="MuiPaper-root makeStyles-tabNav-30 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
@ -447,7 +398,8 @@ exports[`renders correctly with one feature 1`] = `
Activation
</span>
<span
className="MuiTouchRipple-root"
className="PrivateTabIndicator-root-31 PrivateTabIndicator-colorPrimary-32 MuiTabs-indicator"
style={Object {}}
/>
</button>
<button
@ -477,9 +429,6 @@ exports[`renders correctly with one feature 1`] = `
>
Metrics
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
<button
aria-controls="tabpanel-2"
@ -508,9 +457,6 @@ exports[`renders correctly with one feature 1`] = `
>
Variants
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
<button
aria-controls="tabpanel-3"
@ -539,20 +485,8 @@ exports[`renders correctly with one feature 1`] = `
>
Log
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</div>
<span
className="PrivateTabIndicator-root-30 PrivateTabIndicator-colorPrimary-31 MuiTabs-indicator"
style={
Object {
"left": 0,
"width": 0,
}
}
/>
</div>
</div>
</div>
@ -566,7 +500,7 @@ exports[`renders correctly with one feature 1`] = `
role="tabpanel"
>
<UpdateStrategiesComponent
editable={true}
editable={false}
featureToggle={
Object {
"createdAt": "2018-02-04T20:27:52.127Z",

View File

@ -16,6 +16,7 @@ import {
import theme from '../../../../themes/main-theme';
import { createFakeStore } from '../../../../accessStoreFake';
import AccessProvider from '../../../providers/AccessProvider/AccessProvider';
import UIProvider from '../../../providers/UIProvider/UIProvider';
jest.mock('../update-strategies-container', () => ({
__esModule: true,
@ -73,22 +74,24 @@ test('renders correctly with one feature', () => {
<MemoryRouter>
<Provider store={createStore(mockReducer, mockStore)}>
<ThemeProvider theme={theme}>
<AccessProvider
store={createFakeStore([{ permission: ADMIN }])}
>
<ViewFeatureToggleComponent
activeTab={'strategies'}
featureToggleName="another"
features={[feature]}
featureToggle={feature}
fetchFeatureToggles={jest.fn()}
history={{}}
user={{ permissions: [] }}
featureTags={[]}
fetchTags={jest.fn()}
untagFeature={jest.fn()}
/>
</AccessProvider>
<UIProvider>
<AccessProvider
store={createFakeStore([{ permission: ADMIN }])}
>
<ViewFeatureToggleComponent
activeTab={'strategies'}
featureToggleName="another"
features={[feature]}
featureToggle={feature}
fetchFeatureToggles={jest.fn()}
history={{}}
user={{ permissions: [] }}
featureTags={[]}
fetchTags={jest.fn()}
untagFeature={jest.fn()}
/>
</AccessProvider>
</UIProvider>
</ThemeProvider>
</Provider>
</MemoryRouter>

View File

@ -54,7 +54,7 @@ const Header = () => {
const filteredMainRoutes = {
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)),
mobileRoutes: routes.mobileRoutes.filter(filterByFlags(flags)),
adminRoutes: routes.adminRoutes,
adminRoutes: routes.adminRoutes.filter(filterByFlags(flags)),
};
return (
@ -147,7 +147,7 @@ const Header = () => {
<NavigationMenu
id="admin-navigation"
options={routes.adminRoutes}
options={filteredMainRoutes.adminRoutes}
anchorEl={anchorEl}
handleClose={handleClose}
style={{ top: '40px', left: '-125px' }}

View File

@ -146,6 +146,7 @@ Array [
},
Object {
"component": [Function],
"flag": "C",
"layout": "main",
"menu": Object {},
"parent": "/context",
@ -155,6 +156,7 @@ Array [
},
Object {
"component": [Function],
"flag": "C",
"layout": "main",
"menu": Object {},
"parent": "/context",
@ -328,6 +330,24 @@ Array [
"title": "Archived Toggles",
"type": "protected",
},
Object {
"component": [Function],
"flag": "RE",
"layout": "main",
"menu": Object {},
"path": "/admin/create-project-role",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"flag": "RE",
"layout": "main",
"menu": Object {},
"path": "/admin/roles/:id/edit",
"title": "Edit",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
@ -367,6 +387,18 @@ Array [
"title": "Single Sign-On",
"type": "protected",
},
Object {
"component": [Function],
"flag": "RE",
"layout": "main",
"menu": Object {
"adminSettings": true,
},
"parent": "/admin",
"path": "/admin/roles",
"title": "Project Roles",
"type": "protected",
},
Object {
"component": [Function],
"hidden": false,

View File

@ -28,7 +28,7 @@ import AdminUsers from '../admin/users';
import AdminInvoice from '../admin/invoice';
import AdminAuth from '../admin/auth';
import Login from '../user/Login/Login';
import { P, C, E, EEA } from '../common/flags';
import { P, C, E, EEA, RE } from '../common/flags';
import NewUser from '../user/NewUser';
import ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
@ -40,10 +40,13 @@ import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment';
import FeatureView2 from '../feature/FeatureView2/FeatureView2';
import FeatureCreate from '../feature/FeatureCreate/FeatureCreate';
import ProjectRoles from '../admin/ProjectRolesv1/ProjectRoles';
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
import CreateProjectRole from '../admin/project-roles/CreateProjectRole/CreateProjectRole';
import EditProjectRole from '../admin/project-roles/EditProjectRole/EditProjectRole';
export const routes = [
// Project
{
path: '/projects/create',
parent: '/projects',
@ -192,6 +195,7 @@ export const routes = [
component: CreateContextField,
type: 'protected',
layout: 'main',
flag: C,
menu: {},
},
{
@ -201,6 +205,7 @@ export const routes = [
component: EditContextField,
type: 'protected',
layout: 'main',
flag: C,
menu: {},
},
{
@ -373,6 +378,24 @@ export const routes = [
},
// Admin
{
path: '/admin/create-project-role',
title: 'Create',
component: CreateProjectRole,
type: 'protected',
layout: 'main',
menu: {},
flag: RE,
},
{
path: '/admin/roles/:id/edit',
title: 'Edit',
component: EditProjectRole,
type: 'protected',
layout: 'main',
menu: {},
flag: RE,
},
{
path: '/admin/api',
parent: '/admin',
@ -416,8 +439,8 @@ export const routes = [
component: ProjectRoles,
type: 'protected',
layout: 'main',
menu: {},
hidden: true,
flag: RE,
menu: { adminSettings: true },
},
{
path: '/admin',

View File

@ -25,7 +25,7 @@ const Project = () => {
const params = useQueryParams();
const { project, error, loading, refetch } = useProject(id);
const ref = useLoading(loading);
const { toast, setToastData } = useToast();
const { setToastData } = useToast();
const commonStyles = useCommonStyles();
const styles = useStyles();
const history = useHistory();
@ -80,9 +80,8 @@ const Project = () => {
if (created || edited) {
const text = created ? 'Project created' : 'Project updated';
setToastData({
show: true,
type: 'success',
text,
title: text,
});
}
@ -186,7 +185,6 @@ const Project = () => {
</div>
</div>
{renderTabContent()}
{toast}
</div>
);
};

View File

@ -1,5 +1,4 @@
import { Card, Menu, MenuItem } from '@material-ui/core';
import { Dispatch, SetStateAction } from 'react';
import { useStyles } from './ProjectCard.styles';
import MoreVertIcon from '@material-ui/icons/MoreVert';
@ -13,6 +12,7 @@ import { Delete, Edit } from '@material-ui/icons';
import { getProjectEditPath } from '../../../utils/route-path-helpers';
import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton';
import { UPDATE_PROJECT } from '../../../store/project/actions';
import useToast from '../../../hooks/useToast';
interface IProjectCardProps {
name: string;
featureCount: number;
@ -20,13 +20,6 @@ interface IProjectCardProps {
memberCount: number;
id: string;
onHover: () => void;
setToastData: Dispatch<
SetStateAction<{
show: boolean;
type: string;
text: string;
}>
>;
}
const ProjectCard = ({
@ -36,7 +29,6 @@ const ProjectCard = ({
memberCount,
onHover,
id,
setToastData,
}: IProjectCardProps) => {
const styles = useStyles();
const { refetch: refetchProjectOverview } = useProjects();
@ -44,6 +36,7 @@ const ProjectCard = ({
const [showDelDialog, setShowDelDialog] = useState(false);
const { deleteProject } = useProjectApi();
const history = useHistory();
const { setToastData, setToastApiError } = useToast();
const handleClick = e => {
e.preventDefault();
@ -127,18 +120,14 @@ const ProjectCard = ({
deleteProject(id)
.then(() => {
setToastData({
show: true,
title: 'Deleted project',
type: 'success',
text: 'Successfully deleted project',
});
refetchProjectOverview();
})
.catch(e => {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setToastApiError(e.message);
})
.finally(() => {
setShowDelDialog(false);

View File

@ -28,7 +28,7 @@ interface ProjectEnvironmentListProps {
const ProjectEnvironmentList = ({ projectId }: ProjectEnvironmentListProps) => {
// api state
const [envs, setEnvs] = useState<IProjectEnvironment>([]);
const { toast, setToastData } = useToast();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const {
environments,
@ -85,24 +85,20 @@ const ProjectEnvironmentList = ({ projectId }: ProjectEnvironmentListProps) => {
return;
}
setToastData({
title: 'One environment must be active',
text: 'You must always have at least one active environment per project',
type: 'error',
show: true,
});
} else {
try {
await addEnvironmentToProject(projectId, env.name);
setToastData({
text: 'Environment successfully enabled.',
title: 'Environment enabled',
text: 'Environment successfully enabled. You can now use it to segment strategies in your feature toggles.',
type: 'success',
show: true,
});
} catch (error) {
setToastData({
text: errorMsg(true),
type: 'error',
show: true,
});
setToastApiError(errorMsg(true));
}
}
refetch();
@ -115,16 +111,12 @@ const ProjectEnvironmentList = ({ projectId }: ProjectEnvironmentListProps) => {
setSelectedEnv(undefined);
setConfirmName('');
setToastData({
title: 'Environment disabled',
text: 'Environment successfully disabled.',
type: 'success',
show: true,
});
} catch (e) {
setToastData({
text: errorMsg(false),
type: 'error',
show: true,
});
setToastApiError(errorMsg(false));
}
refetch();
@ -229,8 +221,6 @@ const ProjectEnvironmentList = ({ projectId }: ProjectEnvironmentListProps) => {
</Alert>
}
/>
{toast}
</PageContent>
</div>
);

View File

@ -18,7 +18,6 @@ import { CREATE_PROJECT } from '../../providers/AccessProvider/permissions';
import { Add } from '@material-ui/icons';
import ApiError from '../../common/ApiError/ApiError';
import useToast from '../../../hooks/useToast';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
type projectMap = {
@ -47,7 +46,6 @@ function resolveCreateButtonData(isOss: boolean, hasAccess: boolean) {
const ProjectListNew = () => {
const { hasAccess } = useContext(AccessContext);
const history = useHistory();
const { toast, setToastData } = useToast();
const styles = useStyles();
const { projects, loading, error, refetch } = useProjects();
const [fetchedProjects, setFetchedProjects] = useState<projectMap>({});
@ -103,7 +101,6 @@ const ProjectListNew = () => {
health={project?.health}
id={project?.id}
featureCount={project?.featureCount}
setToastData={setToastData}
/>
</Link>
);
@ -122,7 +119,6 @@ const ProjectListNew = () => {
memberCount={2}
health={95}
featureCount={4}
setToastData={setToastData}
/>
);
});
@ -157,7 +153,6 @@ const ProjectListNew = () => {
elseShow={renderProjects()}
/>
</div>
{toast}
</PageContent>
</div>
);

View File

@ -17,6 +17,7 @@ import { FormEvent } from 'react-router/node_modules/@types/react';
import useLoading from '../../hooks/useLoading';
import PermissionButton from '../common/PermissionButton/PermissionButton';
import { UPDATE_PROJECT } from '../../store/project/actions';
import useUser from '../../hooks/api/getters/useUser/useUser';
interface ProjectFormComponentProps {
editMode: boolean;
@ -28,7 +29,7 @@ interface ProjectFormComponentProps {
const ProjectFormComponent = (props: ProjectFormComponentProps) => {
const { editMode } = props;
const { refetch } = useUser();
const { hasAccess } = useContext(AccessContext);
const [project, setProject] = useState(props.project || {});
const [errors, setErrors] = useState<any>({});
@ -97,6 +98,7 @@ const ProjectFormComponent = (props: ProjectFormComponentProps) => {
if (valid) {
const query = editMode ? 'edited=true' : 'created=true';
await props.submit(project);
refetch();
props.history.push(`/projects/${project.id}?${query}`);
}
};

View File

@ -1,6 +1,7 @@
import { FC } from 'react';
import AccessContext from '../../../contexts/AccessContext';
import useUser from '../../../hooks/api/getters/useUser/useUser';
import { ADMIN } from './permissions';
// TODO: Type up redux store
@ -10,10 +11,12 @@ interface IAccessProvider {
interface IPermission {
permission: string;
project: string | null;
project?: string | null;
environment: string | null;
}
const AccessProvider: FC<IAccessProvider> = ({ store, children }) => {
const { permissions } = useUser();
const isAdminHigherOrder = () => {
let called = false;
let result = false;
@ -33,19 +36,37 @@ const AccessProvider: FC<IAccessProvider> = ({ store, children }) => {
const isAdmin = isAdminHigherOrder();
const hasAccess = (permission: string, project: string) => {
const permissions = store.getState().user.get('permissions') || [];
const hasAccess = (
permission: string,
project: string,
environment?: string
) => {
const result = permissions.some((p: IPermission) => {
if (p.permission === ADMIN) {
return true;
}
if (p.permission === permission && p.project === project) {
if (
p.permission === permission &&
(p.project === project || p.project === '*') &&
(p.environment === environment || p.environment === '*')
) {
return true;
}
if (p.permission === permission && project === undefined) {
if (
p.permission === permission &&
(p.project === project || p.project === '*') &&
p.environment === null
) {
return true;
}
if (
p.permission === permission &&
p.project === undefined &&
p.environment === null
) {
return true;
}

View File

@ -26,3 +26,9 @@ export const CREATE_API_TOKEN = 'CREATE_API_TOKEN';
export const DELETE_API_TOKEN = 'DELETE_API_TOKEN';
export const DELETE_ENVIRONMENT = 'DELETE_ENVIRONMENT';
export const UPDATE_ENVIRONMENT = 'UPDATE_ENVIRONMENT';
export const CREATE_FEATURE_STRATEGY = 'CREATE_FEATURE_STRATEGY';
export const UPDATE_FEATURE_STRATEGY = 'UPDATE_FEATURE_STRATEGY';
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';

View File

@ -1,10 +1,9 @@
import { USER_CACHE_KEY } from '../../../hooks/api/getters/useUser/useUser';
import { mutate, SWRConfig, useSWRConfig } from 'swr';
import { useHistory } from 'react-router';
import { IToast } from '../../../hooks/useToast';
import useToast from '../../../hooks/useToast';
interface ISWRProviderProps {
setToastData: (toastData: IToast) => void;
setShowLoader: React.Dispatch<React.SetStateAction<boolean>>;
isUnauthorized: () => boolean;
}
@ -13,16 +12,15 @@ const INVALID_TOKEN_ERROR = 'InvalidTokenError';
const SWRProvider: React.FC<ISWRProviderProps> = ({
children,
setToastData,
isUnauthorized,
setShowLoader,
}) => {
const { cache } = useSWRConfig();
const history = useHistory();
const { setToastApiError } = useToast();
const handleFetchError = error => {
setShowLoader(false);
console.log(error.info.name);
if (error.status === 401) {
cache.clear();
const path = location.pathname;
@ -40,11 +38,7 @@ const SWRProvider: React.FC<ISWRProviderProps> = ({
}
if (!isUnauthorized()) {
setToastData({
show: true,
type: 'error',
text: error.message,
});
setToastApiError(error.message);
}
};

View File

@ -0,0 +1,26 @@
import React, { useState } from 'react';
import UIContext, { IToastData } from '../../../contexts/UIContext';
const UIProvider: React.FC = ({ children }) => {
const [toastData, setToast] = useState<IToastData>({
title: '',
text: '',
components: [],
show: false,
persist: false,
type: '',
});
const context = React.useMemo(
() => ({
setToast,
toastData,
}),
[toastData]
);
return <UIContext.Provider value={context}>{children}</UIContext.Provider>;
};
export default UIProvider;

View File

@ -162,51 +162,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
</div>
<div
className="makeStyles-headerActions-9"
>
<span
aria-describedby={null}
className=""
onBlur={[Function]}
onFocus={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
title="Add new strategy"
>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
data-test="ADD_NEW_STRATEGY_ID"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="button"
>
<span
className="MuiButton-label"
>
Add new strategy
<span
className="MuiButton-endIcon MuiButton-iconSizeMedium"
/>
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</span>
</div>
/>
</div>
</div>
<div
@ -269,11 +225,11 @@ exports[`renders correctly with one strategy without permissions 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
title="Deprecate activation strategy"
title="You don't have access to perform this operation"
>
<button
className="MuiButtonBase-root MuiIconButton-root"
disabled={false}
className="MuiButtonBase-root MuiIconButton-root Mui-disabled Mui-disabled"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
@ -286,7 +242,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
tabIndex={-1}
type="button"
>
<span
@ -303,56 +259,9 @@ exports[`renders correctly with one strategy without permissions 1`] = `
/>
</svg>
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</span>
</div>
<div
aria-describedby={null}
className=""
onBlur={[Function]}
onFocus={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
title="You cannot delete a built-in strategy"
>
<button
className="MuiButtonBase-root MuiIconButton-root Mui-disabled Mui-disabled"
disabled={true}
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={-1}
type="button"
>
<span
className="MuiIconButton-label"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
/>
</svg>
</span>
</button>
</div>
</li>
</ul>
</div>

View File

@ -43,60 +43,9 @@ exports[`it supports editMode 1`] = `
className="addTagTypeForm contentSpacing"
onSubmit={[Function]}
>
<div
className="formButtons"
>
<div>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
disabled={false}
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="submit"
>
<span
className="MuiButton-label"
>
Update
</span>
</button>
 
<button
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="cancel"
>
<span
className="MuiButton-label"
>
Cancel
</span>
</button>
</div>
</div>
<span>
You do not have permissions to save.
</span>
</form>
</section>
</div>
@ -146,60 +95,9 @@ exports[`renders correctly for creating 1`] = `
className="addTagTypeForm contentSpacing"
onSubmit={[Function]}
>
<div
className="formButtons"
>
<div>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
disabled={false}
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="submit"
>
<span
className="MuiButton-label"
>
Create
</span>
</button>
 
<button
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="cancel"
>
<span
className="MuiButton-label"
>
Cancel
</span>
</button>
</div>
</div>
<span>
You do not have permissions to save.
</span>
</form>
</section>
</div>

View File

@ -28,35 +28,7 @@ exports[`renders a list with elements correctly 1`] = `
</div>
<div
className="makeStyles-headerActions-7"
>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="button"
>
<span
className="MuiButton-label"
>
Add new tag type
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</div>
/>
</div>
</div>
<div
@ -104,45 +76,6 @@ exports[`renders a list with elements correctly 1`] = `
Some simple description
</p>
</div>
<button
aria-describedby={null}
className="MuiButtonBase-root MuiIconButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
title="Delete simple"
type="button"
>
<span
className="MuiIconButton-label"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
/>
</svg>
</span>
<span
className="MuiTouchRipple-root"
/>
</button>
</li>
</ul>
</div>
@ -177,32 +110,7 @@ exports[`renders an empty list correctly 1`] = `
</div>
<div
className="makeStyles-headerActions-7"
>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={0}
type="button"
>
<span
className="MuiButton-label"
>
Add new tag type
</span>
</button>
</div>
/>
</div>
</div>
<div

View File

@ -35,7 +35,6 @@ const Authentication = ({
const params = useQueryParams();
const error = params.get('errorMsg');
if (!authDetails) return null;
let content;

View File

@ -0,0 +1 @@
export const RBAC_ENV = false;

View File

@ -0,0 +1,19 @@
import React from 'react';
export interface IToastData {
title: string;
text: string;
components?: JSX.Element[];
show: boolean;
persist: boolean;
confetti?: boolean;
type: string;
}
interface IFeatureStrategiesUIContext {
toastData: IToastData;
setToast: React.Dispatch<React.SetStateAction<IToastData>>;
}
const UIContext = React.createContext<IFeatureStrategiesUIContext | null>(null);
export default UIContext;

View File

@ -57,11 +57,12 @@ const useAPI = ({
const makeRequest = async (
apiCaller: any,
requestId?: string,
loading: boolean = true
loadingOn: boolean = true
): Promise<Response> => {
if (loading) {
if (loadingOn) {
setLoading(true);
}
try {
const res = await apiCaller();
setLoading(false);

View File

@ -0,0 +1,88 @@
import { IPermission } from '../../../../interfaces/project';
import useAPI from '../useApi/useApi';
interface ICreateRolePayload {
name: string;
description: string;
permissions: IPermission[];
}
const useProjectRolesApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createRole = async (payload: ICreateRolePayload) => {
const path = `api/admin/roles`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const editRole = async (id: string, payload: ICreateRolePayload) => {
const path = `api/admin/roles/${id}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const validateRole = async (payload: ICreateRolePayload) => {
const path = `api/admin/roles/validate`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteRole = async (id: number) => {
const path = `api/admin/roles/${id}`;
const req = createRequest(path, {
method: 'DELETE',
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
createRole,
deleteRole,
editRole,
validateRole,
errors,
loading,
};
};
export default useProjectRolesApi;

View File

@ -0,0 +1,35 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
const useProjectRole = (id: string, options: SWRConfiguration = {}) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/roles/${id}`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('project role'))
.then(res => res.json());
};
const { data, error } = useSWR(`api/admin/roles/${id}`, fetcher, options);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(`api/admin/roles/${id}`);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
role: data ? data : {},
error,
loading,
refetch,
};
};
export default useProjectRole;

View File

@ -0,0 +1,61 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import {
IProjectEnvironmentPermissions,
IProjectRolePermissions,
IPermission,
} from '../../../../interfaces/project';
import handleErrorResponses from '../httpErrorResponseHandler';
interface IUseProjectRolePermissions {
permissions:
| IProjectRolePermissions
| {
project: IPermission[];
environments: IProjectEnvironmentPermissions[];
};
loading: boolean;
refetch: () => void;
error: any;
}
const useProjectRolePermissions = (
options: SWRConfiguration = {}
): IUseProjectRolePermissions => {
const fetcher = () => {
const path = formatApiPath(`api/admin/permissions`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Project permissions'))
.then(res => res.json());
};
const KEY = `api/admin/permissions`;
const { data, error } = useSWR<{ permissions: IProjectRolePermissions }>(
KEY,
fetcher,
options
);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
permissions: data?.permissions || { project: [], environments: [] },
error,
loading,
refetch,
};
};
export default useProjectRolePermissions;

View File

@ -12,6 +12,7 @@ const useUser = (
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 15000,
}
) => {
const fetcher = () => {

View File

@ -1,5 +1,5 @@
import { Dispatch, SetStateAction, useState } from 'react';
import Toast from '../component/common/Toast/Toast';
import { Dispatch, SetStateAction, useContext } from 'react';
import UIContext, { IToastData } from '../contexts/UIContext';
export interface IToast {
show: boolean;
@ -9,26 +9,44 @@ export interface IToast {
export type TSetToastData = Dispatch<SetStateAction<IToast>>;
interface IToastOptions {
title: string;
text?: string;
type: string;
persist?: boolean;
confetti?: boolean;
}
const useToast = () => {
const [toastData, setToastData] = useState<IToast>({
show: false,
type: 'success',
text: '',
});
// @ts-ignore
const { setToast } = useContext(UIContext);
const hideToast = () => {
setToastData((prev: IToast) => ({ ...prev, show: false }));
const hideToast = () =>
setToast((prev: IToastData) => ({
...prev,
show: false,
}));
const setToastApiError = (errorText: string, overrides?: IToastOptions) => {
setToast({
title: 'Something went wrong',
text: `We had trouble talking to our API. Here's why: ${errorText}`,
type: 'error',
show: true,
autoHideDuration: 6000,
...overrides,
});
};
const toast = (
<Toast
show={toastData.show}
onClose={hideToast}
text={toastData.text}
type={toastData.type}
/>
);
return { toast, setToastData, hideToast };
const setToastData = (options: IToastOptions) => {
if (options.persist) {
setToast({ ...options, show: true });
} else {
setToast({ ...options, show: true, autoHideDuration: 6000 });
}
};
return { setToastData, setToastApiError, hideToast };
};
export default useToast;

View File

@ -20,6 +20,7 @@ import ScrollToTop from './component/scroll-to-top';
import { writeWarning } from './security-logger';
import AccessProvider from './component/providers/AccessProvider/AccessProvider';
import { getBasePath } from './utils/format-path';
import UIProvider from './component/providers/UIProvider/UIProvider';
let composeEnhancers;
@ -41,18 +42,20 @@ const unleashStore = createStore(
ReactDOM.render(
<Provider store={unleashStore}>
<DndProvider backend={HTML5Backend}>
<AccessProvider store={unleashStore}>
<Router basename={`${getBasePath()}`}>
<ThemeProvider theme={mainTheme}>
<StylesProvider injectFirst>
<CssBaseline />
<ScrollToTop>
<Route path="/" component={App} />
</ScrollToTop>
</StylesProvider>
</ThemeProvider>
</Router>
</AccessProvider>
<UIProvider>
<AccessProvider store={unleashStore}>
<Router basename={`${getBasePath()}`}>
<ThemeProvider theme={mainTheme}>
<StylesProvider injectFirst>
<CssBaseline />
<ScrollToTop>
<Route path="/" component={App} />
</ScrollToTop>
</StylesProvider>
</ThemeProvider>
</Router>
</AccessProvider>
</UIProvider>
</DndProvider>
</Provider>,
document.getElementById('app')

View File

@ -26,3 +26,20 @@ export interface IProjectHealthReport extends IProject {
activeCount: number;
updatedAt: Date;
}
export interface IPermission {
id: number;
name: string;
displayName: string;
environment?: string;
}
export interface IProjectRolePermissions {
project: IPermission[];
environments: IProjectEnvironmentPermissions[];
}
export interface IProjectEnvironmentPermissions {
name: string;
permissions: IPermission[];
}

View File

@ -6,4 +6,10 @@ interface IRole {
type: string;
}
export interface IProjectRole {
id: number;
name: string;
description: string;
}
export default IRole;

View File

@ -13,6 +13,7 @@ export interface ISplash {
export interface IPermission {
permission: string;
project: string;
displayName: string;
}
interface IAuthDetails {