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

Role information

+ +
+

+ What is your role name? +

+ setRoleName(e.target.value)} + error={Boolean(errors.name)} + errorText={errors.name} + onFocus={() => clearErrors()} + onBlur={validateNameUniqueness} + /> + +

+ What is this role for? +

+ setRoleDesc(e.target.value)} + /> +
+
+ + You must select at least one permission for a role. + + } + /> +
+

Project permissions

+
{renderProjectPermissions()}
+

Environment permissions

+
{renderEnvironmentPermissions()}
+
+ + + {submitButtonText} role + +
+
+ ); +}; + +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 = () => { @@ -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`] = ` - @@ -153,377 +128,6 @@ exports[`renders correctly with permissions 1`] = `

-
-
-
-
-
- - -
-
-
-
-
- - -
`; 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 ( +
+
{text}
+
+ ); +}; + +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 ( +
+ +
+ } + 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. +

-
- -

- Environment Id and name -

+ + +

+ Environment Id and name +

-
+
+

+ Unique env name for SDK + configurations. +

+ +
+ + + +
+ {' '} + +
+ +
+ } + elseShow={ + <> +

- Unique env name for SDK configurations. + Currently Unleash does not support more + than 5 environments. If you need more + please reach out.

- -
- - - -
+ +
{' '} - -
- -
- } elseShow={ - <> - -

Currently Unleash does not support more than 5 environments. If you need more please reach out.

-
-
- - - } /> - + + } + /> } /> - {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`] = ` } >
another's description - - - - - - - -

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 -

-
@@ -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`] = `
- - - -
+ />
-
- -
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]} > -
-
- -   - -
-
+ + You do not have permissions to save. + @@ -146,60 +95,9 @@ exports[`renders correctly for creating 1`] = ` className="addTagTypeForm contentSpacing" onSubmit={[Function]} > -
-
- -   - -
-
+ + 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`] = `
- -
+ />
- @@ -177,32 +110,7 @@ exports[`renders an empty list correctly 1`] = `
- -
+ />
>; +} + +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 {