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:
parent
44d1e61db4
commit
97893aa762
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { baseRoutes, getRoute } from '../routes';
|
||||
|
||||
test('returns all baseRoutes', () => {
|
||||
expect(baseRoutes).toHaveLength(39);
|
||||
expect(baseRoutes).toHaveLength(40);
|
||||
expect(baseRoutes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
deleteParagraph: {
|
||||
marginTop: '2rem',
|
||||
},
|
||||
environmentDeleteInput: {
|
||||
marginTop: '1rem',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -1,5 +1,6 @@
|
||||
export const fallbackProject = {
|
||||
features: [],
|
||||
environments: [],
|
||||
name: '',
|
||||
health: 0,
|
||||
members: 0,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ export interface IProject {
|
||||
version: string;
|
||||
name: string;
|
||||
description: string;
|
||||
environments: string[];
|
||||
health: number;
|
||||
features: IFeatureToggleListItem[];
|
||||
}
|
||||
|
6
frontend/src/page/project/environment.tsx
Normal file
6
frontend/src/page/project/environment.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ProjectEnvironment from '../../component/project/ProjectEnvironment/ProjectEnvironment';
|
||||
|
||||
const ProjectEnvironmentConfigPage = () => <ProjectEnvironment />;
|
||||
|
||||
export default ProjectEnvironmentConfigPage;
|
Loading…
Reference in New Issue
Block a user