1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-04 00:18:01 +01:00

feat: add create and edit project screen (NEW) (#602)

* feat: add create and edit project screen

* feat: fix correct permission and validate projectId

* feat: remove unused variable and logs

* feat: remove unused import

* fix: delete unused project components

* fix: add unique validation

* fix: add unused import

* fix: project header

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Youssef Khedher 2022-01-19 14:28:55 +01:00 committed by GitHub
parent 416d67da34
commit 39b5adb950
16 changed files with 520 additions and 348 deletions

View File

@ -73,7 +73,6 @@ const FormTemplate: React.FC<ICreateProps> = ({
<h3 className={styles.subtitle}>
API Command{' '}
<IconButton
className={styles.iconButton}
onClick={copyCommand}
>
<FileCopy className={styles.icon} />

View File

@ -11,6 +11,15 @@ Array [
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"menu": Object {},
"parent": "/projects",
"path": "/projects/:id/edit",
"title": ":id",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",

View File

@ -13,7 +13,6 @@ import ApplicationView from '../../page/applications/view';
import ContextFields from '../../page/context';
import CreateContextField from '../../page/context/create';
import EditContextField from '../../page/context/edit';
import CreateProject from '../../page/project/create';
import ListTagTypes from '../../page/tag-types';
import Addons from '../../page/addons';
import AddonsCreate from '../../page/addons/create';
@ -46,6 +45,8 @@ import EditEnvironment from '../environments/EditEnvironment/EditEnvironment';
import EditTagType from '../tagTypes/EditTagType/EditTagType';
import CreateTagType from '../tagTypes/CreateTagType/CreateTagType';
import EditProject from '../project/Project/EditProject/EditProject';
import CreateProject from '../project/Project/CreateProject/CreateProject';
export const routes = [
// Project
@ -59,6 +60,15 @@ export const routes = [
layout: 'main',
menu: {},
},
{
path: '/projects/:id/edit',
parent: '/projects',
title: ':id',
component: EditProject,
type: 'protected',
layout: 'main',
menu: {},
},
{
path: '/projects/:id/archived',
title: ':name',

View File

@ -1,31 +0,0 @@
.header {
padding: var(--card-header-padding);
margin-bottom: var(--card-margin-y);
word-break: break-all;
border-bottom: var(--default-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: var(--h1-size);
}
.supporting {
font-size: var(--caption-size);
max-width: 450px;
}
.container {
padding: var(--card-padding);
}
.formButtons {
padding-top: 1rem;
}
.formContainer {
margin-bottom: 1.5rem;
max-width: 350px;
}

View File

@ -0,0 +1,94 @@
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi';
import { useHistory } from 'react-router-dom';
import ProjectForm from '../ProjectForm/ProjectForm';
import useProjectForm from '../hooks/useProjectForm';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast';
import useUser from '../../../../hooks/api/getters/useUser/useUser';
const CreateProject = () => {
/* @ts-ignore */
const { setToastData, setToastApiError } = useToast();
const { refetch } = useUser();
const { uiConfig } = useUiConfig();
const history = useHistory();
const {
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
getProjectPayload,
clearErrors,
validateIdUniqueness,
validateName,
errors,
} = useProjectForm();
const { createProject, loading } = useProjectApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
const validName = validateName();
const validId = await validateIdUniqueness();
if (validName && validId) {
const payload = getProjectPayload();
try {
await createProject(payload);
refetch();
history.push(`/projects/${projectId}`);
setToastData({
title: 'Project created',
text: 'Now you can add toggles to this project',
confetti: true,
type: 'success',
});
} catch (e: any) {
setToastApiError(e.toString());
}
}
};
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/projects' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`;
};
const handleCancel = () => {
history.push('/projects');
};
return (
<FormTemplate
loading={loading}
title="Create project"
description="Projects allows you to group feature toggles together in the management UI."
documentationLink="https://docs.getunleash.io/user_guide/projects"
formatApiCode={formatApiCode}
>
<ProjectForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
setProjectName={setProjectName}
projectDesc={projectDesc}
setProjectDesc={setProjectDesc}
submitButtonText="Create"
clearErrors={clearErrors}
validateIdUniqueness={validateIdUniqueness}
/>
</FormTemplate>
);
};
export default CreateProject;

View File

@ -0,0 +1,93 @@
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi';
import { useHistory, useParams } from 'react-router-dom';
import ProjectForm from '../ProjectForm/ProjectForm';
import useProjectForm from '../hooks/useProjectForm';
import useProject from '../../../../hooks/api/getters/useProject/useProject';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast';
const EditProject = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const { id } = useParams<{ id: string }>();
const { project } = useProject(id);
const history = useHistory();
const {
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
getProjectPayload,
clearErrors,
validateIdUniqueness,
validateName,
errors,
} = useProjectForm(id, project.name, project.description);
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/projects/${id}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`;
};
const { refetch } = useProject(id);
const { editProject, loading } = useProjectApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
const payload = getProjectPayload();
const validName = validateName();
if (validName) {
try {
await editProject(id, payload);
refetch();
history.push(`/projects/${id}`);
setToastData({
title: 'Project information updated',
type: 'success',
});
} catch (e: any) {
setToastApiError(e.toString());
}
}
};
const handleCancel = () => {
history.goBack();
};
return (
<FormTemplate
loading={loading}
title="Edit project"
description="Projects allows you to group feature toggles together in the management UI."
documentationLink="https://docs.getunleash.io/user_guide/projects"
formatApiCode={formatApiCode}
>
<ProjectForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
setProjectName={setProjectName}
projectDesc={projectDesc}
setProjectDesc={setProjectDesc}
submitButtonText="Edit"
clearErrors={clearErrors}
validateIdUniqueness={validateIdUniqueness}
/>
</FormTemplate>
);
};
export default EditProject;

View File

@ -13,7 +13,6 @@ import { useEffect } from 'react';
import useTabs from '../../../hooks/useTabs';
import TabPanel from '../../common/TabNav/TabPanel';
import ProjectAccess from '../access-container';
import EditProject from '../edit-project-container';
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
import ProjectOverview from './ProjectOverview';
import ProjectHealth from './ProjectHealth/ProjectHealth';
@ -58,19 +57,6 @@ const Project = () => {
path: `${basePath}/environments`,
name: 'environments',
},
{
title: 'Settings',
// @ts-ignore (fix later)
component: (
<EditProject
projectId={id}
history={history}
title="Edit project"
/>
),
path: `${basePath}/settings`,
name: 'settings',
},
];
useEffect(() => {
@ -101,15 +87,6 @@ const Project = () => {
/* eslint-disable-next-line */
}, []);
const goToTabWithName = (name: string) => {
const index = tabData.findIndex(t => t.name === name);
if (index >= 0) {
const tab = tabData[index];
history.push(tab.path);
setActiveTab(index);
}
};
const renderTabs = () => {
return tabData.map((tab, index) => {
return (
@ -150,9 +127,9 @@ const Project = () => {
Project: {project?.name}{' '}
<PermissionIconButton
permission={UPDATE_PROJECT}
tooltip={'Edit description'}
tooltip="Edit"
projectId={project?.id}
onClick={() => goToTabWithName('settings')}
onClick={() => history.push(`/projects/${id}/edit`)}
data-loading
>
<Edit />

View File

@ -0,0 +1,47 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
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: {
//@ts-ignore
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
}));

View File

@ -0,0 +1,103 @@
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
import { CREATE_PROJECT } from '../../../providers/AccessProvider/permissions';
import Input from '../../../common/Input/Input';
import { TextField, Button } from '@material-ui/core';
import { useStyles } from './ProjectForm.style';
import React from 'react';
import { trim } from '../../../common/util';
interface IProjectForm {
projectId: string;
projectName: string;
projectDesc: string;
setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
submitButtonText: string;
clearErrors: () => void;
validateIdUniqueness: () => void;
}
const ProjectForm = ({
handleSubmit,
handleCancel,
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
errors,
submitButtonText,
validateIdUniqueness,
clearErrors,
}: IProjectForm) => {
const styles = useStyles();
return (
<form onSubmit={handleSubmit} className={styles.form}>
<h3 className={styles.formHeader}>Project Information</h3>
<div className={styles.container}>
<p className={styles.inputDescription}>
What is your project Id?
</p>
<Input
className={styles.input}
label="Project Id"
value={projectId}
onChange={e => setProjectId(trim(e.target.value))}
error={Boolean(errors.id)}
errorText={errors.id}
onFocus={() => clearErrors()}
onBlur={validateIdUniqueness}
disabled={submitButtonText === 'Edit'}
/>
<p className={styles.inputDescription}>
What is your project name?
</p>
<Input
className={styles.input}
label="Project name"
value={projectName}
onChange={e => setProjectName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
/>
<p className={styles.inputDescription}>
What is your project description?
</p>
<TextField
className={styles.input}
label="Project description"
variant="outlined"
multiline
maxRows={4}
value={projectDesc}
onChange={e => setProjectDesc(e.target.value)}
/>
</div>
<div className={styles.buttonContainer}>
<Button onClick={handleCancel} className={styles.cancelButton}>
Cancel
</Button>
<PermissionButton
onClick={handleSubmit}
permission={CREATE_PROJECT}
type="submit"
>
{submitButtonText} project
</PermissionButton>
</div>
</form>
);
};
export default ProjectForm;

View File

@ -55,7 +55,7 @@ const ProjectInfo = ({
component={Link}
className={permissionButtonClass}
data-loading
to={`/projects/${id}/settings`}
to={`/projects/${id}/edit`}
>
<Edit />
</PermissionIconButton>

View File

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi';
import { IPermission } from '../../../../interfaces/project';
export interface ICheckedPermission {
[key: string]: IPermission;
}
const useProjectForm = (
initialProjectId = '',
initialProjectName = '',
initialProjectDesc = ''
) => {
const [projectId, setProjectId] = useState(initialProjectId);
const [projectName, setProjectName] = useState(initialProjectName);
const [projectDesc, setProjectDesc] = useState(initialProjectDesc);
const [errors, setErrors] = useState({});
const { validateId } = useProjectApi();
useEffect(() => {
setProjectId(initialProjectId);
}, [initialProjectId]);
useEffect(() => {
setProjectName(initialProjectName);
}, [initialProjectName]);
useEffect(() => {
setProjectDesc(initialProjectDesc);
}, [initialProjectDesc]);
const getProjectPayload = () => {
return {
id: projectId,
name: projectName,
description: projectDesc,
};
};
const NAME_EXISTS_ERROR = 'Error: A project with this id already exists.';
const validateIdUniqueness = async () => {
try {
await validateId(getProjectPayload());
return true;
} catch (e: any) {
if (e.toString().includes(NAME_EXISTS_ERROR)) {
setErrors(prev => ({
...prev,
id: 'A project with this id already exists',
}));
}
return false;
}
};
const validateName = () => {
if (projectName.length === 0) {
setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
return false;
}
return true;
};
const clearErrors = () => {
setErrors({});
};
return {
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
getProjectPayload,
validateName,
validateIdUniqueness,
clearErrors,
errors,
};
};
export default useProjectForm;

View File

@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import ProjectComponent from './form-project-component';
import { createProject, validateId } from './../../store/project/actions';
import { fetchUser } from './../../store/user/actions';
const mapStateToProps = () => ({
project: { id: '', name: '', description: '' },
});
const mapDispatchToProps = dispatch => ({
validateId,
submit: async project => {
await createProject(project)(dispatch);
fetchUser()(dispatch);
},
editMode: false,
});
const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(ProjectComponent);
export default FormAddContainer;

View File

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import Component from './form-project-component';
import { updateProject, validateId } from './../../store/project/actions';
const mapStateToProps = (state, props) => {
const projectBase = { id: '', name: '', description: '' };
const realProject = state.projects.toJS().find(n => n.id === props.projectId);
const project = Object.assign(projectBase, realProject);
return {
project,
};
};
const mapDispatchToProps = dispatch => ({
validateId,
submit: project => updateProject(project)(dispatch),
editMode: true,
});
const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(Component);
export default FormAddContainer;

View File

@ -1,239 +0,0 @@
import { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { TextField, Typography } from '@material-ui/core';
import styles from './Project.module.scss';
import classnames from 'classnames';
import { FormButtons, styles as commonStyles } from '../common';
import { trim } from '../common/util';
import PageContent from '../common/PageContent/PageContent';
import AccessContext from '../../contexts/AccessContext';
import ConditionallyRender from '../common/ConditionallyRender';
import { CREATE_PROJECT } from '../providers/AccessProvider/permissions';
import HeaderTitle from '../common/HeaderTitle';
import useUiConfig from '../../hooks/api/getters/useUiConfig/useUiConfig';
import { Alert } from '@material-ui/lab';
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;
project: any;
validateId: (id: string) => Promise<void>;
history: any;
submit: (project: any) => Promise<void>;
}
const ProjectFormComponent = (props: ProjectFormComponentProps) => {
const { editMode } = props;
const { refetch } = useUser();
const { hasAccess } = useContext(AccessContext);
const [project, setProject] = useState(props.project || {});
const [errors, setErrors] = useState<any>({});
const { isOss, loading } = useUiConfig();
const ref = useLoading(loading);
useEffect(() => {
if (!project.id && props.project.id) {
setProject(props.project);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.project]);
const setValue = (field: string, value: string) => {
const p = { ...project };
p[field] = value;
setProject(p);
};
const validateId = async (id: string) => {
if (editMode) return true;
const e = { ...errors };
try {
await props.validateId(id);
e.id = undefined;
} catch (err: any) {
e.id = err.message;
}
setErrors(e);
if (e.id) return false;
return true;
};
const validateName = () => {
if (project.name.length === 0) {
setErrors({ ...errors, name: 'Name can not be empty.' });
return false;
}
return true;
};
const validate = async (id: string) => {
const validId = await validateId(id);
const validName = validateName();
return validId && validName;
};
const onCancel = (evt: Event) => {
evt.preventDefault();
if (editMode) {
props.history.push(`/projects/${project.id}`);
return;
}
props.history.push(`/projects/`);
};
const onSubmit = async (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const valid = await validate(project.id);
if (valid) {
const query = editMode ? 'edited=true' : 'created=true';
await props.submit(project);
refetch();
props.history.push(`/projects/${project.id}?${query}`);
}
};
const submitText = editMode ? 'Update' : 'Create';
return (
<div ref={ref}>
<PageContent
headerContent={
<HeaderTitle
title={`${submitText} ${props.project?.name} project`}
/>
}
>
<ConditionallyRender
condition={isOss()}
show={
<Alert data-loading severity="error">
{submitText} project requires a paid version of
Unleash. Check out{' '}
<a
href="https://www.getunleash.io"
target="_blank"
rel="noreferrer"
>
getunleash.io
</a>{' '}
to learn more.
</Alert>
}
elseShow={
<>
<Typography
variant="subtitle1"
style={{ marginBottom: '0.5rem' }}
>
Projects allows you to group feature toggles
together in the management UI.
</Typography>
<form
data-loading
onSubmit={onSubmit}
className={classnames(
commonStyles.contentSpacing,
styles.formContainer
)}
>
<TextField
label="Project Id"
name="id"
placeholder="A-unique-key"
value={project.id}
error={!!errors.id}
helperText={errors.id}
disabled={editMode}
variant="outlined"
size="small"
onBlur={v => validateId(v.target.value)}
onChange={v =>
setValue('id', trim(v.target.value))
}
/>
<br />
<TextField
label="Name"
name="name"
placeholder="Project name"
value={project.name}
error={!!errors.name}
variant="outlined"
size="small"
helperText={errors.name}
onChange={v =>
setValue('name', v.target.value)
}
/>
<TextField
className={commonStyles.fullwidth}
placeholder="A short description"
maxRows={2}
label="Description"
error={!!errors.description}
helperText={errors.description}
variant="outlined"
size="small"
multiline
value={project.description}
onChange={v =>
setValue('description', v.target.value)
}
/>
<ConditionallyRender
condition={
hasAccess(CREATE_PROJECT) && !editMode
}
show={
<div className={styles.formButtons}>
<FormButtons
submitText={submitText}
onCancel={onCancel}
/>
</div>
}
/>
<ConditionallyRender
condition={editMode}
show={
<PermissionButton
permission={UPDATE_PROJECT}
projectId={props.project.id}
type="submit"
style={{ marginTop: '1rem' }}
>
Update project
</PermissionButton>
}
/>
</form>
</>
}
/>
</PageContent>
</div>
);
};
ProjectFormComponent.propTypes = {
project: PropTypes.object.isRequired,
validateId: PropTypes.func.isRequired,
submit: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
editMode: PropTypes.bool.isRequired,
};
export default ProjectFormComponent;

View File

@ -1,10 +1,64 @@
import useAPI from '../useApi/useApi';
interface ICreatePayload {
id: string;
name: string;
description: string;
}
const useProjectApi = () => {
const { makeRequest, createRequest, errors } = useAPI({
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createProject = async (payload: ICreatePayload) => {
const path = `api/admin/projects`;
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 validateId = async (payload: ICreatePayload) => {
const path = `api/admin/projects/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 editProject = async (id: string, payload: ICreatePayload) => {
const path = `api/admin/projects/${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 deleteProject = async (projectId: string) => {
const path = `api/admin/projects/${projectId}`;
const req = createRequest(path, { method: 'DELETE' });
@ -18,7 +72,10 @@ const useProjectApi = () => {
}
};
const addEnvironmentToProject = async (projectId: string, environment: string) => {
const addEnvironmentToProject = async (
projectId: string,
environment: string
) => {
const path = `api/admin/projects/${projectId}/environments`;
const req = createRequest(path, {
method: 'POST',
@ -32,9 +89,12 @@ const useProjectApi = () => {
} catch (e) {
throw e;
}
}
};
const removeEnvironmentFromProject = async (projectId: string, environment: string) => {
const removeEnvironmentFromProject = async (
projectId: string,
environment: string
) => {
const path = `api/admin/projects/${projectId}/environments/${environment}`;
const req = createRequest(path, { method: 'DELETE' });
@ -45,9 +105,18 @@ const useProjectApi = () => {
} catch (e) {
throw e;
}
}
};
return { deleteProject, addEnvironmentToProject, removeEnvironmentFromProject, errors };
return {
createProject,
validateId,
editProject,
deleteProject,
addEnvironmentToProject,
removeEnvironmentFromProject,
errors,
loading,
};
};
export default useProjectApi;

View File

@ -15,6 +15,8 @@ interface IToastOptions {
type: string;
persist?: boolean;
confetti?: boolean;
autoHideDuration?: number;
show?: boolean;
}
const useToast = () => {