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/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx index ebf4d1c407..827951d435 100644 --- a/frontend/src/component/common/Input/Input.tsx +++ b/frontend/src/component/common/Input/Input.tsx @@ -11,6 +11,8 @@ interface IInputProps extends React.InputHTMLAttributes { onChange: (e: any) => any; onFocus?: (e: any) => any; onBlur?: (e: any) => any; + multiline?: boolean; + rows?: number; } const Input = ({ 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..455f0f6b1a --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/CreateFeature/CreateFeature.tsx @@ -0,0 +1,105 @@ +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'; +import { CF_CREATE_BTN_ID } from '../../../../testIds'; + +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..780af6765b --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/EditFeature/EditFeature.tsx @@ -0,0 +1,114 @@ +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, + 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 PATCH '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/features/${featureId}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(createPatch(), 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..bb01c34ecc --- /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: { + marginLeft: '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..44630d11d8 --- /dev/null +++ b/frontend/src/component/feature/CreateFeature/FeatureForm/FeatureForm.tsx @@ -0,0 +1,148 @@ +import { CREATE_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_NAME_ID, + CF_TYPE_ID, +} from '../../../../testIds'; +import useFeatureTypes from '../../../../hooks/api/getters/useFeatureTypes/useFeatureTypes'; +import { KeyboardArrowDownOutlined } from '@material-ui/icons'; +import useUser from '../../../../hooks/api/getters/useUser/useUser'; +import { projectFilterGenerator } from '../../../../utils/project-filter-generator'; +import FeatureProjectSelect from '../../FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect'; +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 { featureTypes } = useFeatureTypes(); + const { permissions } = useUser(); + const editable = mode !== 'Edit'; + + 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))} + inputProps={{ + 'data-test': CF_NAME_ID, + }} + /> + + 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/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx index c327c05159..6163a183f4 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx @@ -134,6 +134,7 @@ const FeatureOverviewEnvironment = ({ name )} arrow + key={name} >
{ propagateErrors: true, }); - const validateFeatureToggleName = async ( - name: string, - ) => { + const validateFeatureToggleName = async (name: string) => { const path = `api/admin/features/validate`; const req = createRequest(path, { method: 'POST', @@ -26,10 +24,9 @@ const useFeatureApi = () => { } }; - const createFeatureToggle = async ( projectId: string, - featureToggle: IFeatureToggleDTO, + featureToggle: IFeatureToggleDTO ) => { const path = `api/admin/projects/${projectId}/features`; const req = createRequest(path, { @@ -183,7 +180,11 @@ const useFeatureApi = () => { } }; - const patchFeatureVariants = async (projectId: string, featureId: string, patchPayload: Operation[]) => { + const patchFeatureVariants = async ( + projectId: string, + featureId: string, + patchPayload: Operation[] + ) => { const path = `api/admin/projects/${projectId}/features/${featureId}/variants`; const req = createRequest(path, { method: 'PATCH',