diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index d8a5b7f59e..dd731ae928 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -73,7 +73,6 @@ const FormTemplate: React.FC = ({

API Command{' '} 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 7cabf10fb7..2a47b35e26 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -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", diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 84e07aeb28..9a96aaf812 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -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', diff --git a/frontend/src/component/project/Project.module.scss b/frontend/src/component/project/Project.module.scss deleted file mode 100644 index 768cabfcb8..0000000000 --- a/frontend/src/component/project/Project.module.scss +++ /dev/null @@ -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; -} diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx new file mode 100644 index 0000000000..f6873ba3c7 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -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 ( + + + + ); +}; + +export default CreateProject; diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx new file mode 100644 index 0000000000..7fd439760c --- /dev/null +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -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 ( + + + + ); +}; + +export default EditProject; diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 816e079358..70c60bd618 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -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: ( - - ), - 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}{' '} goToTabWithName('settings')} + onClick={() => history.push(`/projects/${id}/edit`)} data-loading > diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.style.ts b/frontend/src/component/project/Project/ProjectForm/ProjectForm.style.ts new file mode 100644 index 0000000000..945d8d8b92 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.style.ts @@ -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', + }, +})); diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx new file mode 100644 index 0000000000..1d220f6464 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -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>; + setProjectName: React.Dispatch>; + setProjectDesc: React.Dispatch>; + 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 ( +
+

Project Information

+ +
+

+ What is your project Id? +

+ setProjectId(trim(e.target.value))} + error={Boolean(errors.id)} + errorText={errors.id} + onFocus={() => clearErrors()} + onBlur={validateIdUniqueness} + disabled={submitButtonText === 'Edit'} + /> + +

+ What is your project name? +

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

+ What is your project description? +

+ setProjectDesc(e.target.value)} + /> +
+ +
+ + + {submitButtonText} project + +
+
+ ); +}; + +export default ProjectForm; diff --git a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx index ac3dcc20ff..4beea3b533 100644 --- a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx +++ b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.tsx @@ -55,7 +55,7 @@ const ProjectInfo = ({ component={Link} className={permissionButtonClass} data-loading - to={`/projects/${id}/settings`} + to={`/projects/${id}/edit`} >
diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts new file mode 100644 index 0000000000..22b4e7da4e --- /dev/null +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -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; diff --git a/frontend/src/component/project/create-project-container.js b/frontend/src/component/project/create-project-container.js deleted file mode 100644 index 54e05b02e9..0000000000 --- a/frontend/src/component/project/create-project-container.js +++ /dev/null @@ -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; diff --git a/frontend/src/component/project/edit-project-container.js b/frontend/src/component/project/edit-project-container.js deleted file mode 100644 index 0b93491c3e..0000000000 --- a/frontend/src/component/project/edit-project-container.js +++ /dev/null @@ -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; diff --git a/frontend/src/component/project/form-project-component.tsx b/frontend/src/component/project/form-project-component.tsx deleted file mode 100644 index de793ba268..0000000000 --- a/frontend/src/component/project/form-project-component.tsx +++ /dev/null @@ -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; - history: any; - submit: (project: any) => Promise; -} - -const ProjectFormComponent = (props: ProjectFormComponentProps) => { - const { editMode } = props; - const { refetch } = useUser(); - const { hasAccess } = useContext(AccessContext); - const [project, setProject] = useState(props.project || {}); - const [errors, setErrors] = useState({}); - 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) => { - 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 ( -
- - } - > - - {submitText} project requires a paid version of - Unleash. Check out{' '} - - getunleash.io - {' '} - to learn more. - - } - elseShow={ - <> - - Projects allows you to group feature toggles - together in the management UI. - -
- validateId(v.target.value)} - onChange={v => - setValue('id', trim(v.target.value)) - } - /> -
- - setValue('name', v.target.value) - } - /> - - setValue('description', v.target.value) - } - /> - - - -
- } - /> - - - Update project - - } - /> - - - } - /> - - - ); -}; - -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; diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 36e34e3c9c..9be6552ba2 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -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; diff --git a/frontend/src/hooks/useToast.tsx b/frontend/src/hooks/useToast.tsx index e7f2f341ca..e4ce229faf 100644 --- a/frontend/src/hooks/useToast.tsx +++ b/frontend/src/hooks/useToast.tsx @@ -15,6 +15,8 @@ interface IToastOptions { type: string; persist?: boolean; confetti?: boolean; + autoHideDuration?: number; + show?: boolean; } const useToast = () => {