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

Refactor: get rid of editable constraint wrapper (#9921)

This (admittedly pretty big) PR removes a component layer, moves all
logic for updating constraint values into a single module, and dumbs
down other components.

The main changes are:
- EditableConstraintWrapper is gone. All the logic in there has been
moved into the new `useEditableConstraint` hook. Previously it was split
between the wrapper, editableConstraint itself, the legalValues
component.
- the `useEditableConstraint` hook accepts a constraint and a save
function and returns an editable version of that constraint, the
validator for input values, a function that accepts update commands,
and, when relevant, existing and deleted legal values.
- All the logic for updating a constraint now exists in the
`constraint-reducer` file. As a pure function, it'll be easy to unit
test pretty thoroughly to make sure all commands work as they should
(tests will come later)
- The legal values selector has been dumbed down consiberably as it no
longer needs to create its own internal weak map. The internal
representation of selected values is now a set, so any kind of lookup is
now constant time, which should remove the need for the extra layer of
abstraction.

## Discussion points

I know the reducer pattern isn't one we use a *lot* in Unleash, but I
found a couple examples of it in the front end and it's also quite
similar to how we handle state updates to change request states. I'd be
happy to find a different way to represent it if we can keep it in a
single, testable interface.

Semi-relatedly: I've exposed the actions to submit for the updates at
the moment, but we could map these to functions instead. It'd make
invocations a little easier (you wouldn't need to specify the action
yourself; only use the payload as a function arg if there is one), but
we'd end up doing more mapping to create them. I'm not sure it's worth
it, but I also don't mind if we do 💁🏼
This commit is contained in:
Thomas Heartman 2025-05-09 11:47:22 +02:00 committed by GitHub
parent bfc583b5b7
commit e4ead3bd67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 664 additions and 613 deletions

View File

@ -21,8 +21,6 @@ import {
type Input,
} from '../useConstraintInput/useConstraintInput';
import type React from 'react';
import { useUiFlag } from 'hooks/useUiFlag';
import { LegalValuesSelector } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/LegalValuesSelector';
interface IResolveInputProps {
contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
@ -84,23 +82,11 @@ export const ResolveInput = ({
removeValue,
error,
}: IResolveInputProps) => {
const useNewLegalValueInput = useUiFlag('addEditStrategy');
const resolveInput = () => {
switch (input) {
case IN_OPERATORS_LEGAL_VALUES:
case STRING_OPERATORS_LEGAL_VALUES:
return useNewLegalValueInput ? (
<LegalValuesSelector
data={resolveLegalValues(
constraintValues,
contextDefinition.legalValues,
)}
constraintValues={constraintValues}
values={localConstraint.values || []}
setValuesWithRecord={setValuesWithRecord}
setValues={setValues}
/>
) : (
return (
<RestrictiveLegalValues
data={resolveLegalValues(
constraintValues,

View File

@ -10,15 +10,14 @@ import {
createEmptyConstraint,
} from 'component/common/LegacyConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
import { EditableConstraintWrapper } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper';
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint';
export interface IEditableConstraintsListRef {
addConstraint?: (contextName: string) => void;
}
export interface IEditableConstraintsListProps {
constraints: IConstraint[];
setConstraints?: React.Dispatch<React.SetStateAction<IConstraint[]>>;
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
}
const StyledContainer = styled('div')({
@ -42,29 +41,16 @@ export const EditableConstraintsList = forwardRef<
},
}));
const onRemove =
setConstraints &&
((index: number) => {
setConstraints(
produce((draft) => {
draft.splice(index, 1);
}),
);
});
const onSave =
setConstraints &&
((index: number, constraint: IConstraint) => {
setConstraints(
produce((draft) => {
draft[index] = constraint;
}),
);
});
const onDelete = (index: number) => {
setConstraints(
produce((draft) => {
draft.splice(index, 1);
}),
);
};
const onAutoSave =
setConstraints &&
((id: string | undefined) => (constraint: IConstraint) => {
(id: string | undefined) => (constraint: IConstraint) => {
setConstraints(
produce((draft) => {
return draft.map((oldConstraint) => {
@ -75,7 +61,7 @@ export const EditableConstraintsList = forwardRef<
});
}),
);
});
};
if (context.length === 0) {
return null;
@ -85,12 +71,11 @@ export const EditableConstraintsList = forwardRef<
<StyledContainer>
<ConstraintsList>
{constraints.map((constraint, index) => (
<EditableConstraintWrapper
<EditableConstraint
key={constraint[constraintId]}
constraint={constraint}
onDelete={onRemove?.bind(null, index)}
onSave={onSave!.bind(null, index)}
onAutoSave={onAutoSave?.(constraint[constraintId])}
onDelete={() => onDelete(index)}
onAutoSave={onAutoSave(constraint[constraintId])}
/>
))}
</ConstraintsList>

View File

@ -12,8 +12,8 @@ import {
import { NewConstraintAccordion } from 'component/common/NewConstraintAccordion/NewConstraintAccordion';
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
import { useUiFlag } from 'hooks/useUiFlag';
import { EditableConstraintWrapper } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper';
import { ConstraintAccordionView } from 'component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint';
export interface IConstraintAccordionListProps {
constraints: IConstraint[];
@ -147,16 +147,15 @@ export const NewConstraintAccordionList = forwardRef<
<ConstraintsList>
{constraints.map((constraint, index) =>
addEditStrategy ? (
state.get(constraint)?.editing ? (
<EditableConstraintWrapper
state.get(constraint)?.editing &&
Boolean(setConstraints) ? (
<EditableConstraint
key={constraint[constraintId]}
constraint={constraint}
onCancel={onCancel?.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave!.bind(null, index)}
onAutoSave={onAutoSave?.(
constraint[constraintId],
)}
// @ts-ignore todo: find a better way to do this
onDelete={() => onRemove(index)}
// @ts-ignore
onAutoSave={onAutoSave(constraintId)}
/>
) : (
<ConstraintAccordionView

View File

@ -1,25 +1,9 @@
import { IconButton, styled } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import {
useConstraintInput,
type Input,
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
import {
DATE_AFTER,
dateOperators,
IN,
stringOperators,
type Operator,
} from 'constants/operators';
import { isStringOperator, type Operator } from 'constants/operators';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import type { IUnleashContextDefinition } from 'interfaces/context';
import type { IConstraint } from 'interfaces/strategy';
import { useEffect, useMemo, useRef, useState, type FC } from 'react';
import { oneOf } from 'utils/oneOf';
import {
CURRENT_TIME_CONTEXT_FIELD,
operatorsForContext,
} from 'utils/operatorsForContext';
import { useRef, type FC } from 'react';
import { operatorsForContext } from 'utils/operatorsForContext';
import { ConstraintOperatorSelect } from './ConstraintOperatorSelect';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import Delete from '@mui/icons-material/Delete';
@ -34,8 +18,14 @@ import { ReactComponent as NotEqualsIcon } from 'assets/icons/constraint-not-equ
import { AddSingleValueWidget } from './AddSingleValueWidget';
import { ConstraintDateInput } from './ConstraintDateInput';
import { LegalValuesSelector } from './LegalValuesSelector';
import { resolveLegalValues } from './resolve-legal-values';
import { constraintValidator } from './constraint-validator';
import { useEditableConstraint } from './useEditableConstraint/useEditableConstraint';
import type { IConstraint } from 'interfaces/strategy';
import {
isDateConstraint,
isMultiValueConstraint,
isNumberConstraint,
isSemVerConstraint,
} from './useEditableConstraint/editable-constraint-type';
const Container = styled('article')(({ theme }) => ({
'--padding': theme.spacing(2),
@ -150,111 +140,29 @@ const StyledCaseSensitiveIcon = styled(CaseSensitiveIcon)(({ theme }) => ({
fill: 'currentcolor',
}));
type InputType =
| { input: 'legal values' }
| { input: 'date' }
| { input: 'single value'; type: 'number' | 'semver' }
| { input: 'multiple values' };
const getInputType = (input: Input): InputType => {
switch (input) {
case 'IN_OPERATORS_LEGAL_VALUES':
case 'STRING_OPERATORS_LEGAL_VALUES':
case 'NUM_OPERATORS_LEGAL_VALUES':
case 'SEMVER_OPERATORS_LEGAL_VALUES':
return { input: 'legal values' };
case 'DATE_OPERATORS_SINGLE_VALUE':
return { input: 'date' };
case 'NUM_OPERATORS_SINGLE_VALUE':
return { input: 'single value', type: 'number' };
case 'SEMVER_OPERATORS_SINGLE_VALUE':
return { input: 'single value', type: 'semver' };
case 'IN_OPERATORS_FREETEXT':
case 'STRING_OPERATORS_FREETEXT':
return { input: 'multiple values' };
}
};
type Props = {
constraint: IConstraint;
localConstraint: IConstraint;
setContextName: (contextName: string) => void;
setOperator: (operator: Operator) => void;
setLocalConstraint: React.Dispatch<React.SetStateAction<IConstraint>>;
onDelete?: () => void;
toggleInvertedOperator: () => void;
toggleCaseSensitivity: () => void;
onUndo: () => void;
constraintChanges: IConstraint[];
contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
constraintValues: string[];
constraintValue: string;
setValue: (value: string) => void;
setValues: (values: string[]) => void;
setValuesWithRecord: (values: string[]) => void;
removeValue: (index: number) => void;
onDelete: () => void;
onAutoSave: (constraint: IConstraint) => void;
};
export const EditableConstraint: FC<Props> = ({
constraintChanges,
constraint,
localConstraint,
setLocalConstraint,
setContextName,
setOperator,
onDelete,
onUndo,
toggleInvertedOperator,
toggleCaseSensitivity,
contextDefinition,
constraintValues,
constraintValue,
setValue,
setValues,
setValuesWithRecord,
removeValue,
constraint,
onAutoSave,
}) => {
const { input } = useConstraintInput({
contextDefinition,
localConstraint,
});
const {
constraint: localConstraint,
updateConstraint,
validator,
...constraintMetadata
} = useEditableConstraint(constraint, onAutoSave);
const { context } = useUnleashContext();
const { contextName, operator } = localConstraint;
const [showCaseSensitiveButton, setShowCaseSensitiveButton] =
useState(false);
const showCaseSensitiveButton = isStringOperator(operator);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const addValuesButtonRef = useRef<HTMLButtonElement>(null);
const inputType = getInputType(input);
/* We need a special case to handle the currentTime context field. Since
this field will be the only one to allow DATE_BEFORE and DATE_AFTER operators
this will check if the context field is the current time context field AND check
if it is not already using one of the date operators (to not overwrite if there is existing
data). */
useEffect(() => {
if (
contextName === CURRENT_TIME_CONTEXT_FIELD &&
!oneOf(dateOperators, operator)
) {
setLocalConstraint((prev) => ({
...prev,
operator: DATE_AFTER,
value: new Date().toISOString(),
}));
} else if (
contextName !== CURRENT_TIME_CONTEXT_FIELD &&
oneOf(dateOperators, operator)
) {
setOperator(IN);
}
if (oneOf(stringOperators, operator)) {
setShowCaseSensitiveButton(true);
} else {
setShowCaseSensitiveButton(false);
}
}, [contextName, setOperator, operator, setLocalConstraint]);
if (!context) {
return null;
@ -265,72 +173,76 @@ export const EditableConstraint: FC<Props> = ({
});
const onOperatorChange = (operator: Operator) => {
if (oneOf(stringOperators, operator)) {
setShowCaseSensitiveButton(true);
} else {
setShowCaseSensitiveButton(false);
}
if (oneOf(dateOperators, operator)) {
setLocalConstraint((prev) => ({
...prev,
operator: operator,
value: new Date().toISOString(),
}));
} else {
setOperator(operator);
}
updateConstraint({ type: 'set operator', payload: operator });
};
const validator = useMemo(() => constraintValidator(input), [input]);
const TopRowInput = () => {
switch (inputType.input) {
case 'date':
return (
<ConstraintDateInput
setValue={setValue}
value={localConstraint.value}
validator={validator}
/>
);
case 'single value':
return (
<AddSingleValueWidget
validator={validator}
onAddValue={(newValue) => {
setValue(newValue);
}}
removeValue={() => setValue('')}
currentValue={localConstraint.value}
helpText={
inputType.type === 'number'
? 'Add a single number'
: 'A semver value should be of the format X.Y.Z'
}
inputType={
inputType.type === 'number' ? 'number' : 'text'
}
/>
);
case 'multiple values':
return (
<AddValuesWidget
validator={validator}
helpText='Maximum 100 char length per value'
ref={addValuesButtonRef}
onAddValues={(newValues) => {
// todo (`addEditStrategy`): move deduplication logic higher up in the context handling
const combinedValues = new Set([
...(localConstraint.values || []),
...newValues,
]);
setValuesWithRecord(Array.from(combinedValues));
}}
/>
);
default:
return null;
if (isDateConstraint(localConstraint)) {
return (
<ConstraintDateInput
setValue={(value: string) =>
updateConstraint({
type: 'set value',
payload: value,
})
}
value={localConstraint.value}
validator={validator}
/>
);
}
if (isSemVerConstraint(localConstraint)) {
return (
<AddSingleValueWidget
validator={validator}
onAddValue={(newValue) => {
updateConstraint({
type: 'set value',
payload: newValue,
});
}}
removeValue={() =>
updateConstraint({ type: 'clear values' })
}
currentValue={localConstraint.value}
helpText={'A semver value should be of the format X.Y.Z'}
inputType={'text'}
/>
);
}
if (isNumberConstraint(localConstraint)) {
return (
<AddSingleValueWidget
validator={validator}
onAddValue={(newValue) => {
updateConstraint({
type: 'set value',
payload: newValue,
});
}}
removeValue={() =>
updateConstraint({ type: 'clear values' })
}
currentValue={localConstraint.value}
helpText={'Add a single number'}
inputType={'number'}
/>
);
}
return (
<AddValuesWidget
validator={validator}
helpText='Maximum 100 char length per value'
ref={addValuesButtonRef}
onAddValues={(newValues) => {
updateConstraint({
type: 'add value(s)',
payload: newValues,
});
}}
/>
);
};
return (
@ -346,14 +258,23 @@ export const EditableConstraint: FC<Props> = ({
autoFocus
options={constraintNameOptions}
value={contextName || ''}
onChange={setContextName}
onChange={(contextField) =>
updateConstraint({
type: 'set context field',
payload: contextField,
})
}
variant='standard'
/>
<OperatorOptions>
<StyledButton
type='button'
onClick={toggleInvertedOperator}
onClick={() =>
updateConstraint({
type: 'toggle inverted operator',
})
}
>
{localConstraint.inverted ? (
<StyledNotEqualsIcon aria-label='The constraint operator is exclusive.' />
@ -378,7 +299,11 @@ export const EditableConstraint: FC<Props> = ({
{showCaseSensitiveButton ? (
<StyledButton
type='button'
onClick={toggleCaseSensitivity}
onClick={() =>
updateConstraint({
type: 'toggle case sensitivity',
})
}
>
{localConstraint.caseInsensitive ? (
<StyledCaseInsensitiveIcon aria-label='The match is not case sensitive.' />
@ -397,9 +322,17 @@ export const EditableConstraint: FC<Props> = ({
</OperatorOptions>
</ConstraintOptions>
<ValueList
values={localConstraint.values}
removeValue={removeValue}
setValues={setValuesWithRecord}
values={
isMultiValueConstraint(localConstraint)
? Array.from(localConstraint.values)
: undefined
}
removeValue={(value) =>
updateConstraint({
type: 'remove value from list',
payload: value,
})
}
getExternalFocusTarget={() =>
addValuesButtonRef.current ??
deleteButtonRef.current
@ -421,17 +354,32 @@ export const EditableConstraint: FC<Props> = ({
</StyledIconButton>
</HtmlTooltip>
</TopRow>
{inputType.input === 'legal values' ? (
{'legalValues' in constraintMetadata &&
isMultiValueConstraint(localConstraint) ? (
<LegalValuesContainer>
<LegalValuesSelector
data={resolveLegalValues(
constraintValues,
contextDefinition.legalValues,
)}
constraintValues={constraintValues}
values={localConstraint.values || []}
setValuesWithRecord={setValuesWithRecord}
setValues={setValues}
values={localConstraint.values}
clearAll={() =>
updateConstraint({
type: 'clear values',
})
}
addValues={(newValues) =>
updateConstraint({
type: 'add value(s)',
payload: newValues,
})
}
removeValue={(value) =>
updateConstraint({
type: 'remove value from list',
payload: value,
})
}
deletedLegalValues={
constraintMetadata.deletedLegalValues
}
legalValues={constraintMetadata.legalValues}
/>
</LegalValuesContainer>
) : null}

View File

@ -1,195 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { IConstraint } from 'interfaces/strategy';
import { cleanConstraint } from 'utils/cleanConstraint';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import type { IUnleashContextDefinition } from 'interfaces/context';
import type { Operator } from 'constants/operators';
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint';
interface IConstraintAccordionEditProps {
constraint: IConstraint;
onCancel?: () => void;
onSave: (constraint: IConstraint) => void;
onDelete?: () => void;
onAutoSave?: (constraint: IConstraint) => void;
}
export const CANCEL = 'cancel';
export const SAVE = 'save';
const resolveContextDefinition = (
context: IUnleashContextDefinition[],
contextName: string,
): IUnleashContextDefinition => {
const definition = context.find(
(contextDef) => contextDef.name === contextName,
);
return (
definition || {
name: '',
description: '',
createdAt: '',
sortOrder: 1,
stickiness: false,
}
);
};
export const EditableConstraintWrapper = ({
constraint,
onSave,
onDelete,
onAutoSave,
}: IConstraintAccordionEditProps) => {
const [localConstraint, setLocalConstraint] = useState<IConstraint>(
cleanConstraint(constraint),
);
const [constraintChanges, setConstraintChanges] = useState<IConstraint[]>([
cleanConstraint(constraint),
]);
const { context } = useUnleashContext();
const [contextDefinition, setContextDefinition] = useState(
resolveContextDefinition(context, localConstraint.contextName),
);
useEffect(() => {
setContextDefinition(
resolveContextDefinition(context, localConstraint.contextName),
);
}, [localConstraint.contextName, context]);
const onUndo = () => {
if (constraintChanges.length < 2) return;
const previousChange = constraintChanges[constraintChanges.length - 2];
setLocalConstraint(previousChange);
setConstraintChanges((prev) => prev.slice(0, prev.length - 1));
autoSave(previousChange);
};
const autoSave = (localConstraint: IConstraint) => {
if (onAutoSave) {
onAutoSave(localConstraint);
}
};
const recordChange = (localConstraint: IConstraint) => {
setConstraintChanges((prev) => [...prev, localConstraint]);
autoSave(localConstraint);
};
const setContextName = useCallback((contextName: string) => {
setLocalConstraint((prev) => {
const localConstraint = cleanConstraint({
...prev,
contextName,
values: [],
value: '',
});
recordChange(localConstraint);
return localConstraint;
});
}, []);
const setOperator = useCallback((operator: Operator) => {
setLocalConstraint((prev) => {
const localConstraint = cleanConstraint({
...prev,
operator,
values: [],
value: '',
});
recordChange(localConstraint);
return localConstraint;
});
}, []);
const setValuesWithRecord = useCallback((values: string[]) => {
setLocalConstraint((prev) => {
const localConstraint = { ...prev, values };
recordChange(localConstraint);
return localConstraint;
});
}, []);
const setValues = useCallback((values: string[]) => {
setLocalConstraint((prev) => {
const localConstraint = { ...prev, values };
return localConstraint;
});
}, []);
const setValue = useCallback((value: string) => {
setLocalConstraint((prev) => {
const localConstraint = { ...prev, value };
recordChange(localConstraint);
return localConstraint;
});
}, []);
const setInvertedOperator = () => {
setLocalConstraint((prev) => {
const localConstraint = { ...prev, inverted: !prev.inverted };
recordChange(localConstraint);
return localConstraint;
});
};
const setCaseInsensitive = useCallback(() => {
setLocalConstraint((prev) => {
const localConstraint = {
...prev,
caseInsensitive: !prev.caseInsensitive,
};
recordChange(localConstraint);
return localConstraint;
});
}, []);
const removeValue = useCallback(
(index: number) => {
const valueCopy = [...localConstraint.values!];
valueCopy.splice(index, 1);
setValuesWithRecord(valueCopy);
},
[localConstraint, setValuesWithRecord],
);
return (
<EditableConstraint
localConstraint={localConstraint}
setLocalConstraint={setLocalConstraint}
setContextName={setContextName}
setOperator={setOperator}
toggleInvertedOperator={setInvertedOperator}
toggleCaseSensitivity={setCaseInsensitive}
onDelete={onDelete}
onUndo={onUndo}
constraintChanges={constraintChanges}
setValues={setValues}
setValuesWithRecord={setValuesWithRecord}
setValue={setValue}
constraintValues={constraint?.values || []}
constraintValue={constraint?.value || ''}
contextDefinition={contextDefinition}
removeValue={removeValue}
constraint={constraint}
/>
);
};

View File

@ -93,25 +93,20 @@ export const FeatureStrategyConstraintAccordionList = forwardRef<
}
/>
</StyledHelpIconBox>
<ConditionallyRender
condition={Boolean(addEditStrategy)}
show={
<EditableConstraintsList
ref={ref}
setConstraints={setConstraints}
constraints={constraints}
/>
}
elseShow={
<NewConstraintAccordionList
ref={ref}
setConstraints={setConstraints}
constraints={constraints}
state={state}
/>
}
/>
{addEditStrategy && setConstraints ? (
<EditableConstraintsList
ref={ref}
setConstraints={setConstraints}
constraints={constraints}
/>
) : (
<NewConstraintAccordionList
ref={ref}
setConstraints={setConstraints}
constraints={constraints}
state={state}
/>
)}
<Box
sx={(theme) => ({
marginTop: theme.spacing(2),

View File

@ -1,51 +1,19 @@
import { useEffect, useState } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useState } from 'react';
import { Alert, Button, Checkbox, styled } from '@mui/material';
import type { ILegalValue } from 'interfaces/context';
import {
filterLegalValues,
LegalValueLabel,
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel';
import { ConstraintValueSearch } from './ConstraintValueSearch';
import type { ILegalValue } from 'interfaces/context';
interface IRestrictiveLegalValuesProps {
data: {
legalValues: ILegalValue[];
deletedLegalValues: ILegalValue[];
};
constraintValues: string[];
values: string[];
setValues: (values: string[]) => void;
setValuesWithRecord: (values: string[]) => void;
beforeValues?: JSX.Element;
}
interface IValuesMap {
[key: string]: boolean;
}
const createValuesMap = (values: string[]): IValuesMap => {
return values.reduce((result: IValuesMap, currentValue: string) => {
if (!result[currentValue]) {
result[currentValue] = true;
}
return result;
}, {});
};
export const getLegalValueSet = (values: ILegalValue[]) => {
return new Set(values.map(({ value }) => value));
};
export const getIllegalValues = (
constraintValues: string[],
deletedLegalValues: ILegalValue[],
) => {
const deletedValuesSet = getLegalValueSet(deletedLegalValues);
return constraintValues.filter(
(value) => value && deletedValuesSet.has(value),
);
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 }) => ({
@ -70,66 +38,34 @@ const LegalValuesSelectorWidget = styled('article')(({ theme }) => ({
}));
export const LegalValuesSelector = ({
data,
legalValues,
values,
setValues,
setValuesWithRecord,
constraintValues,
}: IRestrictiveLegalValuesProps) => {
addValues,
removeValue,
clearAll,
deletedLegalValues,
}: LegalValuesSelectorProps) => {
const [filter, setFilter] = useState('');
const { legalValues, deletedLegalValues } = data;
const filteredValues = filterLegalValues(legalValues, filter);
// Lazily initialise the values because there might be a lot of them.
const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
const cleanDeletedLegalValues = (constraintValues: string[]): string[] => {
const deletedValuesSet = getLegalValueSet(deletedLegalValues);
return (
constraintValues?.filter((value) => !deletedValuesSet.has(value)) ||
[]
);
};
const illegalValues = getIllegalValues(
constraintValues,
deletedLegalValues,
);
useEffect(() => {
setValuesMap(createValuesMap(values));
}, [values, setValuesMap, createValuesMap]);
useEffect(() => {
if (illegalValues.length > 0) {
setValues(cleanDeletedLegalValues(values));
}
}, []);
const onChange = (legalValue: string) => {
if (valuesMap[legalValue]) {
const index = values.findIndex((value) => value === legalValue);
const newValues = [...values];
newValues.splice(index, 1);
setValuesWithRecord(newValues);
return;
if (values.has(legalValue)) {
removeValue(legalValue);
} else {
addValues([legalValue]);
}
setValuesWithRecord([...cleanDeletedLegalValues(values), legalValue]);
};
const isAllSelected = legalValues.every((value) =>
values.includes(value.value),
);
const isAllSelected = legalValues.every((value) => values.has(value.value));
const onSelectAll = () => {
if (isAllSelected) {
return setValuesWithRecord([]);
clearAll();
return;
} else {
addValues(legalValues.map(({ value }) => value));
}
setValuesWithRecord([
...legalValues.map((legalValue) => legalValue.value),
]);
};
const handleSearchKeyDown = (event: React.KeyboardEvent) => {
@ -144,22 +80,18 @@ export const LegalValuesSelector = ({
return (
<LegalValuesSelectorWidget>
<ConditionallyRender
condition={Boolean(illegalValues && illegalValues.length > 0)}
show={
<Alert severity='warning'>
This constraint is using legal values that have been
deleted as valid options. If you save changes on this
constraint and then save the strategy the following
values will be removed:
<ul>
{illegalValues?.map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</Alert>
}
/>
{deletedLegalValues?.size ? (
<Alert severity='warning'>
This constraint is using legal values that have been deleted
as valid options. If you save changes on this constraint and
then save the strategy the following values will be removed:
<ul>
{[...deletedLegalValues].map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</Alert>
) : null}
<p>Select values from a predefined set</p>
<Row>
<ConstraintValueSearch
@ -185,13 +117,11 @@ export const LegalValuesSelector = ({
filter={filter}
control={
<Checkbox
checked={Boolean(valuesMap[match.value])}
checked={Boolean(values.has(match.value))}
onChange={() => onChange(match.value)}
name='legal-value'
color='primary'
disabled={deletedLegalValues
.map(({ value }) => value)
.includes(match.value)}
disabled={deletedLegalValues?.has(match.value)}
/>
}
/>

View File

@ -54,9 +54,8 @@ export const ValueChip = styled(
}));
type Props = {
values: string[] | undefined;
removeValue: (index: number) => void;
setValues: (values: string[]) => void;
values?: string[];
removeValue: (value: string) => void;
// the element that should receive focus when all value chips are deleted
getExternalFocusTarget: () => HTMLElement | null;
};
@ -102,7 +101,7 @@ export const ValueList: FC<PropsWithChildren<Props>> = ({
label={value}
onDelete={() => {
nextFocusTarget(index)?.focus();
removeValue(index);
removeValue(value);
}}
/>
</li>

View File

@ -0,0 +1,144 @@
import {
DATE_AFTER,
IN,
type Operator,
isDateOperator,
isSingleValueOperator,
} from 'constants/operators';
import { CURRENT_TIME_CONTEXT_FIELD } from 'utils/operatorsForContext';
import {
isDateConstraint,
isMultiValueConstraint,
isSingleValueConstraint,
type EditableConstraint,
} from './editable-constraint-type';
import { difference, union } from './set-functions';
export type ConstraintUpdateAction =
| { type: 'add value(s)'; payload: string[] }
| { type: 'set value'; payload: string }
| { type: 'clear values' }
| { type: 'remove value from list'; payload: string }
| { type: 'set context field'; payload: string }
| { type: 'set operator'; payload: Operator }
| { type: 'toggle case sensitivity' }
| { type: 'toggle inverted operator' };
const resetValues = (state: EditableConstraint): EditableConstraint => {
if (isSingleValueConstraint(state)) {
if ('values' in state) {
const { values, ...rest } = state;
return {
...rest,
value: '',
};
}
return {
...state,
value: '',
};
}
if ('value' in state) {
const { value, ...rest } = state;
return {
...rest,
values: new Set(),
};
}
return {
...state,
values: new Set(),
};
};
export const constraintReducer = (
state: EditableConstraint,
action: ConstraintUpdateAction,
deletedLegalValues?: Set<string>,
): EditableConstraint => {
switch (action.type) {
case 'set context field':
if (
action.payload === CURRENT_TIME_CONTEXT_FIELD &&
!isDateOperator(state.operator)
) {
return {
...state,
contextName: action.payload,
operator: DATE_AFTER,
value: new Date().toISOString(),
};
} else if (
action.payload !== CURRENT_TIME_CONTEXT_FIELD &&
isDateOperator(state.operator)
) {
return {
...state,
operator: IN,
contextName: action.payload,
values: new Set(),
};
}
return resetValues({
...state,
contextName: action.payload,
});
case 'set operator':
if (isDateConstraint(state) && isDateOperator(action.payload)) {
return {
...state,
operator: action.payload,
};
}
if (isSingleValueOperator(action.payload)) {
return resetValues({
...state,
value: '',
operator: action.payload,
});
}
return resetValues({
...state,
values: new Set(),
operator: action.payload,
});
case 'add value(s)': {
if (!('values' in state)) {
return state;
}
const newValues = new Set(action.payload);
const combinedValues = union(state.values, newValues);
const filteredValues = deletedLegalValues
? difference(combinedValues, deletedLegalValues)
: combinedValues;
return {
...state,
values: filteredValues,
};
}
case 'set value':
if (isMultiValueConstraint(state)) {
return state;
}
return { ...state, value: action.payload };
case 'toggle inverted operator':
return { ...state, inverted: !state.inverted };
case 'toggle case sensitivity':
return { ...state, caseInsensitive: !state.inverted };
case 'remove value from list':
if (isSingleValueConstraint(state)) {
return state;
}
state.values.delete(action.payload);
return {
...state,
values: state.values ?? new Set(),
};
case 'clear values':
return resetValues(state);
}
};

View File

@ -1,8 +1,11 @@
// todo: (flag: `addEditStrategy`) see if this type is better duplicated or extracted to somewhere else
import type { Input } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
import { isValid, parseISO } from 'date-fns';
import semver from 'semver';
import {
type EditableConstraint,
isDateConstraint,
isNumberConstraint,
isSemVerConstraint,
} from './editable-constraint-type';
export type ConstraintValidationResult = [boolean, string];
const numberValidator = (value: string): ConstraintValidationResult => {
@ -56,20 +59,15 @@ const dateValidator = (value: string): ConstraintValidationResult => {
return [true, ''];
};
export const constraintValidator = (input: Input) => {
switch (input) {
case 'IN_OPERATORS_LEGAL_VALUES':
case 'STRING_OPERATORS_LEGAL_VALUES':
case 'NUM_OPERATORS_LEGAL_VALUES':
case 'SEMVER_OPERATORS_LEGAL_VALUES':
case 'IN_OPERATORS_FREETEXT':
case 'STRING_OPERATORS_FREETEXT':
return stringListValidator;
case 'DATE_OPERATORS_SINGLE_VALUE':
return dateValidator;
case 'NUM_OPERATORS_SINGLE_VALUE':
return numberValidator;
case 'SEMVER_OPERATORS_SINGLE_VALUE':
return semVerValidator;
export const constraintValidator = (constraint: EditableConstraint) => {
if (isDateConstraint(constraint)) {
return dateValidator;
}
if (isSemVerConstraint(constraint)) {
return semVerValidator;
}
if (isNumberConstraint(constraint)) {
return numberValidator;
}
return stringListValidator;
};

View File

@ -0,0 +1,98 @@
import {
type DateOperator,
isDateOperator,
isMultiValueOperator,
isSingleValueOperator,
type NumOperator,
type SemVerOperator,
type MultiValueOperator,
isNumOperator,
isSemVerOperator,
} from 'constants/operators';
import type { IConstraint } from 'interfaces/strategy';
type EditableConstraintBase = Omit<
IConstraint,
'operator' | 'values' | 'value'
>;
export type EditableNumberConstraint = EditableConstraintBase & {
operator: NumOperator;
value: string;
};
export type EditableDateConstraint = EditableConstraintBase & {
operator: DateOperator;
value: string;
};
export type EditableSemVerConstraint = EditableConstraintBase & {
operator: SemVerOperator;
value: string;
};
export type EditableMultiValueConstraint = EditableConstraintBase & {
operator: MultiValueOperator;
values: Set<string>;
};
export type EditableSingleValueConstraint =
| EditableNumberConstraint
| EditableDateConstraint
| EditableSemVerConstraint;
export type EditableConstraint =
| EditableSingleValueConstraint
| EditableMultiValueConstraint;
export const isMultiValueConstraint = (
constraint: EditableConstraint,
): constraint is EditableMultiValueConstraint =>
isMultiValueOperator(constraint.operator);
export const isSingleValueConstraint = (
constraint: EditableConstraint,
): constraint is EditableSingleValueConstraint =>
!isMultiValueConstraint(constraint);
export const isDateConstraint = (
constraint: EditableConstraint,
): constraint is EditableDateConstraint => isDateOperator(constraint.operator);
export const isNumberConstraint = (
constraint: EditableConstraint,
): constraint is EditableNumberConstraint => isNumOperator(constraint.operator);
export const isSemVerConstraint = (
constraint: EditableConstraint,
): constraint is EditableSemVerConstraint =>
isSemVerOperator(constraint.operator);
export const fromIConstraint = (
constraint: IConstraint,
): EditableConstraint => {
const { value, values, operator, ...rest } = constraint;
if (isSingleValueOperator(operator)) {
return {
...rest,
operator,
value: value ?? '',
};
} else {
return {
...rest,
operator,
values: new Set(values),
};
}
};
export const toIConstraint = (constraint: EditableConstraint): IConstraint => {
if ('value' in constraint) {
return constraint;
} else {
const { values, ...rest } = constraint;
return {
...rest,
values: Array.from(values),
};
}
};

View File

@ -0,0 +1,25 @@
// because Set.prototype.union and difference are baseline available 2024, but
// not available in GH actions yet. Caniuse also reports coverage at 87%. we can
// likely remove these in favor of the native implementations the next time we
// touch this code.
//
// todo: replace the use of this methods with set.union and set.difference when
// it's available.
export const union = <T>(a: Iterable<T>, b: Set<T>) => {
const result = new Set(a);
for (const element of b) {
result.add(element);
}
return result;
};
export const difference = <T>(a: Iterable<T>, b: Set<T>) => {
const result = new Set(a);
for (const element of a) {
if (!b.has(element)) {
result.add(element);
}
}
return result;
};

View File

@ -0,0 +1,145 @@
import { useMemo, useState } from 'react';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import type { IConstraint } from 'interfaces/strategy';
import {
type EditableMultiValueConstraint,
type EditableSingleValueConstraint,
fromIConstraint,
isSingleValueConstraint,
toIConstraint,
} from './editable-constraint-type';
import type {
ILegalValue,
IUnleashContextDefinition,
} from 'interfaces/context';
import {
constraintReducer,
type ConstraintUpdateAction,
} from './constraint-reducer';
import {
type ConstraintValidationResult,
constraintValidator,
} from './constraint-validator';
import { difference } from './set-functions';
const resolveContextDefinition = (
context: IUnleashContextDefinition[],
contextName: string,
): IUnleashContextDefinition => {
const definition = context.find(
(contextDef) => contextDef.name === contextName,
);
return (
definition || {
name: '',
description: '',
createdAt: '',
sortOrder: 1,
stickiness: false,
}
);
};
type SingleValueConstraintState = {
constraint: EditableSingleValueConstraint;
};
type MultiValueConstraintState = {
constraint: EditableMultiValueConstraint;
};
type LegalValueData = {
legalValues: ILegalValue[];
deletedLegalValues?: Set<string>;
};
type LegalValueConstraintState = {
constraint: EditableMultiValueConstraint;
} & LegalValueData;
type EditableConstraintState = {
updateConstraint: (action: ConstraintUpdateAction) => void;
validator: (...values: string[]) => ConstraintValidationResult;
} & (
| SingleValueConstraintState
| MultiValueConstraintState
| LegalValueConstraintState
);
export const useEditableConstraint = (
constraint: IConstraint,
onAutoSave: (constraint: IConstraint) => void,
): EditableConstraintState => {
const [localConstraint, setLocalConstraint] = useState(() => {
return fromIConstraint(constraint);
});
const { context } = useUnleashContext();
const [contextDefinition, setContextDefinition] = useState(
resolveContextDefinition(context, localConstraint.contextName),
);
const deletedLegalValues = useMemo(() => {
if (
contextDefinition.legalValues?.length &&
constraint.values?.length
) {
const currentLegalValues = new Set(
contextDefinition.legalValues.map(({ value }) => value),
);
const deletedValues = difference(
constraint.values,
currentLegalValues,
);
return deletedValues;
}
return undefined;
}, [
JSON.stringify(contextDefinition.legalValues),
JSON.stringify(constraint.values),
]);
const updateConstraint = (action: ConstraintUpdateAction) => {
const nextState = constraintReducer(
localConstraint,
action,
deletedLegalValues,
);
const contextFieldHasChanged =
localConstraint.contextName !== nextState.contextName;
setLocalConstraint(nextState);
onAutoSave(toIConstraint(nextState));
if (contextFieldHasChanged) {
setContextDefinition(
resolveContextDefinition(context, nextState.contextName),
);
}
};
if (isSingleValueConstraint(localConstraint)) {
return {
updateConstraint,
constraint: localConstraint,
validator: constraintValidator(localConstraint),
};
}
if (contextDefinition.legalValues?.length) {
return {
updateConstraint,
constraint: localConstraint,
validator: constraintValidator(localConstraint),
legalValues: contextDefinition.legalValues,
deletedLegalValues,
};
}
return {
updateConstraint,
constraint: localConstraint,
validator: constraintValidator(localConstraint),
};
};

View File

@ -202,31 +202,25 @@ export const SegmentFormStepTwo: React.FC<ISegmentFormPartTwoProps> = ({
}
/>
<StyledConstraintContainer>
<ConditionallyRender
condition={addEditStrategy}
show={
<EditableConstraintsList
ref={constraintsAccordionListRef}
constraints={constraints}
setConstraints={
hasAccess(modePermission, project)
? setConstraints
: undefined
}
/>
}
elseShow={
<ConstraintAccordionList
ref={constraintsAccordionListRef}
constraints={constraints}
setConstraints={
hasAccess(modePermission, project)
? setConstraints
: undefined
}
/>
}
/>
{addEditStrategy &&
hasAccess(modePermission, project) &&
setConstraints ? (
<EditableConstraintsList
ref={constraintsAccordionListRef}
constraints={constraints}
setConstraints={setConstraints}
/>
) : (
<ConstraintAccordionList
ref={constraintsAccordionListRef}
constraints={constraints}
setConstraints={
hasAccess(modePermission, project)
? setConstraints
: undefined
}
/>
)}
</StyledConstraintContainer>
</StyledForm>
<StyledButtonContainer>