From 53cff043497e8492440d78595ade616aea415570 Mon Sep 17 00:00:00 2001 From: Youssef Khedher Date: Mon, 24 Jan 2022 15:43:45 +0100 Subject: [PATCH] feat: add create and edit context screen (NEW) (#613) * feat: add create and edit context screen * feat: add edit button for contexts list * fix: add legal values when press enter withou submit form * fix: context form Co-authored-by: Fredrik Strand Oseberg --- .../context/ContextForm/ContextForm.styles.ts | 68 ++++++ .../context/ContextForm/ContextForm.tsx | 197 ++++++++++++++++++ .../context/ContextList/ContextList.jsx | 74 +++++-- .../context/CreateContext/CreateContext.tsx | 105 ++++++++++ .../context/EditContext/EditContext.tsx | 117 +++++++++++ .../context/create-context-container.js | 22 -- .../context/edit-context-container.js | 26 --- .../component/context/hooks/useContextForm.ts | 88 ++++++++ frontend/src/component/menu/routes.js | 9 +- .../actions/useContextsApi/useContextsApi.ts | 81 +++++++ .../api/getters/useContext/useContext.ts | 41 ++++ 11 files changed, 758 insertions(+), 70 deletions(-) create mode 100644 frontend/src/component/context/ContextForm/ContextForm.styles.ts create mode 100644 frontend/src/component/context/ContextForm/ContextForm.tsx create mode 100644 frontend/src/component/context/CreateContext/CreateContext.tsx create mode 100644 frontend/src/component/context/EditContext/EditContext.tsx delete mode 100644 frontend/src/component/context/create-context-container.js delete mode 100644 frontend/src/component/context/edit-context-container.js create mode 100644 frontend/src/component/context/hooks/useContextForm.ts create mode 100644 frontend/src/hooks/api/actions/useContextsApi/useContextsApi.ts create mode 100644 frontend/src/hooks/api/getters/useContext/useContext.ts diff --git a/frontend/src/component/context/ContextForm/ContextForm.styles.ts b/frontend/src/component/context/ContextForm/ContextForm.styles.ts new file mode 100644 index 0000000000..dbd0103b9f --- /dev/null +++ b/frontend/src/component/context/ContextForm/ContextForm.styles.ts @@ -0,0 +1,68 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + maxWidth: '470px', + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + inputHeader:{ + marginBottom: '0.3rem' + }, + label: { + minWidth: '300px', + [theme.breakpoints.down(600)]: { + minWidth: 'auto', + }, + }, + tagContainer: { + display: 'flex', + alignItems: 'flex-start', + marginBottom: '1rem' + }, + tagInput: { + width: '75%', + marginRight: 'auto', + }, + tagValue: { + marginRight: '3px', + marginBottom: '1rem' + }, + 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', + }, + switchContainer: { + display: 'flex', + alignItems: 'center', + marginLeft: '-9px' + }, +})); diff --git a/frontend/src/component/context/ContextForm/ContextForm.tsx b/frontend/src/component/context/ContextForm/ContextForm.tsx new file mode 100644 index 0000000000..806017eb24 --- /dev/null +++ b/frontend/src/component/context/ContextForm/ContextForm.tsx @@ -0,0 +1,197 @@ +import Input from '../../common/Input/Input'; +import { TextField, Button, Switch, Chip, Typography } from '@material-ui/core'; +import { useStyles } from './ContextForm.styles'; +import React, { useState } from 'react'; +import { Add } from '@material-ui/icons'; +import { trim } from '../../common/util'; + +interface IContextForm { + contextName: string; + contextDesc: string; + legalValues: Array; + stickiness: boolean; + setContextName: React.Dispatch>; + setContextDesc: React.Dispatch>; + setStickiness: React.Dispatch>; + setLegalValues: React.Dispatch>; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: string; + clearErrors: () => void; + validateNameUniqueness: () => void; + setErrors: React.Dispatch>; +} + +const ENTER = 'Enter'; + +const ContextForm: React.FC = ({ + children, + handleSubmit, + handleCancel, + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + errors, + mode, + validateNameUniqueness, + setErrors, + clearErrors, +}) => { + const styles = useStyles(); + const [value, setValue] = useState(''); + const [focused, setFocused] = useState(false); + + const submit = (event: React.SyntheticEvent) => { + event.preventDefault(); + if (focused) return; + handleSubmit(event); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === ENTER && focused) { + addLegalValue(); + return; + } else if (event.key === ENTER) { + handleSubmit(event); + } + }; + + const sortIgnoreCase = (a: string, b: string) => { + a = a.toLowerCase(); + b = b.toLowerCase(); + if (a === b) return 0; + if (a > b) return 1; + return -1; + }; + + const addLegalValue = () => { + clearErrors(); + if (!value) { + return; + } + + if (legalValues.indexOf(value) !== -1) { + setErrors(prev => ({ + ...prev, + tag: 'Duplicate legal value', + })); + return; + } + setLegalValues(prev => [...prev, trim(value)].sort(sortIgnoreCase)); + setValue(''); + }; + const removeLegalValue = (index: number) => { + const filteredValues = legalValues.filter((_, i) => i !== index); + setLegalValues([...filteredValues]); + }; + + return ( +
+

Context information

+ +
+

+ What is your context name? +

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

+ What is this context for? +

+ setContextDesc(e.target.value)} + /> +

+ Which values do you want to allow? +

+ {legalValues.map((value, index) => { + return ( + removeLegalValue(index)} + title="Remove value" + /> + ); + })} +
+ setValue(trim(e.target.value))} + onKeyPress={e => handleKeyDown(e)} + onBlur={e => setFocused(false)} + onFocus={e => setFocused(true)} + /> + +
+

Custom stickiness (beta)

+

+ By enabling stickiness on this context field you can use it + together with the flexible-rollout strategy. This will + guarantee a consistent behavior for specific values of this + context field. PS! Not all client SDK's support this feature + yet!{' '} + + Read more + +

+
+ setStickiness(!stickiness)} + /> + {stickiness ? 'On' : 'Off'} +
+
+
+ + {children} +
+
+ ); +}; + +export default ContextForm; diff --git a/frontend/src/component/context/ContextList/ContextList.jsx b/frontend/src/component/context/ContextList/ContextList.jsx index d055e93a5f..b10447882e 100644 --- a/frontend/src/component/context/ContextList/ContextList.jsx +++ b/frontend/src/component/context/ContextList/ContextList.jsx @@ -4,7 +4,8 @@ import HeaderTitle from '../../common/HeaderTitle'; import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; import { CREATE_CONTEXT_FIELD, - DELETE_CONTEXT_FIELD, UPDATE_CONTEXT_FIELD, + DELETE_CONTEXT_FIELD, + UPDATE_CONTEXT_FIELD, } from '../../providers/AccessProvider/permissions'; import { IconButton, @@ -16,35 +17,78 @@ import { useMediaQuery, Button, } from '@material-ui/core'; -import { Add, Album, Delete } from '@material-ui/icons'; - +import { Add, Album, Delete, Edit } from '@material-ui/icons'; import { useContext, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { useStyles } from './styles'; import ConfirmDialogue from '../../common/Dialogue'; import AccessContext from '../../../contexts/AccessContext'; +import useUnleashContext from '../../../hooks/api/getters/useUnleashContext/useUnleashContext'; +import useContextsApi from '../../../hooks/api/actions/useContextsApi/useContextsApi'; +import useToast from '../../../hooks/useToast'; -const ContextList = ({ removeContextField, history, contextFields }) => { +const ContextList = ({ removeContextField }) => { const { hasAccess } = useContext(AccessContext); const [showDelDialogue, setShowDelDialogue] = useState(false); const smallScreen = useMediaQuery('(max-width:700px)'); const [name, setName] = useState(); - + const { context, refetch } = useUnleashContext(); + const { removeContext } = useContextsApi(); + const { setToastData, setToastApiError } = useToast(); + const history = useHistory(); const styles = useStyles(); + + const onDeleteContext = async name => { + try { + await removeContext(name); + refetch(); + setToastData({ + type: 'success', + title: 'Successfully deleted context', + text: 'Your context is now deleted', + }); + } catch (e) { + setToastApiError(e.toString()); + } + setName(undefined); + setShowDelDialogue(false); + }; + const contextList = () => - contextFields.map(field => ( + context.map(field => ( - {field.name} - - } elseShow={{field.name}} />} + + {field.name} + + } + elseShow={{field.name}} + /> + } secondary={field.description} /> + + + history.push(`/context/edit/${field.name}`) + } + > + + + + } + /> { > 0} + condition={context.length > 0} show={contextList} elseShow={No context fields defined} /> { - removeContextField({ name }); - setName(undefined); - setShowDelDialogue(false); - }} + onClick={() => onDeleteContext(name)} onClose={() => { setName(undefined); setShowDelDialogue(false); diff --git a/frontend/src/component/context/CreateContext/CreateContext.tsx b/frontend/src/component/context/CreateContext/CreateContext.tsx new file mode 100644 index 0000000000..e1faa2e9f5 --- /dev/null +++ b/frontend/src/component/context/CreateContext/CreateContext.tsx @@ -0,0 +1,105 @@ +import { useHistory } from 'react-router-dom'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from '../../../hooks/useToast'; +import FormTemplate from '../../common/FormTemplate/FormTemplate'; +import useContextForm from '../hooks/useContextForm'; +import ContextForm from '../ContextForm/ContextForm'; +import PermissionButton from '../../common/PermissionButton/PermissionButton'; +import { CREATE_CONTEXT_FIELD } from '../../providers/AccessProvider/permissions'; +import useContextsApi from '../../../hooks/api/actions/useContextsApi/useContextsApi'; +import useUnleashContext from '../../../hooks/api/getters/useUnleashContext/useUnleashContext'; + +const CreateContext = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const history = useHistory(); + const { + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + getContextPayload, + validateNameUniqueness, + validateName, + clearErrors, + setErrors, + errors, + } = useContextForm(); + const { createContext, loading } = useContextsApi(); + const { refetch } = useUnleashContext(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + const validName = validateName(); + if (validName) { + const payload = getContextPayload(); + try { + await createContext(payload); + refetch(); + history.push('/context'); + setToastData({ + title: 'Context created', + confetti: true, + type: 'success', + }); + } catch (e: any) { + setToastApiError(e.toString()); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/context' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getContextPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + history.goBack(); + }; + + return ( + + + + Create context + + + + ); +}; + +export default CreateContext; diff --git a/frontend/src/component/context/EditContext/EditContext.tsx b/frontend/src/component/context/EditContext/EditContext.tsx new file mode 100644 index 0000000000..146353167b --- /dev/null +++ b/frontend/src/component/context/EditContext/EditContext.tsx @@ -0,0 +1,117 @@ +import { useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import useContextsApi from '../../../hooks/api/actions/useContextsApi/useContextsApi'; +import useContext from '../../../hooks/api/getters/useContext/useContext'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from '../../../hooks/useToast'; +import FormTemplate from '../../common/FormTemplate/FormTemplate'; +import PermissionButton from '../../common/PermissionButton/PermissionButton'; +import { scrollToTop } from '../../common/util'; +import { UPDATE_CONTEXT_FIELD } from '../../providers/AccessProvider/permissions'; +import ContextForm from '../ContextForm/ContextForm'; +import useContextForm from '../hooks/useContextForm'; + +const EditContext = () => { + useEffect(() => { + scrollToTop(); + }, []); + + const { uiConfig } = useUiConfig(); + const { setToastData, setToastApiError } = useToast(); + const { name } = useParams<{ name: string }>(); + const { context, refetch } = useContext(name); + const { updateContext, loading } = useContextsApi(); + const history = useHistory(); + const { + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + getContextPayload, + validateNameUniqueness, + validateName, + clearErrors, + setErrors, + errors, + } = useContextForm( + context?.name, + context?.description, + context?.legalValues, + context?.stickiness + ); + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/context/${name}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getContextPayload(), undefined, 2)}'`; + }; + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + const payload = getContextPayload(); + const validName = validateName(); + + if (validName) { + try { + await updateContext(payload); + refetch(); + history.push('/context'); + setToastData({ + title: 'Context information updated', + type: 'success', + }); + } catch (e: any) { + setToastApiError(e.toString()); + } + } + }; + + const handleCancel = () => { + history.goBack(); + }; + + return ( + + + + Edit context + + + + ); +}; + +export default EditContext; diff --git a/frontend/src/component/context/create-context-container.js b/frontend/src/component/context/create-context-container.js deleted file mode 100644 index f575e9c444..0000000000 --- a/frontend/src/component/context/create-context-container.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux'; -import ContextComponent from './form-context-component'; -import { createContextField, validateName } from './../../store/context/actions'; - -const mapStateToProps = (state, props) => { - let contextField = { name: '', description: '', legalValues: [] }; - if (props.contextFieldName) { - contextField = state.context.toJS().find(n => n.name === props.contextFieldName); - } - return { - contextField, - }; -}; - -const mapDispatchToProps = dispatch => ({ - validateName, - submit: contextField => createContextField(contextField)(dispatch), -}); - -const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(ContextComponent); - -export default FormAddContainer; diff --git a/frontend/src/component/context/edit-context-container.js b/frontend/src/component/context/edit-context-container.js deleted file mode 100644 index f7c7d998fe..0000000000 --- a/frontend/src/component/context/edit-context-container.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux'; -import ContextComponent from './form-context-component'; -import { updateContextField, validateName } from './../../store/context/actions'; - -const mapStateToProps = (state, props) => { - const contextFieldBase = { name: '', description: '', legalValues: [] }; - const field = state.context.toJS().find(n => n.name === props.contextFieldName); - const contextField = Object.assign(contextFieldBase, field); - if (!field) { - contextField.initial = true; - } - - return { - contextField, - }; -}; - -const mapDispatchToProps = dispatch => ({ - validateName, - submit: contextField => updateContextField(contextField)(dispatch), - editMode: true, -}); - -const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(ContextComponent); - -export default FormAddContainer; diff --git a/frontend/src/component/context/hooks/useContextForm.ts b/frontend/src/component/context/hooks/useContextForm.ts new file mode 100644 index 0000000000..502e2fcc76 --- /dev/null +++ b/frontend/src/component/context/hooks/useContextForm.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import useContextsApi from '../../../hooks/api/actions/useContextsApi/useContextsApi'; + +const useContextForm = ( + initialcontextName = '', + initialcontextDesc = '', + initialLegalValues = [] as string[], + initialStickiness = false +) => { + const [contextName, setContextName] = useState(initialcontextName); + const [contextDesc, setContextDesc] = useState(initialcontextDesc); + const [legalValues, setLegalValues] = useState(initialLegalValues); + const [stickiness, setStickiness] = useState(initialStickiness); + const [errors, setErrors] = useState({}); + const { validateContextName } = useContextsApi(); + + useEffect(() => { + setContextName(initialcontextName); + }, [initialcontextName]); + + useEffect(() => { + setContextDesc(initialcontextDesc); + }, [initialcontextDesc]); + + useEffect(() => { + setLegalValues(initialLegalValues); + // eslint-disable-next-line + }, [initialLegalValues.length]); + + useEffect(() => { + setStickiness(initialStickiness); + }, [initialStickiness]); + + const getContextPayload = () => { + return { + name: contextName, + description: contextDesc, + legalValues, + stickiness, + }; + }; + + const NAME_EXISTS_ERROR = 'A context field with that name already exist'; + + const validateNameUniqueness = async () => { + try { + await validateContextName(contextName); + } catch (e: any) { + if (e.toString().includes(NAME_EXISTS_ERROR)) { + setErrors(prev => ({ + ...prev, + name: 'A context field with that name already exist', + })); + } + } + }; + + const validateName = () => { + if (contextName.length === 0) { + setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); + return false; + } + return true; + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + contextName, + contextDesc, + legalValues, + stickiness, + setContextName, + setContextDesc, + setLegalValues, + setStickiness, + getContextPayload, + validateNameUniqueness, + validateName, + setErrors, + clearErrors, + errors, + }; +}; + +export default useContextForm; diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 9a96aaf812..b88dd09d8a 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -11,8 +11,6 @@ import Archive from '../../page/archive'; import Applications from '../../page/applications'; import ApplicationView from '../../page/applications/view'; import ContextFields from '../../page/context'; -import CreateContextField from '../../page/context/create'; -import EditContextField from '../../page/context/edit'; import ListTagTypes from '../../page/tag-types'; import Addons from '../../page/addons'; import AddonsCreate from '../../page/addons/create'; @@ -42,7 +40,8 @@ import EditUser from '../admin/users/EditUser/EditUser'; import CreateApiToken from '../admin/api-token/CreateApiToken/CreateApiToken'; import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment'; import EditEnvironment from '../environments/EditEnvironment/EditEnvironment'; - +import CreateContext from '../context/CreateContext/CreateContext'; +import EditContext from '../context/EditContext/EditContext'; import EditTagType from '../tagTypes/EditTagType/EditTagType'; import CreateTagType from '../tagTypes/CreateTagType/CreateTagType'; import EditProject from '../project/Project/EditProject/EditProject'; @@ -205,7 +204,7 @@ export const routes = [ path: '/context/create', parent: '/context', title: 'Create', - component: CreateContextField, + component: CreateContext, type: 'protected', layout: 'main', flag: C, @@ -215,7 +214,7 @@ export const routes = [ path: '/context/edit/:name', parent: '/context', title: ':name', - component: EditContextField, + component: EditContext, type: 'protected', layout: 'main', flag: C, diff --git a/frontend/src/hooks/api/actions/useContextsApi/useContextsApi.ts b/frontend/src/hooks/api/actions/useContextsApi/useContextsApi.ts new file mode 100644 index 0000000000..9b2186cb8e --- /dev/null +++ b/frontend/src/hooks/api/actions/useContextsApi/useContextsApi.ts @@ -0,0 +1,81 @@ +import { IContext } from '../../../../interfaces/context'; +import useAPI from '../useApi/useApi'; + +const useContextsApi = () => { + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + }); + + const URI = 'api/admin/context'; + + const validateContextName = async (name: string) => { + const path = `${URI}/validate`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify({ name }), + }); + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + const createContext = async (payload: IContext) => { + const path = URI; + 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 updateContext = async (context: IContext) => { + const path = `${URI}/${context.name}`; + const req = createRequest(path, { + method: 'PUT', + body: JSON.stringify(context), + }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + const removeContext = async (contextName: string) => { + const path = `${URI}/${contextName}`; + const req = createRequest(path, { method: 'DELETE' }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + return { + createContext, + validateContextName, + updateContext, + removeContext, + errors, + loading + }; +}; + +export default useContextsApi; diff --git a/frontend/src/hooks/api/getters/useContext/useContext.ts b/frontend/src/hooks/api/getters/useContext/useContext.ts new file mode 100644 index 0000000000..b710e69dad --- /dev/null +++ b/frontend/src/hooks/api/getters/useContext/useContext.ts @@ -0,0 +1,41 @@ +import useSWR, { mutate, SWRConfiguration } from 'swr'; +import { useState, useEffect } from 'react'; +import { formatApiPath } from '../../../../utils/format-path'; +import handleErrorResponses from '../httpErrorResponseHandler'; + +const useContext = (name: string, options: SWRConfiguration = {}) => { + const fetcher = async () => { + const path = formatApiPath(`api/admin/context/${name}`); + return fetch(path, { + method: 'GET', + }) + .then(handleErrorResponses('Context data')) + .then(res => res.json()); + }; + + const FEATURE_CACHE_KEY = `api/admin/context/${name}`; + + const { data, error } = useSWR(FEATURE_CACHE_KEY, fetcher, { + ...options, + }); + + const [loading, setLoading] = useState(!error && !data); + + const refetch = () => { + mutate(FEATURE_CACHE_KEY); + }; + + useEffect(() => { + setLoading(!error && !data); + }, [data, error]); + + return { + context: data || { name: '', description: '', legalValues: [], stickiness: false }, + error, + loading, + refetch, + FEATURE_CACHE_KEY, + }; +}; + +export default useContext; \ No newline at end of file