1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: project environments configuration (#365)

This commit is contained in:
Ivar Conradi Østhus 2021-09-30 10:24:16 +02:00 committed by GitHub
parent 44d1e61db4
commit 97893aa762
14 changed files with 326 additions and 7 deletions

View File

@ -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;
}

View File

@ -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",

View File

@ -1,7 +1,7 @@
import { baseRoutes, getRoute } from '../routes';
test('returns all baseRoutes', () => {
expect(baseRoutes).toHaveLength(39);
expect(baseRoutes).toHaveLength(40);
expect(baseRoutes).toMatchSnapshot();
});

View File

@ -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',

View File

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

View File

@ -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<void>;
handleCancelDisableEnvironment: () => void;
confirmName: string;
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
}
const EnvironmentDisableConfirm = ({
env,
open,
handleDisableEnvironment,
handleCancelDisableEnvironment,
confirmName,
setConfirmName,
}: IEnvironmentDisableConfirmProps) => {
const styles = useStyles();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmName(e.currentTarget.value);
return (
<Dialogue
title="Are you sure you want to disable this environment?"
open={open}
primaryButtonText="Disable environment"
secondaryButtonText="Cancel"
onClick={() => handleDisableEnvironment()}
disabledPrimaryButton={env?.name !== confirmName}
onClose={handleCancelDisableEnvironment}
>
<Alert severity="error">
Danger. Disabling an environment can impact client applications
connected to the environment and result in feature toggles being
disabled.
</Alert>
<p className={styles.deleteParagraph}>
In order to disable this environment, please enter the id of the
environment in the textfield below: <strong>{env?.name}</strong>
</p>
<Input
onChange={handleChange}
value={confirmName}
label="Environment name"
className={styles.environmentDeleteInput}
/>
</Dialogue>
);
};
export default EnvironmentDisableConfirm;

View File

@ -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',
},
}));

View File

@ -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<ProjectEnvironment>();
const [confirmName, setConfirmName] = useState('');
const ref = useLoading(loading);
const styles = useStyles();
const refetch = () => {
refetchEnvs();
refetchProject();
}
const renderError = () => {
return (
<ApiError
onClick={refetch}
className={styles.apiError}
text="Error fetching environments"
/>
);
};
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) => (
<>
<code>{env.name}</code> environment is <strong>{env.enabled ? 'enabled' : 'disabled'}</strong>
</>
);
const renderEnvironments = () => {
if(!uiConfig.flags.E) {
return <p>Feature not enabled.</p>
}
return (
<FormGroup>
{envs.map(env => (
<FormControlLabel key={env.name} label={genLabel(env)} control={
<Switch size="medium" disabled={!hasPermission} checked={env.enabled} onChange={toggleEnv.bind(this, env)} />
}
/>
))}
</FormGroup>
);
};
return (
<div ref={ref}>
<PageContent
headerContent={
<HeaderTitle
title={`Configure environments for "${project?.name}"`}
/>
}
>
<ConditionallyRender condition={error} show={renderError()} />
<div className={styles.container}>
<ConditionallyRender
condition={environments.length < 1 && !loading}
show={<div>No environments available.</div>}
elseShow={renderEnvironments()}
/>
</div>
<EnvironmentDisableConfirm
env={selectedEnv}
open={!!selectedEnv}
handleDisableEnvironment={handleDisableEnvironment}
handleCancelDisableEnvironment={handleCancelDisableEnvironment}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
{toast}
</PageContent>
</div>
);
};
export default ProjectEnvironmentList;

View File

@ -118,6 +118,17 @@ class ProjectFormComponent extends Component {
hasAccess(CREATE_PROJECT) && editMode
}
show={
<>
<Button
color="primary"
onClick={() =>
this.props.history.push(
`/projects/${project.id}/environments`
)
}
>
Environments
</Button>
<Button
color="primary"
onClick={() =>
@ -128,6 +139,7 @@ class ProjectFormComponent extends Component {
>
Manage access
</Button>
</>
}
/>
}

View File

@ -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;

View File

@ -1,5 +1,6 @@
export const fallbackProject = {
features: [],
environments: [],
name: '',
health: 0,
members: 0,

View File

@ -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;
}

View File

@ -15,6 +15,7 @@ export interface IProject {
version: string;
name: string;
description: string;
environments: string[];
health: number;
features: IFeatureToggleListItem[];
}

View File

@ -0,0 +1,6 @@
import React from 'react';
import ProjectEnvironment from '../../component/project/ProjectEnvironment/ProjectEnvironment';
const ProjectEnvironmentConfigPage = () => <ProjectEnvironment />;
export default ProjectEnvironmentConfigPage;