From 182d566895afada13fa9c997ee6bb4d766999918 Mon Sep 17 00:00:00 2001
From: Youssef Khedher
Date: Fri, 14 Jan 2022 15:50:02 +0100
Subject: [PATCH] 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
Co-authored-by: sighphyre
---
frontend/package.json | 2 +-
frontend/src/common.styles.js | 13 +
frontend/src/component/App.tsx | 7 +-
.../admin/ProjectRolesv1/RolesList.tsx | 63 ---
.../RolesListItem/RoleListItem.tsx | 61 ---
frontend/src/component/admin/admin-menu.jsx | 14 +-
.../api-token/ApiTokenList/ApiTokenList.tsx | 31 +-
.../CreateProjectRole/CreateProjectRole.tsx | 101 +++++
.../EditProjectRole/EditProjectRole.tsx | 128 ++++++
.../EnvironmentPermissionAccordion.styles.ts | 35 ++
.../EnvironmentPermissionAccordion.tsx | 147 +++++++
.../ProjectRoleForm/ProjectRoleForm.styles.ts | 41 ++
.../ProjectRoleForm/ProjectRoleForm.tsx | 190 +++++++++
.../ProjectRoleDeleteConfirm.styles.ts | 10 +
.../ProjectRoleDeleteConfirm.tsx | 70 ++++
.../ProjectRoleList/ProjectRoleList.tsx | 106 +++++
.../ProjectRoleListItem.styles.ts} | 9 +-
.../ProjectRoleListItem.tsx | 81 ++++
.../ProjectRoles}/ProjectRoles.styles.ts | 0
.../ProjectRoles}/ProjectRoles.tsx | 22 +-
.../project-roles/hooks/useProjectRoleForm.ts | 269 ++++++++++++
.../application-edit-component-test.js.snap | 396 ------------------
.../common/CheckmarkBadge/CheckMarkBadge.tsx | 18 +-
.../common/Codebox/Codebox.styles.ts | 19 +
.../src/component/common/Codebox/Codebox.tsx | 16 +
.../FormTemplate/FormTemplate.styles.ts | 75 ++++
.../common/FormTemplate/FormTemplate.tsx | 98 +++++
.../NoItemsStrategies/NoItemsStrategies.tsx | 5 +-
.../common/PageContent/PageContent.jsx | 2 +-
.../PermissionButton/PermissionButton.tsx | 40 +-
.../PermissionIconButton.tsx | 17 +-
.../PermissionSwitch/PermissionSwitch.tsx | 14 +-
.../ResponsiveButton/ResponsiveButton.tsx | 4 +
frontend/src/component/common/Toast/Toast.tsx | 45 --
.../ToastRenderer/Toast/Toast.styles.ts | 66 +++
.../common/ToastRenderer/Toast/Toast.tsx | 94 +++++
.../common/ToastRenderer/ToastRenderer.tsx | 44 ++
frontend/src/component/common/flags.js | 1 +
.../CreateEnvironment/CreateEnvironment.tsx | 172 ++++----
.../EnvironmentList/EnvironmentList.tsx | 50 +--
.../FeatureToggleListItem.jsx | 5 +-
.../FeatureToggleListNewItem.tsx | 16 +-
.../feature/FeatureView/FeatureView.jsx | 11 +-
.../AddTagDialog/AddTagDialog.tsx | 9 +-
.../FeatureOverviewEnvSwitch.tsx | 30 +-
.../FeatureOverviewEnvSwitches.tsx | 5 +-
.../FeatureOverviewEnvironment.tsx | 6 +-
.../FeatureOverviewEnvironmentBody.tsx | 34 +-
.../FeatureOverviewMetaData.tsx | 2 +-
.../FeatureOverviewTags.tsx | 26 +-
.../FeatureSettingsMetadata.tsx | 12 +-
.../FeatureSettingsProject.tsx | 56 ++-
.../FeatureStrategiesConfigure.tsx | 20 +-
.../FeatureStrategiesEnvironmentList.tsx | 2 -
.../useFeatureStrategiesEnvironmentList.ts | 20 +-
.../FeatureStrategiesEnvironments.tsx | 21 +-
.../FeatureStrategyEditable.tsx | 51 ++-
.../FeatureStrategyCard.tsx | 26 +-
.../FeatureStrategyAccordionBody.tsx | 355 ++++++++--------
.../AddFeatureVariant/AddFeatureVariant.tsx | 12 +-
.../FeatureVariantsList.tsx | 70 ++--
.../feature/FeatureView2/FeatureView2.tsx | 24 +-
.../create/CopyFeature/CopyFeature.jsx | 21 +-
.../update-variant-component-test.jsx.snap | 4 +-
.../view-component-test.jsx.snap | 118 ++----
.../view/__tests__/view-component-test.jsx | 35 +-
frontend/src/component/menu/Header/Header.tsx | 4 +-
.../__snapshots__/routes-test.jsx.snap | 32 ++
frontend/src/component/menu/routes.js | 31 +-
.../src/component/project/Project/Project.tsx | 6 +-
.../project/ProjectCard/ProjectCard.tsx | 19 +-
.../ProjectEnvironment/ProjectEnvironment.tsx | 24 +-
.../project/ProjectList/ProjectList.tsx | 5 -
.../project/form-project-component.tsx | 4 +-
.../AccessProvider/AccessProvider.tsx | 33 +-
.../providers/AccessProvider/permissions.ts | 6 +
.../providers/SWRProvider/SWRProvider.tsx | 12 +-
.../providers/UIProvider/UIProvider.tsx | 26 ++
.../list-component-test.jsx.snap | 101 +----
.../tag-type-create-component-test.js.snap | 114 +----
.../tag-type-list-component-test.js.snap | 96 +----
.../user/Authentication/Authentication.tsx | 1 -
frontend/src/constants/flags.ts | 1 +
frontend/src/contexts/UIContext.ts | 19 +
.../src/hooks/api/actions/useApi/useApi.ts | 5 +-
.../useProjectRolesApi/useProjectRolesApi.ts | 88 ++++
.../getters/useProjectRole/useProjectRole.ts | 35 ++
.../useProjectRolePermissions.ts | 61 +++
.../src/hooks/api/getters/useUser/useUser.ts | 1 +
frontend/src/hooks/useToast.tsx | 54 ++-
frontend/src/index.tsx | 27 +-
frontend/src/interfaces/project.ts | 17 +
frontend/src/interfaces/role.ts | 6 +
frontend/src/interfaces/user.ts | 1 +
94 files changed, 2755 insertions(+), 1641 deletions(-)
delete mode 100644 frontend/src/component/admin/ProjectRolesv1/RolesList.tsx
delete mode 100644 frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.tsx
create mode 100644 frontend/src/component/admin/project-roles/CreateProjectRole/CreateProjectRole.tsx
create mode 100644 frontend/src/component/admin/project-roles/EditProjectRole/EditProjectRole.tsx
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.styles.ts
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.tsx
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx
rename frontend/src/component/admin/{ProjectRolesv1/RolesListItem/RoleListItem.styles.ts => project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.styles.ts} (68%)
create mode 100644 frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.tsx
rename frontend/src/component/admin/{ProjectRolesv1 => project-roles/ProjectRoles}/ProjectRoles.styles.ts (100%)
rename frontend/src/component/admin/{ProjectRolesv1 => project-roles/ProjectRoles}/ProjectRoles.tsx (71%)
create mode 100644 frontend/src/component/admin/project-roles/hooks/useProjectRoleForm.ts
create mode 100644 frontend/src/component/common/Codebox/Codebox.styles.ts
create mode 100644 frontend/src/component/common/Codebox/Codebox.tsx
create mode 100644 frontend/src/component/common/FormTemplate/FormTemplate.styles.ts
create mode 100644 frontend/src/component/common/FormTemplate/FormTemplate.tsx
delete mode 100644 frontend/src/component/common/Toast/Toast.tsx
create mode 100644 frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts
create mode 100644 frontend/src/component/common/ToastRenderer/Toast/Toast.tsx
create mode 100644 frontend/src/component/common/ToastRenderer/ToastRenderer.tsx
create mode 100644 frontend/src/component/providers/UIProvider/UIProvider.tsx
create mode 100644 frontend/src/constants/flags.ts
create mode 100644 frontend/src/contexts/UIContext.ts
create mode 100644 frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts
create mode 100644 frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts
create mode 100644 frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts
diff --git a/frontend/package.json b/frontend/package.json
index a7db18e06f..adbf327637 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/common.styles.js b/frontend/src/common.styles.js
index a8605de4e4..5feab5bd2c 100644
--- a/frontend/src/common.styles.js
+++ b/frontend/src/common.styles.js
@@ -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',
diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx
index f6a048b51e..81730c5347 100644
--- a/frontend/src/component/App.tsx
+++ b/frontend/src/component/App.tsx
@@ -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 (
@@ -141,7 +140,7 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
}
/>
- {toast}
+
}
/>
diff --git a/frontend/src/component/admin/ProjectRolesv1/RolesList.tsx b/frontend/src/component/admin/ProjectRolesv1/RolesList.tsx
deleted file mode 100644
index fdbda0289f..0000000000
--- a/frontend/src/component/admin/ProjectRolesv1/RolesList.tsx
+++ /dev/null
@@ -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 (
-
- );
- });
- };
-
- if (!roles) return null;
-
- return (
-
-
-
-
-
- Project Role
- Description
-
- {hasAccess(ADMIN) ? 'Action' : ''}
-
-
-
- {renderRoles()}
-
-
-
-
- );
-};
-
-export default RolesList;
diff --git a/frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.tsx b/frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.tsx
deleted file mode 100644
index c12b250b1a..0000000000
--- a/frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- {name}
-
-
-
-
- {description}
-
-
-
-
- {
- console.log('hi');
- }}
- permission={ADMIN}
- >
-
-
- {
- console.log('hi');
- }}
- permission={ADMIN}
- >
-
-
-
-
- );
-};
-
-export default RoleListItem;
diff --git a/frontend/src/component/admin/admin-menu.jsx b/frontend/src/component/admin/admin-menu.jsx
index 9e90069983..8169e7e1b3 100644
--- a/frontend/src/component/admin/admin-menu.jsx
+++ b/frontend/src/component/admin/admin-menu.jsx
@@ -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 (
-
+
}
>
- {SHOW_PROJECT_ROLES && (
+ {flags.RE && (
{
const { uiConfig } = useUiConfig();
const [showDelete, setShowDelete] = useState(false);
const [delToken, setDeleteToken] = useState();
- 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) => {
{tokens.map(item => {
return (
-
+
{
elseShow={renderApiTokens(tokens)}
/>
- {toast}
+
{
+ /* @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 (
+
+
+
+ );
+};
+
+export default CreateProjectRole;
diff --git a/frontend/src/component/admin/project-roles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/project-roles/EditProjectRole/EditProjectRole.tsx
new file mode 100644
index 0000000000..56dd493efb
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/EditProjectRole/EditProjectRole.tsx
@@ -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 (
+
+
+
+ );
+};
+
+export default EditProjectRole;
diff --git a/frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts b/frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts
new file mode 100644
index 0000000000..ccc0efa8a2
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts
@@ -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,
+ },
+}));
diff --git a/frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx b/frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx
new file mode 100644
index 0000000000..33c4bf24c4
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx
@@ -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({});
+ 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 (
+
+ handlePermissionChange(
+ permission,
+ environment.name
+ )
+ }
+ color="primary"
+ />
+ }
+ label={permission.displayName || 'Dummy permission'}
+ />
+ );
+ }
+ );
+
+ envPermissions.push(
+
+ checkAllEnvironmentPermissions(environment?.name)
+ }
+ color="primary"
+ />
+ }
+ label={'Select all permissions for this env'}
+ />
+ );
+
+ return envPermissions;
+ };
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ ({permissionCount} /{' '}
+ {environment?.permissions?.length} permissions)
+
+
+
+
+ {renderPermissions()}
+
+
+
+ );
+};
+
+export default EnvironmentPermissionAccordion;
diff --git a/frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.styles.ts b/frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.styles.ts
new file mode 100644
index 0000000000..f2085185d2
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.tsx b/frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.tsx
new file mode 100644
index 0000000000..5c84ab364c
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoleForm/ProjectRoleForm.tsx
@@ -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>;
+ setRoleDesc: React.Dispatch>;
+ 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 (
+
+ handlePermissionChange(permission, 'project')
+ }
+ color="primary"
+ />
+ }
+ label={permission.displayName}
+ />
+ );
+ });
+
+ projectPermissions.push(
+ checkAllProjectPermissions()}
+ color="primary"
+ />
+ }
+ label={'Select all project permissions'}
+ />
+ );
+
+ return projectPermissions;
+ };
+
+ const renderEnvironmentPermissions = () => {
+ return environments.map(environment => {
+ return (
+
+ );
+ });
+ };
+
+ return (
+
+ );
+};
+
+export default ProjectRoleForm;
diff --git a/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts
new file mode 100644
index 0000000000..a2e0213f3a
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts
@@ -0,0 +1,10 @@
+import { makeStyles } from '@material-ui/styles';
+
+export const useStyles = makeStyles(theme => ({
+ deleteParagraph: {
+ marginTop: '2rem',
+ },
+ roleDeleteInput: {
+ marginTop: '1rem',
+ },
+}));
diff --git a/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx
new file mode 100644
index 0000000000..ba0bd06058
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx
@@ -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>;
+ handleDeleteRole: (id: number) => Promise;
+ confirmName: string;
+ setConfirmName: React.Dispatch>;
+}
+
+const ProjectRoleDeleteConfirm = ({
+ role,
+ open,
+ setDeldialogue,
+ handleDeleteRole,
+ confirmName,
+ setConfirmName,
+}: IProjectRoleDeleteConfirmProps) => {
+ const styles = useStyles();
+
+ const handleChange = (e: React.ChangeEvent) =>
+ setConfirmName(e.currentTarget.value);
+
+ const handleCancel = () => {
+ setDeldialogue(false);
+ setConfirmName('');
+ };
+ const formId = 'delete-project-role-confirmation-form';
+ return (
+ handleDeleteRole(role.id)}
+ disabledPrimaryButton={role?.name !== confirmName}
+ onClose={handleCancel}
+ formId={formId}
+ >
+
+ Danger. Deleting this role will result in removing all
+ permissions that are active in this environment across all
+ feature toggles.
+
+
+
+ In order to delete this role, please enter the name of the role
+ in the textfield below: {role?.name}
+
+
+
+
+ );
+};
+
+export default ProjectRoleDeleteConfirm;
diff --git a/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx
new file mode 100644
index 0000000000..12f6e453c7
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx
@@ -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(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 (
+
+ );
+ });
+ };
+
+ if (!roles) return null;
+
+ return (
+
+
+
+
+
+ Project Role
+ Description
+
+ {hasAccess(ADMIN) ? 'Action' : ''}
+
+
+
+ {renderRoles()}
+
+
+
+
+
+ );
+};
+
+export default ProjectRoleList;
diff --git a/frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.styles.ts b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.styles.ts
similarity index 68%
rename from frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.styles.ts
rename to frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.styles.ts
index d6ab74eb03..d30f963c27 100644
--- a/frontend/src/component/admin/ProjectRolesv1/RolesListItem/RoleListItem.styles.ts
+++ b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.styles.ts
@@ -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],
+ },
}));
diff --git a/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.tsx b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.tsx
new file mode 100644
index 0000000000..fc32da759d
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoleList/ProjectRoleListItem/ProjectRoleListItem.tsx
@@ -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>;
+ setDelDialog: React.Dispatch>;
+}
+
+const BUILTIN_ROLE_TYPE = 'project';
+
+const RoleListItem = ({
+ id,
+ name,
+ type,
+ description,
+ setCurrentRole,
+ setDelDialog,
+}: IRoleListItemProps) => {
+ const history = useHistory();
+ const styles = useStyles();
+
+ return (
+ <>
+
+
+
+
+
+
+ {name}
+
+
+
+
+ {description}
+
+
+
+
+ {
+ history.push(`/admin/roles/${id}/edit`);
+ }}
+ permission={ADMIN}
+ >
+
+
+ {
+ setCurrentRole({ id, name, description });
+ setDelDialog(true);
+ }}
+ permission={ADMIN}
+ >
+
+
+
+
+ >
+ );
+};
+
+export default RoleListItem;
diff --git a/frontend/src/component/admin/ProjectRolesv1/ProjectRoles.styles.ts b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoles.styles.ts
similarity index 100%
rename from frontend/src/component/admin/ProjectRolesv1/ProjectRoles.styles.ts
rename to frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoles.styles.ts
diff --git a/frontend/src/component/admin/ProjectRolesv1/ProjectRoles.tsx b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoles.tsx
similarity index 71%
rename from frontend/src/component/admin/ProjectRolesv1/ProjectRoles.tsx
rename to frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoles.tsx
index 03464d6bb8..432fa70cd0 100644
--- a/frontend/src/component/admin/ProjectRolesv1/ProjectRoles.tsx
+++ b/frontend/src/component/admin/project-roles/ProjectRoles/ProjectRoles.tsx
@@ -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 = () => {
console.log('hi')}
+ onClick={() =>
+ history.push(
+ '/admin/create-project-role'
+ )
+ }
>
New Project role
@@ -48,7 +52,7 @@ const ProjectRoles = () => {
>
}
+ show={ }
elseShow={
You need instance admin to access this section.
diff --git a/frontend/src/component/admin/project-roles/hooks/useProjectRoleForm.ts b/frontend/src/component/admin/project-roles/hooks/useProjectRoleForm.ts
new file mode 100644
index 0000000000..12285388a1
--- /dev/null
+++ b/frontend/src/component/admin/project-roles/hooks/useProjectRoleForm.ts
@@ -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(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;
diff --git a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap
index c444d314f0..9f749cf23a 100644
--- a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap
+++ b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap
@@ -107,31 +107,6 @@ exports[`renders correctly with permissions 1`] = `
-
-
- Delete
-
-
@@ -153,377 +128,6 @@ exports[`renders correctly with permissions 1`] = `
-
-
-
-
-
-
-
- Application overview
-
-
-
-
-
- Edit application
-
-
-
-
-
-
-
-
-
-
-
-
- Implemented strategies
-
-
-
-
-
-
- 1
- Instances registered
-
-
-
-
-
-
-
- instance-1 (4.0)
-
-
-
- 123.123.123.123
- last seen at
-
-
- 23/02/2017, 15:56:49
-
-
-
-
-
-
-
-
-
-
-
-
`;
diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
index 50b3a412f9..c2a7cae1a6 100644
--- a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
+++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
@@ -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 (
-
-
+
+ {type === 'error' ? (
+
+ ) : (
+
+ )}
);
};
diff --git a/frontend/src/component/common/Codebox/Codebox.styles.ts b/frontend/src/component/common/Codebox/Codebox.styles.ts
new file mode 100644
index 0000000000..e9c93f45ce
--- /dev/null
+++ b/frontend/src/component/common/Codebox/Codebox.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/common/Codebox/Codebox.tsx b/frontend/src/component/common/Codebox/Codebox.tsx
new file mode 100644
index 0000000000..cdf0a4733d
--- /dev/null
+++ b/frontend/src/component/common/Codebox/Codebox.tsx
@@ -0,0 +1,16 @@
+import { useStyles } from './Codebox.styles';
+
+interface ICodeboxProps {
+ text: string;
+}
+
+const Codebox = ({ text }: ICodeboxProps) => {
+ const styles = useStyles();
+ return (
+
+ );
+};
+
+export default Codebox;
diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts
new file mode 100644
index 0000000000..7e596b78d7
--- /dev/null
+++ b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts
@@ -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' },
+}));
diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx
new file mode 100644
index 0000000000..d8a5b7f59e
--- /dev/null
+++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx
@@ -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
= ({
+ 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 (
+
+
+ {title}
+ {description}
+
+
+
+
+ API Command{' '}
+
+
+
+
+
+ >
+ }
+ />
+
+
+ }
+ elseShow={<>{children}>}
+ />{' '}
+
+
+ );
+};
+
+export default FormTemplate;
diff --git a/frontend/src/component/common/NoItems/NoItemsStrategies/NoItemsStrategies.tsx b/frontend/src/component/common/NoItems/NoItemsStrategies/NoItemsStrategies.tsx
index f5e0577804..9abbcae0fb 100644
--- a/frontend/src/component/common/NoItems/NoItemsStrategies/NoItemsStrategies.tsx
+++ b/frontend/src/component/common/NoItems/NoItemsStrategies/NoItemsStrategies.tsx
@@ -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={
diff --git a/frontend/src/component/common/PageContent/PageContent.jsx b/frontend/src/component/common/PageContent/PageContent.jsx
index 3579359096..0d0bd30664 100644
--- a/frontend/src/component/common/PageContent/PageContent.jsx
+++ b/frontend/src/component/common/PageContent/PageContent.jsx
@@ -11,7 +11,7 @@ const PageContent = ({
headerContent,
disablePadding = false,
disableBorder = false,
- bodyClass = undefined,
+ bodyClass = '',
...rest
}) => {
const styles = useStyles();
diff --git a/frontend/src/component/common/PermissionButton/PermissionButton.tsx b/frontend/src/component/common/PermissionButton/PermissionButton.tsx
index f91706291d..2dcef4fbc7 100644
--- a/frontend/src/component/common/PermissionButton/PermissionButton.tsx
+++ b/frontend/src/component/common/PermissionButton/PermissionButton.tsx
@@ -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 {
- permission: string;
- tooltip: string;
+interface IPermissionIconButtonProps
+ extends React.HTMLProps {
+ permission: string | string[];
+ tooltip?: string;
onClick?: (e: any) => void;
disabled?: boolean;
projectId?: string;
+ environmentId?: string;
}
const PermissionButton: React.FC = ({
@@ -20,13 +21,38 @@ const PermissionButton: React.FC = ({
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
diff --git a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx
index 3c02b8b654..903d29713b 100644
--- a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx
+++ b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx
@@ -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 {
+interface IPermissionIconButtonProps
+ extends React.HTMLProps {
permission: string;
Icon?: React.ElementType;
tooltip: string;
onClick?: (e: any) => void;
projectId?: string;
+ environmentId?: string;
}
const PermissionIconButton: React.FC = ({
@@ -18,13 +19,19 @@ const PermissionIconButton: React.FC = ({
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 || ''
diff --git a/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx b/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx
index ea9ec14acc..4dbfb5d1e6 100644
--- a/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx
+++ b/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx
@@ -9,6 +9,7 @@ interface IPermissionSwitchProps extends OverridableComponent {
onChange?: (e: any) => void;
disabled?: boolean;
projectId?: string;
+ environmentId?: string;
checked: boolean;
}
@@ -17,14 +18,21 @@ const PermissionSwitch: React.FC = ({
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
diff --git a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx
index 8a7e924b42..aa18fd1ff0 100644
--- a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx
+++ b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx
@@ -10,6 +10,7 @@ interface IResponsiveButtonProps {
disabled?: boolean;
permission?: string;
projectId?: string;
+ environmentId?: string;
maxWidth: string;
}
@@ -21,6 +22,7 @@ const ResponsiveButton: React.FC = ({
disabled = false,
children,
permission,
+ environmentId,
projectId,
...rest
}) => {
@@ -35,6 +37,7 @@ const ResponsiveButton: React.FC = ({
onClick={onClick}
permission={permission}
projectId={projectId}
+ environmentId={environmentId}
data-loading
{...rest}
>
@@ -49,6 +52,7 @@ const ResponsiveButton: React.FC = ({
color="primary"
variant="contained"
disabled={disabled}
+ environmentId={environmentId}
data-loading
{...rest}
>
diff --git a/frontend/src/component/common/Toast/Toast.tsx b/frontend/src/component/common/Toast/Toast.tsx
deleted file mode 100644
index 3773fa1a62..0000000000
--- a/frontend/src/component/common/Toast/Toast.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- {text}
-
-
-
-
- );
-};
-
-export default Toast;
diff --git a/frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts b/frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts
new file mode 100644
index 0000000000..1f3872631f
--- /dev/null
+++ b/frontend/src/component/common/ToastRenderer/Toast/Toast.styles.ts
@@ -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%',
+ },
+ },
+}));
diff --git a/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx b/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx
new file mode 100644
index 0000000000..1c11cd10db
--- /dev/null
+++ b/frontend/src/component/common/ToastRenderer/Toast/Toast.tsx
@@ -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 (
+
+ );
+ });
+
+ return styledElements;
+ };
+
+ const hide = () => {
+ setToast((prev: IToastData) => ({ ...prev, show: false }));
+ };
+
+ return (
+
+
+
+ {confetti && renderConfetti()}
+
+
+
+
+
+
+
{title}
+
+ {text}}
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Toast;
diff --git a/frontend/src/component/common/ToastRenderer/ToastRenderer.tsx b/frontend/src/component/common/ToastRenderer/ToastRenderer.tsx
new file mode 100644
index 0000000000..246b240029
--- /dev/null
+++ b/frontend/src/component/common/ToastRenderer/ToastRenderer.tsx
@@ -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 (
+
+
+
+
+
+ );
+};
+
+export default ToastRenderer;
diff --git a/frontend/src/component/common/flags.js b/frontend/src/component/common/flags.js
index d32fa9e92b..0ecfaba1e3 100644
--- a/frontend/src/component/common/flags.js
+++ b/frontend/src/component/common/flags.js
@@ -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;
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
index 2eb5f8bb3f..22706fbb3f 100644
--- a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
@@ -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) => {
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 = () => {
}>
- }
+ show={ }
elseShow={
-
-
- 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.
-
+
+
+ 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.
+
-
-
-
-
-
+
+
- Submit
- {' '}
-
- Cancel
+ Go back
-
-
-
- } elseShow={
- <>
-
- Currently Unleash does not support more than 5 environments. If you need more please reach out.
-
-
- Go back
- >
- } />
-
+ >
+ }
+ />
}
/>
- {toast}
);
};
diff --git a/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx b/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx
index e32a0f8804..f951fec3e3 100644
--- a/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx
+++ b/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx
@@ -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}
);
};
diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx
index d7c9d627b8..a4f4723501 100644
--- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx
+++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListItem/FeatureToggleListItem.jsx
@@ -148,7 +148,10 @@ const FeatureToggleListItem = ({
diff --git a/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx
index e8f9118c71..c3ac3758a1 100644
--- a/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx
+++ b/frontend/src/component/feature/FeatureToggleListNew/FeatureToggleListNewItem/FeatureToggleListNewItem.tsx
@@ -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 = ({
{
handleToggle(env);
@@ -162,7 +159,6 @@ const FeatureToggleListNewItem = ({
);
})}
- {toast}
{
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}
);
};
diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/AddTagDialog/AddTagDialog.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/AddTagDialog/AddTagDialog.tsx
index a4d72e5b18..002f25c8af 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureOverview/AddTagDialog/AddTagDialog.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/AddTagDialog/AddTagDialog.tsx
@@ -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) => {
>
- {toast}
>
);
};
diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx
index 703c763630..96d6a68999 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch.tsx
@@ -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 (
{content}
diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx
index bd8e96f492..5161f2352a 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvSwitches/FeatureOverviewEnvSwitches.tsx
@@ -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
();
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 = () => {
{
setEnvironmentName(env.name);
setShowInfoBox(true);
@@ -49,7 +47,6 @@ const FeatureOverviewEnvSwitches = () => {
{renderEnvironmentSwitches()}
- {toast}
history.push(strategiesLink)}
className={styles.addStrategyButton}
>
diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody.tsx
index 2b750c0bbd..21349904a2 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody.tsx
@@ -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();
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={
<>
-
- history.push(strategiesLink)}
- maxWidth="700px"
- permission={UPDATE_FEATURE}
- >
- Add strategy
-
-
+
+
+ Edit strategies
+
+
+ }
+ />
{
condition={tags.length > 0}
show={
-
+
}
/>
diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx
index 6120be660b..33f484fd51 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx
@@ -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 {
+ projectId: string;
+}
+
+const FeatureOverviewTags: React.FC = ({
+ projectId,
+ ...rest
+}) => {
const [showDelDialog, setShowDelDialog] = useState(false);
const [selectedTag, setSelectedTag] = useState({
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 (
-
+
{
@@ -136,7 +139,6 @@ const FeatureOverviewTags = () => {
elseShow={No tags to display
}
/>
- {toast}
);
};
diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx
index 70b7122ed3..0c7bd6e577 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx
@@ -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 = () => {
}
/>
-
- {toast}
>
);
};
diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx
index 366778a393..c3fa5dd4f9 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx
@@ -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 (
<>
{
onChange={e => setProject(e.target.value)}
label="Project"
enabled={editable}
- filter={projectFilterGenerator({ permissions }, CREATE_FEATURE)}
+ filter={filterProjects()}
/>
setShowConfirmDialog(true)}
projectId={projectId}
@@ -91,7 +116,6 @@ const FeatureSettingsProject = () => {
onClose={() => setShowConfirmDialog(false)}
onClick={updateProject}
/>
- {toast}
>
);
};
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx
index a65741b061..e83070d3be 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx
@@ -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>;
-}
-const FeatureStrategiesConfigure = ({
- setToastData,
-}: IFeatureStrategiesConfigure) => {
+const FeatureStrategiesConfigure = () => {
const history = useHistory();
+ const { setToastData, setToastApiError } = useToast();
const { projectId, featureId } = useParams();
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);
}
};
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx
index eca39f750c..d92c285814 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx
@@ -34,7 +34,6 @@ const FeatureStrategiesEnvironmentList = ({
const {
activeEnvironmentsRef,
- toast,
setToastData,
deleteStrategy,
updateStrategy,
@@ -180,7 +179,6 @@ const FeatureStrategiesEnvironmentList = ({
/>
{dropboxMarkup}
- {toast}
{delDialogueMarkup}
{productionGuardMarkup}
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts
index 6c0674661b..5d21ef1536 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts
@@ -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,
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx
index 1affd1eb1d..5845f78f53 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx
@@ -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();
- 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
@@ -376,14 +380,9 @@ const FeatureStrategiesEnvironments = () => {
{renderTabPanels()}
- }
+ show={ }
/>
- {toast}
>
}
/>
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx
index 902d4bc974..7f7c0c2dcf 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx
@@ -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();
@@ -143,27 +144,23 @@ const FeatureStrategyEditable = ({
setStrategyConstraints={setStrategyConstraints}
dirty={dirty[strategy.id]}
actions={
-
- {
- e.stopPropagation();
- setDelDialog({
- strategyId: strategy.id,
- show: true,
- });
- }}
- >
-
-
-
- }
- />
+
+ {
+ e.stopPropagation();
+ setDelDialog({
+ strategyId: strategy.id,
+ show: true,
+ });
+ }}
+ >
+
+
+
}
>
Discard changes
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx
index b031194aba..4dd9b6f82d 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx
@@ -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
();
+ const { featureId, projectId } = useParams();
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 = ({
-
-
+
{readableName}
diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx
index dc47742308..2de084bb43 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx
@@ -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
>;
}
-const FeatureStrategyAccordionBody: React.FC =
- ({
- strategy,
- updateParameters,
- children,
- constraints,
- updateConstraints,
- setStrategyConstraints,
- }) => {
- const styles = useStyles();
- const { projectId } = useParams();
- 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();
+ 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 (
-
- No constraints configured
-
- );
- }
-
- return constraints.map((constraint, index) => {
- return (
- {
- 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 (
-
-
-
- Constraints
-
- {renderConstraints()}
-
-
- + Add constraints
-
- >
- }
- />
-
-
-
-
-
-
- }
- />
-
- {children}
-
- );
+ 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 (
+
+ No constraints configured
+
+ );
+ }
+
+ return constraints.map((constraint, index) => {
+ return (
+ {
+ 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 (
+
+
+ Constraints
+ {renderConstraints()}
+
+
+ + Add constraints
+
+ >
+ }
+ />
+
+
+
+
+
+
+ }
+ />
+
+ {children}
+
+ );
+};
+
export default FeatureStrategyAccordionBody;
diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx
index d1c854cf91..3f50304527 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx
@@ -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 = ({
{/* 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 */}
1) || (!editing && variants.length > 0)}
+ condition={
+ (editing && variants.length > 1) ||
+ (!editing && variants.length > 0)
+ }
show={
{
const [variants, setVariants] = useState([]);
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 (
{
is used to ensure consistent traffic allocation across
variants.{' '}
Read more
@@ -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 (
-
+
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
- {renderStickiness()}
+
{
title={editing ? 'Edit variant' : 'Add variant'}
/>
- {toast}
{delDialogueMarkup}
);
diff --git a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx
index ed6f1a39e8..c70a121eea 100644
--- a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx
+++ b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx
@@ -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 = () => {
{
{
open={openTagDialog}
setOpen={setOpenTagDialog}
/>
-
- {toast}
}
elseShow={renderFeatureNotExist()}
diff --git a/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx b/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx
index 84b7401ea5..8ac61c9759 100644
--- a/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx
+++ b/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx
@@ -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
{copyToggleName}
diff --git a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap
index eaa6b76035..8fd0e47e58 100644
--- a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap
+++ b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap
@@ -496,10 +496,10 @@ exports[`renders correctly with with variants 1`] = `
Stickiness
diff --git a/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap b/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap
index f037aede64..f37a369297 100644
--- a/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap
+++ b/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap
@@ -29,7 +29,7 @@ exports[`renders correctly with one feature 1`] = `
}
>
Release
@@ -131,7 +94,7 @@ exports[`renders correctly with one feature 1`] = `
/>
@@ -141,10 +104,10 @@ exports[`renders correctly with one feature 1`] = `
Feature type
@@ -200,10 +163,10 @@ exports[`renders correctly with one feature 1`] = `
Project
@@ -234,8 +197,8 @@ exports[`renders correctly with one feature 1`] = `
className="MuiSwitch-root"
>
@@ -263,9 +226,6 @@ exports[`renders correctly with one feature 1`] = `
className="MuiSwitch-thumb"
/>
-
Clone
-
-
@@ -391,15 +345,12 @@ exports[`renders correctly with one feature 1`] = `
>
Archive
-
Metrics
-
Variants
-
Log
-
-
@@ -566,7 +500,7 @@ exports[`renders correctly with one feature 1`] = `
role="tabpanel"
>
({
__esModule: true,
@@ -73,22 +74,24 @@ test('renders correctly with one feature', () => {
-
-
-
+
+
+
+
+
diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx
index 379f0773cf..46d337caa4 100644
--- a/frontend/src/component/menu/Header/Header.tsx
+++ b/frontend/src/component/menu/Header/Header.tsx
@@ -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 = () => {
{
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 = () => {
{renderTabContent()}
- {toast}
);
};
diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.tsx b/frontend/src/component/project/ProjectCard/ProjectCard.tsx
index 113f31c30f..7bc41e92cc 100644
--- a/frontend/src/component/project/ProjectCard/ProjectCard.tsx
+++ b/frontend/src/component/project/ProjectCard/ProjectCard.tsx
@@ -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);
diff --git a/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx
index b114595986..d47b4fb40b 100644
--- a/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx
+++ b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx
@@ -28,7 +28,7 @@ interface ProjectEnvironmentListProps {
const ProjectEnvironmentList = ({ projectId }: ProjectEnvironmentListProps) => {
// api state
const [envs, setEnvs] = useState([]);
- 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) => {
}
/>
-
- {toast}
);
diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx
index 68fea55245..98be2717f0 100644
--- a/frontend/src/component/project/ProjectList/ProjectList.tsx
+++ b/frontend/src/component/project/ProjectList/ProjectList.tsx
@@ -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({});
@@ -103,7 +101,6 @@ const ProjectListNew = () => {
health={project?.health}
id={project?.id}
featureCount={project?.featureCount}
- setToastData={setToastData}
/>
);
@@ -122,7 +119,6 @@ const ProjectListNew = () => {
memberCount={2}
health={95}
featureCount={4}
- setToastData={setToastData}
/>
);
});
@@ -157,7 +153,6 @@ const ProjectListNew = () => {
elseShow={renderProjects()}
/>
- {toast}
);
diff --git a/frontend/src/component/project/form-project-component.tsx b/frontend/src/component/project/form-project-component.tsx
index 78d94c203e..de793ba268 100644
--- a/frontend/src/component/project/form-project-component.tsx
+++ b/frontend/src/component/project/form-project-component.tsx
@@ -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({});
@@ -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}`);
}
};
diff --git a/frontend/src/component/providers/AccessProvider/AccessProvider.tsx b/frontend/src/component/providers/AccessProvider/AccessProvider.tsx
index 94978d5a0a..f03d01607a 100644
--- a/frontend/src/component/providers/AccessProvider/AccessProvider.tsx
+++ b/frontend/src/component/providers/AccessProvider/AccessProvider.tsx
@@ -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 = ({ store, children }) => {
+ const { permissions } = useUser();
const isAdminHigherOrder = () => {
let called = false;
let result = false;
@@ -33,19 +36,37 @@ const AccessProvider: FC = ({ 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;
}
diff --git a/frontend/src/component/providers/AccessProvider/permissions.ts b/frontend/src/component/providers/AccessProvider/permissions.ts
index cfc09f17dd..64e559d21e 100644
--- a/frontend/src/component/providers/AccessProvider/permissions.ts
+++ b/frontend/src/component/providers/AccessProvider/permissions.ts
@@ -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';
diff --git a/frontend/src/component/providers/SWRProvider/SWRProvider.tsx b/frontend/src/component/providers/SWRProvider/SWRProvider.tsx
index d745302624..0a082cf9ad 100644
--- a/frontend/src/component/providers/SWRProvider/SWRProvider.tsx
+++ b/frontend/src/component/providers/SWRProvider/SWRProvider.tsx
@@ -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>;
isUnauthorized: () => boolean;
}
@@ -13,16 +12,15 @@ const INVALID_TOKEN_ERROR = 'InvalidTokenError';
const SWRProvider: React.FC = ({
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 = ({
}
if (!isUnauthorized()) {
- setToastData({
- show: true,
- type: 'error',
- text: error.message,
- });
+ setToastApiError(error.message);
}
};
diff --git a/frontend/src/component/providers/UIProvider/UIProvider.tsx b/frontend/src/component/providers/UIProvider/UIProvider.tsx
new file mode 100644
index 0000000000..ff4c8d6b88
--- /dev/null
+++ b/frontend/src/component/providers/UIProvider/UIProvider.tsx
@@ -0,0 +1,26 @@
+import React, { useState } from 'react';
+
+import UIContext, { IToastData } from '../../../contexts/UIContext';
+
+const UIProvider: React.FC = ({ children }) => {
+ const [toastData, setToast] = useState({
+ title: '',
+ text: '',
+ components: [],
+ show: false,
+ persist: false,
+ type: '',
+ });
+
+ const context = React.useMemo(
+ () => ({
+ setToast,
+ toastData,
+ }),
+ [toastData]
+ );
+
+ return {children} ;
+};
+
+export default UIProvider;
diff --git a/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap b/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap
index 7a872fe07e..b2ef224934 100644
--- a/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap
+++ b/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap
@@ -162,51 +162,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
-
-
-
- Add new strategy
-
-
-
-
-
-
+ />
-
-
diff --git a/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-create-component-test.js.snap b/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-create-component-test.js.snap
index c7c3122dc3..29b3b14a98 100644
--- a/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-create-component-test.js.snap
+++ b/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-create-component-test.js.snap
@@ -43,60 +43,9 @@ exports[`it supports editMode 1`] = `
className="addTagTypeForm contentSpacing"
onSubmit={[Function]}
>
-
-
-
-
- Update
-
-
-
-
-
- Cancel
-
-
-
-
+
+ You do not have permissions to save.
+
@@ -146,60 +95,9 @@ exports[`renders correctly for creating 1`] = `
className="addTagTypeForm contentSpacing"
onSubmit={[Function]}
>
-
-
-
-
- Create
-
-
-
-
-
- Cancel
-
-
-
-
+
+ You do not have permissions to save.
+
diff --git a/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-list-component-test.js.snap b/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-list-component-test.js.snap
index 61eaf28dfe..25cbb51aa8 100644
--- a/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-list-component-test.js.snap
+++ b/frontend/src/component/tag-types/__tests__/__snapshots__/tag-type-list-component-test.js.snap
@@ -28,35 +28,7 @@ exports[`renders a list with elements correctly 1`] = `
-
-
- Add new tag type
-
-
-
-
+ />
-
-
-
-
-
-
-
-
@@ -177,32 +110,7 @@ exports[`renders an empty list correctly 1`] = `
-
-
- Add new tag type
-
-
-
+ />
>;
+}
+
+const UIContext = React.createContext
(null);
+
+export default UIContext;
diff --git a/frontend/src/hooks/api/actions/useApi/useApi.ts b/frontend/src/hooks/api/actions/useApi/useApi.ts
index a81e39e0a2..6c68182107 100644
--- a/frontend/src/hooks/api/actions/useApi/useApi.ts
+++ b/frontend/src/hooks/api/actions/useApi/useApi.ts
@@ -57,11 +57,12 @@ const useAPI = ({
const makeRequest = async (
apiCaller: any,
requestId?: string,
- loading: boolean = true
+ loadingOn: boolean = true
): Promise => {
- if (loading) {
+ if (loadingOn) {
setLoading(true);
}
+
try {
const res = await apiCaller();
setLoading(false);
diff --git a/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts b/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts
new file mode 100644
index 0000000000..679635a048
--- /dev/null
+++ b/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts
@@ -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;
diff --git a/frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts b/frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts
new file mode 100644
index 0000000000..d3eb39ee7a
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts
@@ -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;
diff --git a/frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts b/frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts
new file mode 100644
index 0000000000..824c45fb38
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts
@@ -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;
diff --git a/frontend/src/hooks/api/getters/useUser/useUser.ts b/frontend/src/hooks/api/getters/useUser/useUser.ts
index 5dcd5eb1a0..6297eeec48 100644
--- a/frontend/src/hooks/api/getters/useUser/useUser.ts
+++ b/frontend/src/hooks/api/getters/useUser/useUser.ts
@@ -12,6 +12,7 @@ const useUser = (
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
+ refreshInterval: 15000,
}
) => {
const fetcher = () => {
diff --git a/frontend/src/hooks/useToast.tsx b/frontend/src/hooks/useToast.tsx
index 411a55b3a4..e7f2f341ca 100644
--- a/frontend/src/hooks/useToast.tsx
+++ b/frontend/src/hooks/useToast.tsx
@@ -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>;
+interface IToastOptions {
+ title: string;
+ text?: string;
+ type: string;
+ persist?: boolean;
+ confetti?: boolean;
+}
+
const useToast = () => {
- const [toastData, setToastData] = useState({
- 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 = (
-
- );
- 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;
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index 462b9f0e01..028bac1bcb 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -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(
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
document.getElementById('app')
diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts
index f905258ec4..a915cd1996 100644
--- a/frontend/src/interfaces/project.ts
+++ b/frontend/src/interfaces/project.ts
@@ -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[];
+}
diff --git a/frontend/src/interfaces/role.ts b/frontend/src/interfaces/role.ts
index b53a25bd89..efb2b62a59 100644
--- a/frontend/src/interfaces/role.ts
+++ b/frontend/src/interfaces/role.ts
@@ -6,4 +6,10 @@ interface IRole {
type: string;
}
+export interface IProjectRole {
+ id: number;
+ name: string;
+ description: string;
+}
+
export default IRole;
diff --git a/frontend/src/interfaces/user.ts b/frontend/src/interfaces/user.ts
index 3678971e23..ae48d444e1 100644
--- a/frontend/src/interfaces/user.ts
+++ b/frontend/src/interfaces/user.ts
@@ -13,6 +13,7 @@ export interface ISplash {
export interface IPermission {
permission: string;
project: string;
+ displayName: string;
}
interface IAuthDetails {