From d6afe69a0f0b96de2aa2ea8dd89c5cd760559a95 Mon Sep 17 00:00:00 2001
From: Fredrik Strand Oseberg
Date: Wed, 10 Jan 2024 11:48:15 +0100
Subject: [PATCH] Fix/constraint accordion autosave (#5825)
This PR adds autosave to the constraint accordion which means that when
you add values to it, it will automatically save the constraint locally.
If you unmount the constraint component without any valid values, it
will remove the constraint from the list.
---
.../ConstraintAccordionEdit.tsx | 278 ++++++++++++++++++
.../ConstraintAccordionEditBody.tsx | 84 ++++++
.../ConstraintFormHeader.tsx | 15 +
.../DateSingleValue/DateSingleValue.test.tsx | 18 ++
.../DateSingleValue/DateSingleValue.tsx | 81 +++++
.../DateSingleValue.test.tsx.snap | 8 +
.../FreeTextInput/FreeTextInput.tsx | 169 +++++++++++
.../LegalValueLabel/LegalValueLabel.styles.ts | 17 ++
.../LegalValueLabel/LegalValueLabel.tsx | 39 +++
.../ResolveInput/ResolveInput.tsx | 187 ++++++++++++
.../RestrictiveLegalValues.test.tsx | 52 ++++
.../RestrictiveLegalValues.tsx | 161 ++++++++++
.../SingleLegalValue.test.tsx | 27 ++
.../SingleLegalValue/SingleLegalValue.tsx | 112 +++++++
.../SingleValue/SingleValue.tsx | 52 ++++
.../constraintValidators.test.ts | 110 +++++++
.../constraintValidators.ts | 55 ++++
.../useConstraintInput/useConstraintInput.tsx | 158 ++++++++++
.../ConstraintAccordionEditHeader.tsx | 227 ++++++++++++++
.../ConstraintAccordionEditHeader/helpers.ts | 82 ++++++
.../CaseSensitiveButton.tsx | 52 ++++
.../InvertedOperatorButton.tsx | 47 +++
.../StyledToggleButton/StyledToggleButton.tsx | 39 +++
.../ConstraintAccordionHeaderActions.tsx | 73 +++++
.../ConstraintAccordionView.tsx | 120 ++++++++
.../ConstraintAccordionViewBody.tsx | 34 +++
.../MultipleValues/MultipleValues.tsx | 51 ++++
.../SingleValue/SingleValue.tsx | 44 +++
.../ConstraintAccordionViewHeader.tsx | 64 ++++
.../ConstraintAccordionViewHeaderInfo.tsx | 105 +++++++
...raintAccordionViewHeaderMultipleValues.tsx | 101 +++++++
...nstraintAccordionViewHeaderSingleValue.tsx | 48 +++
.../ConstraintViewHeaderOperator.tsx | 72 +++++
.../StyledIconWrapper.tsx | 37 +++
.../NewConstraintAccordion/ConstraintIcon.tsx | 35 +++
.../ConstraintOperator/ConstraintOperator.tsx | 54 ++++
.../formatOperatorDescription.ts | 23 ++
.../ConstraintOperatorSelect.tsx | 148 ++++++++++
.../ConstraintValueSearch.tsx | 50 ++++
.../NewConstraintAccordion.tsx | 52 ++++
.../NewConstraintAccordionList.tsx | 264 +++++++++++++++++
.../createEmptyConstraint.ts | 21 ++
...FeatureStrategyConstraintAccordionList.tsx | 4 +-
.../FeatureStrategyConstraints.tsx | 27 +-
.../NewFeatureStrategyCreate.test.tsx | 122 +++++---
45 files changed, 3576 insertions(+), 43 deletions(-)
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.test.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.test.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderMultipleValues.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderSingleValue.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintIcon.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordion.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx
create mode 100644 frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/createEmptyConstraint.ts
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx
new file mode 100644
index 000000000000..088489c2180d
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx
@@ -0,0 +1,278 @@
+import { useCallback, useEffect, useState } from 'react';
+import { IConstraint } from 'interfaces/strategy';
+import { ConstraintAccordionEditBody } from './ConstraintAccordionEditBody/ConstraintAccordionEditBody';
+import { ConstraintAccordionEditHeader } from './ConstraintAccordionEditHeader/ConstraintAccordionEditHeader';
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ styled,
+} from '@mui/material';
+import { cleanConstraint } from 'utils/cleanConstraint';
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { IUnleashContextDefinition } from 'interfaces/context';
+import { useConstraintInput } from './ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
+import { Operator } from 'constants/operators';
+import { ResolveInput } from './ConstraintAccordionEditBody/ResolveInput/ResolveInput';
+
+interface IConstraintAccordionEditProps {
+ constraint: IConstraint;
+ onCancel: () => void;
+ onSave: (constraint: IConstraint) => void;
+ compact: boolean;
+ 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,
+ }
+ );
+};
+
+const StyledForm = styled('div')({ padding: 0, margin: 0, width: '100%' });
+
+const StyledAccordion = styled(Accordion)(({ theme }) => ({
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadiusMedium,
+ backgroundColor: theme.palette.background.elevation1,
+ boxShadow: 'none',
+ margin: 0,
+ '& .expanded': {
+ '&:before': {
+ opacity: '0 !important',
+ },
+ },
+}));
+
+const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
+ border: 'none',
+ padding: theme.spacing(0.5, 3),
+ '&:hover .valuesExpandLabel': {
+ textDecoration: 'underline',
+ },
+}));
+
+const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
+ borderTop: `1px dashed ${theme.palette.divider}`,
+ display: 'flex',
+ flexDirection: 'column',
+ padding: 0,
+}));
+
+export const ConstraintAccordionEdit = ({
+ constraint,
+ compact,
+ onCancel,
+ onSave,
+ onDelete,
+ onAutoSave,
+}: IConstraintAccordionEditProps) => {
+ const [localConstraint, setLocalConstraint] = useState(
+ cleanConstraint(constraint),
+ );
+
+ const { context } = useUnleashContext();
+ const [contextDefinition, setContextDefinition] = useState(
+ resolveContextDefinition(context, localConstraint.contextName),
+ );
+ const { validateConstraint } = useFeatureApi();
+ const [expanded, setExpanded] = useState(false);
+ const [action, setAction] = useState('');
+
+ useEffect(() => {
+ // Setting expanded to true on mount will cause the accordion
+ // animation to take effect and transition the expanded accordion in
+ setExpanded(true);
+ }, []);
+
+ useEffect(() => {
+ if (onAutoSave) {
+ onAutoSave(localConstraint);
+ }
+ }, [JSON.stringify(localConstraint)]);
+
+ useEffect(() => {
+ setContextDefinition(
+ resolveContextDefinition(context, localConstraint.contextName),
+ );
+ }, [localConstraint.contextName, context]);
+
+ const setContextName = useCallback((contextName: string) => {
+ setLocalConstraint((prev) => ({
+ ...prev,
+ contextName,
+ values: [],
+ value: '',
+ }));
+ }, []);
+
+ const setOperator = useCallback((operator: Operator) => {
+ setLocalConstraint((prev) => ({
+ ...prev,
+ operator,
+ values: [],
+ value: '',
+ }));
+ }, []);
+
+ const setValues = useCallback((values: string[]) => {
+ setLocalConstraint((prev) => {
+ const localConstraint = { ...prev, values };
+
+ return localConstraint;
+ });
+ }, []);
+
+ const setValue = useCallback((value: string) => {
+ setLocalConstraint((prev) => ({ ...prev, value }));
+ }, []);
+
+ const setInvertedOperator = () => {
+ setLocalConstraint((prev) => ({ ...prev, inverted: !prev.inverted }));
+ };
+
+ const setCaseInsensitive = useCallback(() => {
+ setLocalConstraint((prev) => ({
+ ...prev,
+ caseInsensitive: !prev.caseInsensitive,
+ }));
+ }, []);
+
+ const removeValue = useCallback(
+ (index: number) => {
+ const valueCopy = [...localConstraint.values!];
+ valueCopy.splice(index, 1);
+
+ setValues(valueCopy);
+ },
+ [localConstraint, setValues],
+ );
+
+ const triggerTransition = () => {
+ setExpanded(false);
+ };
+
+ const validateConstraintValues = () => {
+ const hasValues =
+ Array.isArray(localConstraint.values) &&
+ Boolean(localConstraint.values.length > 0);
+ const hasValue = Boolean(localConstraint.value);
+
+ if (hasValues || hasValue) {
+ setError('');
+ return true;
+ }
+ setError('You must provide a value for the constraint');
+ return false;
+ };
+
+ const onSubmit = async () => {
+ const hasValues = validateConstraintValues();
+ if (!hasValues) return;
+ const [typeValidatorResult, err] = validator();
+
+ if (!typeValidatorResult) {
+ setError(err);
+ }
+
+ if (typeValidatorResult) {
+ try {
+ await validateConstraint(localConstraint);
+ setError('');
+ setAction(SAVE);
+ triggerTransition();
+ return;
+ } catch (error: unknown) {
+ setError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const { input, validator, setError, error } = useConstraintInput({
+ contextDefinition,
+ localConstraint,
+ });
+
+ useEffect(() => {
+ setError('');
+ setLocalConstraint((localConstraint) =>
+ cleanConstraint(localConstraint),
+ );
+ }, [localConstraint.operator, localConstraint.contextName, setError]);
+
+ return (
+
+ {
+ if (action === CANCEL) {
+ setAction('');
+ onCancel();
+ } else if (action === SAVE) {
+ setAction('');
+ onSave(localConstraint);
+ }
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx
new file mode 100644
index 000000000000..25eb0b9daff0
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx
@@ -0,0 +1,84 @@
+import { Button, styled } from '@mui/material';
+import { IConstraint } from 'interfaces/strategy';
+import { CANCEL } from '../ConstraintAccordionEdit';
+
+import React from 'react';
+import { newOperators } from 'constants/operators';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { oneOf } from 'utils/oneOf';
+import { OperatorUpgradeAlert } from 'component/common/OperatorUpgradeAlert/OperatorUpgradeAlert';
+
+interface IConstraintAccordionBody {
+ localConstraint: IConstraint;
+ setValues: (values: string[]) => void;
+ triggerTransition: () => void;
+ setValue: (value: string) => void;
+ setAction: React.Dispatch>;
+ onSubmit: () => void;
+}
+
+const StyledInputContainer = styled('div')(({ theme }) => ({
+ padding: theme.spacing(2),
+}));
+
+const StyledButtonContainer = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ marginTop: theme.spacing(2),
+ borderTop: `1px solid ${theme.palette.divider}`,
+ width: '100%',
+ padding: theme.spacing(2),
+}));
+
+const StyledInputButtonContainer = styled('div')({
+ marginLeft: 'auto',
+});
+
+const StyledLeftButton = styled(Button)(({ theme }) => ({
+ marginRight: theme.spacing(1),
+ minWidth: '125px',
+}));
+
+const StyledRightButton = styled(Button)(({ theme }) => ({
+ marginLeft: theme.spacing(1),
+ minWidth: '125px',
+}));
+
+export const ConstraintAccordionEditBody: React.FC =
+ ({ localConstraint, children, triggerTransition, setAction, onSubmit }) => {
+ return (
+ <>
+
+ }
+ />
+ {children}
+
+
+
+
+ Done
+
+ {
+ setAction(CANCEL);
+ triggerTransition();
+ }}
+ >
+ Cancel
+
+
+
+ >
+ );
+ };
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx
new file mode 100644
index 000000000000..9034ac8edced
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { styled } from '@mui/material';
+
+const StyledHeader = styled('h3')(({ theme }) => ({
+ fontSize: theme.fontSizes.bodySize,
+ fontWeight: theme.typography.fontWeightRegular,
+ marginTop: theme.spacing(2),
+ marginBottom: theme.spacing(0.5),
+}));
+
+export const ConstraintFormHeader: React.FC<
+ React.HTMLAttributes
+> = ({ children, ...rest }) => {
+ return {children};
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx
new file mode 100644
index 000000000000..732b181a1fbe
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx
@@ -0,0 +1,18 @@
+import { parseDateValue } from 'component/common/util';
+
+test(`Date component is able to parse midnight when it's 00`, () => {
+ const f = parseDateValue('2022-03-15T12:27');
+ const midnight = parseDateValue('2022-03-15T00:27');
+ expect(f).toEqual('2022-03-15T12:27');
+ expect(midnight).toEqual('2022-03-15T00:27');
+});
+
+test(`Date component - snapshot matching`, () => {
+ const midnight = '2022-03-15T00:00';
+ const midday = '2022-03-15T12:00';
+ const obj = {
+ midnight: parseDateValue(midnight),
+ midday: parseDateValue(midday),
+ };
+ expect(obj).toMatchSnapshot();
+});
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx
new file mode 100644
index 000000000000..b70f621e738a
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx
@@ -0,0 +1,81 @@
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import Input from 'component/common/Input/Input';
+import { parseDateValue, parseValidDate } from 'component/common/util';
+
+import { useMemo, useState } from 'react';
+import { styled } from '@mui/material';
+import TimezoneCountries from 'countries-and-timezones';
+
+interface IDateSingleValueProps {
+ setValue: (value: string) => void;
+ value?: string;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+const StyledWrapper = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'row',
+ marginBottom: theme.spacing(1),
+ alignItems: 'center',
+ gap: theme.spacing(1),
+}));
+
+export const DateSingleValue = ({
+ setValue,
+ value,
+ error,
+ setError,
+}: IDateSingleValueProps) => {
+ const timezones = Object.values(
+ TimezoneCountries.getAllTimezones({ deprecated: false }),
+ ).map((timezone) => ({
+ key: timezone.name,
+ label: `${timezone.name}`,
+ utcOffset: timezone.utcOffsetStr,
+ }));
+ const { timeZone: localTimezoneName } =
+ Intl.DateTimeFormat().resolvedOptions();
+ const [pickedDate, setPickedDate] = useState(value || '');
+
+ const timezoneText = useMemo(() => {
+ const localTimezone = timezones.find(
+ (t) => t.key === localTimezoneName,
+ );
+ if (localTimezone != null) {
+ return `${localTimezone.key} (UTC ${localTimezone.utcOffset})`;
+ } else {
+ return 'The time shown is in your local time zone according to your browser.';
+ }
+ }, [timezones, localTimezoneName]);
+
+ if (!value) return null;
+
+ return (
+ <>
+ Select a date
+
+ {
+ setError('');
+ const parsedDate = parseValidDate(e.target.value);
+ const dateString = parsedDate?.toISOString();
+ dateString && setPickedDate(dateString);
+ dateString && setValue(dateString);
+ }}
+ InputLabelProps={{
+ shrink: true,
+ }}
+ error={Boolean(error)}
+ errorText={error}
+ required
+ />
+ {timezoneText}
+
+ >
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap
new file mode 100644
index 000000000000..a3e0f94081ed
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap
@@ -0,0 +1,8 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Date component - snapshot matching 1`] = `
+{
+ "midday": "2022-03-15T12:00",
+ "midnight": "2022-03-15T00:00",
+}
+`;
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx
new file mode 100644
index 000000000000..5cbe4847fb92
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx
@@ -0,0 +1,169 @@
+import { Button, Chip } from '@mui/material';
+import { makeStyles } from 'tss-react/mui';
+import Input from 'component/common/Input/Input';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import React, { useState } from 'react';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import { parseParameterStrings } from 'utils/parseParameter';
+
+interface IFreeTextInputProps {
+ values: string[];
+ removeValue: (index: number) => void;
+ setValues: (values: string[]) => void;
+ beforeValues?: JSX.Element;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+const useStyles = makeStyles()((theme) => ({
+ valueChip: {
+ margin: '0 0.5rem 0.5rem 0',
+ },
+ chipValue: {
+ whiteSpace: 'pre',
+ },
+ inputContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(700)]: {
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+ },
+ inputInnerContainer: {
+ minWidth: '300px',
+ [theme.breakpoints.down(700)]: {
+ minWidth: '100%',
+ },
+ },
+ input: {
+ width: '100%',
+ margin: '1rem 0',
+ },
+ button: {
+ marginLeft: '1rem',
+ [theme.breakpoints.down(700)]: {
+ marginLeft: 0,
+ marginBottom: '0.5rem',
+ },
+ },
+ valuesContainer: { marginTop: '1rem' },
+}));
+
+const ENTER = 'Enter';
+
+export const FreeTextInput = ({
+ values,
+ removeValue,
+ setValues,
+ error,
+ setError,
+}: IFreeTextInputProps) => {
+ const [inputValues, setInputValues] = useState('');
+ const { classes: styles } = useStyles();
+
+ const onKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === ENTER) {
+ event.preventDefault();
+ addValues();
+ }
+ };
+
+ const addValues = () => {
+ const newValues = uniqueValues([
+ ...values,
+ ...parseParameterStrings(inputValues),
+ ]);
+
+ if (newValues.length === 0) {
+ setError('values cannot be empty');
+ } else if (newValues.some((v) => v.length > 100)) {
+ setError('values cannot be longer than 100 characters');
+ } else {
+ setError('');
+ setInputValues('');
+ setValues(newValues);
+ }
+ };
+
+ return (
+
+
+ Set values (maximum 100 char length per value)
+
+
+
+ {
+ setError('');
+ }}
+ onChange={(e) => setInputValues(e.target.value)}
+ placeholder='value1, value2, value3...'
+ className={styles.input}
+ error={Boolean(error)}
+ errorText={error}
+ data-testid='CONSTRAINT_VALUES_INPUT'
+ />
+
+
+
+
+
+
+
+ );
+};
+
+interface IConstraintValueChipsProps {
+ values: string[];
+ removeValue: (index: number) => void;
+}
+
+const ConstraintValueChips = ({
+ values,
+ removeValue,
+}: IConstraintValueChipsProps) => {
+ const { classes: styles } = useStyles();
+ return (
+ <>
+ {values.map((value, index) => {
+ // Key is not ideal, but we don't have anything guaranteed to
+ // be unique here.
+ return (
+
+ }
+ key={`${value}-${index}`}
+ onDelete={() => removeValue(index)}
+ className={styles.valueChip}
+ />
+ );
+ })}
+ >
+ );
+};
+
+const uniqueValues = (values: T[]): T[] => {
+ return Array.from(new Set(values));
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts
new file mode 100644
index 000000000000..7586394e18a3
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts
@@ -0,0 +1,17 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()((theme) => ({
+ container: {
+ display: 'inline-block',
+ wordBreak: 'break-word',
+ },
+ value: {
+ lineHeight: 1.33,
+ fontSize: theme.fontSizes.smallBody,
+ },
+ description: {
+ lineHeight: 1.33,
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.action.active,
+ },
+}));
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx
new file mode 100644
index 000000000000..ac9772c68c2d
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx
@@ -0,0 +1,39 @@
+import { ILegalValue } from 'interfaces/context';
+import { useStyles } from './LegalValueLabel.styles';
+import React from 'react';
+import { FormControlLabel } from '@mui/material';
+
+interface ILegalValueTextProps {
+ legal: ILegalValue;
+ control: React.ReactElement;
+}
+
+export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+ {legal.value}
+
+ {legal.description}
+
+ >
+ }
+ />
+
+ );
+};
+
+export const filterLegalValues = (
+ legalValues: ILegalValue[],
+ filter: string,
+): ILegalValue[] => {
+ return legalValues.filter((legalValue) => {
+ return legalValue.value.includes(filter);
+ });
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx
new file mode 100644
index 000000000000..14b86c6b23f5
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx
@@ -0,0 +1,187 @@
+import { ILegalValue, IUnleashContextDefinition } from 'interfaces/context';
+import { IConstraint } from 'interfaces/strategy';
+import { DateSingleValue } from '../DateSingleValue/DateSingleValue';
+import { FreeTextInput } from '../FreeTextInput/FreeTextInput';
+import { RestrictiveLegalValues } from '../RestrictiveLegalValues/RestrictiveLegalValues';
+import { SingleLegalValue } from '../SingleLegalValue/SingleLegalValue';
+import { SingleValue } from '../SingleValue/SingleValue';
+import {
+ IN_OPERATORS_LEGAL_VALUES,
+ STRING_OPERATORS_FREETEXT,
+ STRING_OPERATORS_LEGAL_VALUES,
+ SEMVER_OPERATORS_SINGLE_VALUE,
+ NUM_OPERATORS_LEGAL_VALUES,
+ NUM_OPERATORS_SINGLE_VALUE,
+ SEMVER_OPERATORS_LEGAL_VALUES,
+ DATE_OPERATORS_SINGLE_VALUE,
+ IN_OPERATORS_FREETEXT,
+ Input,
+} from '../useConstraintInput/useConstraintInput';
+import React from 'react';
+
+interface IResolveInputProps {
+ contextDefinition: IUnleashContextDefinition;
+ localConstraint: IConstraint;
+ constraintValues: string[];
+ constraintValue: string;
+ setValue: (value: string) => void;
+ setValues: (values: string[]) => void;
+ setError: React.Dispatch>;
+ removeValue: (index: number) => void;
+ input: Input;
+ error: string;
+}
+
+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,
+ };
+};
+
+export const ResolveInput = ({
+ input,
+ contextDefinition,
+ constraintValues,
+ constraintValue,
+ localConstraint,
+ setValue,
+ setValues,
+ setError,
+ removeValue,
+ error,
+}: IResolveInputProps) => {
+ const resolveInput = () => {
+ switch (input) {
+ case IN_OPERATORS_LEGAL_VALUES:
+ case STRING_OPERATORS_LEGAL_VALUES:
+ return (
+ <>
+
+ >
+ );
+ case NUM_OPERATORS_LEGAL_VALUES:
+ return (
+ <>
+ Number(legalValue.value),
+ ) || []
+ }
+ error={error}
+ setError={setError}
+ />
+ >
+ );
+ case SEMVER_OPERATORS_LEGAL_VALUES:
+ return (
+ <>
+
+ >
+ );
+ case DATE_OPERATORS_SINGLE_VALUE:
+ return (
+
+ );
+ case IN_OPERATORS_FREETEXT:
+ return (
+
+ );
+ case STRING_OPERATORS_FREETEXT:
+ return (
+ <>
+
+ >
+ );
+ case NUM_OPERATORS_SINGLE_VALUE:
+ return (
+
+ );
+ case SEMVER_OPERATORS_SINGLE_VALUE:
+ return (
+
+ );
+ }
+ };
+
+ return <>{resolveInput()}>;
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.test.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.test.tsx
new file mode 100644
index 000000000000..a9cc6c87240b
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.test.tsx
@@ -0,0 +1,52 @@
+import { render } from 'utils/testRenderer';
+import { screen } from '@testing-library/react';
+import { RestrictiveLegalValues } from './RestrictiveLegalValues';
+
+test('should show alert when you have illegal legal values', async () => {
+ const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }];
+ const fixedValues = ['value1', 'value2'];
+ const localValues = ['value1', 'value2'];
+ const deletedLegalValues = [{ value: 'value1' }];
+
+ render(
+ {}}
+ error={''}
+ setError={() => {}}
+ />,
+ );
+
+ await screen.findByText(
+ '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:',
+ );
+});
+
+test('Should remove illegal legal values from internal value state when mounting', () => {
+ const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }];
+ const fixedValues = ['value1', 'value2'];
+ let localValues = ['value1', 'value2'];
+ const deletedLegalValues = [{ value: 'value1' }];
+
+ const setValues = (values: string[]) => {
+ localValues = values;
+ };
+
+ render(
+ {}}
+ />,
+ );
+
+ expect(localValues).toEqual(['value2']);
+});
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx
new file mode 100644
index 000000000000..29be7359801c
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useState } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Alert, Checkbox } from '@mui/material';
+import { useThemeStyles } from 'themes/themeStyles';
+import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import { ILegalValue } from 'interfaces/context';
+import {
+ filterLegalValues,
+ LegalValueLabel,
+} from '../LegalValueLabel/LegalValueLabel';
+
+interface IRestrictiveLegalValuesProps {
+ data: {
+ legalValues: ILegalValue[];
+ deletedLegalValues: ILegalValue[];
+ };
+ constraintValues: string[];
+ values: string[];
+ setValues: (values: string[]) => void;
+ beforeValues?: JSX.Element;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+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) => deletedValuesSet.has(value));
+};
+
+export const RestrictiveLegalValues = ({
+ data,
+ values,
+ setValues,
+ error,
+ setError,
+ constraintValues,
+}: IRestrictiveLegalValuesProps) => {
+ 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 { classes: styles } = useThemeStyles();
+
+ 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) => {
+ setError('');
+
+ if (valuesMap[legalValue]) {
+ const index = values.findIndex((value) => value === legalValue);
+ const newValues = [...values];
+ newValues.splice(index, 1);
+ setValues(newValues);
+ return;
+ }
+
+ setValues([...cleanDeletedLegalValues(values), legalValue]);
+ };
+
+ return (
+ <>
+ 0)}
+ show={
+
+ 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:
+
+ {illegalValues?.map((value) => (
+ - {value}
+ ))}
+
+
+ }
+ />
+
+
+ Select values from a predefined set
+
+ 100}
+ show={
+
+ }
+ />
+ {filteredValues.map((match) => (
+ onChange(match.value)}
+ name={match.value}
+ color='primary'
+ disabled={deletedLegalValues
+ .map(({ value }) => value)
+ .includes(match.value)}
+ />
+ }
+ />
+ ))}
+
+ {error}
}
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.test.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.test.tsx
new file mode 100644
index 000000000000..402f73664a26
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.test.tsx
@@ -0,0 +1,27 @@
+import { render } from 'utils/testRenderer';
+import { screen } from '@testing-library/react';
+import { SingleLegalValue } from './SingleLegalValue';
+
+test('should show alert when you have illegal legal values', async () => {
+ const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }];
+ const fixedValue = 'value1';
+ const localValue = 'value1';
+ const deletedLegalValues = [{ value: 'value1' }];
+
+ render(
+ {}}
+ type='number'
+ legalValues={contextDefinitionValues}
+ error={''}
+ setError={() => {}}
+ />,
+ );
+
+ await screen.findByText(
+ 'This constraint is using legal values that have been deleted as a valid option. Please select a new value from the remaining predefined legal values. The constraint will be updated with the new value when you save the strategy.',
+ );
+});
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx
new file mode 100644
index 000000000000..7c75a874d87d
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx
@@ -0,0 +1,112 @@
+import React, { useState } from 'react';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import { FormControl, RadioGroup, Radio, Alert } from '@mui/material';
+import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useThemeStyles } from 'themes/themeStyles';
+import { ILegalValue } from 'interfaces/context';
+import {
+ LegalValueLabel,
+ filterLegalValues,
+} from '../LegalValueLabel/LegalValueLabel';
+import { getIllegalValues } from '../RestrictiveLegalValues/RestrictiveLegalValues';
+
+interface ISingleLegalValueProps {
+ setValue: (value: string) => void;
+ value?: string;
+ type: string;
+ legalValues: ILegalValue[];
+ error: string;
+ setError: React.Dispatch>;
+ data: {
+ legalValues: ILegalValue[];
+ deletedLegalValues: ILegalValue[];
+ };
+ constraintValue: string;
+}
+
+export const SingleLegalValue = ({
+ setValue,
+ value,
+ type,
+ legalValues,
+ error,
+ setError,
+ data,
+ constraintValue,
+}: ISingleLegalValueProps) => {
+ const [filter, setFilter] = useState('');
+ const { classes: styles } = useThemeStyles();
+ const filteredValues = filterLegalValues(legalValues, filter);
+
+ const { deletedLegalValues } = data;
+
+ const illegalValues = getIllegalValues(
+ [constraintValue],
+ deletedLegalValues,
+ );
+
+ return (
+ <>
+ 0)}
+ show={
+ ({ marginTop: theme.spacing(1) })}
+ >
+ {' '}
+ This constraint is using legal values that have been
+ deleted as a valid option. Please select a new value
+ from the remaining predefined legal values. The
+ constraint will be updated with the new value when you
+ save the strategy.
+
+ }
+ />
+
+ Add a single {type.toLowerCase()} value
+
+ 100)}
+ show={
+
+ }
+ />
+
+ {
+ setError('');
+ setValue(e.target.value);
+ }}
+ >
+ {filteredValues.map((match) => (
+ }
+ />
+ ))}
+
+
+ }
+ elseShow={
+ No valid legal values available for this operator.
+ }
+ />
+ {error}}
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx
new file mode 100644
index 000000000000..b644f53473cc
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx
@@ -0,0 +1,52 @@
+import Input from 'component/common/Input/Input';
+import { makeStyles } from 'tss-react/mui';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+
+interface ISingleValueProps {
+ setValue: (value: string) => void;
+ value?: string;
+ type: string;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+const useStyles = makeStyles()((theme) => ({
+ singleValueContainer: { maxWidth: '300px', marginTop: '-1rem' },
+ singleValueInput: {
+ width: '100%',
+ margin: '1rem 0',
+ },
+}));
+
+export const SingleValue = ({
+ setValue,
+ value,
+ type,
+ error,
+ setError,
+}: ISingleValueProps) => {
+ const { classes: styles } = useStyles();
+ return (
+ <>
+
+ Add a single {type.toLowerCase()} value
+
+
+ {
+ setError('');
+ setValue(e.target.value.trim());
+ }}
+ onFocus={() => setError('')}
+ placeholder={`Enter a single ${type} value`}
+ className={styles.singleValueInput}
+ error={Boolean(error)}
+ errorText={error}
+ />
+
+ >
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts
new file mode 100644
index 000000000000..0dcd5870f0ce
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts
@@ -0,0 +1,110 @@
+import {
+ numberValidatorGenerator,
+ semVerValidatorGenerator,
+ dateValidatorGenerator,
+ stringValidatorGenerator,
+} from './constraintValidators';
+
+test('numbervalidator should accept 0', () => {
+ const numValidator = numberValidatorGenerator(0);
+ const [result, err] = numValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('number validator should reject value that cannot be parsed to number', () => {
+ const numValidator = numberValidatorGenerator('testa31');
+ const [result, err] = numValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value must be a number');
+});
+
+test('number validator should reject NaN', () => {
+ const numValidator = numberValidatorGenerator(NaN);
+ const [result, err] = numValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value must be a number');
+});
+
+test('number validator should accept value that can be parsed to number', () => {
+ const numValidator = numberValidatorGenerator('31');
+ const [result, err] = numValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('number validator should accept float values', () => {
+ const numValidator = numberValidatorGenerator('31.12');
+ const [result, err] = numValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('semver validator should reject prefixed values', () => {
+ const semVerValidator = semVerValidatorGenerator('v1.4.2');
+ const [result, err] = semVerValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value is not a valid semver. For example 1.2.4');
+});
+
+test('semver validator should reject partial semver values', () => {
+ const semVerValidator = semVerValidatorGenerator('4.2');
+ const [result, err] = semVerValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value is not a valid semver. For example 1.2.4');
+});
+
+test('semver validator should accept semver complient values', () => {
+ const semVerValidator = semVerValidatorGenerator('1.4.2');
+ const [result, err] = semVerValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('date validator should reject invalid date', () => {
+ const dateValidator = dateValidatorGenerator('114mydate2005');
+ const [result, err] = dateValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value must be a valid date matching RFC3339');
+});
+
+test('date validator should accept valid date', () => {
+ const dateValidator = dateValidatorGenerator('2022-03-03T10:15:23.262Z');
+ const [result, err] = dateValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('string validator should accept a list of strings', () => {
+ const stringValidator = stringValidatorGenerator(['1234', '4121']);
+ const [result, err] = stringValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('string validator should reject values that are not arrays', () => {
+ const stringValidator = stringValidatorGenerator(4);
+ const [result, err] = stringValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Values must be a list of strings');
+});
+
+test('string validator should reject arrays that are not arrays of strings', () => {
+ const stringValidator = stringValidatorGenerator(['test', NaN, 5]);
+ const [result, err] = stringValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Values must be a list of strings');
+});
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts
new file mode 100644
index 000000000000..75d528441c2e
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts
@@ -0,0 +1,55 @@
+import { isValid, parseISO } from 'date-fns';
+import semver from 'semver';
+
+export type ConstraintValidatorOutput = [boolean, string];
+
+export const numberValidatorGenerator = (value: unknown) => {
+ return (): ConstraintValidatorOutput => {
+ const converted = Number(value);
+
+ if (typeof converted !== 'number' || Number.isNaN(converted)) {
+ return [false, 'Value must be a number'];
+ }
+
+ return [true, ''];
+ };
+};
+
+export const stringValidatorGenerator = (values: unknown) => {
+ return (): ConstraintValidatorOutput => {
+ const error: ConstraintValidatorOutput = [
+ false,
+ 'Values must be a list of strings',
+ ];
+ if (!Array.isArray(values)) {
+ return error;
+ }
+
+ if (!values.every((value) => typeof value === 'string')) {
+ return error;
+ }
+
+ return [true, ''];
+ };
+};
+
+export const semVerValidatorGenerator = (value: string) => {
+ return (): ConstraintValidatorOutput => {
+ const isCleanValue = semver.clean(value) === value;
+
+ if (!semver.valid(value) || !isCleanValue) {
+ return [false, 'Value is not a valid semver. For example 1.2.4'];
+ }
+
+ return [true, ''];
+ };
+};
+
+export const dateValidatorGenerator = (value: string) => {
+ return (): ConstraintValidatorOutput => {
+ if (!isValid(parseISO(value))) {
+ return [false, 'Value must be a valid date matching RFC3339'];
+ }
+ return [true, ''];
+ };
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx
new file mode 100644
index 000000000000..bd1c82279a59
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx
@@ -0,0 +1,158 @@
+import {
+ inOperators,
+ stringOperators,
+ numOperators,
+ semVerOperators,
+ dateOperators,
+} from 'constants/operators';
+import { IUnleashContextDefinition } from 'interfaces/context';
+import { IConstraint } from 'interfaces/strategy';
+import React, { useCallback, useEffect, useState } from 'react';
+import { oneOf } from 'utils/oneOf';
+
+import {
+ numberValidatorGenerator,
+ stringValidatorGenerator,
+ semVerValidatorGenerator,
+ dateValidatorGenerator,
+ ConstraintValidatorOutput,
+} from './constraintValidators';
+import { nonEmptyArray } from 'utils/nonEmptyArray';
+
+interface IUseConstraintInputProps {
+ contextDefinition: IUnleashContextDefinition;
+ localConstraint: IConstraint;
+}
+
+interface IUseConstraintOutput {
+ input: Input;
+ error: string;
+ validator: () => ConstraintValidatorOutput;
+ setError: React.Dispatch>;
+}
+
+export const IN_OPERATORS_LEGAL_VALUES = 'IN_OPERATORS_LEGAL_VALUES';
+export const STRING_OPERATORS_LEGAL_VALUES = 'STRING_OPERATORS_LEGAL_VALUES';
+export const NUM_OPERATORS_LEGAL_VALUES = 'NUM_OPERATORS_LEGAL_VALUES';
+export const SEMVER_OPERATORS_LEGAL_VALUES = 'SEMVER_OPERATORS_LEGAL_VALUES';
+export const DATE_OPERATORS_SINGLE_VALUE = 'DATE_OPERATORS_SINGLE_VALUE';
+export const IN_OPERATORS_FREETEXT = 'IN_OPERATORS_FREETEXT';
+export const STRING_OPERATORS_FREETEXT = 'STRING_OPERATORS_FREETEXT';
+export const NUM_OPERATORS_SINGLE_VALUE = 'NUM_OPERATORS_SINGLE_VALUE';
+export const SEMVER_OPERATORS_SINGLE_VALUE = 'SEMVER_OPERATORS_SINGLE_VALUE';
+
+export type Input =
+ | 'IN_OPERATORS_LEGAL_VALUES'
+ | 'STRING_OPERATORS_LEGAL_VALUES'
+ | 'NUM_OPERATORS_LEGAL_VALUES'
+ | 'SEMVER_OPERATORS_LEGAL_VALUES'
+ | 'DATE_OPERATORS_SINGLE_VALUE'
+ | 'IN_OPERATORS_FREETEXT'
+ | 'STRING_OPERATORS_FREETEXT'
+ | 'NUM_OPERATORS_SINGLE_VALUE'
+ | 'SEMVER_OPERATORS_SINGLE_VALUE';
+
+const NUMBER_VALIDATOR = 'NUMBER_VALIDATOR';
+const SEMVER_VALIDATOR = 'SEMVER_VALIDATOR';
+const STRING_ARRAY_VALIDATOR = 'STRING_ARRAY_VALIDATOR';
+const DATE_VALIDATOR = 'DATE_VALIDATOR';
+
+type Validator =
+ | 'NUMBER_VALIDATOR'
+ | 'SEMVER_VALIDATOR'
+ | 'STRING_ARRAY_VALIDATOR'
+ | 'DATE_VALIDATOR';
+
+export const useConstraintInput = ({
+ contextDefinition,
+ localConstraint,
+}: IUseConstraintInputProps): IUseConstraintOutput => {
+ const [input, setInput] = useState(IN_OPERATORS_LEGAL_VALUES);
+ const [validator, setValidator] = useState(
+ STRING_ARRAY_VALIDATOR,
+ );
+ const [error, setError] = useState('');
+
+ const resolveInputType = useCallback(() => {
+ if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(inOperators, localConstraint.operator)
+ ) {
+ setInput(IN_OPERATORS_LEGAL_VALUES);
+ } else if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(stringOperators, localConstraint.operator)
+ ) {
+ setInput(STRING_OPERATORS_LEGAL_VALUES);
+ } else if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(numOperators, localConstraint.operator)
+ ) {
+ setInput(NUM_OPERATORS_LEGAL_VALUES);
+ } else if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(semVerOperators, localConstraint.operator)
+ ) {
+ setInput(SEMVER_OPERATORS_LEGAL_VALUES);
+ } else if (oneOf(dateOperators, localConstraint.operator)) {
+ setInput(DATE_OPERATORS_SINGLE_VALUE);
+ } else if (oneOf(inOperators, localConstraint.operator)) {
+ setInput(IN_OPERATORS_FREETEXT);
+ } else if (oneOf(stringOperators, localConstraint.operator)) {
+ setInput(STRING_OPERATORS_FREETEXT);
+ } else if (oneOf(numOperators, localConstraint.operator)) {
+ setInput(NUM_OPERATORS_SINGLE_VALUE);
+ } else if (oneOf(semVerOperators, localConstraint.operator)) {
+ setInput(SEMVER_OPERATORS_SINGLE_VALUE);
+ }
+ }, [localConstraint, contextDefinition]);
+
+ const resolveValidator = () => {
+ switch (validator) {
+ case NUMBER_VALIDATOR:
+ return numberValidatorGenerator(localConstraint.value);
+ case STRING_ARRAY_VALIDATOR:
+ return stringValidatorGenerator(localConstraint.values || []);
+ case SEMVER_VALIDATOR:
+ return semVerValidatorGenerator(localConstraint.value || '');
+ case DATE_VALIDATOR:
+ return dateValidatorGenerator(localConstraint.value || '');
+ }
+ };
+
+ const resolveValidatorType = useCallback(
+ (operator: string) => {
+ if (oneOf(numOperators, operator)) {
+ setValidator(NUMBER_VALIDATOR);
+ }
+
+ if (oneOf([...stringOperators, ...inOperators], operator)) {
+ setValidator(STRING_ARRAY_VALIDATOR);
+ }
+
+ if (oneOf(semVerOperators, operator)) {
+ setValidator(SEMVER_VALIDATOR);
+ }
+
+ if (oneOf(dateOperators, operator)) {
+ setValidator(DATE_VALIDATOR);
+ }
+ },
+ [setValidator],
+ );
+
+ useEffect(() => {
+ resolveValidatorType(localConstraint.operator);
+ }, [
+ localConstraint.operator,
+ localConstraint.value,
+ localConstraint.values,
+ resolveValidatorType,
+ ]);
+
+ useEffect(() => {
+ resolveInputType();
+ }, [contextDefinition, localConstraint, resolveInputType]);
+
+ return { input, error, validator: resolveValidator(), setError };
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx
new file mode 100644
index 000000000000..c30e39a0ff8d
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx
@@ -0,0 +1,227 @@
+import { IConstraint } from 'interfaces/strategy';
+
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ dateOperators,
+ DATE_AFTER,
+ IN,
+ stringOperators,
+ inOperators,
+} from 'constants/operators';
+import { resolveText } from './helpers';
+import { oneOf } from 'utils/oneOf';
+import React, { useEffect, useState } from 'react';
+import { Operator } from 'constants/operators';
+import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect';
+import {
+ operatorsForContext,
+ CURRENT_TIME_CONTEXT_FIELD,
+} from 'utils/operatorsForContext';
+import { InvertedOperatorButton } from '../StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton';
+import { CaseSensitiveButton } from '../StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton';
+import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions';
+import { styled } from '@mui/material';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+
+interface IConstraintAccordionViewHeader {
+ localConstraint: IConstraint;
+ setContextName: (contextName: string) => void;
+ setOperator: (operator: Operator) => void;
+ setLocalConstraint: React.Dispatch>;
+ action: string;
+ compact: boolean;
+ onDelete?: () => void;
+ setInvertedOperator: () => void;
+ setCaseInsensitive: () => void;
+}
+
+const StyledHeaderContainer = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ [theme.breakpoints.down('sm')]: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ position: 'relative',
+ },
+}));
+const StyledSelectContainer = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(770)]: {
+ flexDirection: 'column',
+ },
+}));
+const StyledBottomSelect = styled('div')(({ theme }) => ({
+ [theme.breakpoints.down(770)]: {
+ marginTop: theme.spacing(2),
+ },
+ display: 'inline-flex',
+}));
+
+const StyledHeaderSelect = styled('div')(({ theme }) => ({
+ marginRight: theme.spacing(2),
+ width: '200px',
+ [theme.breakpoints.between(1101, 1365)]: {
+ width: '170px',
+ marginRight: theme.spacing(1),
+ },
+}));
+
+const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
+ marginRight: theme.spacing(2),
+ width: '200px',
+ [theme.breakpoints.between(1101, 1365)]: {
+ width: '170px',
+ marginRight: theme.spacing(1),
+ },
+}));
+
+const StyledHeaderText = styled('p')(({ theme }) => ({
+ maxWidth: '400px',
+ fontSize: theme.fontSizes.smallBody,
+ [theme.breakpoints.down('xl')]: {
+ display: 'none',
+ },
+}));
+
+export const ConstraintAccordionEditHeader = ({
+ compact,
+ localConstraint,
+ setLocalConstraint,
+ setContextName,
+ setOperator,
+ onDelete,
+ setInvertedOperator,
+ setCaseInsensitive,
+}: IConstraintAccordionViewHeader) => {
+ const { context } = useUnleashContext();
+ const { contextName, operator } = localConstraint;
+ const [showCaseSensitiveButton, setShowCaseSensitiveButton] =
+ useState(false);
+ const { uiConfig } = useUiConfig();
+
+ const caseInsensitiveInOperators = Boolean(
+ uiConfig.flags.caseInsensitiveInOperators,
+ );
+
+ /* We need a special case to handle the currenTime 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) ||
+ (oneOf(inOperators, operator) && caseInsensitiveInOperators)
+ ) {
+ setShowCaseSensitiveButton(true);
+ } else {
+ setShowCaseSensitiveButton(false);
+ }
+ }, [
+ contextName,
+ setOperator,
+ operator,
+ setLocalConstraint,
+ caseInsensitiveInOperators,
+ ]);
+
+ if (!context) {
+ return null;
+ }
+
+ const constraintNameOptions = context.map((context) => {
+ return { key: context.name, label: context.name };
+ });
+
+ const onOperatorChange = (operator: Operator) => {
+ if (
+ oneOf(stringOperators, operator) ||
+ (oneOf(inOperators, operator) && caseInsensitiveInOperators)
+ ) {
+ setShowCaseSensitiveButton(true);
+ } else {
+ setShowCaseSensitiveButton(false);
+ }
+
+ if (oneOf(dateOperators, operator)) {
+ setLocalConstraint((prev) => ({
+ ...prev,
+ operator: operator,
+ value: new Date().toISOString(),
+ }));
+ } else {
+ setOperator(operator);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {resolveText(operator, contextName)}
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts
new file mode 100644
index 000000000000..f0d2b1338456
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts
@@ -0,0 +1,82 @@
+import {
+ DATE_BEFORE,
+ DATE_AFTER,
+ IN,
+ NOT_IN,
+ NUM_EQ,
+ NUM_GT,
+ NUM_GTE,
+ NUM_LT,
+ NUM_LTE,
+ STR_CONTAINS,
+ STR_ENDS_WITH,
+ STR_STARTS_WITH,
+ SEMVER_EQ,
+ SEMVER_GT,
+ SEMVER_LT,
+ Operator,
+} from 'constants/operators';
+
+export const resolveText = (operator: Operator, contextName: string) => {
+ const base = `To satisfy this constraint, values passed into the SDK as ${contextName} must`;
+
+ if (operator === IN) {
+ return `${base} include:`;
+ }
+
+ if (operator === NOT_IN) {
+ return `${base} not include:`;
+ }
+
+ if (operator === STR_ENDS_WITH) {
+ return `${base} end with:`;
+ }
+
+ if (operator === STR_STARTS_WITH) {
+ return `${base} start with:`;
+ }
+
+ if (operator === STR_CONTAINS) {
+ return `${base} contain:`;
+ }
+
+ if (operator === NUM_EQ) {
+ return `${base} match:`;
+ }
+
+ if (operator === NUM_GT) {
+ return `${base} be greater than:`;
+ }
+
+ if (operator === NUM_GTE) {
+ return `${base} be greater than or equal to:`;
+ }
+
+ if (operator === NUM_LT) {
+ return `${base} be less than:`;
+ }
+
+ if (operator === NUM_LTE) {
+ return `${base} be less than or equal to:`;
+ }
+
+ if (operator === DATE_AFTER) {
+ return `${base} be after the following date`;
+ }
+
+ if (operator === DATE_BEFORE) {
+ return `${base} be before the following date:`;
+ }
+
+ if (operator === SEMVER_EQ) {
+ return `${base} match the following version:`;
+ }
+
+ if (operator === SEMVER_GT) {
+ return `${base} be greater than the following version:`;
+ }
+
+ if (operator === SEMVER_LT) {
+ return `${base} be less than the following version:`;
+ }
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx
new file mode 100644
index 000000000000..fb74322bbc5f
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx
@@ -0,0 +1,52 @@
+import { Tooltip, Box } from '@mui/material';
+import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg';
+import { ReactComponent as CaseSensitiveOff } from 'assets/icons/24_Text format off.svg';
+import React from 'react';
+import {
+ StyledToggleButtonOff,
+ StyledToggleButtonOn,
+} from '../StyledToggleButton';
+import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
+import { IConstraint } from 'interfaces/strategy';
+
+interface CaseSensitiveButtonProps {
+ localConstraint: IConstraint;
+ setCaseInsensitive: () => void;
+}
+
+export const CaseSensitiveButton = ({
+ localConstraint,
+ setCaseInsensitive,
+}: CaseSensitiveButtonProps) => (
+
+
+
+
+
+ }
+ elseShow={
+
+
+
+ }
+ />
+
+
+);
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx
new file mode 100644
index 000000000000..31f523fc86f8
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx
@@ -0,0 +1,47 @@
+import { Box, Tooltip } from '@mui/material';
+import { ReactComponent as NegatedOnIcon } from 'assets/icons/not_operator_selected.svg';
+import { ReactComponent as NegatedOffIcon } from 'assets/icons/not_operator_unselected.svg';
+import { IConstraint } from 'interfaces/strategy';
+import {
+ StyledToggleButtonOff,
+ StyledToggleButtonOn,
+} from '../StyledToggleButton';
+import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
+
+interface InvertedOperatorButtonProps {
+ localConstraint: IConstraint;
+ setInvertedOperator: () => void;
+}
+
+export const InvertedOperatorButton = ({
+ localConstraint,
+ setInvertedOperator,
+}: InvertedOperatorButtonProps) => (
+
+
+
+
+
+ }
+ elseShow={
+
+
+
+ }
+ />
+
+
+);
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx
new file mode 100644
index 000000000000..0a0003eaa396
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx
@@ -0,0 +1,39 @@
+import { styled } from '@mui/system';
+import { IconButton } from '@mui/material';
+
+export const StyledToggleButtonOff = styled(IconButton)(({ theme }) => ({
+ width: '28px',
+ minWidth: '28px',
+ maxWidth: '28px',
+ height: 'auto',
+ backgroundColor: theme.palette.background.paper,
+ borderRadius: theme.shape.borderRadius,
+ padding: '0 1px 0',
+ marginRight: '1rem',
+ '&:hover': {
+ background: theme.palette.background.application,
+ },
+ [theme.breakpoints.between(1101, 1365)]: {
+ marginRight: '0.5rem',
+ alignItems: 'center',
+ },
+}));
+
+export const StyledToggleButtonOn = styled(IconButton)(({ theme }) => ({
+ width: '28px',
+ minWidth: '28px',
+ maxWidth: '28px',
+ color: theme.palette.primary.contrastText,
+ backgroundColor: theme.palette.primary.main,
+ borderRadius: theme.shape.borderRadius,
+ marginRight: '1rem',
+ padding: '0 1px 0',
+ '&:hover': {
+ color: theme.palette.primary.contrastText,
+ backgroundColor: theme.palette.primary.main,
+ },
+ [theme.breakpoints.between(1101, 1365)]: {
+ marginRight: '0.5rem',
+ alignItems: 'center',
+ },
+}));
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx
new file mode 100644
index 000000000000..324a7ee9da6c
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { IconButton, styled, Tooltip } from '@mui/material';
+import { Delete, Edit } from '@mui/icons-material';
+import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender';
+
+interface ConstraintAccordionHeaderActionsProps {
+ onDelete?: () => void;
+ onEdit?: () => void;
+ disableEdit?: boolean;
+ disableDelete?: boolean;
+}
+
+const StyledHeaderActions = styled('div')(({ theme }) => ({
+ marginLeft: 'auto',
+ whiteSpace: 'nowrap',
+ [theme.breakpoints.down('sm')]: {
+ display: 'none',
+ },
+}));
+
+export const ConstraintAccordionHeaderActions = ({
+ onEdit,
+ onDelete,
+ disableDelete = false,
+ disableEdit = false,
+}: ConstraintAccordionHeaderActionsProps) => {
+ const onEditClick =
+ onEdit &&
+ ((event: React.SyntheticEvent) => {
+ event.stopPropagation();
+ onEdit();
+ });
+
+ const onDeleteClick =
+ onDelete &&
+ ((event: React.SyntheticEvent) => {
+ event.stopPropagation();
+ onDelete();
+ });
+
+ return (
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx
new file mode 100644
index 000000000000..d66cfd2eb3ee
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx
@@ -0,0 +1,120 @@
+import { useState } from 'react';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ SxProps,
+ Theme,
+ styled,
+} from '@mui/material';
+import { IConstraint } from 'interfaces/strategy';
+import { ConstraintAccordionViewBody } from './ConstraintAccordionViewBody/ConstraintAccordionViewBody';
+import { ConstraintAccordionViewHeader } from './ConstraintAccordionViewHeader/ConstraintAccordionViewHeader';
+import { oneOf } from 'utils/oneOf';
+import {
+ dateOperators,
+ numOperators,
+ semVerOperators,
+} from 'constants/operators';
+
+interface IConstraintAccordionViewProps {
+ constraint: IConstraint;
+ onDelete?: () => void;
+ onEdit?: () => void;
+ sx?: SxProps;
+ compact?: boolean;
+ disabled?: boolean;
+ renderAfter?: JSX.Element;
+}
+
+const StyledAccordion = styled(Accordion)(({ theme }) => ({
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadiusMedium,
+ backgroundColor: 'transparent',
+ boxShadow: 'none',
+ margin: 0,
+ '&:before': {
+ opacity: '0',
+ },
+}));
+
+const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
+ '& .root': {
+ border: 'none',
+ padding: theme.spacing(0.5, 3),
+ '&:hover .valuesExpandLabel': {
+ textDecoration: 'underline',
+ },
+ },
+ userSelect: 'auto',
+ '-webkit-user-select': 'auto',
+ '-moz-user-select': 'auto',
+ '-ms-user-select': 'auto',
+}));
+const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
+ borderTop: `1px dashed ${theme.palette.divider}`,
+ display: 'flex',
+ flexDirection: 'column',
+}));
+
+const StyledWrapper = styled('div')({
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+});
+
+export const ConstraintAccordionView = ({
+ constraint,
+ onEdit,
+ onDelete,
+ sx = undefined,
+ compact = false,
+ disabled = false,
+ renderAfter,
+}: IConstraintAccordionViewProps) => {
+ const [expandable, setExpandable] = useState(true);
+ const [expanded, setExpanded] = useState(false);
+
+ const singleValue = oneOf(
+ [...semVerOperators, ...numOperators, ...dateOperators],
+ constraint.operator,
+ );
+ const handleClick = () => {
+ if (expandable) {
+ setExpanded(!expanded);
+ }
+ };
+
+ return (
+
+
+
+
+ {renderAfter}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx
new file mode 100644
index 000000000000..44c43bf7583f
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx
@@ -0,0 +1,34 @@
+import { IConstraint } from 'interfaces/strategy';
+import { formatConstraintValue } from 'utils/formatConstraintValue';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+import { MultipleValues } from './MultipleValues/MultipleValues';
+import { SingleValue } from './SingleValue/SingleValue';
+import { styled } from '@mui/material';
+
+interface IConstraintAccordionViewBodyProps {
+ constraint: IConstraint;
+}
+
+const StyledValueContainer = styled('div')(({ theme }) => ({
+ padding: theme.spacing(2, 0),
+ maxHeight: '400px',
+ overflowY: 'auto',
+}));
+
+export const ConstraintAccordionViewBody = ({
+ constraint,
+}: IConstraintAccordionViewBodyProps) => {
+ const { locationSettings } = useLocationSettings();
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx
new file mode 100644
index 000000000000..dd13705ae662
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { Chip, styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import { ConstraintValueSearch } from '../../../ConstraintValueSearch/ConstraintValueSearch';
+
+interface IMultipleValuesProps {
+ values: string[] | undefined;
+}
+
+const StyledTruncator = styled(StringTruncator)({
+ whiteSpace: 'pre',
+});
+
+const StyledChip = styled(Chip)(({ theme }) => ({
+ margin: theme.spacing(0, 1, 1, 0),
+}));
+
+export const MultipleValues = ({ values }: IMultipleValuesProps) => {
+ const [filter, setFilter] = useState('');
+
+ if (!values || values.length === 0) return null;
+
+ return (
+ <>
+ 20}
+ show={
+
+ }
+ />
+ {values
+ .filter((value) => value.includes(filter))
+ .map((value, index) => (
+
+ }
+ />
+ ))}
+ >
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx
new file mode 100644
index 000000000000..e20d657721c4
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx
@@ -0,0 +1,44 @@
+import { Chip, styled } from '@mui/material';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+
+interface ISingleValueProps {
+ value: string | undefined;
+ operator: string;
+}
+
+const StyledDiv = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(600)]: { flexDirection: 'column' },
+}));
+
+const StyledParagraph = styled('p')(({ theme }) => ({
+ marginRight: theme.spacing(1.5),
+ [theme.breakpoints.down(600)]: {
+ marginBottom: theme.spacing(1.5),
+ marginRight: 0,
+ },
+}));
+
+const StyledChip = styled(Chip)(({ theme }) => ({
+ margin: theme.spacing(0, 1, 1, 0),
+}));
+
+export const SingleValue = ({ value, operator }: ISingleValueProps) => {
+ if (!value) return null;
+
+ return (
+
+ Value must be {operator}{' '}
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx
new file mode 100644
index 000000000000..65145ce7c06f
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx
@@ -0,0 +1,64 @@
+import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon';
+import { IConstraint } from 'interfaces/strategy';
+import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo';
+import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions';
+import { styled } from '@mui/system';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+
+interface IConstraintAccordionViewHeaderProps {
+ constraint: IConstraint;
+ onDelete?: () => void;
+ onEdit?: () => void;
+ singleValue: boolean;
+ expanded: boolean;
+ allowExpand: (shouldExpand: boolean) => void;
+ compact?: boolean;
+ disabled?: boolean;
+}
+
+const StyledContainer = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ [theme.breakpoints.down('sm')]: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ position: 'relative',
+ },
+}));
+
+export const ConstraintAccordionViewHeader = ({
+ constraint,
+ onEdit,
+ onDelete,
+ singleValue,
+ allowExpand,
+ expanded,
+ compact,
+ disabled,
+}: IConstraintAccordionViewHeaderProps) => {
+ const { context } = useUnleashContext();
+ const { contextName } = constraint;
+
+ const disableEdit = !context
+ .map((contextDefinition) => contextDefinition.name)
+ .includes(contextName);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo.tsx
new file mode 100644
index 000000000000..5617cf345a14
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo.tsx
@@ -0,0 +1,105 @@
+import { styled, Tooltip } from '@mui/material';
+import { ConstraintViewHeaderOperator } from './ConstraintViewHeaderOperator';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { ConstraintAccordionViewHeaderSingleValue } from './ConstraintAccordionViewHeaderSingleValue';
+import { ConstraintAccordionViewHeaderMultipleValues } from './ConstraintAccordionViewHeaderMultipleValues';
+import React from 'react';
+import { IConstraint } from 'interfaces/strategy';
+
+const StyledHeaderText = styled('span')(({ theme }) => ({
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ maxWidth: '100px',
+ minWidth: '100px',
+ marginRight: '10px',
+ marginTop: 'auto',
+ marginBottom: 'auto',
+ wordBreak: 'break-word',
+ fontSize: theme.fontSizes.smallBody,
+ [theme.breakpoints.down(710)]: {
+ textAlign: 'center',
+ padding: theme.spacing(1, 0),
+ marginRight: 'inherit',
+ maxWidth: 'inherit',
+ },
+}));
+
+const StyledHeaderWrapper = styled('div')(({ theme }) => ({
+ display: 'flex',
+ width: '100%',
+ justifyContent: 'space-between',
+ borderRadius: theme.spacing(1),
+}));
+
+const StyledHeaderMetaInfo = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'stretch',
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.down('sm')]: {
+ marginLeft: 0,
+ flexDirection: 'column',
+ alignItems: 'center',
+ width: '100%',
+ },
+}));
+
+interface ConstraintAccordionViewHeaderMetaInfoProps {
+ constraint: IConstraint;
+ singleValue: boolean;
+ expanded: boolean;
+ allowExpand: (shouldExpand: boolean) => void;
+ disabled?: boolean;
+ maxLength?: number;
+}
+
+export const ConstraintAccordionViewHeaderInfo = ({
+ constraint,
+ singleValue,
+ allowExpand,
+ expanded,
+ disabled = false,
+ maxLength = 112, //The max number of characters in the values text for NOT allowing expansion
+}: ConstraintAccordionViewHeaderMetaInfoProps) => {
+ return (
+
+
+
+ ({
+ color: disabled
+ ? theme.palette.text.secondary
+ : 'inherit',
+ })}
+ >
+ {constraint.contextName}
+
+
+
+
+ }
+ elseShow={
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderMultipleValues.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderMultipleValues.tsx
new file mode 100644
index 000000000000..f819e8052cad
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderMultipleValues.tsx
@@ -0,0 +1,101 @@
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { styled } from '@mui/material';
+import React, { useEffect, useMemo, useState } from 'react';
+import { IConstraint } from 'interfaces/strategy';
+
+const StyledValuesSpan = styled('span')(({ theme }) => ({
+ display: '-webkit-box',
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ wordBreak: 'break-word',
+ fontSize: theme.fontSizes.smallBody,
+ margin: 'auto 0',
+ [theme.breakpoints.down('sm')]: {
+ margin: theme.spacing(1, 0),
+ textAlign: 'center',
+ },
+}));
+
+interface ConstraintSingleValueProps {
+ constraint: IConstraint;
+ expanded: boolean;
+ maxLength: number;
+ allowExpand: (shouldExpand: boolean) => void;
+ disabled?: boolean;
+}
+
+const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'stretch',
+ margin: 'auto 0',
+}));
+
+const StyledHeaderValuesContainer = styled('div')(({ theme }) => ({
+ display: 'flex',
+ justifyContent: 'stretch',
+ margin: 'auto 0',
+ flexDirection: 'column',
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.down('sm')]: {
+ marginLeft: 0,
+ },
+}));
+
+const StyledHeaderValuesExpand = styled('p')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ marginTop: theme.spacing(0.5),
+ color: theme.palette.links,
+ [theme.breakpoints.down('sm')]: {
+ textAlign: 'center',
+ },
+}));
+
+export const ConstraintAccordionViewHeaderMultipleValues = ({
+ constraint,
+ expanded,
+ allowExpand,
+ maxLength,
+ disabled = false,
+}: ConstraintSingleValueProps) => {
+ const [expandable, setExpandable] = useState(false);
+
+ const text = useMemo(() => {
+ return constraint?.values?.map((value) => value).join(', ');
+ }, [constraint]);
+
+ useEffect(() => {
+ if (text) {
+ allowExpand((text?.length ?? 0) > maxLength);
+ setExpandable((text?.length ?? 0) > maxLength);
+ }
+ }, [text, maxLength, allowExpand, setExpandable]);
+
+ return (
+
+
+ ({
+ color: disabled
+ ? theme.palette.text.secondary
+ : 'inherit',
+ })}
+ >
+ {text}
+
+
+ {!expanded
+ ? `View all (${constraint?.values?.length})`
+ : 'View less'}
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderSingleValue.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderSingleValue.tsx
new file mode 100644
index 000000000000..b3a3efc3a074
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderSingleValue.tsx
@@ -0,0 +1,48 @@
+import React, { useEffect } from 'react';
+import { Chip, styled } from '@mui/material';
+import { formatConstraintValue } from 'utils/formatConstraintValue';
+import { IConstraint } from 'interfaces/strategy';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+
+const StyledSingleValueChip = styled(Chip)(({ theme }) => ({
+ margin: 'auto 0',
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.down('sm')]: {
+ margin: theme.spacing(1, 0),
+ },
+}));
+
+interface ConstraintSingleValueProps {
+ constraint: IConstraint;
+ allowExpand: (shouldExpand: boolean) => void;
+ disabled?: boolean;
+}
+
+const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'stretch',
+ margin: 'auto 0',
+}));
+
+export const ConstraintAccordionViewHeaderSingleValue = ({
+ constraint,
+ allowExpand,
+ disabled = false,
+}: ConstraintSingleValueProps) => {
+ const { locationSettings } = useLocationSettings();
+
+ useEffect(() => {
+ allowExpand(false);
+ }, [allowExpand]);
+
+ return (
+
+ ({
+ color: disabled ? theme.palette.text.secondary : 'inherit',
+ })}
+ label={formatConstraintValue(constraint, locationSettings)}
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator.tsx
new file mode 100644
index 000000000000..af3ba01a1399
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator.tsx
@@ -0,0 +1,72 @@
+import { IConstraint } from 'interfaces/strategy';
+import { ConditionallyRender } from '../../../ConditionallyRender/ConditionallyRender';
+import { Tooltip, Box, styled } from '@mui/material';
+import { stringOperators } from 'constants/operators';
+import { ReactComponent as NegatedOnIcon } from 'assets/icons/not_operator_selected.svg';
+import { ConstraintOperator } from '../../ConstraintOperator/ConstraintOperator';
+import { StyledIconWrapper } from './StyledIconWrapper';
+import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg';
+import { oneOf } from 'utils/oneOf';
+import { useTheme } from '@mui/material';
+
+interface ConstraintViewHeaderOperatorProps {
+ constraint: IConstraint;
+ disabled?: boolean;
+}
+
+const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'stretch',
+ margin: 'auto 0',
+}));
+
+const StyledHeaderConstraintContainer = styled('div')(({ theme }) => ({
+ minWidth: '152px',
+ position: 'relative',
+ [theme.breakpoints.down('sm')]: {
+ paddingRight: 0,
+ },
+}));
+
+export const ConstraintViewHeaderOperator = ({
+ constraint,
+ disabled = false,
+}: ConstraintViewHeaderOperatorProps) => {
+ const theme = useTheme();
+ return (
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper.tsx
new file mode 100644
index 000000000000..ee47d9344804
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper.tsx
@@ -0,0 +1,37 @@
+import { forwardRef, ReactNode } from 'react';
+import { styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+export const StyledIconWrapperBase = styled('div')<{
+ prefix?: boolean;
+}>(({ theme }) => ({
+ backgroundColor: theme.palette.background.elevation2,
+ width: 24,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'stretch',
+ color: theme.palette.primary.main,
+ marginLeft: theme.spacing(1),
+ borderRadius: theme.shape.borderRadius,
+}));
+
+const StyledPrefixIconWrapper = styled(StyledIconWrapperBase)(({ theme }) => ({
+ width: 'auto',
+ paddingLeft: theme.spacing(1),
+ paddingRight: theme.spacing(1),
+ marginLeft: 0,
+ borderTopRightRadius: 0,
+ borderBottomRightRadius: 0,
+}));
+
+export const StyledIconWrapper = forwardRef<
+ HTMLDivElement,
+ { isPrefix?: boolean; children?: ReactNode }
+>(({ isPrefix, ...props }, ref) => (
+ }
+ elseShow={() => }
+ />
+));
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintIcon.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintIcon.tsx
new file mode 100644
index 000000000000..4434f838c2b3
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintIcon.tsx
@@ -0,0 +1,35 @@
+import { VFC } from 'react';
+import { Box } from '@mui/material';
+import { TrackChanges } from '@mui/icons-material';
+
+interface IConstraintIconProps {
+ compact?: boolean;
+ disabled?: boolean;
+}
+
+export const ConstraintIcon: VFC = ({
+ compact,
+ disabled,
+}) => (
+ ({
+ backgroundColor: disabled
+ ? theme.palette.neutral.border
+ : 'primary.light',
+ p: compact ? '1px' : '2px',
+ borderRadius: '50%',
+ width: compact ? '18px' : '24px',
+ height: compact ? '18px' : '24px',
+ marginRight: '13px',
+ })}
+ >
+ ({
+ fill: theme.palette.common.white,
+ display: 'block',
+ width: compact ? theme.spacing(2) : theme.spacing(2.5),
+ height: compact ? theme.spacing(2) : theme.spacing(2.5),
+ })}
+ />
+
+);
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx
new file mode 100644
index 000000000000..0e05726d3300
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx
@@ -0,0 +1,54 @@
+import { IConstraint } from 'interfaces/strategy';
+import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription';
+import React from 'react';
+import { styled } from '@mui/material';
+
+interface IConstraintOperatorProps {
+ constraint: IConstraint;
+ hasPrefix?: boolean;
+ disabled?: boolean;
+}
+
+const StyledContainer = styled('div')(({ theme }) => ({
+ padding: theme.spacing(0.5, 1.5),
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.palette.background.elevation2,
+ lineHeight: 1.25,
+}));
+
+const StyledName = styled('div', {
+ shouldForwardProp: (prop) => prop !== 'disabled',
+})<{ disabled: boolean }>(({ theme, disabled }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ lineHeight: 17 / 14,
+ color: disabled ? theme.palette.text.secondary : theme.palette.text.primary,
+}));
+
+const StyledText = styled('div', {
+ shouldForwardProp: (prop) => prop !== 'disabled',
+})<{ disabled: boolean }>(({ theme, disabled }) => ({
+ fontSize: theme.fontSizes.smallerBody,
+ color: disabled ? theme.palette.text.secondary : theme.palette.neutral.main,
+}));
+
+export const ConstraintOperator = ({
+ constraint,
+ hasPrefix,
+ disabled = false,
+}: IConstraintOperatorProps) => {
+ const operatorName = constraint.operator;
+ const operatorText = formatOperatorDescription(constraint.operator);
+
+ return (
+
+ {operatorName}
+ {operatorText}
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts
new file mode 100644
index 000000000000..ef4a76118e50
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts
@@ -0,0 +1,23 @@
+import { Operator } from 'constants/operators';
+
+export const formatOperatorDescription = (operator: Operator): string => {
+ return constraintOperatorDescriptions[operator];
+};
+
+const constraintOperatorDescriptions = {
+ IN: 'is one of',
+ NOT_IN: 'is not one of',
+ STR_CONTAINS: 'is a string that contains',
+ STR_STARTS_WITH: 'is a string that starts with',
+ STR_ENDS_WITH: 'is a string that ends with',
+ NUM_EQ: 'is a number equal to',
+ NUM_GT: 'is a number greater than',
+ NUM_GTE: 'is a number greater than or equal to',
+ NUM_LT: 'is a number less than',
+ NUM_LTE: 'is a number less than or equal to',
+ DATE_BEFORE: 'is a date before',
+ DATE_AFTER: 'is a date after',
+ SEMVER_EQ: 'is a SemVer equal to',
+ SEMVER_GT: 'is a SemVer greater than',
+ SEMVER_LT: 'is a SemVer less than',
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx
new file mode 100644
index 000000000000..30647dc81c40
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx
@@ -0,0 +1,148 @@
+import {
+ Select,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ SelectChangeEvent,
+ styled,
+} from '@mui/material';
+import {
+ Operator,
+ stringOperators,
+ semVerOperators,
+ dateOperators,
+ numOperators,
+ inOperators,
+} from 'constants/operators';
+import React, { useState } from 'react';
+import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription';
+
+interface IConstraintOperatorSelectProps {
+ options: Operator[];
+ value: Operator;
+ onChange: (value: Operator) => void;
+}
+
+const StyledValueContainer = styled('div')(({ theme }) => ({
+ lineHeight: 1.1,
+ marginTop: -2,
+ marginBottom: -10,
+}));
+
+const StyledLabel = styled('div')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+}));
+
+const StyledDescription = styled('div')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.neutral.main,
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+}));
+
+const StyledFormInput = styled(FormControl)(({ theme }) => ({
+ [theme.breakpoints.between(1101, 1365)]: {
+ width: '170px',
+ marginRight: theme.spacing(0.5),
+ },
+}));
+
+const StyledMenuItem = styled(MenuItem, {
+ shouldForwardProp: (prop) => prop !== 'separator',
+})<{ separator: boolean }>(({ theme, separator }) =>
+ separator
+ ? {
+ position: 'relative',
+ overflow: 'visible',
+ marginTop: theme.spacing(2),
+ '&:before': {
+ content: '""',
+ display: 'block',
+ position: 'absolute',
+ top: theme.spacing(-1),
+ left: 0,
+ right: 0,
+ borderTop: '1px solid',
+ borderTopColor: theme.palette.divider,
+ },
+ }
+ : {},
+);
+
+const StyledOptionContainer = styled('div')(({ theme }) => ({
+ lineHeight: 1.2,
+}));
+
+export const ConstraintOperatorSelect = ({
+ options,
+ value,
+ onChange,
+}: IConstraintOperatorSelectProps) => {
+ const [open, setOpen] = useState(false);
+
+ const onSelectChange = (event: SelectChangeEvent) => {
+ onChange(event.target.value as Operator);
+ };
+
+ const renderValue = () => {
+ return (
+
+ {value}
+
+ {formatOperatorDescription(value)}
+
+
+ );
+ };
+
+ return (
+
+ Operator
+
+
+ );
+};
+
+const needSeparatorAbove = (options: Operator[], option: Operator): boolean => {
+ if (option === options[0]) {
+ return false;
+ }
+
+ return operatorGroups.some((group) => {
+ return group[0] === option;
+ });
+};
+
+const operatorGroups = [
+ inOperators,
+ stringOperators,
+ numOperators,
+ dateOperators,
+ semVerOperators,
+];
diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx
new file mode 100644
index 000000000000..fa0d765c7a5f
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx
@@ -0,0 +1,50 @@
+import { TextField, InputAdornment, Chip } from '@mui/material';
+import { Search } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+interface IConstraintValueSearchProps {
+ filter: string;
+ setFilter: React.Dispatch>;
+}
+
+export const ConstraintValueSearch = ({
+ filter,
+ setFilter,
+}: IConstraintValueSearchProps) => {
+ return (
+
+
+ setFilter(e.target.value)}
+ placeholder='Filter values'
+ style={{
+ width: '100%',
+ margin: '1rem 0',
+ }}
+ variant='outlined'
+ size='small'
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
+
setFilter('')}
+ />
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordion.tsx b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordion.tsx
new file mode 100644
index 000000000000..6087195116b1
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordion.tsx
@@ -0,0 +1,52 @@
+import { IConstraint } from 'interfaces/strategy';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+import { ConstraintAccordionEdit } from './ConstraintAccordionEdit/ConstraintAccordionEdit';
+import { ConstraintAccordionView } from './ConstraintAccordionView/ConstraintAccordionView';
+
+export interface IConstraintAccordionProps {
+ compact: boolean;
+ editing: boolean;
+ constraint: IConstraint;
+ onCancel: () => void;
+ onEdit?: () => void;
+ onDelete?: () => void;
+ onAutoSave?: (constraint: IConstraint) => void;
+ onSave?: (constraint: IConstraint) => void;
+}
+
+export const NewConstraintAccordion = ({
+ constraint,
+ compact = false,
+ editing,
+ onEdit,
+ onCancel,
+ onDelete,
+ onSave,
+ onAutoSave,
+}: IConstraintAccordionProps) => {
+ if (!constraint) return null;
+
+ return (
+
+ }
+ elseShow={
+
+ }
+ />
+ );
+};
diff --git a/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx
new file mode 100644
index 000000000000..e655389e6fea
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx
@@ -0,0 +1,264 @@
+import React, {
+ forwardRef,
+ Fragment,
+ Ref,
+ RefObject,
+ useImperativeHandle,
+} from 'react';
+import { Button, styled, Tooltip } from '@mui/material';
+import { HelpOutline } from '@mui/icons-material';
+import { IConstraint } from 'interfaces/strategy';
+import produce from 'immer';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { IUseWeakMap, useWeakMap } from 'hooks/useWeakMap';
+import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
+import {
+ IConstraintAccordionProps,
+ NewConstraintAccordion,
+} from 'component/common/NewConstraintAccordion/NewConstraintAccordion';
+
+export interface IConstraintAccordionListProps {
+ constraints: IConstraint[];
+ setConstraints?: React.Dispatch>;
+ showCreateButton?: boolean;
+ /* Add "constraints" title on the top - default `true` */
+ showLabel?: boolean;
+}
+
+// Ref methods exposed by this component.
+export interface IConstraintAccordionListRef {
+ addConstraint?: (contextName: string) => void;
+}
+
+// Extra form state for each constraint.
+interface IConstraintAccordionListItemState {
+ // Is the constraint new (never been saved)?
+ new?: boolean;
+ // Is the constraint currently being edited?
+ editing?: boolean;
+}
+
+export const constraintAccordionListId = 'constraintAccordionListId';
+
+const StyledContainer = styled('div')({
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+});
+
+const StyledHelpWrapper = styled(Tooltip)(({ theme }) => ({
+ marginLeft: theme.spacing(0.75),
+ height: theme.spacing(1.5),
+}));
+
+const StyledHelp = styled(HelpOutline)(({ theme }) => ({
+ fill: theme.palette.action.active,
+ [theme.breakpoints.down(860)]: {
+ display: 'none',
+ },
+}));
+
+const StyledConstraintLabel = styled('p')(({ theme }) => ({
+ marginBottom: theme.spacing(1),
+ color: theme.palette.text.secondary,
+}));
+
+const StyledAddCustomLabel = styled('div')(({ theme }) => ({
+ marginTop: theme.spacing(1),
+ marginBottom: theme.spacing(1),
+ color: theme.palette.text.primary,
+ display: 'flex',
+}));
+
+export const useConstraintAccordionList = (
+ setConstraints:
+ | React.Dispatch>
+ | undefined,
+ ref: React.RefObject,
+) => {
+ const state = useWeakMap();
+ const { context } = useUnleashContext();
+
+ const addConstraint =
+ setConstraints &&
+ ((contextName: string) => {
+ const constraint = createEmptyConstraint(contextName);
+ state.set(constraint, { editing: true, new: true });
+ setConstraints((prev) => [...prev, constraint]);
+ });
+
+ useImperativeHandle(ref, () => ({
+ addConstraint,
+ }));
+
+ const onAdd =
+ addConstraint &&
+ (() => {
+ addConstraint(context[0].name);
+ });
+
+ return { onAdd, state, context };
+};
+
+export const ConstraintAccordionList = forwardRef<
+ IConstraintAccordionListRef | undefined,
+ IConstraintAccordionListProps
+>(
+ (
+ { constraints, setConstraints, showCreateButton, showLabel = true },
+ ref,
+ ) => {
+ const { onAdd, state, context } = useConstraintAccordionList(
+ setConstraints,
+ ref as RefObject,
+ );
+
+ if (context.length === 0) {
+ return null;
+ }
+
+ return (
+
+ 0 && showLabel
+ }
+ show={
+
+ Constraints
+
+ }
+ />
+
+
+
+ Add any number of constraints
+
+
+
+
+
+
+
+
+ }
+ />
+
+ );
+ },
+);
+
+interface IConstraintList {
+ constraints: IConstraint[];
+ setConstraints?: React.Dispatch>;
+ state: IUseWeakMap;
+}
+
+export const NewConstraintAccordionList = forwardRef<
+ IConstraintAccordionListRef | undefined,
+ IConstraintList
+>(({ constraints, setConstraints, state }, ref) => {
+ const { context } = useUnleashContext();
+
+ const onEdit =
+ setConstraints &&
+ ((constraint: IConstraint) => {
+ state.set(constraint, { editing: true });
+ });
+
+ const onRemove =
+ setConstraints &&
+ ((index: number) => {
+ const constraint = constraints[index];
+ state.set(constraint, {});
+ setConstraints(
+ produce((draft) => {
+ draft.splice(index, 1);
+ }),
+ );
+ });
+
+ const onSave =
+ setConstraints &&
+ ((index: number, constraint: IConstraint) => {
+ state.set(constraint, {});
+ setConstraints(
+ produce((draft) => {
+ draft[index] = constraint;
+ }),
+ );
+ });
+
+ const onAutoSave =
+ setConstraints &&
+ ((index: number, constraint: IConstraint) => {
+ state.set(constraint, { editing: true });
+ setConstraints(
+ produce((draft) => {
+ draft[index] = constraint;
+ }),
+ );
+ });
+
+ const onCancel = (index: number) => {
+ const constraint = constraints[index];
+ state.get(constraint)?.new && onRemove?.(index);
+ state.set(constraint, {});
+ };
+
+ if (context.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {constraints.map((constraint, index) => (
+ // biome-ignore lint: reason=objectId would change every time values change - this is no different than using index
+
+ 0}
+ show={}
+ />
+
+
+
+ ))}
+
+ );
+});
diff --git a/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/createEmptyConstraint.ts b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/createEmptyConstraint.ts
new file mode 100644
index 000000000000..5ad8f8edfa6a
--- /dev/null
+++ b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/createEmptyConstraint.ts
@@ -0,0 +1,21 @@
+import { dateOperators } from 'constants/operators';
+import { IConstraint } from 'interfaces/strategy';
+import { oneOf } from 'utils/oneOf';
+import { operatorsForContext } from 'utils/operatorsForContext';
+
+export const createEmptyConstraint = (contextName: string): IConstraint => {
+ const operator = operatorsForContext(contextName)[0];
+
+ const value = oneOf(dateOperators, operator)
+ ? new Date().toISOString()
+ : '';
+
+ return {
+ contextName,
+ operator,
+ value,
+ values: [],
+ caseInsensitive: false,
+ inverted: false,
+ };
+};
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx
index 710b75de6ece..3ff36833c0c9 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx
@@ -12,6 +12,8 @@ import {
IConstraintAccordionListRef,
useConstraintAccordionList,
} from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
+import { NewConstraintAccordion } from 'component/common/NewConstraintAccordion/NewConstraintAccordion';
+import { NewConstraintAccordionList } from 'component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList';
interface IConstraintAccordionListProps {
constraints: IConstraint[];
@@ -109,7 +111,7 @@ export const FeatureStrategyConstraintAccordionList = forwardRef<
}
/>
- ;
}
+const filterConstraints = (constraint: any) => {
+ if (constraint.hasOwnProperty('values')) {
+ return constraint.values && constraint.values.length > 0;
+ }
+
+ if (constraint.hasOwnProperty('value')) {
+ return constraint.value !== '';
+ }
+};
+
export const FeatureStrategyConstraints = ({
projectId,
environmentId,
strategy,
setStrategy,
}: IFeatureStrategyConstraintsProps) => {
+ useEffect(() => {
+ return () => {
+ if (!strategy.constraints) {
+ return;
+ }
+
+ // If the component is unmounting we want to remove all constraints that do not have valid single value or
+ // valid multivalues
+ setStrategy((prev) => ({
+ ...prev,
+ constraints: prev.constraints?.filter(filterConstraints),
+ }));
+ };
+ }, []);
+
const constraints = useMemo(() => {
return strategy.constraints ?? [];
}, [strategy]);
diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx
index ce5b49688c42..c22f2288cb42 100644
--- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx
@@ -57,6 +57,7 @@ const setupComponent = () => {
expectedVariantName: 'Blue',
expectedSliderValue: '50',
expectedConstraintValue: 'new value',
+ expectedMultipleValues: '1234,4141,51515',
};
};
@@ -92,9 +93,8 @@ describe('NewFeatureStrategyCreate', () => {
test('should navigate tabs', async () => {
setupComponent();
- await waitFor(() => {
- expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
- });
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
const slider = await screen.findByRole('slider', { name: /rollout/i });
expect(slider).toHaveValue('100');
@@ -102,24 +102,21 @@ describe('NewFeatureStrategyCreate', () => {
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
- await waitFor(() => {
- expect(screen.getByText('Segments')).toBeInTheDocument();
- });
+ const segmentsEl = await screen.findByText('Segments');
+ expect(segmentsEl).toBeInTheDocument();
const variantEl = screen.getByText('Variants');
fireEvent.click(variantEl);
- await waitFor(() => {
- expect(screen.getByText('Add variant')).toBeInTheDocument();
- });
+ const addVariantEl = await screen.findByText('Add variant');
+ expect(addVariantEl).toBeInTheDocument();
});
test('should change general settings', async () => {
const { expectedGroupId, expectedSliderValue } = setupComponent();
- await waitFor(() => {
- expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
- });
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
const slider = await screen.findByRole('slider', { name: /rollout/i });
const groupIdInput = await screen.getByLabelText('groupId');
@@ -138,17 +135,14 @@ describe('NewFeatureStrategyCreate', () => {
const { expectedConstraintValue, expectedSegmentName } =
setupComponent();
- await waitFor(() => {
- expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
- });
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
- await waitFor(() => {
- const addConstraintEl = screen.getByText('Add constraint');
- fireEvent.click(addConstraintEl);
- });
+ const addConstraintEl = await screen.findByText('Add constraint');
+ fireEvent.click(addConstraintEl);
const inputElement = screen.getByPlaceholderText(
'value1, value2, value3...',
@@ -176,17 +170,14 @@ describe('NewFeatureStrategyCreate', () => {
test('should change variants settings', async () => {
const { expectedVariantName } = setupComponent();
- await waitFor(() => {
- expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
- });
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
- await waitFor(() => {
- const addVariantEl = screen.getByText('Add variant');
- fireEvent.click(addVariantEl);
- });
+ const addVariantEl = await screen.findByText('Add variant');
+ fireEvent.click(addVariantEl);
const inputElement = screen.getAllByRole('textbox')[0];
fireEvent.change(inputElement, {
@@ -199,27 +190,20 @@ describe('NewFeatureStrategyCreate', () => {
test('should change variant name after changing tab', async () => {
const { expectedVariantName } = setupComponent();
- await waitFor(() => {
- expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
- });
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
- await waitFor(() => {
- const addVariantEl = screen.getByText('Add variant');
- fireEvent.click(addVariantEl);
- });
+ const addVariantEl = await screen.findByText('Add variant');
+ fireEvent.click(addVariantEl);
- await waitFor(() => {
- const targetingEl = screen.getByText('Targeting');
- fireEvent.click(targetingEl);
- });
+ const targetingEl = await screen.findByText('Targeting');
+ fireEvent.click(targetingEl);
- await waitFor(() => {
- const addConstraintEl = screen.getByText('Add constraint');
- expect(addConstraintEl).toBeInTheDocument();
- });
+ const addConstraintEl = await screen.findByText('Add constraint');
+ expect(addConstraintEl).toBeInTheDocument();
fireEvent.click(variantsEl);
@@ -230,4 +214,60 @@ describe('NewFeatureStrategyCreate', () => {
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
});
+
+ test('Should autosave constraint settings when navigating between tabs', async () => {
+ const { expectedMultipleValues } = setupComponent();
+
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
+
+ const targetingEl = screen.getByText('Targeting');
+ fireEvent.click(targetingEl);
+
+ const addConstraintEl = await screen.findByText('Add constraint');
+ fireEvent.click(addConstraintEl);
+
+ const inputElement = screen.getByPlaceholderText(
+ 'value1, value2, value3...',
+ );
+ fireEvent.change(inputElement, {
+ target: { value: expectedMultipleValues },
+ });
+
+ const addValueEl = await screen.findByText('Add values');
+ fireEvent.click(addValueEl);
+
+ const variantsEl = screen.getByText('Variants');
+ fireEvent.click(variantsEl);
+
+ fireEvent.click(targetingEl);
+
+ const values = expectedMultipleValues.split(',');
+
+ expect(screen.getByText(values[0])).toBeInTheDocument();
+ expect(screen.getByText(values[1])).toBeInTheDocument();
+ expect(screen.getByText(values[2])).toBeInTheDocument();
+ });
+
+ test('Should remove constraint when no valid values are set and moving between tabs', async () => {
+ setupComponent();
+
+ const titleEl = await screen.findByText('Gradual rollout');
+ expect(titleEl).toBeInTheDocument();
+
+ const targetingEl = screen.getByText('Targeting');
+ fireEvent.click(targetingEl);
+
+ const addConstraintEl = await screen.findByText('Add constraint');
+ fireEvent.click(addConstraintEl);
+
+ const variantsEl = screen.getByText('Variants');
+ fireEvent.click(variantsEl);
+ fireEvent.click(targetingEl);
+
+ const seconAddConstraintEl = await screen.findByText('Add constraint');
+
+ expect(seconAddConstraintEl).toBeInTheDocument();
+ expect(screen.queryByText('appName')).not.toBeInTheDocument();
+ });
});