1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-12 13:48:35 +02:00

Single legal values (#9935)

Adds an input form for single legal values (i.e. for number and semver
operators).

- Uses the `validator` for the constraint to check the list of legal
values and provides a list of "invalid legal values" for values that
don't pass validation.
- Values in the "invalid legal values" set are "disabled" when rendered
in the UI. Additionally, there's an extra bit of text that tells you
that values that aren't valid are disabled.
- Makes the legal values selector more generic and makes it adapt to
multi- or single-value selection based on input props. The external
interface is two separate components (to make it clearer that they are
different things and because their props don't line up perfectly).

Rendered:
<img width="957" alt="image"
src="https://github.com/user-attachments/assets/cd8d2f32-057d-4e31-8fd3-174676eeb65e"
/>


Still todo: testing deleted/invalid legal value detection. I'll do that
as part of the big testathon in a followup.
This commit is contained in:
Thomas Heartman 2025-05-12 13:35:45 +02:00 committed by GitHub
parent 520d708978
commit 6efbe2d545
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 260 additions and 104 deletions

View File

@ -16,7 +16,10 @@ import { ReactComponent as EqualsIcon } from 'assets/icons/constraint-equals.svg
import { ReactComponent as NotEqualsIcon } from 'assets/icons/constraint-not-equals.svg'; import { ReactComponent as NotEqualsIcon } from 'assets/icons/constraint-not-equals.svg';
import { AddSingleValueWidget } from './AddSingleValueWidget'; import { AddSingleValueWidget } from './AddSingleValueWidget';
import { ConstraintDateInput } from './ConstraintDateInput'; import { ConstraintDateInput } from './ConstraintDateInput';
import { LegalValuesSelector } from './LegalValuesSelector'; import {
LegalValuesSelector,
SingleLegalValueSelector,
} from './LegalValuesSelector';
import { useEditableConstraint } from './useEditableConstraint/useEditableConstraint'; import { useEditableConstraint } from './useEditableConstraint/useEditableConstraint';
import type { IConstraint } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { import {
@ -227,9 +230,11 @@ export const EditableConstraint: FC<Props> = ({
constraint: localConstraint, constraint: localConstraint,
updateConstraint, updateConstraint,
validator, validator,
...constraintMetadata ...legalValueData
} = useEditableConstraint(constraint, onAutoSave); } = useEditableConstraint(constraint, onAutoSave);
const isLegalValueConstraint = 'legalValues' in legalValueData;
const { context } = useUnleashContext(); const { context } = useUnleashContext();
const { contextName, operator } = localConstraint; const { contextName, operator } = localConstraint;
const showCaseSensitiveButton = isStringOperator(operator); const showCaseSensitiveButton = isStringOperator(operator);
@ -327,25 +332,37 @@ export const EditableConstraint: FC<Props> = ({
values={ values={
isMultiValueConstraint(localConstraint) isMultiValueConstraint(localConstraint)
? Array.from(localConstraint.values) ? Array.from(localConstraint.values)
: undefined : 'legalValues' in legalValueData &&
} localConstraint.value
removeValue={(value) => ? [localConstraint.value]
updateConstraint({ : undefined
type: 'remove value from list',
payload: value,
})
} }
removeValue={(value) => {
if (isMultiValueConstraint(localConstraint)) {
updateConstraint({
type: 'remove value from list',
payload: value,
});
} else {
updateConstraint({
type: 'set value',
payload: '',
});
}
}}
getExternalFocusTarget={() => getExternalFocusTarget={() =>
addValuesButtonRef.current ?? addValuesButtonRef.current ??
deleteButtonRef.current deleteButtonRef.current
} }
> >
<TopRowInput {isLegalValueConstraint ? null : (
localConstraint={localConstraint} <TopRowInput
updateConstraint={updateConstraint} localConstraint={localConstraint}
validator={validator} updateConstraint={updateConstraint}
addValuesButtonRef={addValuesButtonRef} validator={validator}
/> addValuesButtonRef={addValuesButtonRef}
/>
)}
</ValueList> </ValueList>
</ConstraintDetails> </ConstraintDetails>
<ButtonPlaceholder /> <ButtonPlaceholder />
@ -361,33 +378,47 @@ export const EditableConstraint: FC<Props> = ({
</StyledIconButton> </StyledIconButton>
</HtmlTooltip> </HtmlTooltip>
</TopRow> </TopRow>
{'legalValues' in constraintMetadata && {'legalValues' in legalValueData ? (
isMultiValueConstraint(localConstraint) ? (
<LegalValuesContainer> <LegalValuesContainer>
<LegalValuesSelector {isMultiValueConstraint(localConstraint) ? (
values={localConstraint.values} <LegalValuesSelector
clearAll={() => values={localConstraint.values}
updateConstraint({ clearAll={() =>
type: 'clear values', updateConstraint({
}) type: 'clear values',
} })
addValues={(newValues) => }
updateConstraint({ addValues={(newValues) =>
type: 'add value(s)', updateConstraint({
payload: newValues, type: 'add value(s)',
}) payload: newValues,
} })
removeValue={(value) => }
updateConstraint({ removeValue={(value) =>
type: 'remove value from list', updateConstraint({
payload: value, type: 'remove value from list',
}) payload: value,
} })
deletedLegalValues={ }
constraintMetadata.deletedLegalValues {...legalValueData}
} />
legalValues={constraintMetadata.legalValues} ) : (
/> <SingleLegalValueSelector
value={localConstraint.value}
clear={() =>
updateConstraint({
type: 'clear values',
})
}
addValue={(newValues) =>
updateConstraint({
type: 'set value',
payload: newValues,
})
}
{...legalValueData}
/>
)}
</LegalValuesContainer> </LegalValuesContainer>
) : null} ) : null}
</Container> </Container>

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { type FC, useId, useState } from 'react';
import { Alert, Button, Checkbox, styled } from '@mui/material'; import { Alert, Button, Checkbox, Radio, styled } from '@mui/material';
import { import {
filterLegalValues, filterLegalValues,
LegalValueLabel, LegalValueLabel,
@ -7,15 +7,6 @@ import {
import { ConstraintValueSearch } from './ConstraintValueSearch'; import { ConstraintValueSearch } from './ConstraintValueSearch';
import type { ILegalValue } from 'interfaces/context'; import type { ILegalValue } from 'interfaces/context';
type LegalValuesSelectorProps = {
values: Set<string>;
addValues: (values: string[]) => void;
removeValue: (value: string) => void;
clearAll: () => void;
deletedLegalValues?: Set<string>;
legalValues: ILegalValue[];
};
const StyledValuesContainer = styled('div')(({ theme }) => ({ const StyledValuesContainer = styled('div')(({ theme }) => ({
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
@ -37,36 +28,39 @@ const LegalValuesSelectorWidget = styled('article')(({ theme }) => ({
gap: theme.spacing(2), gap: theme.spacing(2),
})); }));
export const LegalValuesSelector = ({ type BaseProps = {
legalValues: ILegalValue[];
onChange: (value: string) => void;
deletedLegalValues?: Set<string>;
invalidLegalValues?: Set<string>;
isInputSelected: (value: string) => boolean;
multiSelect?: {
selectAll: () => void;
clearAll: () => void;
values: Set<string>;
};
};
const BaseLegalValueSelector: FC<BaseProps> = ({
legalValues, legalValues,
values, onChange,
addValues,
removeValue,
clearAll,
deletedLegalValues, deletedLegalValues,
}: LegalValuesSelectorProps) => { invalidLegalValues,
isInputSelected: isInputChecked,
multiSelect,
}) => {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const labelId = useId();
const descriptionId = useId();
const groupNameId = useId();
const alertId = useId();
const filteredValues = filterLegalValues(legalValues, filter); const filteredValues = filterLegalValues(legalValues, filter);
const onChange = (legalValue: string) => { const isAllSelected =
if (values.has(legalValue)) { multiSelect &&
removeValue(legalValue); legalValues.length ===
} else { multiSelect.values.size + (deletedLegalValues?.size ?? 0);
addValues([legalValue]);
}
};
const isAllSelected = legalValues.every((value) => values.has(value.value));
const onSelectAll = () => {
if (isAllSelected) {
clearAll();
return;
} else {
addValues(legalValues.map(({ value }) => value));
}
};
const handleSearchKeyDown = (event: React.KeyboardEvent) => { const handleSearchKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@ -77,11 +71,12 @@ export const LegalValuesSelector = ({
} }
} }
}; };
const Control = multiSelect ? Checkbox : Radio;
return ( return (
<LegalValuesSelectorWidget> <LegalValuesSelectorWidget>
{deletedLegalValues?.size ? ( {deletedLegalValues?.size ? (
<Alert severity='warning'> <Alert id={alertId} severity='warning'>
This constraint is using legal values that have been deleted This constraint is using legal values that have been deleted
as valid options. If you save changes on this constraint and as valid options. If you save changes on this constraint and
then save the strategy the following values will be removed: then save the strategy the following values will be removed:
@ -92,36 +87,58 @@ export const LegalValuesSelector = ({
</ul> </ul>
</Alert> </Alert>
) : null} ) : null}
<p>Select values from a predefined set</p> <p>
<span id={labelId}>Select values from a predefined set</span>
{invalidLegalValues?.size ? (
<span id={descriptionId}>
Values that are not valid for your chosen operator have
been disabled.
</span>
) : null}
</p>
<Row> <Row>
<ConstraintValueSearch <ConstraintValueSearch
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
/> />
<Button {multiSelect ? (
sx={{ <Button
whiteSpace: 'nowrap', sx={{
}} whiteSpace: 'nowrap',
variant={'text'} }}
onClick={onSelectAll} variant={'text'}
> onClick={() => {
{isAllSelected ? 'Unselect all' : 'Select all'} if (isAllSelected) {
</Button> multiSelect.clearAll();
return;
} else {
multiSelect.selectAll();
}
}}
>
{isAllSelected ? 'Unselect all' : 'Select all'}
</Button>
) : null}
</Row> </Row>
<StyledValuesContainer> <StyledValuesContainer
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-details={alertId}
role={multiSelect ? undefined : 'radiogroup'}
>
{filteredValues.map((match) => ( {filteredValues.map((match) => (
<LegalValueLabel <LegalValueLabel
key={match.value} key={match.value}
legal={match} legal={match}
filter={filter} filter={filter}
control={ control={
<Checkbox <Control
checked={Boolean(values.has(match.value))}
onChange={() => onChange(match.value)}
name='legal-value'
color='primary' color='primary'
disabled={deletedLegalValues?.has(match.value)} name={`legal-value-${groupNameId}`}
checked={isInputChecked(match.value)}
onChange={() => onChange(match.value)}
disabled={invalidLegalValues?.has(match.value)}
/> />
} }
/> />
@ -130,3 +147,78 @@ export const LegalValuesSelector = ({
</LegalValuesSelectorWidget> </LegalValuesSelectorWidget>
); );
}; };
type LegalValuesSelectorProps = {
values: Set<string>;
addValues: (values: string[]) => void;
removeValue: (value: string) => void;
clearAll: () => void;
deletedLegalValues?: Set<string>;
invalidLegalValues?: Set<string>;
legalValues: ILegalValue[];
};
export const LegalValuesSelector = ({
legalValues,
values,
addValues,
removeValue,
clearAll,
...baseProps
}: LegalValuesSelectorProps) => {
const onChange = (legalValue: string) => {
if (values.has(legalValue)) {
removeValue(legalValue);
} else {
addValues([legalValue]);
}
};
return (
<BaseLegalValueSelector
legalValues={legalValues}
isInputSelected={(inputValue) => values.has(inputValue)}
onChange={onChange}
multiSelect={{
clearAll,
selectAll: () => {
addValues(legalValues.map(({ value }) => value));
},
values,
}}
{...baseProps}
/>
);
};
type SingleLegalValueSelectorProps = {
value: string;
addValue: (value: string) => void;
clear: () => void;
deletedLegalValues?: Set<string>;
legalValues: ILegalValue[];
invalidLegalValues?: Set<string>;
};
export const SingleLegalValueSelector = ({
value,
addValue,
clear,
...baseProps
}: SingleLegalValueSelectorProps) => {
const onChange = (newValue: string) => {
if (value === newValue) {
clear();
} else {
addValue(newValue);
}
};
return (
<BaseLegalValueSelector
onChange={onChange}
isInputSelected={(inputValue) => inputValue === value}
{...baseProps}
/>
);
};

View File

@ -52,6 +52,7 @@ type MultiValueConstraintState = {
type LegalValueData = { type LegalValueData = {
legalValues: ILegalValue[]; legalValues: ILegalValue[];
deletedLegalValues?: Set<string>; deletedLegalValues?: Set<string>;
invalidLegalValues?: Set<string>;
}; };
type LegalValueConstraintState = { type LegalValueConstraintState = {
@ -80,11 +81,14 @@ export const useEditableConstraint = (
resolveContextDefinition(context, localConstraint.contextName), resolveContextDefinition(context, localConstraint.contextName),
); );
const validator = constraintValidator(localConstraint);
const deletedLegalValues = useMemo(() => { const deletedLegalValues = useMemo(() => {
if ( if (
contextDefinition.legalValues?.length && contextDefinition.legalValues?.length &&
constraint.values?.length constraint.values?.length
) { ) {
// todo: extract and test
const currentLegalValues = new Set( const currentLegalValues = new Set(
contextDefinition.legalValues.map(({ value }) => value), contextDefinition.legalValues.map(({ value }) => value),
); );
@ -101,6 +105,24 @@ export const useEditableConstraint = (
JSON.stringify(constraint.values), JSON.stringify(constraint.values),
]); ]);
const invalidLegalValues = useMemo(() => {
if (
contextDefinition.legalValues?.length &&
isSingleValueConstraint(localConstraint)
) {
// todo: extract and test
return new Set(
contextDefinition.legalValues
.filter(({ value }) => !validator(value)[0])
.map(({ value }) => value),
);
}
return undefined;
}, [
JSON.stringify(contextDefinition.legalValues),
JSON.stringify(localConstraint.operator),
]);
const updateConstraint = (action: ConstraintUpdateAction) => { const updateConstraint = (action: ConstraintUpdateAction) => {
const nextState = constraintReducer( const nextState = constraintReducer(
localConstraint, localConstraint,
@ -120,26 +142,37 @@ export const useEditableConstraint = (
} }
}; };
if (contextDefinition.legalValues?.length) {
if (isSingleValueConstraint(localConstraint)) {
return {
updateConstraint,
constraint: localConstraint,
validator,
legalValues: contextDefinition.legalValues,
invalidLegalValues,
deletedLegalValues,
};
}
return {
updateConstraint,
constraint: localConstraint,
validator,
legalValues: contextDefinition.legalValues,
invalidLegalValues,
deletedLegalValues,
};
}
if (isSingleValueConstraint(localConstraint)) { if (isSingleValueConstraint(localConstraint)) {
return { return {
updateConstraint, updateConstraint,
constraint: localConstraint, constraint: localConstraint,
validator: constraintValidator(localConstraint), validator,
};
}
if (contextDefinition.legalValues?.length) {
return {
updateConstraint,
constraint: localConstraint,
validator: constraintValidator(localConstraint),
legalValues: contextDefinition.legalValues,
deletedLegalValues,
}; };
} }
return { return {
updateConstraint, updateConstraint,
constraint: localConstraint, constraint: localConstraint,
validator: constraintValidator(localConstraint), validator,
}; };
}; };