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, IN_OPERATORS_FREETEXT,
Input, Input,
} from '../useConstraintInput/useConstraintInput'; } from '../useConstraintInput/useConstraintInput';
import React from 'react';
interface IResolveInputProps { interface IResolveInputProps {
contextDefinition: IUnleashContextDefinition; contextDefinition: IUnleashContextDefinition;
@ -81,7 +82,7 @@ export const ResolveInput = ({
type="number" type="number"
legalValues={ legalValues={
contextDefinition.legalValues?.filter( contextDefinition.legalValues?.filter(
(value: string) => Number(value) legalValue => Number(legalValue.value)
) || [] ) || []
} }
error={error} 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 { useCommonStyles } from 'themes/commonStyles';
import ConditionallyRender from 'component/common/ConditionallyRender'; 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 { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
import { ILegalValue } from 'interfaces/context';
import {
LegalValueLabel,
filterLegalValues,
} from '../LegalValueLabel/LegalValueLabel';
// Parent component
interface IRestrictiveLegalValuesProps { interface IRestrictiveLegalValuesProps {
legalValues: string[]; legalValues: ILegalValue[];
values: string[]; values: string[];
setValues: (values: string[]) => void; setValues: (values: string[]) => void;
beforeValues?: JSX.Element; beforeValues?: JSX.Element;
@ -36,6 +40,8 @@ export const RestrictiveLegalValues = ({
setError, setError,
}: IRestrictiveLegalValuesProps) => { }: IRestrictiveLegalValuesProps) => {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const filteredValues = filterLegalValues(legalValues, filter);
// Lazily initialise the values because there might be a lot of them. // Lazily initialise the values because there might be a lot of them.
const [valuesMap, setValuesMap] = useState(() => createValuesMap(values)); const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
const styles = useCommonStyles(); const styles = useCommonStyles();
@ -63,12 +69,20 @@ export const RestrictiveLegalValues = ({
Select values from a predefined set Select values from a predefined set
</ConstraintFormHeader> </ConstraintFormHeader>
<ConstraintValueSearch filter={filter} setFilter={setFilter} /> <ConstraintValueSearch filter={filter} setFilter={setFilter} />
<LegalValueOptions {filteredValues.map(match => (
legalValues={legalValues} <LegalValueLabel
filter={filter} key={match.value}
onChange={onChange} legal={match}
valuesMap={valuesMap} control={
<Checkbox
checked={Boolean(valuesMap[match.value])}
onChange={() => onChange(match.value)}
name={match.value}
color="primary"
/> />
}
/>
))}
<ConditionallyRender <ConditionallyRender
condition={Boolean(error)} condition={Boolean(error)}
show={<p className={styles.error}>{error}</p>} 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 { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
import { import { FormControl, RadioGroup, Radio } from '@material-ui/core';
FormControl,
FormLabel,
FormControlLabel,
RadioGroup,
Radio,
} from '@material-ui/core';
import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch'; import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import ConditionallyRender from 'component/common/ConditionallyRender'; import ConditionallyRender from 'component/common/ConditionallyRender';
import { useCommonStyles } from 'themes/commonStyles'; import { useCommonStyles } from 'themes/commonStyles';
import { ILegalValue } from 'interfaces/context';
// Parent component import {
LegalValueLabel,
filterLegalValues,
} from '../LegalValueLabel/LegalValueLabel';
interface ISingleLegalValueProps { interface ISingleLegalValueProps {
setValue: (value: string) => void; setValue: (value: string) => void;
value?: string; value?: string;
type: string; type: string;
legalValues: string[]; legalValues: ILegalValue[];
error: string; error: string;
setError: React.Dispatch<React.SetStateAction<string>>; setError: React.Dispatch<React.SetStateAction<string>>;
} }
@ -32,21 +29,18 @@ export const SingleLegalValue = ({
}: ISingleLegalValueProps) => { }: ISingleLegalValueProps) => {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const styles = useCommonStyles(); const styles = useCommonStyles();
const filteredValues = filterLegalValues(legalValues, filter);
return ( return (
<> <>
<ConstraintFormHeader> <ConstraintFormHeader>
Add a single {type.toLowerCase()} value Add a single {type.toLowerCase()} value
</ConstraintFormHeader> </ConstraintFormHeader>
<ConstraintValueSearch filter={filter} setFilter={setFilter} /> <ConstraintValueSearch filter={filter} setFilter={setFilter} />
<ConditionallyRender <ConditionallyRender
condition={Boolean(legalValues.length)} condition={Boolean(legalValues.length)}
show={ show={
<FormControl component="fieldset"> <FormControl component="fieldset">
<FormLabel component="legend">
Available values
</FormLabel>
<RadioGroup <RadioGroup
aria-label="selected-value" aria-label="selected-value"
name="selected" name="selected"
@ -56,10 +50,13 @@ export const SingleLegalValue = ({
setValue(e.target.value); setValue(e.target.value);
}} }}
> >
<RadioOptions {filteredValues.map(match => (
legalValues={legalValues} <LegalValueLabel
filter={filter} key={match.value}
legal={match}
control={<Radio />}
/> />
))}
</RadioGroup> </RadioGroup>
</FormControl> </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: { tagContainer: {
display: 'flex', display: 'grid',
alignItems: 'flex-start', gridTemplateColumns: '1fr auto',
gap: '0.5rem',
marginBottom: '1rem', marginBottom: '1rem',
}, },
tagInput: { tagInput: {
width: '75%', gridColumn: 1,
marginRight: 'auto',
}, },
tagValue: { tagButton: {
marginRight: '3px', gridColumn: 2,
marginBottom: '1rem',
}, },
buttonContainer: { buttonContainer: {
marginTop: 'auto', marginTop: 'auto',

View File

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

View File

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

View File

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