From 8c82a6bceb188e073996e5b5ba49126bb40a3cb1 Mon Sep 17 00:00:00 2001 From: Youssef Date: Tue, 18 Jan 2022 12:16:08 +0100 Subject: [PATCH] feat: create edit and creat feature screen --- .../common/BreadcrumbNav/BreadcrumbNav.tsx | 4 +- .../CreateFeature/CreateFeature.tsx | 103 +++++++++++++ .../CreateFeature/EditFeature/EditFeature.tsx | 115 ++++++++++++++ .../FeatureForm/FeatureForm.styles.ts | 61 ++++++++ .../CreateFeature/FeatureForm/FeatureForm.tsx | 145 ++++++++++++++++++ .../CreateFeature/hooks/useFeatureForm.ts | 90 +++++++++++ frontend/src/component/menu/routes.js | 17 +- 7 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/feature/CreateFeature/CreateFeature/CreateFeature.tsx create mode 100644 frontend/src/component/feature/CreateFeature/EditFeature/EditFeature.tsx create mode 100644 frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.styles.ts create mode 100644 frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.tsx create mode 100644 frontend/src/component/feature/CreateFeature/hooks/useFeatureForm.ts diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx index 4281fd2bdd..380410945f 100644 --- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -24,7 +24,9 @@ const BreadcrumbNav = () => { item !== 'strategies' && item !== 'features' && item !== 'features2' && - item !== 'create-toggle' + item !== 'create-toggle'&& + item !== 'settings' + ); return ( diff --git a/frontend/src/component/feature/CreateFeature/CreateFeature/CreateFeature.tsx b/frontend/src/component/feature/CreateFeature/CreateFeature/CreateFeature.tsx new file mode 100644 index 0000000000..c30dd74e1c --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/CreateFeature/CreateFeature.tsx @@ -0,0 +1,103 @@ +import FormTemplate from '../../../common/FormTemplate/FormTemplate'; +import { useHistory } from 'react-router-dom'; +import FeatureForm from '../FeatureForm/FeatureForm'; +import useFeatureForm from '../hooks/useFeatureForm'; +import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from '../../../../hooks/useToast'; +import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi'; +import { CREATE_FEATURE } from '../../../providers/AccessProvider/permissions'; +import PermissionButton from '../../../common/PermissionButton/PermissionButton'; + +const CreateFeature = () => { + /* @ts-ignore */ + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const history = useHistory(); + + const { + type, + setType, + name, + setName, + project, + setProject, + description, + setDescription, + getTogglePayload, + validateName, + clearErrors, + errors, + } = useFeatureForm(); + + const { createFeatureToggle, loading } = useFeatureApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + await validateName(name); + const payload = getTogglePayload(); + try { + await createFeatureToggle(project, payload); + history.push(`/projects/${project}/features2/${name}`); + setToastData({ + title: 'Toggle created successfully', + text: 'Now you can start using your toggle.', + confetti: true, + type: 'success', + }); + } catch (e: any) { + setToastApiError(e.toString()); + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/projects/${project}/features' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getTogglePayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + history.goBack(); + }; + + return ( + + + + Create toggle + + + + ); +}; + +export default CreateFeature; diff --git a/frontend/src/component/feature/CreateFeature/EditFeature/EditFeature.tsx b/frontend/src/component/feature/CreateFeature/EditFeature/EditFeature.tsx new file mode 100644 index 0000000000..4690544692 --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/EditFeature/EditFeature.tsx @@ -0,0 +1,115 @@ +import FormTemplate from '../../../common/FormTemplate/FormTemplate'; +import { useHistory, useParams } from 'react-router-dom'; +import FeatureForm from '../FeatureForm/FeatureForm'; +import useFeatureForm from '../hooks/useFeatureForm'; +import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from '../../../../hooks/useToast'; +import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi'; +import useFeature from '../../../../hooks/api/getters/useFeature/useFeature'; +import { IFeatureViewParams } from '../../../../interfaces/params'; +import * as jsonpatch from 'fast-json-patch'; +import PermissionButton from '../../../common/PermissionButton/PermissionButton'; +import { UPDATE_FEATURE } from '../../../providers/AccessProvider/permissions'; + +const EditFeature = () => { + /* @ts-ignore */ + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const history = useHistory(); + const { projectId, featureId } = useParams(); + const { patchFeatureToggle, loading } = useFeatureApi(); + const { feature } = useFeature(projectId, featureId); + + const { + type, + setType, + name, + setName, + project, + setProject, + description, + setDescription, + getTogglePayload, + clearErrors, + errors, + } = useFeatureForm( + feature?.name, + feature?.type, + feature?.project, + feature?.description + ); + + const createPatch = () => { + const comparison = { ...feature, type, description }; + const patch = jsonpatch.compare(feature, comparison); + return patch; + }; + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + const patch = createPatch(); + try { + await patchFeatureToggle(project, featureId, patch); + history.push(`/projects/${project}/features2/${name}`); + setToastData({ + title: 'Toggle updated successfully', + text: 'Now you can start using your toggle.', + type: 'success', + }); + } catch (e: any) { + setToastApiError(e.toString()); + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/features/${featureId}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getTogglePayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + history.goBack(); + }; + + return ( + + + + Edit toggle + + + + ); +}; + +export default EditFeature; diff --git a/frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.styles.ts b/frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.styles.ts new file mode 100644 index 0000000000..ee92ce8ac7 --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.styles.ts @@ -0,0 +1,61 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + maxWidth: '400px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + selectInput: { + marginBottom: '1rem', + minWidth: '400px', + [theme.breakpoints.down(600)]: { + minWidth: '379px', + }, + }, + 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', + }, + typeDescription: { + //@ts-ignore + fontSize: theme.fontSizes.smallBody, + color: theme.palette.grey[600], + top: '-13px', + position: 'relative', + }, + 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/feature/CreateFeature/FeatureForm/FeatureForm.tsx b/frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.tsx new file mode 100644 index 0000000000..c1aa7df3f5 --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.tsx @@ -0,0 +1,145 @@ +import { + CREATE_FEATURE, + UPDATE_FEATURE, +} from '../../../providers/AccessProvider/permissions'; +import Input from '../../../common/Input/Input'; +import { Button } from '@material-ui/core'; +import { useStyles } from './FeatureForm.styles'; +import FeatureTypeSelect from '../../FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect'; +import { CF_DESC_ID, CF_TYPE_ID } from '../../../../testIds'; +import useFeatureTypes from '../../../../hooks/api/getters/useFeatureTypes/useFeatureTypes'; +import { KeyboardArrowDownOutlined } from '@material-ui/icons'; +import { useContext } from 'react'; +import useUser from '../../../../hooks/api/getters/useUser/useUser'; +import { projectFilterGenerator } from '../../../../utils/project-filter-generator'; +import FeatureProjectSelect from '../../FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect'; +import AccessContext from '../../../../contexts/AccessContext'; +import ConditionallyRender from '../../../common/ConditionallyRender'; +import { trim } from '../../../common/util'; + +interface IFeatureToggleForm { + type: string; + name: string; + description: string; + project: string; + setType: React.Dispatch>; + setName: React.Dispatch>; + setDescription: React.Dispatch>; + setProject: React.Dispatch>; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: string; + clearErrors: () => void; +} + +const FeatureForm: React.FC = ({ + children, + type, + name, + description, + project, + setType, + setName, + setDescription, + setProject, + handleSubmit, + handleCancel, + errors, + mode, + clearErrors, +}) => { + const styles = useStyles(); + const { hasAccess } = useContext(AccessContext); + const { featureTypes } = useFeatureTypes(); + const { permissions } = useUser(); + const editable = hasAccess(UPDATE_FEATURE, project); + + const renderToggleDescription = () => { + return featureTypes.find(toggle => toggle.id === type)?.description; + }; + + return ( +
+
+

+ What kind of feature toggle do you want to create? +

+ setType(e.target.value)} + label={'Toggle type'} + id="feature-type-select" + editable + inputProps={{ + 'data-test': CF_TYPE_ID, + }} + IconComponent={KeyboardArrowDownOutlined} + className={styles.selectInput} + /> +

+ {renderToggleDescription()} +

+ +

+ What would you like to call your toggle? +

+ clearErrors()} + value={name} + onChange={e => setName(trim(e.target.value))} + /> + + In which project do you want to save the toggle? +

+ } + /> + setProject(e.target.value)} + enabled={editable} + label="Project" + filter={projectFilterGenerator( + { permissions }, + CREATE_FEATURE + )} + IconComponent={KeyboardArrowDownOutlined} + className={styles.selectInput} + /> + +

+ How would you describe your feature toggle? +

+ setDescription(e.target.value)} + /> +
+ +
+ + {children} +
+
+ ); +}; + +export default FeatureForm; diff --git a/frontend/src/component/feature/CreateFeature/hooks/useFeatureForm.ts b/frontend/src/component/feature/CreateFeature/hooks/useFeatureForm.ts new file mode 100644 index 0000000000..1aaf1c92b8 --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/hooks/useFeatureForm.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi'; +import useQueryParams from '../../../../hooks/useQueryParams'; +import { IFeatureViewParams } from '../../../../interfaces/params'; + +const useFeatureForm = ( + initialName = '', + initialType = 'release', + initialProject = 'default', + initialDescription = '' +) => { + const { projectId } = useParams(); + const params = useQueryParams(); + const { validateFeatureToggleName } = useFeatureApi(); + const toggleQueryName = params.get('name'); + const [type, setType] = useState(initialType); + const [name, setName] = useState(toggleQueryName || initialName); + const [project, setProject] = useState(projectId || initialProject); + const [description, setDescription] = useState(initialDescription); + const [errors, setErrors] = useState({}); + + useEffect(() => { + setType(initialType); + }, [initialType]); + + useEffect(() => { + if (!toggleQueryName) setName(initialName); + else setName(toggleQueryName); + }, [initialName, toggleQueryName]); + + useEffect(() => { + if (!projectId) setProject(initialProject); + else setProject(projectId); + }, [initialProject, projectId]); + + useEffect(() => { + setDescription(initialDescription); + }, [initialDescription]); + + const getTogglePayload = () => { + return { + type: type, + name: name, + projectId: project, + description: description, + }; + }; + + const validateName = async (name: string) => { + if (name.length === 0) { + setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); + return false; + } + if (name.length > 0) { + try { + await validateFeatureToggleName(name); + } catch (err: any) { + setErrors(prev => ({ + ...prev, + name: + err && err.message + ? err.message + : 'Could not check name', + })); + } + } + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + type, + setType, + name, + setName, + project, + setProject, + description, + setDescription, + getTogglePayload, + validateName, + clearErrors, + errors, + }; +}; + +export default useFeatureForm; diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index b88dd09d8a..9b00679068 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -31,13 +31,13 @@ import RedirectFeatureViewPage from '../../page/features/redirect'; import RedirectArchive from '../feature/RedirectArchive/RedirectArchive'; import EnvironmentList from '../environments/EnvironmentList/EnvironmentList'; import FeatureView2 from '../feature/FeatureView2/FeatureView2'; -import FeatureCreate from '../feature/FeatureCreate/FeatureCreate'; import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles'; import CreateProjectRole from '../admin/project-roles/CreateProjectRole/CreateProjectRole'; import EditProjectRole from '../admin/project-roles/EditProjectRole/EditProjectRole'; import CreateUser from '../admin/users/CreateUser/CreateUser'; import EditUser from '../admin/users/EditUser/EditUser'; import CreateApiToken from '../admin/api-token/CreateApiToken/CreateApiToken'; +<<<<<<< HEAD import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment'; import EditEnvironment from '../environments/EditEnvironment/EditEnvironment'; import CreateContext from '../context/CreateContext/CreateContext'; @@ -46,6 +46,10 @@ 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'; +======= +import CreateFeature from '../feature/CreateFeature/CreateFeature/CreateFeature'; +import EditFeature from '../feature/CreateFeature/EditFeature/EditFeature'; +>>>>>>> 937e090b (feat: create edit and creat feature screen) export const routes = [ // Project @@ -95,6 +99,15 @@ export const routes = [ layout: 'main', menu: {}, }, + { + path: '/projects/:projectId/features2/:featureId/settings', + parent: '/projects', + title: 'Edit Feature', + component: EditFeature, + type: 'protected', + layout: 'main', + menu: {}, + }, { path: '/projects/:projectId/features2/:featureId', parent: '/projects', @@ -118,7 +131,7 @@ export const routes = [ path: '/projects/:projectId/create-toggle', parent: '/projects/:id/features', title: 'Create feature toggle', - component: FeatureCreate, + component: CreateFeature, type: 'protected', layout: 'main', menu: {},