diff --git a/frontend/src/component/admin/api-token/ApiTokenForm/ApiTokenForm.tsx b/frontend/src/component/admin/api-token/ApiTokenForm/ApiTokenForm.tsx index ef62565172..9f160f0ad2 100644 --- a/frontend/src/component/admin/api-token/ApiTokenForm/ApiTokenForm.tsx +++ b/frontend/src/component/admin/api-token/ApiTokenForm/ApiTokenForm.tsx @@ -18,7 +18,7 @@ interface IApiTokenFormProps { handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; - mode: string; + mode: 'Create' | 'Edit'; clearErrors: () => void; } const ApiTokenForm: React.FC = ({ diff --git a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx index 4c76656312..8127a90350 100644 --- a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx +++ b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { FormControl, InputLabel, MenuItem, Select } from '@material-ui/core'; import { SELECT_ITEM_ID } from '../../../testIds'; +import { KeyboardArrowDownOutlined } from '@material-ui/icons'; export interface ISelectOption { key: string; @@ -71,6 +72,7 @@ const GeneralSelect: React.FC = ({ label={label} id={id} value={value} + IconComponent={KeyboardArrowDownOutlined} {...rest} > {renderSelectItems()} diff --git a/frontend/src/component/context/ContextForm/ContextForm.tsx b/frontend/src/component/context/ContextForm/ContextForm.tsx index b9ad29089e..1170e02a48 100644 --- a/frontend/src/component/context/ContextForm/ContextForm.tsx +++ b/frontend/src/component/context/ContextForm/ContextForm.tsx @@ -17,7 +17,7 @@ interface IContextForm { handleSubmit: (e: any) => void; onCancel: () => void; errors: { [key: string]: string }; - mode: string; + mode: 'Create' | 'Edit'; clearErrors: () => void; validateContext?: () => void; setErrors: React.Dispatch>; diff --git a/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx b/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx index ed29fc049d..abb7fab8e9 100644 --- a/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx +++ b/frontend/src/component/environments/EnvironmentForm/EnvironmentForm.tsx @@ -14,7 +14,7 @@ interface IEnvironmentForm { handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; - mode: string; + mode: 'Create' | 'Edit'; clearErrors: () => void; } diff --git a/frontend/src/component/feature/FeatureForm/FeatureForm.tsx b/frontend/src/component/feature/FeatureForm/FeatureForm.tsx index 1e75297b7b..fb573f2f8f 100644 --- a/frontend/src/component/feature/FeatureForm/FeatureForm.tsx +++ b/frontend/src/component/feature/FeatureForm/FeatureForm.tsx @@ -35,7 +35,7 @@ interface IFeatureToggleForm { handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; - mode: string; + mode: 'Create' | 'Edit'; clearErrors: () => void; } 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 b065d0886f..db7196d66c 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -208,8 +208,17 @@ Array [ "layout": "main", "menu": Object {}, "parent": "/strategies", - "path": "/strategies/:activeTab/:strategyName", - "title": ":strategyName", + "path": "/strategies/:name/edit", + "title": ":name", + "type": "protected", + }, + Object { + "component": [Function], + "layout": "main", + "menu": Object {}, + "parent": "/strategies", + "path": "/strategies/:name", + "title": ":name", "type": "protected", }, Object { diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 50e4594094..02273e404e 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -1,5 +1,4 @@ import { FeatureToggleListContainer } from '../feature/FeatureToggleList/FeatureToggleListContainer'; -import { StrategyForm } from '../strategies/StrategyForm/StrategyForm'; import { StrategyView } from '../strategies/StrategyView/StrategyView'; import { StrategiesList } from '../strategies/StrategiesList/StrategiesList'; import { ArchiveListContainer } from '../archive/ArchiveListContainer'; @@ -45,6 +44,8 @@ import { EditAddon } from '../addons/EditAddon/EditAddon'; import { CopyFeatureToggle } from '../feature/CopyFeature/CopyFeature'; import { EventHistoryPage } from '../history/EventHistoryPage/EventHistoryPage'; import { FeatureEventHistoryPage } from '../history/FeatureEventHistoryPage/FeatureEventHistoryPage'; +import { CreateStrategy } from '../strategies/CreateStrategy/CreateStrategy'; +import { EditStrategy } from '../strategies/EditStrategy/EditStrategy'; export const routes = [ // Project @@ -243,14 +244,23 @@ export const routes = [ path: '/strategies/create', title: 'Create', parent: '/strategies', - component: StrategyForm, + component: CreateStrategy, type: 'protected', layout: 'main', menu: {}, }, { - path: '/strategies/:activeTab/:strategyName', - title: ':strategyName', + path: '/strategies/:name/edit', + title: ':name', + parent: '/strategies', + component: EditStrategy, + type: 'protected', + layout: 'main', + menu: {}, + }, + { + path: '/strategies/:name', + title: ':name', parent: '/strategies', component: StrategyView, type: 'protected', diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index 67b040f969..cc40ee91db 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -14,7 +14,7 @@ interface IProjectForm { handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; - mode: string; + mode: 'Create' | 'Edit'; clearErrors: () => void; validateIdUniqueness: () => void; } diff --git a/frontend/src/component/strategies/CreateStrategy/CreateStrategy.tsx b/frontend/src/component/strategies/CreateStrategy/CreateStrategy.tsx new file mode 100644 index 0000000000..e429f371b4 --- /dev/null +++ b/frontend/src/component/strategies/CreateStrategy/CreateStrategy.tsx @@ -0,0 +1,99 @@ +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 { useStrategyForm } from '../hooks/useStrategyForm'; +import { StrategyForm } from '../StrategyForm/StrategyForm'; +import PermissionButton from '../../common/PermissionButton/PermissionButton'; +import { CREATE_STRATEGY } from '../../providers/AccessProvider/permissions'; +import useStrategiesApi from '../../../hooks/api/actions/useStrategiesApi/useStrategiesApi'; +import useStrategies from '../../../hooks/api/getters/useStrategies/useStrategies'; +import { formatUnknownError } from 'utils/format-unknown-error'; + +export const CreateStrategy = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const history = useHistory(); + const { + strategyName, + strategyDesc, + params, + setParams, + setStrategyName, + setStrategyDesc, + getStrategyPayload, + validateStrategyName, + validateParams, + clearErrors, + setErrors, + errors, + } = useStrategyForm(); + const { createStrategy, loading } = useStrategiesApi(); + const { refetchStrategies } = useStrategies(); + + const handleSubmit = async (e: React.FormEvent) => { + clearErrors(); + e.preventDefault(); + const validName = validateStrategyName(); + + if (validName && validateParams()) { + const payload = getStrategyPayload(); + try { + await createStrategy(payload); + refetchStrategies(); + history.push(`/strategies/${strategyName}`); + setToastData({ + title: 'Strategy created', + text: 'Successfully created strategy', + confetti: true, + type: 'success', + }); + } catch (e: unknown) { + setToastApiError(formatUnknownError(e)); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/strategies' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getStrategyPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + history.goBack(); + }; + + return ( + + + + Create strategy + + + + ); +}; diff --git a/frontend/src/component/strategies/EditStrategy/EditStrategy.tsx b/frontend/src/component/strategies/EditStrategy/EditStrategy.tsx new file mode 100644 index 0000000000..efdb3ac90b --- /dev/null +++ b/frontend/src/component/strategies/EditStrategy/EditStrategy.tsx @@ -0,0 +1,102 @@ +import { useHistory, useParams } from 'react-router-dom'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useStrategyForm } from '../hooks/useStrategyForm'; +import { StrategyForm } from '../StrategyForm/StrategyForm'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { CREATE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import useStrategiesApi from 'hooks/api/actions/useStrategiesApi/useStrategiesApi'; +import useStrategies from 'hooks/api/getters/useStrategies/useStrategies'; +import { formatUnknownError } from 'utils/format-unknown-error'; +import useStrategy from 'hooks/api/getters/useStrategy/useStrategy'; + +export const EditStrategy = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const history = useHistory(); + const { name } = useParams<{ name: string }>(); + const { strategy } = useStrategy(name); + const { + strategyName, + strategyDesc, + params, + setParams, + setStrategyName, + setStrategyDesc, + getStrategyPayload, + validateParams, + clearErrors, + setErrors, + errors, + } = useStrategyForm( + strategy?.name, + strategy?.description, + strategy?.parameters + ); + const { updateStrategy, loading } = useStrategiesApi(); + const { refetchStrategies } = useStrategies(); + + const handleSubmit = async (e: React.FormEvent) => { + clearErrors(); + e.preventDefault(); + if (validateParams()) { + const payload = getStrategyPayload(); + try { + await updateStrategy(payload); + history.push(`/strategies/${strategyName}`); + setToastData({ + type: 'success', + title: 'Success', + text: 'Successfully updated strategy', + }); + refetchStrategies(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/strategies/${name}' \\ +--header 'Authorization: INSERT_API_KEY' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify(getStrategyPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + history.goBack(); + }; + + return ( + + + + Save + + + + ); +}; diff --git a/frontend/src/component/strategies/StrategiesList/StrategiesList.styles.ts b/frontend/src/component/strategies/StrategiesList/StrategiesList.styles.ts index 4c2800d79d..063d1d8165 100644 --- a/frontend/src/component/strategies/StrategiesList/StrategiesList.styles.ts +++ b/frontend/src/component/strategies/StrategiesList/StrategiesList.styles.ts @@ -5,16 +5,10 @@ export const useStyles = makeStyles(theme => ({ padding: '0', ['& a']: { textDecoration: 'none', - color: 'inherit', + color: theme.palette.primary.light, }, '&:hover': { backgroundColor: theme.palette.grey[200], }, }, - deprecated: { - '& a': { - // @ts-expect-error - color: theme.palette.links.deprecated, - }, - }, })); diff --git a/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx b/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx index e75db53b6d..c75cc09f58 100644 --- a/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx +++ b/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx @@ -1,5 +1,4 @@ import { useContext, useState } from 'react'; -import classnames from 'classnames'; import { Link, useHistory } from 'react-router-dom'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import { @@ -13,6 +12,7 @@ import { import { Add, Delete, + Edit, Extension, Visibility, VisibilityOff, @@ -22,21 +22,21 @@ import { DELETE_STRATEGY, UPDATE_STRATEGY, } from '../../providers/AccessProvider/permissions'; -import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; -import PageContent from '../../common/PageContent/PageContent'; -import HeaderTitle from '../../common/HeaderTitle'; +import ConditionallyRender from 'component/common/ConditionallyRender/ConditionallyRender'; +import PageContent from 'component/common/PageContent/PageContent'; +import HeaderTitle from 'component/common/HeaderTitle'; import { useStyles } from './StrategiesList.styles'; -import AccessContext from '../../../contexts/AccessContext'; -import Dialogue from '../../common/Dialogue'; -import { ADD_NEW_STRATEGY_ID } from '../../../testIds'; -import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton'; -import PermissionButton from '../../common/PermissionButton/PermissionButton'; -import { getHumanReadableStrategyName } from '../../../utils/strategy-names'; -import useStrategies from '../../../hooks/api/getters/useStrategies/useStrategies'; -import useStrategiesApi from '../../../hooks/api/actions/useStrategiesApi/useStrategiesApi'; -import useToast from '../../../hooks/useToast'; -import { IStrategy } from '../../../interfaces/strategy'; -import { formatUnknownError } from '../../../utils/format-unknown-error'; +import AccessContext from 'contexts/AccessContext'; +import Dialogue from 'component/common/Dialogue'; +import { ADD_NEW_STRATEGY_ID } from 'testIds'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { getHumanReadableStrategyName } from 'utils/strategy-names'; +import useStrategies from 'hooks/api/getters/useStrategies/useStrategies'; +import useStrategiesApi from 'hooks/api/actions/useStrategiesApi/useStrategiesApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/format-unknown-error'; +import { ICustomStrategy } from 'interfaces/strategy'; interface IDialogueMetaData { show: boolean; @@ -91,8 +91,8 @@ export const StrategiesList = () => { /> ); - const strategyLink = ({ name, deprecated }: IStrategy) => ( - + const strategyLink = (name: string, deprecated: boolean) => ( + {getHumanReadableStrategyName(name)} { ); - const onReactivateStrategy = (strategy: IStrategy) => { + const onReactivateStrategy = (strategy: ICustomStrategy) => { setDialogueMetaData({ show: true, title: 'Really reactivate strategy?', @@ -121,7 +121,7 @@ export const StrategiesList = () => { }); }; - const onDeprecateStrategy = (strategy: IStrategy) => { + const onDeprecateStrategy = (strategy: ICustomStrategy) => { setDialogueMetaData({ show: true, title: 'Really deprecate strategy?', @@ -141,7 +141,7 @@ export const StrategiesList = () => { }); }; - const onDeleteStrategy = (strategy: IStrategy) => { + const onDeleteStrategy = (strategy: ICustomStrategy) => { setDialogueMetaData({ show: true, title: 'Really delete strategy?', @@ -161,7 +161,7 @@ export const StrategiesList = () => { }); }; - const reactivateButton = (strategy: IStrategy) => ( + const reactivateButton = (strategy: ICustomStrategy) => ( onReactivateStrategy(strategy)} @@ -172,7 +172,7 @@ export const StrategiesList = () => { ); - const deprecateButton = (strategy: IStrategy) => ( + const deprecateButton = (strategy: ICustomStrategy) => ( { /> ); - const deleteButton = (strategy: IStrategy) => ( + const editButton = (strategy: ICustomStrategy) => ( + history.push(`/strategies/${strategy?.name}/edit`) + } + permission={UPDATE_STRATEGY} + tooltip={'Edit strategy'} + > + + + } + elseShow={ + +
+ + + +
+
+ } + /> + ); + + const deleteButton = (strategy: ICustomStrategy) => ( + onDeleteStrategy(strategy)} @@ -223,19 +249,12 @@ export const StrategiesList = () => { const strategyList = () => strategies.map(strategy => ( - + { show={reactivateButton(strategy)} elseShow={deprecateButton(strategy)} /> + ({ + container: { + maxWidth: 400, + }, + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + input: { width: '100%', marginBottom: '1rem' }, + selectInput: { + marginBottom: '1rem', + minWidth: '400px', + [theme.breakpoints.down(600)]: { + minWidth: '379px', + }, + }, + link: { + color: theme.palette.primary.light, + }, + 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: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.grey[600], + top: '-13px', + position: 'relative', + }, + formHeader: { + fontWeight: 'normal', + marginTop: '0', + }, + header: { + fontWeight: 'normal', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, + flexRow: { + display: 'flex', + alignItems: 'center', + marginTop: '0.5rem', + }, + paramButton: { + color: theme.palette.primary.dark, + }, +})); diff --git a/frontend/src/component/strategies/StrategyForm/StrategyForm.tsx b/frontend/src/component/strategies/StrategyForm/StrategyForm.tsx index e1fcbeec9e..1008d75746 100644 --- a/frontend/src/component/strategies/StrategyForm/StrategyForm.tsx +++ b/frontend/src/component/strategies/StrategyForm/StrategyForm.tsx @@ -1,195 +1,114 @@ -import React, { useState } from 'react'; -import { Typography, TextField, Button } from '@material-ui/core'; +import Input from '../../common/Input/Input'; +import { Button } from '@material-ui/core'; +import { useStyles } from './StrategyForm.styles'; import { Add } from '@material-ui/icons'; -import PageContent from '../../common/PageContent/PageContent'; -import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; -import { styles as commonStyles, FormButtons } from '../../common'; import { trim } from '../../common/util'; -import StrategyParameters from './StrategyParameters/StrategyParameters'; -import { useHistory } from 'react-router-dom'; -import useStrategiesApi from '../../../hooks/api/actions/useStrategiesApi/useStrategiesApi'; -import { IStrategy } from '../../../interfaces/strategy'; -import useToast from '../../../hooks/useToast'; -import useStrategies from '../../../hooks/api/getters/useStrategies/useStrategies'; -import { formatUnknownError } from '../../../utils/format-unknown-error'; - -interface ICustomStrategyParams { - name?: string; - type?: string; - description?: string; - required?: boolean; -} - -interface ICustomStrategyErrors { - name?: string; -} +import { StrategyParameters } from './StrategyParameters/StrategyParameters'; +import { ICustomStrategyParameter } from 'interfaces/strategy'; interface IStrategyFormProps { - editMode: boolean; - strategy: IStrategy; + strategyName: string; + strategyDesc: string; + params: ICustomStrategyParameter[]; + setStrategyName: React.Dispatch>; + setStrategyDesc: React.Dispatch>; + setParams: React.Dispatch>; + handleSubmit: (e: React.FormEvent) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: () => void; + setErrors: React.Dispatch>>; } -export const StrategyForm = ({ editMode, strategy }: IStrategyFormProps) => { - const history = useHistory(); - const [name, setName] = useState(strategy?.name || ''); - const [description, setDescription] = useState(strategy?.description || ''); - const [params, setParams] = useState( - // @ts-expect-error - strategy?.parameters || [] - ); - const [errors, setErrors] = useState({}); - const { createStrategy, updateStrategy } = useStrategiesApi(); - const { refetchStrategies } = useStrategies(); - const { setToastData, setToastApiError } = useToast(); - - const clearErrors = () => { - setErrors({}); - }; - - const getHeaderTitle = () => { - if (editMode) return 'Edit strategy'; - return 'Create a new strategy'; - }; - - const appParameter = () => { - setParams(prev => [...prev, {}]); - }; +export const StrategyForm: React.FC = ({ + children, + handleSubmit, + handleCancel, + strategyName, + strategyDesc, + params, + setParams, + setStrategyName, + setStrategyDesc, + errors, + mode, + clearErrors, +}) => { + const styles = useStyles(); const updateParameter = (index: number, updated: object) => { let item = { ...params[index] }; params[index] = Object.assign({}, item, updated); setParams(prev => [...prev]); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const parameters = (params || []) - .filter(({ name }) => !!name) - .map( - ({ - name, - type = 'string', - description = '', - required = false, - }) => ({ - name, - type, - description, - required, - }) - ); - setParams(prev => [...parameters]); - if (editMode) { - try { - await updateStrategy({ name, description, parameters }); - history.push(`/strategies/view/${name}`); - setToastData({ - type: 'success', - title: 'Success', - text: 'Successfully updated strategy', - }); - refetchStrategies(); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - } else { - try { - await createStrategy({ name, description, parameters }); - history.push(`/strategies`); - setToastData({ - type: 'success', - title: 'Success', - text: 'Successfully created new strategy', - }); - refetchStrategies(); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - } + const appParameter = () => { + setParams(prev => [ + ...prev, + { name: '', type: 'string', description: '', required: false }, + ]); }; - const handleCancel = () => history.goBack(); - return ( - - - Be careful! Changing a strategy definition might also - require changes to the implementation in the clients. - - } - /> +
+

Strategy type information

- - +

+ What would you like to call your strategy? +

+ setStrategyName(trim(e.target.value))} error={Boolean(errors.name)} - helperText={errors.name} - onChange={e => { - clearErrors(); - setName(trim(e.target.value)); - }} - value={name} - variant="outlined" - size="small" + errorText={errors.name} + onFocus={() => clearErrors()} /> - - + What is your strategy description? +

+ setStrategyDesc(e.target.value)} rows={2} - label="Description" - name="description" - placeholder="" - onChange={e => setDescription(e.target.value)} - value={description} - variant="outlined" - size="small" + multiline /> - - - Save - - } - elseShow={ - - } - /> - -
+ +
+ {children} + +
+ ); }; diff --git a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.jsx b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.jsx deleted file mode 100644 index e27d0b3135..0000000000 --- a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { TextField, Checkbox, FormControlLabel } from '@material-ui/core'; -import PropTypes from 'prop-types'; - -import { styles as commonStyles } from '../../../../common'; -import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect'; - -const paramTypesOptions = [ - { key: 'string', label: 'string' }, - { key: 'percentage', label: 'percentage' }, - { key: 'list', label: 'list' }, - { key: 'number', label: 'number' }, - { key: 'boolean', label: 'boolean' }, -]; - -const StrategyParameter = ({ set, input = {}, index }) => { - const handleTypeChange = event => { - set({ type: event.target.value }); - }; - - return ( -
- set({ name: target.value }, true)} - value={input.name || ''} - variant="outlined" - size="small" - /> - - - set({ description: target.value })} - value={input.description || ''} - variant="outlined" - size="small" - /> - set({ required: !input.required })} - /> - } - label="Required" - /> -
- ); -}; - -StrategyParameter.propTypes = { - input: PropTypes.object, - set: PropTypes.func, - index: PropTypes.number, -}; - -export default StrategyParameter; diff --git a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts new file mode 100644 index 0000000000..92f6a1a7b7 --- /dev/null +++ b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts @@ -0,0 +1,43 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + paramsContainer: { + maxWidth: '400px', + }, + divider: { borderStyle: 'dashed', marginBottom: '1rem !important' }, + nameContainer: { + display: 'flex', + alignItems: 'center', + marginBottom: '1rem', + }, + name: { + minWidth: '365px', + width: '100%', + }, + input: { minWidth: '365px', width: '100%', marginBottom: '1rem' }, + description: { + minWidth: '365px', + marginBottom: '1rem', + }, + checkboxLabel: { + marginBottom: '1rem', + }, + inputDescription: { + marginBottom: '0.5rem', + }, + typeDescription: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.grey[600], + top: '-13px', + position: 'relative', + }, + errorMessage: { + fontSize: theme.fontSizes.smallBody, + color: theme.palette.error.main, + position: 'absolute', + top: '-8px', + }, + paramButton: { + color: theme.palette.primary.dark, + }, +})); diff --git a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.tsx b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.tsx new file mode 100644 index 0000000000..a69f918198 --- /dev/null +++ b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.tsx @@ -0,0 +1,132 @@ +import { Checkbox, FormControlLabel, IconButton } from '@material-ui/core'; +import { Delete } from '@material-ui/icons'; +import { useStyles } from './StrategyParameter.styles'; +import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; +import Input from 'component/common/Input/Input'; +import ConditionallyRender from 'component/common/ConditionallyRender'; +import React from 'react'; +import { ICustomStrategyParameter } from 'interfaces/strategy'; + +const paramTypesOptions = [ + { + key: 'string', + label: 'string', + description: 'A string is a collection of characters', + }, + { + key: 'percentage', + label: 'percentage', + description: + 'Percentage is used when you want to make your feature visible to a process part of your customers', + }, + { + key: 'list', + label: 'list', + description: + 'A list is used when you want to define several parameters that must be met before your feature becomes visible to your customers', + }, + { + key: 'number', + label: 'number', + description: + 'Number is used when you have one or more digits that must be met for your feature to be visible to your customers', + }, + { + key: 'boolean', + label: 'boolean', + description: + 'A boolean value represents a truth value, which is either true or false', + }, +]; + +interface IStrategyParameterProps { + set: React.Dispatch>; + input: ICustomStrategyParameter; + index: number; + params: ICustomStrategyParameter[]; + setParams: React.Dispatch>; + errors: { [key: string]: string }; +} + +export const StrategyParameter = ({ + set, + input, + index, + params, + setParams, + errors, +}: IStrategyParameterProps) => { + const styles = useStyles(); + const handleTypeChange = ( + event: React.ChangeEvent<{ name?: string; value: unknown }> + ) => { + set({ type: event.target.value }); + }; + + const renderParamTypeDescription = () => { + return paramTypesOptions.find(param => param.key === input.type) + ?.description; + }; + + return ( +
+
+ + The parameters define how the strategy will look like. +

+ } + /> +
+ set({ name: e.target.value })} + value={input.name} + className={styles.name} + error={Boolean(errors?.[`paramName${index}`])} + errorText={errors?.[`paramName${index}`]} + /> + { + setParams(params.filter((e, i) => i !== index)); + }} + > + + +
+ +

+ {renderParamTypeDescription()} +

+ set({ description: target.value })} + value={input.description} + className={styles.description} + /> + set({ required: !input.required })} + /> + } + label="Required" + className={styles.checkboxLabel} + /> +
+ ); +}; diff --git a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameters.jsx b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameters.jsx deleted file mode 100644 index 7ce319ab65..0000000000 --- a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameters.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import StrategyParameter from './StrategyParameter/StrategyParameter'; -import PropTypes from 'prop-types'; - -function gerArrayWithEntries(num) { - return Array.from(Array(num)); -} - -const StrategyParameters = ({ input = [], count = 0, updateParameter }) => ( -
- {gerArrayWithEntries(count).map((v, i) => ( - updateParameter(i, v)} - index={i} - input={input[i]} - /> - ))} -
-); - -StrategyParameters.propTypes = { - input: PropTypes.array, - updateParameter: PropTypes.func.isRequired, - count: PropTypes.number, -}; - -export default StrategyParameters; diff --git a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameters.tsx b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameters.tsx new file mode 100644 index 0000000000..12c9383902 --- /dev/null +++ b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameters.tsx @@ -0,0 +1,31 @@ +import { StrategyParameter } from './StrategyParameter/StrategyParameter'; +import React from 'react'; +import { ICustomStrategyParameter } from 'interfaces/strategy'; + +interface IStrategyParametersProps { + input: ICustomStrategyParameter[]; + updateParameter: (index: number, updated: object) => void; + setParams: React.Dispatch>; + errors: { [key: string]: string }; +} + +export const StrategyParameters = ({ + input = [], + updateParameter, + setParams, + errors, +}: IStrategyParametersProps) => ( +
+ {input.map((item, index) => ( + updateParameter(index, value)} + index={index} + input={input[index]} + setParams={setParams} + errors={errors} + /> + ))} +
+); diff --git a/frontend/src/component/strategies/StrategyView/StrategyDetails/StrategyDetails.tsx b/frontend/src/component/strategies/StrategyView/StrategyDetails/StrategyDetails.tsx index 8016dd31a1..ad85bc7281 100644 --- a/frontend/src/component/strategies/StrategyView/StrategyDetails/StrategyDetails.tsx +++ b/frontend/src/component/strategies/StrategyView/StrategyDetails/StrategyDetails.tsx @@ -7,13 +7,13 @@ import { Tooltip, } from '@material-ui/core'; import { Add, RadioButtonChecked } from '@material-ui/icons'; -import { AppsLinkList } from '../../../common'; -import ConditionallyRender from '../../../common/ConditionallyRender'; +import { AppsLinkList } from 'component/common'; +import ConditionallyRender from 'component/common/ConditionallyRender'; import styles from '../../strategies.module.scss'; import { TogglesLinkList } from '../../TogglesLinkList/TogglesLinkList'; -import { IParameter, IStrategy } from '../../../../interfaces/strategy'; -import { IApplication } from '../../../../interfaces/application'; -import { IFeatureToggle } from '../../../../interfaces/featureToggle'; +import { IParameter, IStrategy } from 'interfaces/strategy'; +import { IApplication } from 'interfaces/application'; +import { IFeatureToggle } from 'interfaces/featureToggle'; interface IStrategyDetailsProps { strategy: IStrategy; @@ -28,7 +28,7 @@ export const StrategyDetails = ({ }: IStrategyDetailsProps) => { const { parameters = [] } = strategy; const renderParameters = (params: IParameter[]) => { - if (params) { + if (params.length > 0) { return params.map(({ name, type, description, required }, i) => ( )); } else { - return (no params); + return No params; } }; diff --git a/frontend/src/component/strategies/StrategyView/StrategyView.tsx b/frontend/src/component/strategies/StrategyView/StrategyView.tsx index 5d968dd109..c0f9caf52b 100644 --- a/frontend/src/component/strategies/StrategyView/StrategyView.tsx +++ b/frontend/src/component/strategies/StrategyView/StrategyView.tsx @@ -1,77 +1,64 @@ -import { useContext } from 'react'; -import { Grid, Typography } from '@material-ui/core'; -import { StrategyForm } from '../StrategyForm/StrategyForm'; -import { UPDATE_STRATEGY } from '../../providers/AccessProvider/permissions'; -import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; -import TabNav from '../../common/TabNav/TabNav'; -import PageContent from '../../common/PageContent/PageContent'; -import AccessContext from '../../../contexts/AccessContext'; -import useStrategies from '../../../hooks/api/getters/useStrategies/useStrategies'; -import { useParams } from 'react-router-dom'; -import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures'; -import useApplications from '../../../hooks/api/getters/useApplications/useApplications'; +import { Grid } from '@material-ui/core'; +import { UPDATE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import PageContent from 'component/common/PageContent/PageContent'; +import useStrategies from 'component/../hooks/api/getters/useStrategies/useStrategies'; +import { useHistory, useParams } from 'react-router-dom'; +import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures'; +import useApplications from 'hooks/api/getters/useApplications/useApplications'; import { StrategyDetails } from './StrategyDetails/StrategyDetails'; +import HeaderTitle from 'component/common/HeaderTitle'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { Edit } from '@material-ui/icons'; +import ConditionallyRender from 'component/common/ConditionallyRender'; export const StrategyView = () => { - const { hasAccess } = useContext(AccessContext); - const { strategyName } = useParams<{ strategyName: string }>(); + const { name } = useParams<{ name: string }>(); const { strategies } = useStrategies(); const { features } = useFeatures(); const { applications } = useApplications(); + const history = useHistory(); const toggles = features.filter(toggle => { - return toggle?.strategies?.find(s => s.name === strategyName); + return toggle?.strategies?.find(strategy => strategy.name === name); }); - const strategy = strategies.find(n => n.name === strategyName); + const strategy = strategies.find(strategy => strategy.name === name); - const tabData = [ - { - label: 'Details', - component: ( - - ), - }, - { - label: 'Edit', - // @ts-expect-error - component: , - }, - ]; + const handleEdit = () => { + history.push(`/strategies/${name}/edit`); + }; if (!strategy) return null; return ( - + + + + } + /> + } + /> + } + > - - {strategy.description} - - - - - } - elseShow={ -
-
- -
-
- } +
diff --git a/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap b/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap index 073d9db764..d1f70d522a 100644 --- a/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap +++ b/frontend/src/component/strategies/__tests__/__snapshots__/list-component-test.jsx.snap @@ -11,23 +11,23 @@ exports[`renders correctly with one strategy 1`] = ` } >

Strategies

+
+ +

Strategies

+
+ +
{ + const [strategyName, setStrategyName] = useState(initialStrategyName); + const [strategyDesc, setStrategyDesc] = useState(initialStrategyDesc); + const [params, setParams] = useState(initialParams); + const [errors, setErrors] = useState({}); + const { strategies } = useStrategies(); + + useEffect(() => { + setStrategyName(initialStrategyName); + /* eslint-disable-next-line */ + }, [initialStrategyName]); + + useEffect(() => { + setStrategyDesc(initialStrategyDesc); + /* eslint-disable-next-line */ + }, [initialStrategyDesc]); + + useEffect(() => { + setParams(initialParams); + /* eslint-disable-next-line */ + }, [JSON.stringify(initialParams)]); + + const getStrategyPayload = () => { + return { + name: strategyName, + description: strategyDesc, + parameters: params, + }; + }; + + const validateStrategyName = () => { + if (strategyName.length === 0) { + setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); + return false; + } + if (strategies.some(strategy => strategy.name === strategyName)) { + setErrors(prev => ({ + ...prev, + name: 'A strategy name with that name already exist', + })); + return false; + } + return true; + }; + + const validateParams = () => { + let res = true; + // eslint-disable-next-line + for (const [index, p] of Object.entries(params)) { + // eslint-disable-next-line + params.forEach((p, index) => { + if (p.name.length === 0) { + setErrors(prev => ({ + ...prev, + [`paramName${index}`]: 'Name can not be empty', + })); + res = false; + } + }); + } + return res; + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + strategyName, + strategyDesc, + params, + setStrategyName, + setStrategyDesc, + setParams, + getStrategyPayload, + validateStrategyName, + validateParams, + setErrors, + clearErrors, + errors, + }; +}; diff --git a/frontend/src/component/tags/TagTypeForm/TagTypeForm.tsx b/frontend/src/component/tags/TagTypeForm/TagTypeForm.tsx index fea1001cdb..e962fc0714 100644 --- a/frontend/src/component/tags/TagTypeForm/TagTypeForm.tsx +++ b/frontend/src/component/tags/TagTypeForm/TagTypeForm.tsx @@ -14,7 +14,7 @@ interface ITagTypeForm { handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; - mode: string; + mode: 'Create' | 'Edit'; clearErrors: () => void; validateNameUniqueness?: () => void; } diff --git a/frontend/src/hooks/api/actions/useStrategiesApi/useStrategiesApi.ts b/frontend/src/hooks/api/actions/useStrategiesApi/useStrategiesApi.ts index 867e97fe42..d8cf8fd0d4 100644 --- a/frontend/src/hooks/api/actions/useStrategiesApi/useStrategiesApi.ts +++ b/frontend/src/hooks/api/actions/useStrategiesApi/useStrategiesApi.ts @@ -1,11 +1,6 @@ +import { ICustomStrategyPayload } from 'interfaces/strategy'; import useAPI from '../useApi/useApi'; -export interface ICustomStrategyPayload { - name: string; - description: string; - parameters: object[]; -} - const useStrategiesApi = () => { const { makeRequest, createRequest, errors, loading } = useAPI({ propagateErrors: true, diff --git a/frontend/src/hooks/api/getters/useStrategy/defaultStrategy.ts b/frontend/src/hooks/api/getters/useStrategy/defaultStrategy.ts new file mode 100644 index 0000000000..a89ba584af --- /dev/null +++ b/frontend/src/hooks/api/getters/useStrategy/defaultStrategy.ts @@ -0,0 +1,10 @@ +import { IStrategy } from 'interfaces/strategy'; + +export const defaultStrategy: IStrategy = { + name: '', + description: '', + displayName: '', + editable: false, + deprecated: false, + parameters: [], +}; diff --git a/frontend/src/hooks/api/getters/useStrategy/useStrategy.ts b/frontend/src/hooks/api/getters/useStrategy/useStrategy.ts new file mode 100644 index 0000000000..6ecbafc43c --- /dev/null +++ b/frontend/src/hooks/api/getters/useStrategy/useStrategy.ts @@ -0,0 +1,30 @@ +import useSWR, { mutate, SWRConfiguration } from 'swr'; +import { formatApiPath } from 'utils/format-path'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { defaultStrategy } from './defaultStrategy'; + +const useStrategy = (strategyName: string, options: SWRConfiguration = {}) => { + const STRATEGY_CACHE_KEY = `api/admin/strategies/${strategyName}`; + const path = formatApiPath(STRATEGY_CACHE_KEY); + + const fetcher = () => { + return fetch(path) + .then(handleErrorResponses(`${strategyName} strategy`)) + .then(res => res.json()); + }; + + const { data, error } = useSWR(STRATEGY_CACHE_KEY, fetcher, options); + + const refetchStrategy = () => { + mutate(STRATEGY_CACHE_KEY); + }; + + return { + strategy: data || defaultStrategy, + error, + loading: !error && !data, + refetchStrategy, + }; +}; + +export default useStrategy; diff --git a/frontend/src/interfaces/strategy.ts b/frontend/src/interfaces/strategy.ts index dc5bc099c8..3841258135 100644 --- a/frontend/src/interfaces/strategy.ts +++ b/frontend/src/interfaces/strategy.ts @@ -39,3 +39,22 @@ export interface IStrategyPayload { constraints: IConstraint[]; parameters: IParameter; } +export interface ICustomStrategyParameter { + name: string; + description: string; + required: boolean; + type: string; +} + +export interface ICustomStrategyPayload { + name: string; + description: string; + parameters: IParameter[]; +} + +export interface ICustomStrategy { + name: string; + description: string; + parameters: IParameter[]; + editable: boolean; +}