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

feat: add context value descriptions (#874)

* feat: add context value descriptions

* refcator: use ConditionallyRender for ...conditional render

* refactor: fix context form enter behaviour

* refactor: decrease margin between inputs

* refactor: show error on missing value

* refactor: disable add button on error

* refactor: avoid clearing value error on name focus
This commit is contained in:
olav 2022-04-19 15:20:01 +02:00 committed by GitHub
parent 9200e74c90
commit cb8add5c30
13 changed files with 302 additions and 169 deletions

View File

@ -0,0 +1,16 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'inline-block',
},
value: {
lineHeight: 1.33,
fontSize: theme.fontSizes.smallBody,
},
description: {
lineHeight: 1.33,
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.grey[700],
},
}));

View File

@ -0,0 +1,39 @@
import { ILegalValue } from 'interfaces/context';
import { useStyles } from './LegalValueLabel.styles';
import React from 'react';
import { FormControlLabel } from '@material-ui/core';
interface ILegalValueTextProps {
legal: ILegalValue;
control: React.ReactElement;
}
export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => {
const styles = useStyles();
return (
<div className={styles.container}>
<FormControlLabel
value={legal.value}
control={control}
label={
<>
<div className={styles.value}>{legal.value}</div>
<div className={styles.description}>
{legal.description}
</div>
</>
}
/>
</div>
);
};
export const filterLegalValues = (
legalValues: ILegalValue[],
filter: string
): ILegalValue[] => {
return legalValues.filter(legalValue => {
return legalValue.value.includes(filter);
});
};

View File

@ -18,6 +18,7 @@ import {
IN_OPERATORS_FREETEXT,
Input,
} from '../useConstraintInput/useConstraintInput';
import React from 'react';
interface IResolveInputProps {
contextDefinition: IUnleashContextDefinition;
@ -81,7 +82,7 @@ export const ResolveInput = ({
type="number"
legalValues={
contextDefinition.legalValues?.filter(
(value: string) => Number(value)
legalValue => Number(legalValue.value)
) || []
}
error={error}

View File

@ -1,13 +1,17 @@
import { Checkbox, FormControlLabel } from '@material-ui/core';
import { Checkbox } from '@material-ui/core';
import { useCommonStyles } from 'themes/commonStyles';
import ConditionallyRender from 'component/common/ConditionallyRender';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
import { ILegalValue } from 'interfaces/context';
import {
LegalValueLabel,
filterLegalValues,
} from '../LegalValueLabel/LegalValueLabel';
// Parent component
interface IRestrictiveLegalValuesProps {
legalValues: string[];
legalValues: ILegalValue[];
values: string[];
setValues: (values: string[]) => void;
beforeValues?: JSX.Element;
@ -36,6 +40,8 @@ export const RestrictiveLegalValues = ({
setError,
}: IRestrictiveLegalValuesProps) => {
const [filter, setFilter] = useState('');
const filteredValues = filterLegalValues(legalValues, filter);
// Lazily initialise the values because there might be a lot of them.
const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
const styles = useCommonStyles();
@ -63,12 +69,20 @@ export const RestrictiveLegalValues = ({
Select values from a predefined set
</ConstraintFormHeader>
<ConstraintValueSearch filter={filter} setFilter={setFilter} />
<LegalValueOptions
legalValues={legalValues}
filter={filter}
onChange={onChange}
valuesMap={valuesMap}
{filteredValues.map(match => (
<LegalValueLabel
key={match.value}
legal={match}
control={
<Checkbox
checked={Boolean(valuesMap[match.value])}
onChange={() => onChange(match.value)}
name={match.value}
color="primary"
/>
}
/>
))}
<ConditionallyRender
condition={Boolean(error)}
show={<p className={styles.error}>{error}</p>}
@ -76,41 +90,3 @@ export const RestrictiveLegalValues = ({
</>
);
};
// Child component
interface ILegalValueOptionsProps {
legalValues: string[];
filter: string;
onChange: (legalValue: string) => void;
valuesMap: IValuesMap;
}
const LegalValueOptions = ({
legalValues,
filter,
onChange,
valuesMap,
}: ILegalValueOptionsProps) => {
return (
<>
{legalValues
.filter(legalValue => legalValue.includes(filter))
.map(legalValue => {
return (
<FormControlLabel
key={legalValue}
control={
<Checkbox
checked={Boolean(valuesMap[legalValue])}
onChange={() => onChange(legalValue)}
color="primary"
name={legalValue}
/>
}
label={legalValue}
/>
);
})}
</>
);
};

View File

@ -1,23 +1,20 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
import {
FormControl,
FormLabel,
FormControlLabel,
RadioGroup,
Radio,
} from '@material-ui/core';
import { FormControl, RadioGroup, Radio } from '@material-ui/core';
import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import ConditionallyRender from 'component/common/ConditionallyRender';
import { useCommonStyles } from 'themes/commonStyles';
// Parent component
import { ILegalValue } from 'interfaces/context';
import {
LegalValueLabel,
filterLegalValues,
} from '../LegalValueLabel/LegalValueLabel';
interface ISingleLegalValueProps {
setValue: (value: string) => void;
value?: string;
type: string;
legalValues: string[];
legalValues: ILegalValue[];
error: string;
setError: React.Dispatch<React.SetStateAction<string>>;
}
@ -32,21 +29,18 @@ export const SingleLegalValue = ({
}: ISingleLegalValueProps) => {
const [filter, setFilter] = useState('');
const styles = useCommonStyles();
const filteredValues = filterLegalValues(legalValues, filter);
return (
<>
<ConstraintFormHeader>
Add a single {type.toLowerCase()} value
</ConstraintFormHeader>
<ConstraintValueSearch filter={filter} setFilter={setFilter} />
<ConditionallyRender
condition={Boolean(legalValues.length)}
show={
<FormControl component="fieldset">
<FormLabel component="legend">
Available values
</FormLabel>
<RadioGroup
aria-label="selected-value"
name="selected"
@ -56,10 +50,13 @@ export const SingleLegalValue = ({
setValue(e.target.value);
}}
>
<RadioOptions
legalValues={legalValues}
filter={filter}
{filteredValues.map(match => (
<LegalValueLabel
key={match.value}
legal={match}
control={<Radio />}
/>
))}
</RadioGroup>
</FormControl>
}
@ -74,28 +71,3 @@ export const SingleLegalValue = ({
</>
);
};
// Child components
interface IRadioOptionsProps {
legalValues: string[];
filter: string;
}
const RadioOptions = ({ legalValues, filter }: IRadioOptionsProps) => {
return (
<>
{legalValues
.filter(legalValue => legalValue.includes(filter))
.map((value, index) => {
return (
<FormControlLabel
key={`${value}-${index}`}
value={value}
control={<Radio />}
label={value}
/>
);
})}
</>
);
};

View File

@ -0,0 +1,36 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'grid',
lineHeight: 1.25,
gridTemplateColumns: '1fr auto',
alignSelf: 'start',
alignItems: 'start',
gap: '0.5rem',
padding: '0.5rem',
background: theme.palette.grey[200],
borderRadius: theme.borders.radius.main,
},
label: {
fontSize: theme.fontSizes.smallBody,
},
description: {
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.grey[700],
},
button: {
all: 'unset',
lineHeight: 0.1,
paddingTop: 1,
display: 'block',
cursor: 'pointer',
'& svg': {
fontSize: '1rem',
opacity: 0.5,
},
'&:hover svg, &:focus-visible svg': {
opacity: 0.75,
},
},
}));

View File

@ -0,0 +1,34 @@
import { useStyles } from 'component/context/ContectFormChip/ContextFormChip.styles';
import { Cancel } from '@material-ui/icons';
import ConditionallyRender from 'component/common/ConditionallyRender';
interface IContextFormChipProps {
label: string;
description?: string;
onRemove: () => void;
}
export const ContextFormChip = ({
label,
description,
onRemove,
}: IContextFormChipProps) => {
const styles = useStyles();
return (
<li className={styles.container}>
<div>
<div className={styles.label}>{label}</div>
<ConditionallyRender
condition={Boolean(description)}
show={() => (
<div className={styles.description}>{description}</div>
)}
/>
</div>
<button onClick={onRemove} className={styles.button}>
<Cancel titleAccess="Remove" />
</button>
</li>
);
};

View File

@ -0,0 +1,13 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
listStyleType: 'none',
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
padding: 0,
margin: 0,
marginBottom: '1rem !important',
},
}));

View File

@ -0,0 +1,8 @@
import { useStyles } from 'component/context/ContectFormChip/ContextFormChipList.styles';
import React from 'react';
export const ContextFormChipList: React.FC = ({ children }) => {
const styles = useStyles();
return <ul className={styles.container}>{children}</ul>;
};

View File

@ -20,17 +20,16 @@ export const useStyles = makeStyles(theme => ({
},
},
tagContainer: {
display: 'flex',
alignItems: 'flex-start',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '0.5rem',
marginBottom: '1rem',
},
tagInput: {
width: '75%',
marginRight: 'auto',
gridColumn: 1,
},
tagValue: {
marginRight: '3px',
marginBottom: '1rem',
tagButton: {
gridColumn: 2,
},
buttonContainer: {
marginTop: 'auto',

View File

@ -1,24 +1,26 @@
import Input from 'component/common/Input/Input';
import { TextField, Button, Switch, Chip, Typography } from '@material-ui/core';
import { TextField, Button, Switch, Typography } from '@material-ui/core';
import { useStyles } from './ContextForm.styles';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Add } from '@material-ui/icons';
import { trim } from 'component/common/util';
import { ILegalValue } from 'interfaces/context';
import { ContextFormChip } from 'component/context/ContectFormChip/ContextFormChip';
import { ContextFormChipList } from 'component/context/ContectFormChip/ContextFormChipList';
interface IContextForm {
contextName: string;
contextDesc: string;
legalValues: Array<string>;
legalValues: ILegalValue[];
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[]>>;
setLegalValues: React.Dispatch<React.SetStateAction<ILegalValue[]>>;
handleSubmit: (e: any) => void;
onCancel: () => void;
errors: { [key: string]: string };
mode: 'Create' | 'Edit';
clearErrors: () => void;
clearErrors: (key?: string) => void;
validateContext?: () => void;
setErrors: React.Dispatch<React.SetStateAction<Object>>;
}
@ -45,54 +47,64 @@ export const ContextForm: React.FC<IContextForm> = ({
}) => {
const styles = useStyles();
const [value, setValue] = useState('');
const [focused, setFocused] = useState(false);
const [valueDesc, setValueDesc] = useState('');
const [valueFocused, setValueFocused] = useState(false);
const submit = (event: React.SyntheticEvent) => {
const isMissingValue = valueDesc.trim() && !value.trim();
const isDuplicateValue = legalValues.some(legalValue => {
return legalValue.value.trim() === value.trim();
});
useEffect(() => {
setErrors(prev => ({
...prev,
tag: isMissingValue
? 'Value cannot be empty'
: isDuplicateValue
? 'Duplicate value'
: undefined,
}));
}, [setErrors, isMissingValue, isDuplicateValue]);
const onSubmit = (event: React.SyntheticEvent) => {
event.preventDefault();
if (focused) return;
handleSubmit(event);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === ENTER && focused) {
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === ENTER) {
event.preventDefault();
if (valueFocused) {
addLegalValue();
return;
} else if (event.key === ENTER) {
} else {
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 sortLegalValues = (a: ILegalValue, b: ILegalValue) => {
return a.value.toLowerCase().localeCompare(b.value.toLowerCase());
};
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 next: ILegalValue = {
value: value.trim(),
description: valueDesc.trim(),
};
const removeLegalValue = (index: number) => {
const filteredValues = legalValues.filter((_, i) => i !== index);
setLegalValues([...filteredValues]);
if (next.value && !isDuplicateValue) {
setValue('');
setValueDesc('');
setLegalValues(prev => [...prev, next].sort(sortLegalValues));
}
};
const removeLegalValue = (value: ILegalValue) => {
setLegalValues(prev => prev.filter(p => p.value !== value.value));
};
return (
<form onSubmit={submit} className={styles.form}>
<form onSubmit={onSubmit} className={styles.form}>
<div className={styles.container}>
<p className={styles.inputDescription}>
What is your context name?
@ -102,10 +114,10 @@ export const ContextForm: React.FC<IContextForm> = ({
label="Context name"
value={contextName}
disabled={mode === 'Edit'}
onChange={e => setContextName(trim(e.target.value))}
onChange={e => setContextName(e.target.value.trim())}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
onFocus={() => clearErrors('name')}
onBlur={validateContext}
autoFocus
/>
@ -119,25 +131,15 @@ export const ContextForm: React.FC<IContextForm> = ({
multiline
maxRows={4}
value={contextDesc}
size="small"
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 (optional)"
label="Legal value (optional)"
name="value"
className={styles.tagInput}
value={value}
@ -145,20 +147,47 @@ export const ContextForm: React.FC<IContextForm> = ({
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)}
onChange={e => setValue(e.target.value)}
onKeyPress={e => onKeyDown(e)}
onBlur={() => setValueFocused(false)}
onFocus={() => setValueFocused(true)}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Value description (optional)"
className={styles.tagInput}
value={valueDesc}
variant="outlined"
size="small"
onChange={e => setValueDesc(e.target.value)}
onKeyPress={e => onKeyDown(e)}
onBlur={() => setValueFocused(false)}
onFocus={() => setValueFocused(true)}
inputProps={{ maxLength: 100 }}
/>
<Button
className={styles.tagButton}
startIcon={<Add />}
onClick={addLegalValue}
variant="contained"
variant="outlined"
color="primary"
disabled={!value.trim() || isDuplicateValue}
>
Add
</Button>
</div>
<ContextFormChipList>
{legalValues.map(legalValue => {
return (
<ContextFormChip
key={legalValue.value}
label={legalValue.value}
description={legalValue.description}
onRemove={() => removeLegalValue(legalValue)}
/>
);
})}
</ContextFormChipList>
<p className={styles.inputHeader}>Custom stickiness</p>
<p>
By enabling stickiness on this context field you can use it

View File

@ -1,26 +1,27 @@
import { useEffect, useState } from 'react';
import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi';
import { ILegalValue } from 'interfaces/context';
export const useContextForm = (
initialcontextName = '',
initialcontextDesc = '',
initialLegalValues = [] as string[],
initialContextName = '',
initialContextDesc = '',
initialLegalValues = [] as ILegalValue[],
initialStickiness = false
) => {
const [contextName, setContextName] = useState(initialcontextName);
const [contextDesc, setContextDesc] = useState(initialcontextDesc);
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]);
setContextName(initialContextName);
}, [initialContextName]);
useEffect(() => {
setContextDesc(initialcontextDesc);
}, [initialcontextDesc]);
setContextDesc(initialContextDesc);
}, [initialContextDesc]);
useEffect(() => {
setLegalValues(initialLegalValues);
@ -66,8 +67,12 @@ export const useContextForm = (
}
};
const clearErrors = () => {
const clearErrors = (key?: string) => {
if (key) {
setErrors(prev => ({ ...prev, [key]: undefined }));
} else {
setErrors({});
}
};
return {

View File

@ -4,5 +4,10 @@ export interface IUnleashContextDefinition {
createdAt: string;
sortOrder: number;
stickiness: boolean;
legalValues?: string[];
legalValues?: ILegalValue[];
}
export interface ILegalValue {
value: string;
description?: string;
}