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:
parent
bfc583b5b7
commit
e4ead3bd67
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
<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),
|
||||
|
@ -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)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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 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;
|
||||
};
|
@ -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,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>
|
||||
|
Loading…
Reference in New Issue
Block a user