mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-02 01:17:58 +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:
parent
bfc583b5b7
commit
e4ead3bd67
@ -21,8 +21,6 @@ import {
|
|||||||
type Input,
|
type Input,
|
||||||
} from '../useConstraintInput/useConstraintInput';
|
} from '../useConstraintInput/useConstraintInput';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
|
||||||
import { LegalValuesSelector } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/LegalValuesSelector';
|
|
||||||
|
|
||||||
interface IResolveInputProps {
|
interface IResolveInputProps {
|
||||||
contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
|
contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
|
||||||
@ -84,23 +82,11 @@ export const ResolveInput = ({
|
|||||||
removeValue,
|
removeValue,
|
||||||
error,
|
error,
|
||||||
}: IResolveInputProps) => {
|
}: IResolveInputProps) => {
|
||||||
const useNewLegalValueInput = useUiFlag('addEditStrategy');
|
|
||||||
const resolveInput = () => {
|
const resolveInput = () => {
|
||||||
switch (input) {
|
switch (input) {
|
||||||
case IN_OPERATORS_LEGAL_VALUES:
|
case IN_OPERATORS_LEGAL_VALUES:
|
||||||
case STRING_OPERATORS_LEGAL_VALUES:
|
case STRING_OPERATORS_LEGAL_VALUES:
|
||||||
return useNewLegalValueInput ? (
|
return (
|
||||||
<LegalValuesSelector
|
|
||||||
data={resolveLegalValues(
|
|
||||||
constraintValues,
|
|
||||||
contextDefinition.legalValues,
|
|
||||||
)}
|
|
||||||
constraintValues={constraintValues}
|
|
||||||
values={localConstraint.values || []}
|
|
||||||
setValuesWithRecord={setValuesWithRecord}
|
|
||||||
setValues={setValues}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<RestrictiveLegalValues
|
<RestrictiveLegalValues
|
||||||
data={resolveLegalValues(
|
data={resolveLegalValues(
|
||||||
constraintValues,
|
constraintValues,
|
||||||
|
@ -10,15 +10,14 @@ import {
|
|||||||
createEmptyConstraint,
|
createEmptyConstraint,
|
||||||
} from 'component/common/LegacyConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
|
} from 'component/common/LegacyConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
|
||||||
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
|
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 {
|
export interface IEditableConstraintsListRef {
|
||||||
addConstraint?: (contextName: string) => void;
|
addConstraint?: (contextName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEditableConstraintsListProps {
|
export interface IEditableConstraintsListProps {
|
||||||
constraints: IConstraint[];
|
constraints: IConstraint[];
|
||||||
setConstraints?: React.Dispatch<React.SetStateAction<IConstraint[]>>;
|
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledContainer = styled('div')({
|
const StyledContainer = styled('div')({
|
||||||
@ -42,29 +41,16 @@ export const EditableConstraintsList = forwardRef<
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onRemove =
|
const onDelete = (index: number) => {
|
||||||
setConstraints &&
|
|
||||||
((index: number) => {
|
|
||||||
setConstraints(
|
setConstraints(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.splice(index, 1);
|
draft.splice(index, 1);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
const onSave =
|
|
||||||
setConstraints &&
|
|
||||||
((index: number, constraint: IConstraint) => {
|
|
||||||
setConstraints(
|
|
||||||
produce((draft) => {
|
|
||||||
draft[index] = constraint;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAutoSave =
|
const onAutoSave =
|
||||||
setConstraints &&
|
(id: string | undefined) => (constraint: IConstraint) => {
|
||||||
((id: string | undefined) => (constraint: IConstraint) => {
|
|
||||||
setConstraints(
|
setConstraints(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
return draft.map((oldConstraint) => {
|
return draft.map((oldConstraint) => {
|
||||||
@ -75,7 +61,7 @@ export const EditableConstraintsList = forwardRef<
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
if (context.length === 0) {
|
if (context.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -85,12 +71,11 @@ export const EditableConstraintsList = forwardRef<
|
|||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<ConstraintsList>
|
<ConstraintsList>
|
||||||
{constraints.map((constraint, index) => (
|
{constraints.map((constraint, index) => (
|
||||||
<EditableConstraintWrapper
|
<EditableConstraint
|
||||||
key={constraint[constraintId]}
|
key={constraint[constraintId]}
|
||||||
constraint={constraint}
|
constraint={constraint}
|
||||||
onDelete={onRemove?.bind(null, index)}
|
onDelete={() => onDelete(index)}
|
||||||
onSave={onSave!.bind(null, index)}
|
onAutoSave={onAutoSave(constraint[constraintId])}
|
||||||
onAutoSave={onAutoSave?.(constraint[constraintId])}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ConstraintsList>
|
</ConstraintsList>
|
||||||
|
@ -12,8 +12,8 @@ import {
|
|||||||
import { NewConstraintAccordion } from 'component/common/NewConstraintAccordion/NewConstraintAccordion';
|
import { NewConstraintAccordion } from 'component/common/NewConstraintAccordion/NewConstraintAccordion';
|
||||||
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
|
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { EditableConstraintWrapper } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper';
|
|
||||||
import { ConstraintAccordionView } from 'component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
|
import { ConstraintAccordionView } from 'component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
|
||||||
|
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint';
|
||||||
|
|
||||||
export interface IConstraintAccordionListProps {
|
export interface IConstraintAccordionListProps {
|
||||||
constraints: IConstraint[];
|
constraints: IConstraint[];
|
||||||
@ -147,16 +147,15 @@ export const NewConstraintAccordionList = forwardRef<
|
|||||||
<ConstraintsList>
|
<ConstraintsList>
|
||||||
{constraints.map((constraint, index) =>
|
{constraints.map((constraint, index) =>
|
||||||
addEditStrategy ? (
|
addEditStrategy ? (
|
||||||
state.get(constraint)?.editing ? (
|
state.get(constraint)?.editing &&
|
||||||
<EditableConstraintWrapper
|
Boolean(setConstraints) ? (
|
||||||
|
<EditableConstraint
|
||||||
key={constraint[constraintId]}
|
key={constraint[constraintId]}
|
||||||
constraint={constraint}
|
constraint={constraint}
|
||||||
onCancel={onCancel?.bind(null, index)}
|
// @ts-ignore todo: find a better way to do this
|
||||||
onDelete={onRemove?.bind(null, index)}
|
onDelete={() => onRemove(index)}
|
||||||
onSave={onSave!.bind(null, index)}
|
// @ts-ignore
|
||||||
onAutoSave={onAutoSave?.(
|
onAutoSave={onAutoSave(constraintId)}
|
||||||
constraint[constraintId],
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConstraintAccordionView
|
<ConstraintAccordionView
|
||||||
|
@ -1,25 +1,9 @@
|
|||||||
import { IconButton, styled } from '@mui/material';
|
import { IconButton, styled } from '@mui/material';
|
||||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||||
import {
|
import { isStringOperator, type Operator } from 'constants/operators';
|
||||||
useConstraintInput,
|
|
||||||
type Input,
|
|
||||||
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
|
|
||||||
import {
|
|
||||||
DATE_AFTER,
|
|
||||||
dateOperators,
|
|
||||||
IN,
|
|
||||||
stringOperators,
|
|
||||||
type Operator,
|
|
||||||
} from 'constants/operators';
|
|
||||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||||
import type { IUnleashContextDefinition } from 'interfaces/context';
|
import { useRef, type FC } from 'react';
|
||||||
import type { IConstraint } from 'interfaces/strategy';
|
import { operatorsForContext } from 'utils/operatorsForContext';
|
||||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react';
|
|
||||||
import { oneOf } from 'utils/oneOf';
|
|
||||||
import {
|
|
||||||
CURRENT_TIME_CONTEXT_FIELD,
|
|
||||||
operatorsForContext,
|
|
||||||
} from 'utils/operatorsForContext';
|
|
||||||
import { ConstraintOperatorSelect } from './ConstraintOperatorSelect';
|
import { ConstraintOperatorSelect } from './ConstraintOperatorSelect';
|
||||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||||
import Delete from '@mui/icons-material/Delete';
|
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 { AddSingleValueWidget } from './AddSingleValueWidget';
|
||||||
import { ConstraintDateInput } from './ConstraintDateInput';
|
import { ConstraintDateInput } from './ConstraintDateInput';
|
||||||
import { LegalValuesSelector } from './LegalValuesSelector';
|
import { LegalValuesSelector } from './LegalValuesSelector';
|
||||||
import { resolveLegalValues } from './resolve-legal-values';
|
import { useEditableConstraint } from './useEditableConstraint/useEditableConstraint';
|
||||||
import { constraintValidator } from './constraint-validator';
|
import type { IConstraint } from 'interfaces/strategy';
|
||||||
|
import {
|
||||||
|
isDateConstraint,
|
||||||
|
isMultiValueConstraint,
|
||||||
|
isNumberConstraint,
|
||||||
|
isSemVerConstraint,
|
||||||
|
} from './useEditableConstraint/editable-constraint-type';
|
||||||
|
|
||||||
const Container = styled('article')(({ theme }) => ({
|
const Container = styled('article')(({ theme }) => ({
|
||||||
'--padding': theme.spacing(2),
|
'--padding': theme.spacing(2),
|
||||||
@ -150,111 +140,29 @@ const StyledCaseSensitiveIcon = styled(CaseSensitiveIcon)(({ theme }) => ({
|
|||||||
fill: 'currentcolor',
|
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 = {
|
type Props = {
|
||||||
constraint: IConstraint;
|
constraint: IConstraint;
|
||||||
localConstraint: IConstraint;
|
onDelete: () => void;
|
||||||
setContextName: (contextName: string) => void;
|
onAutoSave: (constraint: IConstraint) => 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditableConstraint: FC<Props> = ({
|
export const EditableConstraint: FC<Props> = ({
|
||||||
constraintChanges,
|
|
||||||
constraint,
|
|
||||||
localConstraint,
|
|
||||||
setLocalConstraint,
|
|
||||||
setContextName,
|
|
||||||
setOperator,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
onUndo,
|
constraint,
|
||||||
toggleInvertedOperator,
|
onAutoSave,
|
||||||
toggleCaseSensitivity,
|
|
||||||
contextDefinition,
|
|
||||||
constraintValues,
|
|
||||||
constraintValue,
|
|
||||||
setValue,
|
|
||||||
setValues,
|
|
||||||
setValuesWithRecord,
|
|
||||||
removeValue,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { input } = useConstraintInput({
|
const {
|
||||||
contextDefinition,
|
constraint: localConstraint,
|
||||||
localConstraint,
|
updateConstraint,
|
||||||
});
|
validator,
|
||||||
|
...constraintMetadata
|
||||||
|
} = useEditableConstraint(constraint, onAutoSave);
|
||||||
|
|
||||||
const { context } = useUnleashContext();
|
const { context } = useUnleashContext();
|
||||||
const { contextName, operator } = localConstraint;
|
const { contextName, operator } = localConstraint;
|
||||||
const [showCaseSensitiveButton, setShowCaseSensitiveButton] =
|
const showCaseSensitiveButton = isStringOperator(operator);
|
||||||
useState(false);
|
|
||||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const addValuesButtonRef = 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) {
|
if (!context) {
|
||||||
return null;
|
return null;
|
||||||
@ -265,72 +173,76 @@ export const EditableConstraint: FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onOperatorChange = (operator: Operator) => {
|
const onOperatorChange = (operator: Operator) => {
|
||||||
if (oneOf(stringOperators, operator)) {
|
updateConstraint({ type: 'set operator', payload: operator });
|
||||||
setShowCaseSensitiveButton(true);
|
|
||||||
} else {
|
|
||||||
setShowCaseSensitiveButton(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oneOf(dateOperators, operator)) {
|
|
||||||
setLocalConstraint((prev) => ({
|
|
||||||
...prev,
|
|
||||||
operator: operator,
|
|
||||||
value: new Date().toISOString(),
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setOperator(operator);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validator = useMemo(() => constraintValidator(input), [input]);
|
|
||||||
const TopRowInput = () => {
|
const TopRowInput = () => {
|
||||||
switch (inputType.input) {
|
if (isDateConstraint(localConstraint)) {
|
||||||
case 'date':
|
|
||||||
return (
|
return (
|
||||||
<ConstraintDateInput
|
<ConstraintDateInput
|
||||||
setValue={setValue}
|
setValue={(value: string) =>
|
||||||
|
updateConstraint({
|
||||||
|
type: 'set value',
|
||||||
|
payload: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
value={localConstraint.value}
|
value={localConstraint.value}
|
||||||
validator={validator}
|
validator={validator}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'single value':
|
}
|
||||||
|
if (isSemVerConstraint(localConstraint)) {
|
||||||
return (
|
return (
|
||||||
<AddSingleValueWidget
|
<AddSingleValueWidget
|
||||||
validator={validator}
|
validator={validator}
|
||||||
onAddValue={(newValue) => {
|
onAddValue={(newValue) => {
|
||||||
setValue(newValue);
|
updateConstraint({
|
||||||
|
type: 'set value',
|
||||||
|
payload: newValue,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
removeValue={() => setValue('')}
|
removeValue={() =>
|
||||||
|
updateConstraint({ type: 'clear values' })
|
||||||
|
}
|
||||||
currentValue={localConstraint.value}
|
currentValue={localConstraint.value}
|
||||||
helpText={
|
helpText={'A semver value should be of the format X.Y.Z'}
|
||||||
inputType.type === 'number'
|
inputType={'text'}
|
||||||
? 'Add a single number'
|
|
||||||
: 'A semver value should be of the format X.Y.Z'
|
|
||||||
}
|
|
||||||
inputType={
|
|
||||||
inputType.type === 'number' ? 'number' : 'text'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'multiple values':
|
}
|
||||||
|
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 (
|
return (
|
||||||
<AddValuesWidget
|
<AddValuesWidget
|
||||||
validator={validator}
|
validator={validator}
|
||||||
helpText='Maximum 100 char length per value'
|
helpText='Maximum 100 char length per value'
|
||||||
ref={addValuesButtonRef}
|
ref={addValuesButtonRef}
|
||||||
onAddValues={(newValues) => {
|
onAddValues={(newValues) => {
|
||||||
// todo (`addEditStrategy`): move deduplication logic higher up in the context handling
|
updateConstraint({
|
||||||
const combinedValues = new Set([
|
type: 'add value(s)',
|
||||||
...(localConstraint.values || []),
|
payload: newValues,
|
||||||
...newValues,
|
});
|
||||||
]);
|
|
||||||
setValuesWithRecord(Array.from(combinedValues));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -346,14 +258,23 @@ export const EditableConstraint: FC<Props> = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
options={constraintNameOptions}
|
options={constraintNameOptions}
|
||||||
value={contextName || ''}
|
value={contextName || ''}
|
||||||
onChange={setContextName}
|
onChange={(contextField) =>
|
||||||
|
updateConstraint({
|
||||||
|
type: 'set context field',
|
||||||
|
payload: contextField,
|
||||||
|
})
|
||||||
|
}
|
||||||
variant='standard'
|
variant='standard'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OperatorOptions>
|
<OperatorOptions>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type='button'
|
type='button'
|
||||||
onClick={toggleInvertedOperator}
|
onClick={() =>
|
||||||
|
updateConstraint({
|
||||||
|
type: 'toggle inverted operator',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{localConstraint.inverted ? (
|
{localConstraint.inverted ? (
|
||||||
<StyledNotEqualsIcon aria-label='The constraint operator is exclusive.' />
|
<StyledNotEqualsIcon aria-label='The constraint operator is exclusive.' />
|
||||||
@ -378,7 +299,11 @@ export const EditableConstraint: FC<Props> = ({
|
|||||||
{showCaseSensitiveButton ? (
|
{showCaseSensitiveButton ? (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type='button'
|
type='button'
|
||||||
onClick={toggleCaseSensitivity}
|
onClick={() =>
|
||||||
|
updateConstraint({
|
||||||
|
type: 'toggle case sensitivity',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{localConstraint.caseInsensitive ? (
|
{localConstraint.caseInsensitive ? (
|
||||||
<StyledCaseInsensitiveIcon aria-label='The match is not case sensitive.' />
|
<StyledCaseInsensitiveIcon aria-label='The match is not case sensitive.' />
|
||||||
@ -397,9 +322,17 @@ export const EditableConstraint: FC<Props> = ({
|
|||||||
</OperatorOptions>
|
</OperatorOptions>
|
||||||
</ConstraintOptions>
|
</ConstraintOptions>
|
||||||
<ValueList
|
<ValueList
|
||||||
values={localConstraint.values}
|
values={
|
||||||
removeValue={removeValue}
|
isMultiValueConstraint(localConstraint)
|
||||||
setValues={setValuesWithRecord}
|
? Array.from(localConstraint.values)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
removeValue={(value) =>
|
||||||
|
updateConstraint({
|
||||||
|
type: 'remove value from list',
|
||||||
|
payload: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
getExternalFocusTarget={() =>
|
getExternalFocusTarget={() =>
|
||||||
addValuesButtonRef.current ??
|
addValuesButtonRef.current ??
|
||||||
deleteButtonRef.current
|
deleteButtonRef.current
|
||||||
@ -421,17 +354,32 @@ export const EditableConstraint: FC<Props> = ({
|
|||||||
</StyledIconButton>
|
</StyledIconButton>
|
||||||
</HtmlTooltip>
|
</HtmlTooltip>
|
||||||
</TopRow>
|
</TopRow>
|
||||||
{inputType.input === 'legal values' ? (
|
{'legalValues' in constraintMetadata &&
|
||||||
|
isMultiValueConstraint(localConstraint) ? (
|
||||||
<LegalValuesContainer>
|
<LegalValuesContainer>
|
||||||
<LegalValuesSelector
|
<LegalValuesSelector
|
||||||
data={resolveLegalValues(
|
values={localConstraint.values}
|
||||||
constraintValues,
|
clearAll={() =>
|
||||||
contextDefinition.legalValues,
|
updateConstraint({
|
||||||
)}
|
type: 'clear values',
|
||||||
constraintValues={constraintValues}
|
})
|
||||||
values={localConstraint.values || []}
|
}
|
||||||
setValuesWithRecord={setValuesWithRecord}
|
addValues={(newValues) =>
|
||||||
setValues={setValues}
|
updateConstraint({
|
||||||
|
type: 'add value(s)',
|
||||||
|
payload: newValues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
removeValue={(value) =>
|
||||||
|
updateConstraint({
|
||||||
|
type: 'remove value from list',
|
||||||
|
payload: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deletedLegalValues={
|
||||||
|
constraintMetadata.deletedLegalValues
|
||||||
|
}
|
||||||
|
legalValues={constraintMetadata.legalValues}
|
||||||
/>
|
/>
|
||||||
</LegalValuesContainer>
|
</LegalValuesContainer>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -93,25 +93,20 @@ export const FeatureStrategyConstraintAccordionList = forwardRef<
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</StyledHelpIconBox>
|
</StyledHelpIconBox>
|
||||||
<ConditionallyRender
|
{addEditStrategy && setConstraints ? (
|
||||||
condition={Boolean(addEditStrategy)}
|
|
||||||
show={
|
|
||||||
<EditableConstraintsList
|
<EditableConstraintsList
|
||||||
ref={ref}
|
ref={ref}
|
||||||
setConstraints={setConstraints}
|
setConstraints={setConstraints}
|
||||||
constraints={constraints}
|
constraints={constraints}
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
elseShow={
|
|
||||||
<NewConstraintAccordionList
|
<NewConstraintAccordionList
|
||||||
ref={ref}
|
ref={ref}
|
||||||
setConstraints={setConstraints}
|
setConstraints={setConstraints}
|
||||||
constraints={constraints}
|
constraints={constraints}
|
||||||
state={state}
|
state={state}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
marginTop: theme.spacing(2),
|
marginTop: theme.spacing(2),
|
||||||
|
@ -1,51 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { Alert, Button, Checkbox, styled } from '@mui/material';
|
import { Alert, Button, Checkbox, styled } from '@mui/material';
|
||||||
import type { ILegalValue } from 'interfaces/context';
|
|
||||||
import {
|
import {
|
||||||
filterLegalValues,
|
filterLegalValues,
|
||||||
LegalValueLabel,
|
LegalValueLabel,
|
||||||
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel';
|
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel';
|
||||||
import { ConstraintValueSearch } from './ConstraintValueSearch';
|
import { ConstraintValueSearch } from './ConstraintValueSearch';
|
||||||
|
import type { ILegalValue } from 'interfaces/context';
|
||||||
|
|
||||||
interface IRestrictiveLegalValuesProps {
|
type LegalValuesSelectorProps = {
|
||||||
data: {
|
values: Set<string>;
|
||||||
|
addValues: (values: string[]) => void;
|
||||||
|
removeValue: (value: string) => void;
|
||||||
|
clearAll: () => void;
|
||||||
|
deletedLegalValues?: Set<string>;
|
||||||
legalValues: ILegalValue[];
|
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),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledValuesContainer = styled('div')(({ theme }) => ({
|
const StyledValuesContainer = styled('div')(({ theme }) => ({
|
||||||
@ -70,66 +38,34 @@ const LegalValuesSelectorWidget = styled('article')(({ theme }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const LegalValuesSelector = ({
|
export const LegalValuesSelector = ({
|
||||||
data,
|
legalValues,
|
||||||
values,
|
values,
|
||||||
setValues,
|
addValues,
|
||||||
setValuesWithRecord,
|
removeValue,
|
||||||
constraintValues,
|
clearAll,
|
||||||
}: IRestrictiveLegalValuesProps) => {
|
deletedLegalValues,
|
||||||
|
}: LegalValuesSelectorProps) => {
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const { legalValues, deletedLegalValues } = data;
|
|
||||||
|
|
||||||
const filteredValues = filterLegalValues(legalValues, filter);
|
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) => {
|
const onChange = (legalValue: string) => {
|
||||||
if (valuesMap[legalValue]) {
|
if (values.has(legalValue)) {
|
||||||
const index = values.findIndex((value) => value === legalValue);
|
removeValue(legalValue);
|
||||||
const newValues = [...values];
|
} else {
|
||||||
newValues.splice(index, 1);
|
addValues([legalValue]);
|
||||||
setValuesWithRecord(newValues);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setValuesWithRecord([...cleanDeletedLegalValues(values), legalValue]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllSelected = legalValues.every((value) =>
|
const isAllSelected = legalValues.every((value) => values.has(value.value));
|
||||||
values.includes(value.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSelectAll = () => {
|
const onSelectAll = () => {
|
||||||
if (isAllSelected) {
|
if (isAllSelected) {
|
||||||
return setValuesWithRecord([]);
|
clearAll();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
addValues(legalValues.map(({ value }) => value));
|
||||||
}
|
}
|
||||||
setValuesWithRecord([
|
|
||||||
...legalValues.map((legalValue) => legalValue.value),
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchKeyDown = (event: React.KeyboardEvent) => {
|
const handleSearchKeyDown = (event: React.KeyboardEvent) => {
|
||||||
@ -144,22 +80,18 @@ export const LegalValuesSelector = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LegalValuesSelectorWidget>
|
<LegalValuesSelectorWidget>
|
||||||
<ConditionallyRender
|
{deletedLegalValues?.size ? (
|
||||||
condition={Boolean(illegalValues && illegalValues.length > 0)}
|
|
||||||
show={
|
|
||||||
<Alert severity='warning'>
|
<Alert severity='warning'>
|
||||||
This constraint is using legal values that have been
|
This constraint is using legal values that have been deleted
|
||||||
deleted as valid options. If you save changes on this
|
as valid options. If you save changes on this constraint and
|
||||||
constraint and then save the strategy the following
|
then save the strategy the following values will be removed:
|
||||||
values will be removed:
|
|
||||||
<ul>
|
<ul>
|
||||||
{illegalValues?.map((value) => (
|
{[...deletedLegalValues].map((value) => (
|
||||||
<li key={value}>{value}</li>
|
<li key={value}>{value}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Alert>
|
</Alert>
|
||||||
}
|
) : null}
|
||||||
/>
|
|
||||||
<p>Select values from a predefined set</p>
|
<p>Select values from a predefined set</p>
|
||||||
<Row>
|
<Row>
|
||||||
<ConstraintValueSearch
|
<ConstraintValueSearch
|
||||||
@ -185,13 +117,11 @@ export const LegalValuesSelector = ({
|
|||||||
filter={filter}
|
filter={filter}
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={Boolean(valuesMap[match.value])}
|
checked={Boolean(values.has(match.value))}
|
||||||
onChange={() => onChange(match.value)}
|
onChange={() => onChange(match.value)}
|
||||||
name='legal-value'
|
name='legal-value'
|
||||||
color='primary'
|
color='primary'
|
||||||
disabled={deletedLegalValues
|
disabled={deletedLegalValues?.has(match.value)}
|
||||||
.map(({ value }) => value)
|
|
||||||
.includes(match.value)}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -54,9 +54,8 @@ export const ValueChip = styled(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
values: string[] | undefined;
|
values?: string[];
|
||||||
removeValue: (index: number) => void;
|
removeValue: (value: string) => void;
|
||||||
setValues: (values: string[]) => void;
|
|
||||||
// the element that should receive focus when all value chips are deleted
|
// the element that should receive focus when all value chips are deleted
|
||||||
getExternalFocusTarget: () => HTMLElement | null;
|
getExternalFocusTarget: () => HTMLElement | null;
|
||||||
};
|
};
|
||||||
@ -102,7 +101,7 @@ export const ValueList: FC<PropsWithChildren<Props>> = ({
|
|||||||
label={value}
|
label={value}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
nextFocusTarget(index)?.focus();
|
nextFocusTarget(index)?.focus();
|
||||||
removeValue(index);
|
removeValue(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
@ -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 { isValid, parseISO } from 'date-fns';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
import {
|
||||||
|
type EditableConstraint,
|
||||||
|
isDateConstraint,
|
||||||
|
isNumberConstraint,
|
||||||
|
isSemVerConstraint,
|
||||||
|
} from './editable-constraint-type';
|
||||||
export type ConstraintValidationResult = [boolean, string];
|
export type ConstraintValidationResult = [boolean, string];
|
||||||
|
|
||||||
const numberValidator = (value: string): ConstraintValidationResult => {
|
const numberValidator = (value: string): ConstraintValidationResult => {
|
||||||
@ -56,20 +59,15 @@ const dateValidator = (value: string): ConstraintValidationResult => {
|
|||||||
return [true, ''];
|
return [true, ''];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const constraintValidator = (input: Input) => {
|
export const constraintValidator = (constraint: EditableConstraint) => {
|
||||||
switch (input) {
|
if (isDateConstraint(constraint)) {
|
||||||
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;
|
return dateValidator;
|
||||||
case 'NUM_OPERATORS_SINGLE_VALUE':
|
}
|
||||||
return numberValidator;
|
if (isSemVerConstraint(constraint)) {
|
||||||
case 'SEMVER_OPERATORS_SINGLE_VALUE':
|
|
||||||
return semVerValidator;
|
return semVerValidator;
|
||||||
}
|
}
|
||||||
|
if (isNumberConstraint(constraint)) {
|
||||||
|
return numberValidator;
|
||||||
|
}
|
||||||
|
return stringListValidator;
|
||||||
};
|
};
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
@ -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;
|
||||||
|
};
|
@ -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),
|
||||||
|
};
|
||||||
|
};
|
@ -202,20 +202,15 @@ export const SegmentFormStepTwo: React.FC<ISegmentFormPartTwoProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StyledConstraintContainer>
|
<StyledConstraintContainer>
|
||||||
<ConditionallyRender
|
{addEditStrategy &&
|
||||||
condition={addEditStrategy}
|
hasAccess(modePermission, project) &&
|
||||||
show={
|
setConstraints ? (
|
||||||
<EditableConstraintsList
|
<EditableConstraintsList
|
||||||
ref={constraintsAccordionListRef}
|
ref={constraintsAccordionListRef}
|
||||||
constraints={constraints}
|
constraints={constraints}
|
||||||
setConstraints={
|
setConstraints={setConstraints}
|
||||||
hasAccess(modePermission, project)
|
|
||||||
? setConstraints
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
elseShow={
|
|
||||||
<ConstraintAccordionList
|
<ConstraintAccordionList
|
||||||
ref={constraintsAccordionListRef}
|
ref={constraintsAccordionListRef}
|
||||||
constraints={constraints}
|
constraints={constraints}
|
||||||
@ -225,8 +220,7 @@ export const SegmentFormStepTwo: React.FC<ISegmentFormPartTwoProps> = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
</StyledConstraintContainer>
|
</StyledConstraintContainer>
|
||||||
</StyledForm>
|
</StyledForm>
|
||||||
<StyledButtonContainer>
|
<StyledButtonContainer>
|
||||||
|
Loading…
Reference in New Issue
Block a user