1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

Feat/custom strategy screen (#722)

* feat: setup new screen structure

* refactor: strategyParameter

* feat: add strategy input errors for required fields

* feat: add create strategy to routes

* feat: add EditStrategy component

* feat: edit strategy view and EditStrategy component

* feat: update EditStrategy component

* test: update snapshots

* fix: styles

* test: update snapshots

* refactor: rename StrategyForm and fix ts errors

* test: update snapshots

* fix: remove test route

* fix: update PR based on feedback

* fix: update PR based on feedback

* refactor: restore feature settings (#712)

* refactor: resotre feature settings

* fix: update PR based on feedback

* feat: add feature information in Metadata container

* fix: update PR based on feedback

* fix: update PR based on feedback

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>

* chore(deps): update dependency @types/react-dom to v17.0.13

* refactor: expect existing TS errors (#767)

* refactor: expect existing TS errors

* refactor: fail build on new TS errors

* fix: styles

* refactor: rename StrategyForm and fix ts errors

* fix: update PR based on feedback

* fix: cleaning up

* fix: remove errors and warnings

* fix: remove ts-expect-error and fix errors

* fix: ts errors

* Update src/component/strategies/StrategyView/StrategyView.tsx

* Update src/component/strategies/StrategyView/StrategyView.tsx

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: olav <mail@olav.io>
This commit is contained in:
Youssef Khedher 2022-03-04 23:39:41 +01:00 committed by GitHub
parent fa33bd3ddd
commit ee730e0708
28 changed files with 950 additions and 388 deletions

View File

@ -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<IApiTokenFormProps> = ({

View File

@ -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<ISelectMenuProps> = ({
label={label}
id={id}
value={value}
IconComponent={KeyboardArrowDownOutlined}
{...rest}
>
{renderSelectItems()}

View File

@ -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<React.SetStateAction<Object>>;

View File

@ -14,7 +14,7 @@ interface IEnvironmentForm {
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
mode: string;
mode: 'Create' | 'Edit';
clearErrors: () => void;
}

View File

@ -35,7 +35,7 @@ interface IFeatureToggleForm {
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
mode: string;
mode: 'Create' | 'Edit';
clearErrors: () => void;
}

View File

@ -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 {

View File

@ -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',

View File

@ -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;
}

View File

@ -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<HTMLFormElement>) => {
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 (
<FormTemplate
loading={loading}
title="Create strategy type"
description="The strategy type and the parameters will be selectable when adding an activation strategy to a toggle in the environments.
The parameter defines the type of activation strategy. E.g. you can create a type 'Teams' and add a parameter 'List'. Then it's easy to add team names to the activation strategy"
documentationLink="https://docs.getunleash.io/advanced/custom_activation_strategy"
formatApiCode={formatApiCode}
>
<StrategyForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
strategyName={strategyName}
setStrategyName={setStrategyName}
strategyDesc={strategyDesc}
setStrategyDesc={setStrategyDesc}
params={params}
setParams={setParams}
mode="Create"
setErrors={setErrors}
clearErrors={clearErrors}
>
<PermissionButton permission={CREATE_STRATEGY} type="submit">
Create strategy
</PermissionButton>
</StrategyForm>
</FormTemplate>
);
};

View File

@ -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<HTMLFormElement>) => {
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 (
<FormTemplate
loading={loading}
title="Edit strategy type"
description="The strategy type and the parameters will be selectable when adding an activation strategy to a toggle in the environments.
The parameter defines the type of activation strategy. E.g. you can create a type 'Teams' and add a parameter 'List'. Then it's easy to add team names to the activation strategy"
documentationLink="https://docs.getunleash.io/advanced/custom_activation_strategy"
formatApiCode={formatApiCode}
>
<StrategyForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
strategyName={strategyName}
setStrategyName={setStrategyName}
strategyDesc={strategyDesc}
setStrategyDesc={setStrategyDesc}
params={params}
setParams={setParams}
mode="Edit"
setErrors={setErrors}
clearErrors={clearErrors}
>
<PermissionButton permission={CREATE_STRATEGY} type="submit">
Save
</PermissionButton>
</StrategyForm>
</FormTemplate>
);
};

View File

@ -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,
},
},
}));

View File

@ -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) => (
<Link to={`/strategies/view/${name}`}>
const strategyLink = (name: string, deprecated: boolean) => (
<Link to={`/strategies/${name}`}>
<strong>{getHumanReadableStrategyName(name)}</strong>
<ConditionallyRender
condition={deprecated}
@ -101,7 +101,7 @@ export const StrategiesList = () => {
</Link>
);
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) => (
<Tooltip title="Reactivate activation strategy">
<PermissionIconButton
onClick={() => onReactivateStrategy(strategy)}
@ -172,7 +172,7 @@ export const StrategiesList = () => {
</Tooltip>
);
const deprecateButton = (strategy: IStrategy) => (
const deprecateButton = (strategy: ICustomStrategy) => (
<ConditionallyRender
condition={strategy.name === 'default'}
show={
@ -198,9 +198,35 @@ export const StrategiesList = () => {
/>
);
const deleteButton = (strategy: IStrategy) => (
const editButton = (strategy: ICustomStrategy) => (
<ConditionallyRender
condition={strategy.editable}
condition={strategy?.editable}
show={
<PermissionIconButton
onClick={() =>
history.push(`/strategies/${strategy?.name}/edit`)
}
permission={UPDATE_STRATEGY}
tooltip={'Edit strategy'}
>
<Edit titleAccess="Edit strategy" />
</PermissionIconButton>
}
elseShow={
<Tooltip title="You cannot delete a built-in strategy">
<div>
<IconButton disabled>
<Edit titleAccess="Edit strategy" />
</IconButton>
</div>
</Tooltip>
}
/>
);
const deleteButton = (strategy: ICustomStrategy) => (
<ConditionallyRender
condition={strategy?.editable}
show={
<PermissionIconButton
onClick={() => onDeleteStrategy(strategy)}
@ -223,19 +249,12 @@ export const StrategiesList = () => {
const strategyList = () =>
strategies.map(strategy => (
<ListItem
key={strategy.name}
classes={{
root: classnames(styles.listItem, {
[styles.deprecated]: strategy.deprecated,
}),
}}
>
<ListItem key={strategy.name} className={styles.listItem}>
<ListItemAvatar>
<Extension style={{ color: '#0000008a' }} />
</ListItemAvatar>
<ListItemText
primary={strategyLink(strategy)}
primary={strategyLink(strategy?.name, strategy?.deprecated)}
secondary={strategy.description}
/>
<ConditionallyRender
@ -243,6 +262,10 @@ export const StrategiesList = () => {
show={reactivateButton(strategy)}
elseShow={deprecateButton(strategy)}
/>
<ConditionallyRender
condition={hasAccess(UPDATE_STRATEGY)}
show={editButton(strategy)}
/>
<ConditionallyRender
condition={hasAccess(DELETE_STRATEGY)}
show={deleteButton(strategy)}

View File

@ -0,0 +1,67 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
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,
},
}));

View File

@ -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<React.SetStateAction<string>>;
setStrategyDesc: React.Dispatch<React.SetStateAction<string>>;
setParams: React.Dispatch<React.SetStateAction<ICustomStrategyParameter[]>>;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
handleCancel: () => void;
errors: { [key: string]: string };
mode: 'Create' | 'Edit';
clearErrors: () => void;
setErrors: React.Dispatch<React.SetStateAction<Record<string, string>>>;
}
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<ICustomStrategyParams[]>(
// @ts-expect-error
strategy?.parameters || []
);
const [errors, setErrors] = useState<ICustomStrategyErrors>({});
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<IStrategyFormProps> = ({
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 (
<PageContent headerContent={getHeaderTitle()}>
<ConditionallyRender
condition={editMode}
show={
<Typography variant="body1">
Be careful! Changing a strategy definition might also
require changes to the implementation in the clients.
</Typography>
}
/>
<form onSubmit={handleSubmit} className={styles.form}>
<h3 className={styles.formHeader}>Strategy type information</h3>
<form
onSubmit={handleSubmit}
className={commonStyles.contentSpacing}
style={{ maxWidth: '400px' }}
>
<TextField
label="Strategy name"
name="name"
placeholder=""
disabled={editMode}
<div className={styles.container}>
<p className={styles.inputDescription}>
What would you like to call your strategy?
</p>
<Input
disabled={mode === 'Edit'}
autoFocus
className={styles.input}
label="Strategy name*"
value={strategyName}
onChange={e => 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()}
/>
<TextField
className={commonStyles.fullwidth}
multiline
<p className={styles.inputDescription}>
What is your strategy description?
</p>
<Input
className={styles.input}
label="Strategy description"
value={strategyDesc}
onChange={e => setStrategyDesc(e.target.value)}
rows={2}
label="Description"
name="description"
placeholder=""
onChange={e => setDescription(e.target.value)}
value={description}
variant="outlined"
size="small"
multiline
/>
<StrategyParameters
input={params}
count={params.length}
updateParameter={updateParameter}
setParams={setParams}
errors={errors}
/>
<Button
onClick={e => {
e.preventDefault();
appParameter();
}}
variant="outlined"
color="secondary"
className={styles.paramButton}
startIcon={<Add />}
>
Add parameter
</Button>
<ConditionallyRender
condition={editMode}
show={
<Button
type="submit"
variant="contained"
color="primary"
style={{ display: 'block' }}
>
Save
</Button>
}
elseShow={
<FormButtons
submitText={'Create'}
onCancel={handleCancel}
/>
}
/>
</form>
</PageContent>
</div>
<div className={styles.buttonContainer}>
{children}
<Button
type="button"
onClick={handleCancel}
className={styles.cancelButton}
>
Cancel
</Button>
</div>
</form>
);
};

View File

@ -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 (
<div className={commonStyles.contentSpacing}>
<TextField
style={{ width: '50%', marginRight: '5px' }}
label={`Parameter name ${index + 1}`}
onChange={({ target }) => set({ name: target.value }, true)}
value={input.name || ''}
variant="outlined"
size="small"
/>
<GeneralSelect
label="Type"
options={paramTypesOptions}
value={input.type || 'string'}
onChange={handleTypeChange}
id={`prop-type-${index}-select`}
/>
<TextField
style={{ width: '100%' }}
rows={2}
multiline
label={`Parameter name ${index + 1} description`}
onChange={({ target }) => set({ description: target.value })}
value={input.description || ''}
variant="outlined"
size="small"
/>
<FormControlLabel
control={
<Checkbox
checked={!!input.required}
onChange={() => set({ required: !input.required })}
/>
}
label="Required"
/>
</div>
);
};
StrategyParameter.propTypes = {
input: PropTypes.object,
set: PropTypes.func,
index: PropTypes.number,
};
export default StrategyParameter;

View File

@ -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,
},
}));

View File

@ -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<React.SetStateAction<object>>;
input: ICustomStrategyParameter;
index: number;
params: ICustomStrategyParameter[];
setParams: React.Dispatch<React.SetStateAction<ICustomStrategyParameter[]>>;
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 (
<div className={styles.paramsContainer}>
<hr className={styles.divider} />
<ConditionallyRender
condition={index === 0}
show={
<p className={styles.input}>
The parameters define how the strategy will look like.
</p>
}
/>
<div className={styles.nameContainer}>
<Input
autoFocus
label={`Parameter name ${index + 1}*`}
onChange={e => set({ name: e.target.value })}
value={input.name}
className={styles.name}
error={Boolean(errors?.[`paramName${index}`])}
errorText={errors?.[`paramName${index}`]}
/>
<IconButton
onClick={() => {
setParams(params.filter((e, i) => i !== index));
}}
>
<Delete titleAccess="Delete" />
</IconButton>
</div>
<GeneralSelect
label="Type*"
name="type"
options={paramTypesOptions}
value={input.type}
onChange={handleTypeChange}
id={`prop-type-${index}-select`}
className={styles.input}
/>
<p className={styles.typeDescription}>
{renderParamTypeDescription()}
</p>
<Input
rows={2}
multiline
label={`Parameter name ${index + 1} description`}
onChange={({ target }) => set({ description: target.value })}
value={input.description}
className={styles.description}
/>
<FormControlLabel
control={
<Checkbox
checked={Boolean(input.required)}
onChange={() => set({ required: !input.required })}
/>
}
label="Required"
className={styles.checkboxLabel}
/>
</div>
);
};

View File

@ -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 }) => (
<div>
{gerArrayWithEntries(count).map((v, i) => (
<StrategyParameter
key={i}
set={v => updateParameter(i, v)}
index={i}
input={input[i]}
/>
))}
</div>
);
StrategyParameters.propTypes = {
input: PropTypes.array,
updateParameter: PropTypes.func.isRequired,
count: PropTypes.number,
};
export default StrategyParameters;

View File

@ -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<React.SetStateAction<ICustomStrategyParameter[]>>;
errors: { [key: string]: string };
}
export const StrategyParameters = ({
input = [],
updateParameter,
setParams,
errors,
}: IStrategyParametersProps) => (
<div>
{input.map((item, index) => (
<StrategyParameter
params={input}
key={index}
set={value => updateParameter(index, value)}
index={index}
input={input[index]}
setParams={setParams}
errors={errors}
/>
))}
</div>
);

View File

@ -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) => (
<ListItem key={`${name}-${i}`}>
<ConditionallyRender
@ -59,7 +59,7 @@ export const StrategyDetails = ({
</ListItem>
));
} else {
return <ListItem>(no params)</ListItem>;
return <ListItem>No params</ListItem>;
}
};

View File

@ -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: (
<StrategyDetails
// @ts-expect-error
strategy={strategy}
toggles={toggles}
applications={applications}
/>
),
},
{
label: 'Edit',
// @ts-expect-error
component: <StrategyForm strategy={strategy} editMode />,
},
];
const handleEdit = () => {
history.push(`/strategies/${name}/edit`);
};
if (!strategy) return null;
return (
<PageContent headerContent={strategy.name}>
<PageContent
headerContent={
<HeaderTitle
title={strategy?.name}
subtitle={strategy?.description}
actions={
<ConditionallyRender
condition={strategy.editable}
show={
<PermissionIconButton
permission={UPDATE_STRATEGY}
tooltip={'Edit strategy'}
data-loading
onClick={handleEdit}
>
<Edit titleAccess="Edit strategy" />
</PermissionIconButton>
}
/>
}
/>
}
>
<Grid container>
<Grid item xs={12} sm={12}>
<Typography variant="subtitle1">
{strategy.description}
</Typography>
<ConditionallyRender
condition={
strategy.editable && hasAccess(UPDATE_STRATEGY)
}
show={
<div>
<TabNav tabData={tabData} />
</div>
}
elseShow={
<section>
<div className="content">
<StrategyDetails
strategy={strategy}
toggles={toggles}
applications={applications}
/>
</div>
</section>
}
<StrategyDetails
strategy={strategy}
toggles={toggles}
applications={applications}
/>
</Grid>
</Grid>

View File

@ -11,23 +11,23 @@ exports[`renders correctly with one strategy 1`] = `
}
>
<div
className="makeStyles-headerContainer-3"
className="makeStyles-headerContainer-2"
>
<div
className="makeStyles-headerTitleContainer-7"
className="makeStyles-headerTitleContainer-6"
>
<div
className=""
data-loading={true}
>
<h2
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
className="MuiTypography-root makeStyles-headerTitle-7 MuiTypography-h2"
>
Strategies
</h2>
</div>
<div
className="makeStyles-headerActions-9"
className="makeStyles-headerActions-8"
>
<span
aria-describedby={null}
@ -76,7 +76,7 @@ exports[`renders correctly with one strategy 1`] = `
</div>
</div>
<div
className="makeStyles-bodyContainer-4"
className="makeStyles-bodyContainer-3"
>
<ul
className="MuiList-root MuiList-padding"
@ -111,7 +111,7 @@ exports[`renders correctly with one strategy 1`] = `
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/strategies/view/flexibleRollout"
href="/strategies/flexibleRollout"
onClick={[Function]}
>
<strong>
@ -175,6 +175,53 @@ exports[`renders correctly with one strategy 1`] = `
</button>
</span>
</div>
<div
aria-describedby={null}
className=""
onBlur={[Function]}
onFocus={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
title="You cannot delete a built-in strategy"
>
<button
className="MuiButtonBase-root MuiIconButton-root Mui-disabled Mui-disabled"
disabled={true}
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={-1}
type="button"
>
<span
className="MuiIconButton-label"
>
<svg
className="MuiSvgIcon-root"
focusable="false"
role="img"
viewBox="0 0 24 24"
>
<path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
<title>
Edit strategy
</title>
</svg>
</span>
</button>
</div>
<div
aria-describedby={null}
className=""
@ -236,23 +283,23 @@ exports[`renders correctly with one strategy without permissions 1`] = `
}
>
<div
className="makeStyles-headerContainer-3"
className="makeStyles-headerContainer-2"
>
<div
className="makeStyles-headerTitleContainer-7"
className="makeStyles-headerTitleContainer-6"
>
<div
className=""
data-loading={true}
>
<h2
className="MuiTypography-root makeStyles-headerTitle-8 MuiTypography-h2"
className="MuiTypography-root makeStyles-headerTitle-7 MuiTypography-h2"
>
Strategies
</h2>
</div>
<div
className="makeStyles-headerActions-9"
className="makeStyles-headerActions-8"
>
<span
aria-describedby={null}
@ -301,7 +348,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
</div>
</div>
<div
className="makeStyles-bodyContainer-4"
className="makeStyles-bodyContainer-3"
>
<ul
className="MuiList-root MuiList-padding"
@ -336,7 +383,7 @@ exports[`renders correctly with one strategy without permissions 1`] = `
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/strategies/view/flexibleRollout"
href="/strategies/flexibleRollout"
onClick={[Function]}
>
<strong>
@ -400,6 +447,53 @@ exports[`renders correctly with one strategy without permissions 1`] = `
</button>
</span>
</div>
<div
aria-describedby={null}
className=""
onBlur={[Function]}
onFocus={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
title="You cannot delete a built-in strategy"
>
<button
className="MuiButtonBase-root MuiIconButton-root Mui-disabled Mui-disabled"
disabled={true}
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
tabIndex={-1}
type="button"
>
<span
className="MuiIconButton-label"
>
<svg
className="MuiSvgIcon-root"
focusable="false"
role="img"
viewBox="0 0 24 24"
>
<path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
<title>
Edit strategy
</title>
</svg>
</span>
</button>
</div>
<div
aria-describedby={null}
className=""

View File

@ -0,0 +1,90 @@
import { ICustomStrategyParameter } from 'interfaces/strategy';
import { useEffect, useState } from 'react';
import useStrategies from 'hooks/api/getters/useStrategies/useStrategies';
export const useStrategyForm = (
initialStrategyName: string = '',
initialStrategyDesc: string = '',
initialParams: ICustomStrategyParameter[] = []
) => {
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,
};
};

View File

@ -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;
}

View File

@ -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,

View File

@ -0,0 +1,10 @@
import { IStrategy } from 'interfaces/strategy';
export const defaultStrategy: IStrategy = {
name: '',
description: '',
displayName: '',
editable: false,
deprecated: false,
parameters: [],
};

View File

@ -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;

View File

@ -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;
}