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

chore: Set up the basis of the new constraint editing component. (#9701)

This PR creates/steals the logic and basic components that we need for
the new constraint editing design and shows it instead of the old one if
the flag is on.

The interface needs a lot of work, but this essentially wires everything
up so that it works with the API on direct editing:

<img width="781" alt="image"
src="https://github.com/user-attachments/assets/97489a08-5f12-47ee-98b3-aefc0b840a2b"
/>

Additionally the code here will need a lot of refactoring. This is a
first draft where I've yanked all the constraint editing logic out of a
nested hierarchy of components that handle validation and lots more. I
expect to clean this up significantly before finishing it up, so please
excuse the mess it's currently in. It turns out to have been lots and
lots more logic than I had anticipated.

This is just a PR to get started, so that the next one will be easier to
work on.
This commit is contained in:
Thomas Heartman 2025-04-07 14:50:42 +02:00 committed by GitHub
parent 6ea823f011
commit 5e35a0fa22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 579 additions and 15 deletions

View File

@ -33,6 +33,7 @@ export const ConstraintsList: FC<{ children: ReactNode }> = ({ children }) => {
result.push(
<StyledListItem key={index}>
{index > 0 ? (
// todo (addEditStrategy): change divider for edit screen (probably a new component or a prop)
<ConstraintSeparator key={`${index}-divider`} />
) : null}
{child}

View File

@ -96,6 +96,7 @@ export const ConstraintOperatorSelect = ({
);
};
// todo (addEditStrategy): add prop to configure the select element or style it. (currently, the chevron is different from the other select element we use). Maybe add a new component.
return (
<StyledFormInput variant='outlined' size='small' fullWidth>
<InputLabel htmlFor='operator-select'>Operator</InputLabel>

View File

@ -14,6 +14,7 @@ import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStra
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';
export interface IConstraintAccordionListProps {
constraints: IConstraint[];
@ -86,6 +87,7 @@ export const NewConstraintAccordionList = forwardRef<
>(({ constraints, setConstraints, state }, ref) => {
const { context } = useUnleashContext();
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const addEditStrategy = useUiFlag('addEditStrategy');
const onEdit =
setConstraints &&
@ -146,19 +148,36 @@ export const NewConstraintAccordionList = forwardRef<
return (
<StyledContainer id={constraintAccordionListId}>
<ConstraintsList>
{constraints.map((constraint, index) => (
<NewConstraintAccordion
key={constraint[constraintId]}
constraint={constraint}
onEdit={onEdit?.bind(null, constraint)}
onCancel={onCancel.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave?.bind(null, index)}
onAutoSave={onAutoSave?.(constraint[constraintId])}
editing={Boolean(state.get(constraint)?.editing)}
compact
/>
))}
{constraints.map((constraint, index) =>
addEditStrategy ? (
<EditableConstraintWrapper
key={constraint[constraintId]}
constraint={constraint}
onCancel={onCancel.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave!.bind(null, index)}
onAutoSave={onAutoSave?.(
constraint[constraintId],
)}
/>
) : (
<NewConstraintAccordion
key={constraint[constraintId]}
constraint={constraint}
onEdit={onEdit?.bind(null, constraint)}
onCancel={onCancel.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave?.bind(null, index)}
onAutoSave={onAutoSave?.(
constraint[constraintId],
)}
editing={Boolean(
state.get(constraint)?.editing,
)}
compact
/>
),
)}
</ConstraintsList>
</StyledContainer>
);

View File

@ -0,0 +1,334 @@
import { styled } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { DateSingleValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue';
import { FreeTextInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput';
import { RestrictiveLegalValues } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues';
import { SingleLegalValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue';
import { SingleValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue';
import {
DATE_OPERATORS_SINGLE_VALUE,
IN_OPERATORS_FREETEXT,
IN_OPERATORS_LEGAL_VALUES,
NUM_OPERATORS_LEGAL_VALUES,
NUM_OPERATORS_SINGLE_VALUE,
SEMVER_OPERATORS_LEGAL_VALUES,
SEMVER_OPERATORS_SINGLE_VALUE,
STRING_OPERATORS_FREETEXT,
STRING_OPERATORS_LEGAL_VALUES,
type Input,
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
import { CaseSensitiveButton } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton';
import { ConstraintOperatorSelect } from 'component/common/NewConstraintAccordion/ConstraintOperatorSelect';
import {
DATE_AFTER,
dateOperators,
IN,
stringOperators,
type Operator,
} from 'constants/operators';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import type {
ILegalValue,
IUnleashContextDefinition,
} from 'interfaces/context';
import type { IConstraint } from 'interfaces/strategy';
import { useEffect, useState, type FC } from 'react';
import { oneOf } from 'utils/oneOf';
import {
CURRENT_TIME_CONTEXT_FIELD,
operatorsForContext,
} from 'utils/operatorsForContext';
const Container = styled('article')(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusLarge,
border: `1px solid ${theme.palette.divider}`,
}));
const resolveLegalValues = (
values: IConstraint['values'],
legalValues: IUnleashContextDefinition['legalValues'],
): { legalValues: ILegalValue[]; deletedLegalValues: ILegalValue[] } => {
if (legalValues?.length === 0) {
return {
legalValues: [],
deletedLegalValues: [],
};
}
const deletedLegalValues = (values || [])
.filter(
(value) =>
!(legalValues || []).some(
({ value: legalValue }) => legalValue === value,
),
)
.map((v) => ({ value: v, description: '' }));
return {
legalValues: legalValues || [],
deletedLegalValues,
};
};
type Props = {
localConstraint: IConstraint;
setContextName: (contextName: string) => void;
setOperator: (operator: Operator) => void;
setLocalConstraint: React.Dispatch<React.SetStateAction<IConstraint>>;
action: string;
onDelete?: () => void;
setInvertedOperator: () => void;
setCaseInsensitive: () => 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;
setError: React.Dispatch<React.SetStateAction<string>>;
removeValue: (index: number) => void;
input: Input;
error: string;
};
export const EditableConstraint: FC<Props> = ({
constraintChanges,
localConstraint,
setLocalConstraint,
setContextName,
setOperator,
onDelete,
onUndo,
setInvertedOperator,
setCaseInsensitive,
input,
contextDefinition,
constraintValues,
constraintValue,
setValue,
setValues,
setValuesWithRecord,
setError,
removeValue,
error,
}) => {
const { context } = useUnleashContext();
const { contextName, operator } = localConstraint;
const [showCaseSensitiveButton, setShowCaseSensitiveButton] =
useState(false);
/* 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;
}
const constraintNameOptions = context.map((context) => {
return { key: context.name, label: context.name };
});
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);
}
};
const resolveInput = () => {
switch (input) {
case IN_OPERATORS_LEGAL_VALUES:
case STRING_OPERATORS_LEGAL_VALUES:
return (
<>
<RestrictiveLegalValues
data={resolveLegalValues(
constraintValues,
contextDefinition.legalValues,
)}
constraintValues={constraintValues}
values={localConstraint.values || []}
setValuesWithRecord={setValuesWithRecord}
setValues={setValues}
error={error}
setError={setError}
/>
</>
);
case NUM_OPERATORS_LEGAL_VALUES:
return (
<>
<SingleLegalValue
data={resolveLegalValues(
[constraintValue],
contextDefinition.legalValues,
)}
setValue={setValue}
value={localConstraint.value}
constraintValue={constraintValue}
type='number'
legalValues={
contextDefinition.legalValues?.filter(
(legalValue) => Number(legalValue.value),
) || []
}
error={error}
setError={setError}
/>
</>
);
case SEMVER_OPERATORS_LEGAL_VALUES:
return (
<>
<SingleLegalValue
data={resolveLegalValues(
[constraintValue],
contextDefinition.legalValues,
)}
setValue={setValue}
value={localConstraint.value}
constraintValue={constraintValue}
type='semver'
legalValues={contextDefinition.legalValues || []}
error={error}
setError={setError}
/>
</>
);
case DATE_OPERATORS_SINGLE_VALUE:
return (
<DateSingleValue
value={localConstraint.value}
setValue={setValue}
error={error}
setError={setError}
/>
);
case IN_OPERATORS_FREETEXT:
return (
<FreeTextInput
values={localConstraint.values || []}
removeValue={removeValue}
setValues={setValuesWithRecord}
error={error}
setError={setError}
/>
);
case STRING_OPERATORS_FREETEXT:
return (
<>
<FreeTextInput
values={localConstraint.values || []}
removeValue={removeValue}
setValues={setValuesWithRecord}
error={error}
setError={setError}
/>
</>
);
case NUM_OPERATORS_SINGLE_VALUE:
return (
<SingleValue
setValue={setValue}
value={localConstraint.value}
type='number'
error={error}
setError={setError}
/>
);
case SEMVER_OPERATORS_SINGLE_VALUE:
return (
<SingleValue
setValue={setValue}
value={localConstraint.value}
type='semver'
error={error}
setError={setError}
/>
);
}
};
return (
<Container>
<GeneralSelect
id='context-field-select'
name='contextName'
label='Context Field'
autoFocus
options={constraintNameOptions}
value={contextName || ''}
onChange={setContextName}
/>
<ConstraintOperatorSelect
options={operatorsForContext(contextName)}
value={operator}
onChange={onOperatorChange}
/>
{/* this is how to style them */}
{/* <StrategyEvaluationChip label='label' /> */}
{showCaseSensitiveButton ? (
<CaseSensitiveButton
localConstraint={localConstraint}
setCaseInsensitive={setCaseInsensitive}
/>
) : null}
{resolveInput()}
{/* <ul>
<li>
<Chip
label='value1'
onDelete={() => console.log('Clicked')}
/>
</li>
<li>
<Chip
label='value2'
onDelete={() => console.log('Clicked')}
/>
</li>
</ul> */}
</Container>
);
};

View File

@ -0,0 +1,211 @@
import { useCallback, useEffect, useState } from 'react';
import type { IConstraint } from 'interfaces/strategy';
import { cleanConstraint } from 'utils/cleanConstraint';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
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';
import { useConstraintInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
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),
);
const { validateConstraint } = useFeatureApi();
const [action, setAction] = useState('');
const { input, validator, setError, error } = useConstraintInput({
contextDefinition,
localConstraint,
});
useEffect(() => {
setContextDefinition(
resolveContextDefinition(context, localConstraint.contextName),
);
}, [localConstraint.contextName, context]);
useEffect(() => {
setError('');
}, [setError]);
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}
action={action}
setInvertedOperator={setInvertedOperator}
setCaseInsensitive={setCaseInsensitive}
onDelete={onDelete}
onUndo={onUndo}
constraintChanges={constraintChanges}
setValues={setValues}
setValuesWithRecord={setValuesWithRecord}
setValue={setValue}
setError={setError}
constraintValues={constraint?.values || []}
constraintValue={constraint?.value || ''}
input={input}
error={error}
contextDefinition={contextDefinition}
removeValue={removeValue}
/>
);
};

View File

@ -18,8 +18,6 @@ interface IConstraintAccordionListProps {
constraints: IConstraint[];
setConstraints?: React.Dispatch<React.SetStateAction<IConstraint[]>>;
showCreateButton?: boolean;
/* Add "constraints" title on the top - default `true` */
showLabel?: boolean;
}
export const constraintAccordionListId = 'constraintAccordionListId';