diff --git a/frontend/src/component/common/Toast/Toast.tsx b/frontend/src/component/common/Toast/Toast.tsx index c626edf1d0..3773fa1a62 100644 --- a/frontend/src/component/common/Toast/Toast.tsx +++ b/frontend/src/component/common/Toast/Toast.tsx @@ -1,13 +1,11 @@ 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 { - show: boolean; +interface IToastProps extends IToast { onClose: () => void; - type: string; - text: string; autoHideDuration?: number; } diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap index b2e90b24de..3463279fa2 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -184,6 +184,15 @@ Array [ "title": ":id", "type": "protected", }, + Object { + "component": [Function], + "layout": "main", + "menu": Object {}, + "parent": "/projects", + "path": "/projects/:id/environments", + "title": "Environments", + "type": "protected", + }, Object { "component": [Function], "layout": "main", diff --git a/frontend/src/component/menu/__tests__/routes-test.jsx b/frontend/src/component/menu/__tests__/routes-test.jsx index f62d60785b..71f7b64d6a 100644 --- a/frontend/src/component/menu/__tests__/routes-test.jsx +++ b/frontend/src/component/menu/__tests__/routes-test.jsx @@ -1,7 +1,7 @@ import { baseRoutes, getRoute } from '../routes'; test('returns all baseRoutes', () => { - expect(baseRoutes).toHaveLength(39); + expect(baseRoutes).toHaveLength(40); expect(baseRoutes).toMatchSnapshot(); }); diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 7f507c98dc..0c9d520bf0 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -17,6 +17,7 @@ import EditContextField from '../../page/context/edit'; import CreateProject from '../../page/project/create'; import EditProject from '../../page/project/edit'; import EditProjectAccess from '../../page/project/access'; +import EditProjectEnvironment from '../../page/project/environment'; import ListTagTypes from '../../page/tag-types'; import CreateTagType from '../../page/tag-types/create'; import EditTagType from '../../page/tag-types/edit'; @@ -225,6 +226,15 @@ export const routes = [ layout: 'main', menu: {}, }, + { + path: '/projects/:id/environments', + parent: '/projects', + title: 'Environments', + component: EditProjectEnvironment, + type: 'protected', + layout: 'main', + menu: {}, + }, { path: '/projects/:id/archived', title: ':name', diff --git a/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.styles.ts b/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.styles.ts new file mode 100644 index 0000000000..bd3b321fc4 --- /dev/null +++ b/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + deleteParagraph: { + marginTop: '2rem', + }, + environmentDeleteInput: { + marginTop: '1rem', + }, +})); diff --git a/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.tsx b/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.tsx new file mode 100644 index 0000000000..abd63fe1aa --- /dev/null +++ b/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.tsx @@ -0,0 +1,61 @@ +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import Dialogue from '../../../common/Dialogue'; +import Input from '../../../common/Input/Input'; +import { ProjectEnvironment } from '../ProjectEnvironment'; +import { useStyles } from './EnvironmentDisableConfirm.styles'; + +interface IEnvironmentDisableConfirmProps { + env?: ProjectEnvironment; + open: boolean; + handleDisableEnvironment: () => Promise; + handleCancelDisableEnvironment: () => void; + confirmName: string; + setConfirmName: React.Dispatch>; +} + +const EnvironmentDisableConfirm = ({ + env, + open, + handleDisableEnvironment, + handleCancelDisableEnvironment, + confirmName, + setConfirmName, +}: IEnvironmentDisableConfirmProps) => { + const styles = useStyles(); + + const handleChange = (e: React.ChangeEvent) => + setConfirmName(e.currentTarget.value); + + return ( + handleDisableEnvironment()} + disabledPrimaryButton={env?.name !== confirmName} + onClose={handleCancelDisableEnvironment} + > + + Danger. Disabling an environment can impact client applications + connected to the environment and result in feature toggles being + disabled. + + +

+ In order to disable this environment, please enter the id of the + environment in the textfield below: {env?.name} +

+ + +
+ ); +}; + +export default EnvironmentDisableConfirm; diff --git a/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.styles.ts b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.styles.ts new file mode 100644 index 0000000000..2b3bfb963e --- /dev/null +++ b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.styles.ts @@ -0,0 +1,24 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + [theme.breakpoints.down('xs')]: { + justifyContent: 'center', + }, + }, + apiError: { + maxWidth: '400px', + marginBottom: '1rem', + }, + cardLink: { + color: 'inherit', + textDecoration: 'none', + border: 'none', + padding: '0', + background: 'transparent', + fontFamily: theme.typography.fontFamily, + pointer: 'cursor', + }, +})); diff --git a/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx new file mode 100644 index 0000000000..86b888d92e --- /dev/null +++ b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx @@ -0,0 +1,158 @@ +import { useContext, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import ConditionallyRender from '../../common/ConditionallyRender'; +import { useStyles } from './ProjectEnvironment.styles'; + +import useLoading from '../../../hooks/useLoading'; +import PageContent from '../../common/PageContent'; +import AccessContext from '../../../contexts/AccessContext'; +import HeaderTitle from '../../common/HeaderTitle'; +import { UPDATE_PROJECT } from '../../AccessProvider/permissions'; + +import ApiError from '../../common/ApiError/ApiError'; +import useToast from '../../../hooks/useToast'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useEnvironments from '../../../hooks/api/getters/useEnvironments/useEnvironments'; +import useProject from '../../../hooks/api/getters/useProject/useProject'; +import { FormControlLabel, FormGroup, Switch } from '@material-ui/core'; +import useProjectApi from '../../../hooks/api/actions/useProjectApi/useProjectApi'; +import EnvironmentDisableConfirm from './EnvironmentDisableConfirm/EnvironmentDisableConfirm'; + +export interface ProjectEnvironment { + name: string; + enabled: boolean; +} + +const ProjectEnvironmentList = () => { + const { id } = useParams<{id: string}>(); + const { hasAccess } = useContext(AccessContext); + + // api state + const { toast, setToastData } = useToast(); + const { uiConfig } = useUiConfig(); + const { environments, loading, error, refetch: refetchEnvs } = useEnvironments(); + const { project, refetch: refetchProject } = useProject(id); + const { removeEnvironmentFromProject, addEnvironmentToProject } = useProjectApi(); + + // local state + const [selectedEnv, setSelectedEnv] = useState(); + const [confirmName, setConfirmName] = useState(''); + const ref = useLoading(loading); + const styles = useStyles(); + + const refetch = () => { + refetchEnvs(); + refetchProject(); + } + + const renderError = () => { + return ( + + ); + }; + + const errorMsg = (enable: boolean): string => { + return `Got an API error when trying to ${enable ? 'enable' : 'disable'} the environment.` + } + + const toggleEnv = async (env: ProjectEnvironment) => { + if(env.enabled) { + setSelectedEnv(env); + } else { + try { + await addEnvironmentToProject(id, env.name); + setToastData({ text: 'Environment successfully enabled.', type: 'success', show: true}); + } catch (error) { + setToastData({text: errorMsg(true), type: 'error', show: true}); + } + } + refetch(); + } + + const handleDisableEnvironment = async () => { + if(selectedEnv && confirmName===selectedEnv.name) { + try { + await removeEnvironmentFromProject(id, selectedEnv.name); + setSelectedEnv(undefined); + setConfirmName(''); + setToastData({ text: 'Environment successfully disabled.', type: 'success', show: true}); + } catch (e) { + setToastData({ text: errorMsg(false), type: 'error', show: true}); + } + + refetch(); + } + } + + const handleCancelDisableEnvironment = () => { + setSelectedEnv(undefined); + setConfirmName(''); + } + + const envs = environments.map(e => ({ + name: e.name, + enabled: (project?.environments).includes(e.name), + })); + + const hasPermission = hasAccess(UPDATE_PROJECT, id); + + const genLabel = (env: ProjectEnvironment) => ( + <> + {env.name} environment is {env.enabled ? 'enabled' : 'disabled'} + + ); + + const renderEnvironments = () => { + if(!uiConfig.flags.E) { + return

Feature not enabled.

+ } + + return ( + + {envs.map(env => ( + + } + /> + ))} + + ); + }; + + + return ( +
+ + } + > + +
+ No environments available.
} + elseShow={renderEnvironments()} + /> +
+ + {toast} + + + ); +}; + +export default ProjectEnvironmentList; diff --git a/frontend/src/component/project/form-project-component.jsx b/frontend/src/component/project/form-project-component.jsx index 05783d6859..a01f4dc869 100644 --- a/frontend/src/component/project/form-project-component.jsx +++ b/frontend/src/component/project/form-project-component.jsx @@ -118,6 +118,17 @@ class ProjectFormComponent extends Component { hasAccess(CREATE_PROJECT) && editMode } show={ + <> + + } /> } diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 59644b95ec..36e34e3c9c 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -18,7 +18,36 @@ const useProjectApi = () => { } }; - return { deleteProject, errors }; + const addEnvironmentToProject = async (projectId: string, environment: string) => { + const path = `api/admin/projects/${projectId}/environments`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify({ environment }), + }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + } + + const removeEnvironmentFromProject = async (projectId: string, environment: string) => { + const path = `api/admin/projects/${projectId}/environments/${environment}`; + const req = createRequest(path, { method: 'DELETE' }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + } + + return { deleteProject, addEnvironmentToProject, removeEnvironmentFromProject, errors }; }; export default useProjectApi; diff --git a/frontend/src/hooks/api/getters/useProject/fallbackProject.ts b/frontend/src/hooks/api/getters/useProject/fallbackProject.ts index 844cebad27..b4412e9b80 100644 --- a/frontend/src/hooks/api/getters/useProject/fallbackProject.ts +++ b/frontend/src/hooks/api/getters/useProject/fallbackProject.ts @@ -1,5 +1,6 @@ export const fallbackProject = { features: [], + environments: [], name: '', health: 0, members: 0, diff --git a/frontend/src/hooks/useToast.tsx b/frontend/src/hooks/useToast.tsx index 3c388fc79d..8b96340d32 100644 --- a/frontend/src/hooks/useToast.tsx +++ b/frontend/src/hooks/useToast.tsx @@ -3,7 +3,7 @@ import Toast from '../component/common/Toast/Toast'; export interface IToast { show: boolean; - type: string; + type: 'success' | 'info' | 'warning' | 'error'; text: string; } diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index 619584dda0..7ea03ed0ee 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -15,6 +15,7 @@ export interface IProject { version: string; name: string; description: string; + environments: string[]; health: number; features: IFeatureToggleListItem[]; } diff --git a/frontend/src/page/project/environment.tsx b/frontend/src/page/project/environment.tsx new file mode 100644 index 0000000000..b6beca81da --- /dev/null +++ b/frontend/src/page/project/environment.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import ProjectEnvironment from '../../component/project/ProjectEnvironment/ProjectEnvironment'; + +const ProjectEnvironmentConfigPage = () => ; + +export default ProjectEnvironmentConfigPage;