mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +01:00
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 <fredrik.no@gmail.com>
This commit is contained in:
parent
6a4fe7182a
commit
53cff04349
@ -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'
|
||||||
|
},
|
||||||
|
}));
|
197
frontend/src/component/context/ContextForm/ContextForm.tsx
Normal file
197
frontend/src/component/context/ContextForm/ContextForm.tsx
Normal file
@ -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<string>;
|
||||||
|
stickiness: boolean;
|
||||||
|
setContextName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setContextDesc: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setStickiness: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setLegalValues: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
handleSubmit: (e: any) => void;
|
||||||
|
handleCancel: () => void;
|
||||||
|
errors: { [key: string]: string };
|
||||||
|
mode: string;
|
||||||
|
clearErrors: () => void;
|
||||||
|
validateNameUniqueness: () => void;
|
||||||
|
setErrors: React.Dispatch<React.SetStateAction<Object>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTER = 'Enter';
|
||||||
|
|
||||||
|
const ContextForm: React.FC<IContextForm> = ({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={submit} className={styles.form}>
|
||||||
|
<h3 className={styles.formHeader}>Context information</h3>
|
||||||
|
|
||||||
|
<div className={styles.container}>
|
||||||
|
<p className={styles.inputDescription}>
|
||||||
|
What is your context name?
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
label="Context name"
|
||||||
|
value={contextName}
|
||||||
|
disabled={mode === 'Edit'}
|
||||||
|
onChange={e => setContextName(e.target.value)}
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
errorText={errors.name}
|
||||||
|
onFocus={() => clearErrors()}
|
||||||
|
onBlur={validateNameUniqueness}
|
||||||
|
/>
|
||||||
|
<p className={styles.inputDescription}>
|
||||||
|
What is this context for?
|
||||||
|
</p>
|
||||||
|
<TextField
|
||||||
|
className={styles.input}
|
||||||
|
label="Context description"
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
maxRows={4}
|
||||||
|
value={contextDesc}
|
||||||
|
onChange={e => setContextDesc(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className={styles.inputDescription}>
|
||||||
|
Which values do you want to allow?
|
||||||
|
</p>
|
||||||
|
{legalValues.map((value, index) => {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={index + value}
|
||||||
|
label={value}
|
||||||
|
className={styles.tagValue}
|
||||||
|
onDelete={() => removeLegalValue(index)}
|
||||||
|
title="Remove value"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className={styles.tagContainer}>
|
||||||
|
<TextField
|
||||||
|
label="Value"
|
||||||
|
name="value"
|
||||||
|
className={styles.tagInput}
|
||||||
|
value={value}
|
||||||
|
error={Boolean(errors.tag)}
|
||||||
|
helperText={errors.tag}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onChange={e => setValue(trim(e.target.value))}
|
||||||
|
onKeyPress={e => handleKeyDown(e)}
|
||||||
|
onBlur={e => setFocused(false)}
|
||||||
|
onFocus={e => setFocused(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={addLegalValue}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.inputHeader}>Custom stickiness (beta)</p>
|
||||||
|
<p>
|
||||||
|
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!{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.getunleash.io/advanced/stickiness"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className={styles.switchContainer}>
|
||||||
|
<Switch
|
||||||
|
checked={stickiness}
|
||||||
|
value={stickiness}
|
||||||
|
onChange={() => setStickiness(!stickiness)}
|
||||||
|
/>
|
||||||
|
<Typography>{stickiness ? 'On' : 'Off'}</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttonContainer}>
|
||||||
|
<Button onClick={handleCancel} className={styles.cancelButton}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContextForm;
|
@ -4,7 +4,8 @@ import HeaderTitle from '../../common/HeaderTitle';
|
|||||||
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
||||||
import {
|
import {
|
||||||
CREATE_CONTEXT_FIELD,
|
CREATE_CONTEXT_FIELD,
|
||||||
DELETE_CONTEXT_FIELD, UPDATE_CONTEXT_FIELD,
|
DELETE_CONTEXT_FIELD,
|
||||||
|
UPDATE_CONTEXT_FIELD,
|
||||||
} from '../../providers/AccessProvider/permissions';
|
} from '../../providers/AccessProvider/permissions';
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton,
|
||||||
@ -16,35 +17,78 @@ import {
|
|||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
Button,
|
Button,
|
||||||
} from '@material-ui/core';
|
} 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 { useContext, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { useStyles } from './styles';
|
import { useStyles } from './styles';
|
||||||
import ConfirmDialogue from '../../common/Dialogue';
|
import ConfirmDialogue from '../../common/Dialogue';
|
||||||
import AccessContext from '../../../contexts/AccessContext';
|
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 { hasAccess } = useContext(AccessContext);
|
||||||
const [showDelDialogue, setShowDelDialogue] = useState(false);
|
const [showDelDialogue, setShowDelDialogue] = useState(false);
|
||||||
const smallScreen = useMediaQuery('(max-width:700px)');
|
const smallScreen = useMediaQuery('(max-width:700px)');
|
||||||
const [name, setName] = useState();
|
const [name, setName] = useState();
|
||||||
|
const { context, refetch } = useUnleashContext();
|
||||||
|
const { removeContext } = useContextsApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const history = useHistory();
|
||||||
const styles = useStyles();
|
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 = () =>
|
const contextList = () =>
|
||||||
contextFields.map(field => (
|
context.map(field => (
|
||||||
<ListItem key={field.name} classes={{ root: styles.listItem }}>
|
<ListItem key={field.name} classes={{ root: styles.listItem }}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Album />
|
<Album />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={
|
primary={
|
||||||
<ConditionallyRender condition={hasAccess(UPDATE_CONTEXT_FIELD)} show={<Link to={`/context/edit/${field.name}`}>
|
<ConditionallyRender
|
||||||
|
condition={hasAccess(UPDATE_CONTEXT_FIELD)}
|
||||||
|
show={
|
||||||
|
<Link to={`/context/edit/${field.name}`}>
|
||||||
<strong>{field.name}</strong>
|
<strong>{field.name}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
} elseShow={<strong>{field.name}</strong>} />}
|
}
|
||||||
|
elseShow={<strong>{field.name}</strong>}
|
||||||
|
/>
|
||||||
|
}
|
||||||
secondary={field.description}
|
secondary={field.description}
|
||||||
/>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={hasAccess(UPDATE_CONTEXT_FIELD)}
|
||||||
|
show={
|
||||||
|
<Tooltip title="Edit context field">
|
||||||
|
<IconButton
|
||||||
|
aria-label="edit"
|
||||||
|
onClick={() =>
|
||||||
|
history.push(`/context/edit/${field.name}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={hasAccess(DELETE_CONTEXT_FIELD)}
|
condition={hasAccess(DELETE_CONTEXT_FIELD)}
|
||||||
show={
|
show={
|
||||||
@ -102,18 +146,14 @@ const ContextList = ({ removeContextField, history, contextFields }) => {
|
|||||||
>
|
>
|
||||||
<List>
|
<List>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={contextFields.length > 0}
|
condition={context.length > 0}
|
||||||
show={contextList}
|
show={contextList}
|
||||||
elseShow={<ListItem>No context fields defined</ListItem>}
|
elseShow={<ListItem>No context fields defined</ListItem>}
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
open={showDelDialogue}
|
open={showDelDialogue}
|
||||||
onClick={() => {
|
onClick={() => onDeleteContext(name)}
|
||||||
removeContextField({ name });
|
|
||||||
setName(undefined);
|
|
||||||
setShowDelDialogue(false);
|
|
||||||
}}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setName(undefined);
|
setName(undefined);
|
||||||
setShowDelDialogue(false);
|
setShowDelDialogue(false);
|
||||||
|
105
frontend/src/component/context/CreateContext/CreateContext.tsx
Normal file
105
frontend/src/component/context/CreateContext/CreateContext.tsx
Normal file
@ -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 (
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
title="Create context"
|
||||||
|
description="Context fields are a basic building block used in Unleash to control roll-out.
|
||||||
|
They can be used together with strategy constraints as part of the activation strategy evaluation."
|
||||||
|
documentationLink="https://docs.getunleash.io/how-to/how-to-define-custom-context-fields"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<ContextForm
|
||||||
|
errors={errors}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
contextName={contextName}
|
||||||
|
setContextName={setContextName}
|
||||||
|
contextDesc={contextDesc}
|
||||||
|
setContextDesc={setContextDesc}
|
||||||
|
legalValues={legalValues}
|
||||||
|
setLegalValues={setLegalValues}
|
||||||
|
stickiness={stickiness}
|
||||||
|
setStickiness={setStickiness}
|
||||||
|
mode="Create"
|
||||||
|
validateNameUniqueness={validateNameUniqueness}
|
||||||
|
setErrors={setErrors}
|
||||||
|
clearErrors={clearErrors}
|
||||||
|
>
|
||||||
|
<PermissionButton
|
||||||
|
permission={CREATE_CONTEXT_FIELD}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create context
|
||||||
|
</PermissionButton>
|
||||||
|
</ContextForm>
|
||||||
|
</FormTemplate>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateContext;
|
117
frontend/src/component/context/EditContext/EditContext.tsx
Normal file
117
frontend/src/component/context/EditContext/EditContext.tsx
Normal file
@ -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 (
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
title="Edit context"
|
||||||
|
description="Context fields are a basic building block used in Unleash to control roll-out.
|
||||||
|
They can be used together with strategy constraints as part of the activation strategy evaluation."
|
||||||
|
documentationLink="https://docs.getunleash.io/how-to/how-to-define-custom-context-fields"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<ContextForm
|
||||||
|
errors={errors}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
contextName={contextName}
|
||||||
|
setContextName={setContextName}
|
||||||
|
contextDesc={contextDesc}
|
||||||
|
setContextDesc={setContextDesc}
|
||||||
|
legalValues={legalValues}
|
||||||
|
setLegalValues={setLegalValues}
|
||||||
|
stickiness={stickiness}
|
||||||
|
setStickiness={setStickiness}
|
||||||
|
mode="Edit"
|
||||||
|
validateNameUniqueness={validateNameUniqueness}
|
||||||
|
setErrors={setErrors}
|
||||||
|
clearErrors={clearErrors}
|
||||||
|
>
|
||||||
|
<PermissionButton
|
||||||
|
permission={UPDATE_CONTEXT_FIELD}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Edit context
|
||||||
|
</PermissionButton>
|
||||||
|
</ContextForm>
|
||||||
|
</FormTemplate>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditContext;
|
@ -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;
|
|
@ -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;
|
|
88
frontend/src/component/context/hooks/useContextForm.ts
Normal file
88
frontend/src/component/context/hooks/useContextForm.ts
Normal file
@ -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;
|
@ -11,8 +11,6 @@ import Archive from '../../page/archive';
|
|||||||
import Applications from '../../page/applications';
|
import Applications from '../../page/applications';
|
||||||
import ApplicationView from '../../page/applications/view';
|
import ApplicationView from '../../page/applications/view';
|
||||||
import ContextFields from '../../page/context';
|
import ContextFields from '../../page/context';
|
||||||
import CreateContextField from '../../page/context/create';
|
|
||||||
import EditContextField from '../../page/context/edit';
|
|
||||||
import ListTagTypes from '../../page/tag-types';
|
import ListTagTypes from '../../page/tag-types';
|
||||||
import Addons from '../../page/addons';
|
import Addons from '../../page/addons';
|
||||||
import AddonsCreate from '../../page/addons/create';
|
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 CreateApiToken from '../admin/api-token/CreateApiToken/CreateApiToken';
|
||||||
import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment';
|
import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment';
|
||||||
import EditEnvironment from '../environments/EditEnvironment/EditEnvironment';
|
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 EditTagType from '../tagTypes/EditTagType/EditTagType';
|
||||||
import CreateTagType from '../tagTypes/CreateTagType/CreateTagType';
|
import CreateTagType from '../tagTypes/CreateTagType/CreateTagType';
|
||||||
import EditProject from '../project/Project/EditProject/EditProject';
|
import EditProject from '../project/Project/EditProject/EditProject';
|
||||||
@ -205,7 +204,7 @@ export const routes = [
|
|||||||
path: '/context/create',
|
path: '/context/create',
|
||||||
parent: '/context',
|
parent: '/context',
|
||||||
title: 'Create',
|
title: 'Create',
|
||||||
component: CreateContextField,
|
component: CreateContext,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
flag: C,
|
flag: C,
|
||||||
@ -215,7 +214,7 @@ export const routes = [
|
|||||||
path: '/context/edit/:name',
|
path: '/context/edit/:name',
|
||||||
parent: '/context',
|
parent: '/context',
|
||||||
title: ':name',
|
title: ':name',
|
||||||
component: EditContextField,
|
component: EditContext,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
flag: C,
|
flag: C,
|
||||||
|
@ -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;
|
41
frontend/src/hooks/api/getters/useContext/useContext.ts
Normal file
41
frontend/src/hooks/api/getters/useContext/useContext.ts
Normal file
@ -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;
|
Loading…
Reference in New Issue
Block a user