diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/AddValuesWidget.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/AddValuesWidget.tsx new file mode 100644 index 0000000000..8c8a4535c5 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/AddValuesWidget.tsx @@ -0,0 +1,166 @@ +import Add from '@mui/icons-material/Add'; +import { Button, Popover, styled, TextField } from '@mui/material'; +import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly'; +import { + forwardRef, + useId, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { parseParameterStrings } from 'utils/parseParameter'; + +const AddValuesButton = styled('button')(({ theme }) => ({ + color: theme.palette.primary.main, + svg: { + fill: theme.palette.primary.main, + height: theme.fontSizes.smallerBody, + width: theme.fontSizes.smallerBody, + }, + border: 'none', + borderRadius: theme.shape.borderRadiusExtraLarge, + display: 'flex', + flexFlow: 'row nowrap', + whiteSpace: 'nowrap', + gap: theme.spacing(0.25), + alignItems: 'center', + padding: theme.spacing(0.5, 1.5, 0.5, 1.5), + height: 'auto', + transition: 'all 0.3s ease', + outline: `1px solid #0000`, + background: theme.palette.background.elevation1, + ':hover, :focus-visible': { + background: theme.palette.background.elevation1, + outlineColor: theme.palette.secondary.dark, + }, +})); + +const StyledPopover = styled(Popover)(({ theme }) => ({ + '& .MuiPaper-root': { + borderRadius: theme.shape.borderRadiusLarge, + border: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(2), + width: '250px', + }, +})); + +const StyledTextField = styled(TextField)(({ theme }) => ({ + flexGrow: 1, +})); + +const InputRow = styled('div')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'start', + width: '100%', +})); + +const ErrorMessage = styled('div')(({ theme }) => ({ + color: theme.palette.error.main, + fontSize: theme.typography.caption.fontSize, + marginBottom: theme.spacing(1), +})); + +interface AddValuesProps { + onAddValues: (newValues: string[]) => void; +} + +export const AddValuesWidget = forwardRef( + ({ onAddValues }, ref) => { + const [open, setOpen] = useState(false); + const [inputValues, setInputValues] = useState(''); + const [error, setError] = useState(''); + const positioningRef = useRef(null); + useImperativeHandle( + ref, + () => positioningRef.current as HTMLButtonElement, + ); + const inputRef = useRef(null); + const inputId = useId(); + + const handleAdd = () => { + const newValues = parseParameterStrings(inputValues); + + if (newValues.length === 0) { + setError('Values cannot be empty'); + return; + } + + if (newValues.some((v) => v.length > 100)) { + setError('Values cannot be longer than 100 characters'); + return; + } + + onAddValues(newValues); + setInputValues(''); + setError(''); + inputRef?.current?.focus(); + }; + + return ( + <> + setOpen(true)} + type='button' + > + + Add values + + setOpen(false)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > +
{ + e.stopPropagation(); + e.preventDefault(); + handleAdd(); + }} + > + {error && {error}} + + + + + { + setInputValues(e.target.value); + setError(''); + }} + size='small' + variant='standard' + fullWidth + inputRef={inputRef} + autoFocus + /> + + +
+
+ + ); + }, +); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx index 389b9268a9..6e6c1daf12 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx @@ -30,7 +30,7 @@ import type { IUnleashContextDefinition, } from 'interfaces/context'; import type { IConstraint } from 'interfaces/strategy'; -import { useEffect, useState, type FC } from 'react'; +import { useEffect, useRef, useState, type FC } from 'react'; import { oneOf } from 'utils/oneOf'; import { CURRENT_TIME_CONTEXT_FIELD, @@ -43,6 +43,7 @@ import { ValueList } from './ValueList'; import { ReactComponent as CaseSensitiveIcon } from 'assets/icons/case-sensitive.svg'; import { ReactComponent as CaseInsensitiveIcon } from 'assets/icons/case-insensitive.svg'; import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly'; +import { AddValuesWidget } from './AddValuesWidget'; const Container = styled('article')(({ theme }) => ({ '--padding': theme.spacing(2), @@ -143,6 +144,11 @@ const CaseButton = styled(StyledButton)(({ theme }) => ({ placeItems: 'center', })); +const OPERATORS_WITH_ADD_VALUES_WIDGET = [ + 'IN_OPERATORS_FREETEXT', + 'STRING_OPERATORS_FREETEXT', +]; + type Props = { localConstraint: IConstraint; setContextName: (contextName: string) => void; @@ -190,6 +196,10 @@ export const EditableConstraint: FC = ({ const { contextName, operator } = localConstraint; const [showCaseSensitiveButton, setShowCaseSensitiveButton] = useState(false); + const deleteButtonRef = useRef(null); + const addValuesButtonRef = useRef(null); + const showAddValuesButton = + OPERATORS_WITH_ADD_VALUES_WIDGET.includes(input); /* We need a special case to handle the currentTime context field. Since this field will be the only one to allow DATE_BEFORE and DATE_AFTER operators @@ -410,17 +420,40 @@ export const EditableConstraint: FC = ({ ) : null} - {!input.includes('LEGAL_VALUES') && ( - - )} + + addValuesButtonRef.current ?? + deleteButtonRef.current + } + > + {showAddValuesButton ? ( + { + // todo (`addEditStrategy`): move deduplication logic higher up in the context handling + const combinedValues = new Set([ + ...(localConstraint.values || []), + ...newValues, + ]); + setValuesWithRecord( + Array.from(combinedValues), + ); + }} + /> + ) : null} + - + diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/ValueList.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/ValueList.tsx index a63b10fc60..883c00bc9e 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/ValueList.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/ValueList.tsx @@ -1,23 +1,6 @@ -import Add from '@mui/icons-material/Add'; import Clear from '@mui/icons-material/Clear'; -import { - Button, - Chip, - type ChipProps, - Popover, - styled, - TextField, -} from '@mui/material'; -import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly'; -import { - type FC, - forwardRef, - useId, - useImperativeHandle, - useRef, - useState, -} from 'react'; -import { parseParameterStrings } from 'utils/parseParameter'; +import { Chip, type ChipProps, styled } from '@mui/material'; +import { type FC, forwardRef, type PropsWithChildren, useRef } from 'react'; const ValueListWrapper = styled('div')(({ theme }) => ({ display: 'flex', @@ -72,181 +55,28 @@ const ValueChip = styled(ValueChipBase)(({ theme }) => ({ }, })); -const AddValuesButton = styled('button')(({ theme }) => ({ - color: theme.palette.primary.main, - svg: { - fill: theme.palette.primary.main, - height: theme.fontSizes.smallerBody, - width: theme.fontSizes.smallerBody, - }, - border: 'none', - borderRadius: theme.shape.borderRadiusExtraLarge, - display: 'flex', - flexFlow: 'row nowrap', - whiteSpace: 'nowrap', - gap: theme.spacing(0.25), - alignItems: 'center', - padding: theme.spacing(0.5, 1.5, 0.5, 1.5), - height: 'auto', - transition: 'all 0.3s ease', - outline: `1px solid #0000`, - background: theme.palette.background.elevation1, - ':hover, :focus-visible': { - background: theme.palette.background.elevation1, - outlineColor: theme.palette.secondary.dark, - }, -})); - -const StyledPopover = styled(Popover)(({ theme }) => ({ - '& .MuiPaper-root': { - borderRadius: theme.shape.borderRadiusLarge, - border: `1px solid ${theme.palette.divider}`, - padding: theme.spacing(2), - width: '250px', - }, -})); - -const StyledTextField = styled(TextField)(({ theme }) => ({ - flexGrow: 1, -})); - -const InputRow = styled('div')(({ theme }) => ({ - display: 'flex', - gap: theme.spacing(1), - alignItems: 'start', - width: '100%', -})); - -const ErrorMessage = styled('div')(({ theme }) => ({ - color: theme.palette.error.main, - fontSize: theme.typography.caption.fontSize, - marginBottom: theme.spacing(1), -})); - -interface AddValuesProps { - onAddValues: (values: string[]) => void; -} - -const AddValues = forwardRef( - ({ onAddValues }, ref) => { - const [open, setOpen] = useState(false); - const [inputValues, setInputValues] = useState(''); - const [error, setError] = useState(''); - const positioningRef = useRef(null); - useImperativeHandle( - ref, - () => positioningRef.current as HTMLButtonElement, - ); - const inputRef = useRef(null); - const inputId = useId(); - - const handleAdd = () => { - const newValues = parseParameterStrings(inputValues); - - if (newValues.length === 0) { - setError('Values cannot be empty'); - return; - } - - if (newValues.some((v) => v.length > 100)) { - setError('Values cannot be longer than 100 characters'); - return; - } - - onAddValues(newValues); - setInputValues(''); - setError(''); - inputRef?.current?.focus(); - }; - - return ( - <> - setOpen(true)} - type='button' - > - - Add values - - setOpen(false)} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'left', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - > -
{ - e.stopPropagation(); - e.preventDefault(); - handleAdd(); - }} - > - {error && {error}} - - - - - { - setInputValues(e.target.value); - setError(''); - }} - size='small' - variant='standard' - fullWidth - inputRef={inputRef} - autoFocus - /> - - -
-
- - ); - }, -); - type Props = { values: string[] | undefined; removeValue: (index: number) => void; setValues: (values: string[]) => void; + // the element that should receive focus when all value chips are deleted + getExternalFocusTarget: () => HTMLElement | null; }; -export const ValueList: FC = ({ +export const ValueList: FC> = ({ values = [], removeValue, - setValues, + getExternalFocusTarget, + children, }) => { const constraintElementRefs: React.MutableRefObject< (HTMLDivElement | null)[] > = useRef([]); - const addValuesButtonRef = useRef(null); const nextFocusTarget = (deletedIndex: number) => { if (deletedIndex === values.length - 1) { if (deletedIndex === 0) { - return addValuesButtonRef.current; + return getExternalFocusTarget(); } else { return constraintElementRefs.current[deletedIndex - 1]; } @@ -255,11 +85,6 @@ export const ValueList: FC = ({ } }; - const handleAddValues = (newValues: string[]) => { - const combinedValues = uniqueValues([...(values || []), ...newValues]); - setValues(combinedValues); - }; - return ( @@ -279,11 +104,7 @@ export const ValueList: FC = ({ ))} - + {children} ); }; - -const uniqueValues = (values: T[]): T[] => { - return Array.from(new Set(values)); -};