diff --git a/frontend/src/component/common/ConstraintsList/ConstraintsList.tsx b/frontend/src/component/common/ConstraintsList/ConstraintsList.tsx index 07495b8f56..8ea4bbd08d 100644 --- a/frontend/src/component/common/ConstraintsList/ConstraintsList.tsx +++ b/frontend/src/component/common/ConstraintsList/ConstraintsList.tsx @@ -33,6 +33,7 @@ export const ConstraintsList: FC<{ children: ReactNode }> = ({ children }) => { result.push( {index > 0 ? ( + // todo (addEditStrategy): change divider for edit screen (probably a new component or a prop) ) : null} {child} diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx index 15c47fb1d7..d31ff94829 100644 --- a/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx +++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintOperatorSelect.tsx @@ -96,6 +96,7 @@ export const ConstraintOperatorSelect = ({ ); }; + // todo (addEditStrategy): add prop to configure the select element or style it. (currently, the chevron is different from the other select element we use). Maybe add a new component. return ( Operator diff --git a/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx index 609be101c2..7048fe4824 100644 --- a/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx +++ b/frontend/src/component/common/NewConstraintAccordion/NewConstraintAccordionList/NewConstraintAccordionList.tsx @@ -14,6 +14,7 @@ import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStra import { NewConstraintAccordion } from 'component/common/NewConstraintAccordion/NewConstraintAccordion'; import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList'; import { useUiFlag } from 'hooks/useUiFlag'; +import { EditableConstraintWrapper } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper'; export interface IConstraintAccordionListProps { constraints: IConstraint[]; @@ -86,6 +87,7 @@ export const NewConstraintAccordionList = forwardRef< >(({ constraints, setConstraints, state }, ref) => { const { context } = useUnleashContext(); const flagOverviewRedesign = useUiFlag('flagOverviewRedesign'); + const addEditStrategy = useUiFlag('addEditStrategy'); const onEdit = setConstraints && @@ -146,19 +148,36 @@ export const NewConstraintAccordionList = forwardRef< return ( - {constraints.map((constraint, index) => ( - - ))} + {constraints.map((constraint, index) => + addEditStrategy ? ( + + ) : ( + + ), + )} ); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx new file mode 100644 index 0000000000..9209a71f2f --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint.tsx @@ -0,0 +1,334 @@ +import { styled } from '@mui/material'; +import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; +import { DateSingleValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue'; +import { FreeTextInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput'; +import { RestrictiveLegalValues } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues'; +import { SingleLegalValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue'; +import { SingleValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue'; +import { + DATE_OPERATORS_SINGLE_VALUE, + IN_OPERATORS_FREETEXT, + IN_OPERATORS_LEGAL_VALUES, + NUM_OPERATORS_LEGAL_VALUES, + NUM_OPERATORS_SINGLE_VALUE, + SEMVER_OPERATORS_LEGAL_VALUES, + SEMVER_OPERATORS_SINGLE_VALUE, + STRING_OPERATORS_FREETEXT, + STRING_OPERATORS_LEGAL_VALUES, + type Input, +} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput'; +import { CaseSensitiveButton } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton'; +import { ConstraintOperatorSelect } from 'component/common/NewConstraintAccordion/ConstraintOperatorSelect'; +import { + DATE_AFTER, + dateOperators, + IN, + stringOperators, + type Operator, +} from 'constants/operators'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import type { + ILegalValue, + IUnleashContextDefinition, +} from 'interfaces/context'; +import type { IConstraint } from 'interfaces/strategy'; +import { useEffect, useState, type FC } from 'react'; +import { oneOf } from 'utils/oneOf'; +import { + CURRENT_TIME_CONTEXT_FIELD, + operatorsForContext, +} from 'utils/operatorsForContext'; + +const Container = styled('article')(({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadiusLarge, + border: `1px solid ${theme.palette.divider}`, +})); + +const resolveLegalValues = ( + values: IConstraint['values'], + legalValues: IUnleashContextDefinition['legalValues'], +): { legalValues: ILegalValue[]; deletedLegalValues: ILegalValue[] } => { + if (legalValues?.length === 0) { + return { + legalValues: [], + deletedLegalValues: [], + }; + } + + const deletedLegalValues = (values || []) + .filter( + (value) => + !(legalValues || []).some( + ({ value: legalValue }) => legalValue === value, + ), + ) + .map((v) => ({ value: v, description: '' })); + + return { + legalValues: legalValues || [], + deletedLegalValues, + }; +}; + +type Props = { + localConstraint: IConstraint; + setContextName: (contextName: string) => void; + setOperator: (operator: Operator) => void; + setLocalConstraint: React.Dispatch>; + action: string; + onDelete?: () => void; + setInvertedOperator: () => void; + setCaseInsensitive: () => void; + onUndo: () => void; + constraintChanges: IConstraint[]; + contextDefinition: Pick; + constraintValues: string[]; + constraintValue: string; + setValue: (value: string) => void; + setValues: (values: string[]) => void; + setValuesWithRecord: (values: string[]) => void; + setError: React.Dispatch>; + removeValue: (index: number) => void; + input: Input; + error: string; +}; +export const EditableConstraint: FC = ({ + constraintChanges, + localConstraint, + setLocalConstraint, + setContextName, + setOperator, + onDelete, + onUndo, + setInvertedOperator, + setCaseInsensitive, + input, + contextDefinition, + constraintValues, + constraintValue, + setValue, + setValues, + setValuesWithRecord, + setError, + removeValue, + error, +}) => { + const { context } = useUnleashContext(); + const { contextName, operator } = localConstraint; + const [showCaseSensitiveButton, setShowCaseSensitiveButton] = + useState(false); + + /* We need a special case to handle the currentTime context field. Since + this field will be the only one to allow DATE_BEFORE and DATE_AFTER operators + this will check if the context field is the current time context field AND check + if it is not already using one of the date operators (to not overwrite if there is existing + data). */ + useEffect(() => { + if ( + contextName === CURRENT_TIME_CONTEXT_FIELD && + !oneOf(dateOperators, operator) + ) { + setLocalConstraint((prev) => ({ + ...prev, + operator: DATE_AFTER, + value: new Date().toISOString(), + })); + } else if ( + contextName !== CURRENT_TIME_CONTEXT_FIELD && + oneOf(dateOperators, operator) + ) { + setOperator(IN); + } + + if (oneOf(stringOperators, operator)) { + setShowCaseSensitiveButton(true); + } else { + setShowCaseSensitiveButton(false); + } + }, [contextName, setOperator, operator, setLocalConstraint]); + + if (!context) { + return null; + } + + const constraintNameOptions = context.map((context) => { + return { key: context.name, label: context.name }; + }); + + const onOperatorChange = (operator: Operator) => { + if (oneOf(stringOperators, operator)) { + setShowCaseSensitiveButton(true); + } else { + setShowCaseSensitiveButton(false); + } + + if (oneOf(dateOperators, operator)) { + setLocalConstraint((prev) => ({ + ...prev, + operator: operator, + value: new Date().toISOString(), + })); + } else { + setOperator(operator); + } + }; + + const resolveInput = () => { + switch (input) { + case IN_OPERATORS_LEGAL_VALUES: + case STRING_OPERATORS_LEGAL_VALUES: + return ( + <> + + + ); + 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 ( + + + + + {/* this is how to style them */} + {/* */} + {showCaseSensitiveButton ? ( + + ) : null} + {resolveInput()} + {/*
    +
  • + console.log('Clicked')} + /> +
  • +
  • + console.log('Clicked')} + /> +
  • +
*/} +
+ ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper.tsx new file mode 100644 index 0000000000..44b69b4b57 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraintWrapper.tsx @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { IConstraint } from 'interfaces/strategy'; +import { cleanConstraint } from 'utils/cleanConstraint'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import type { IUnleashContextDefinition } from 'interfaces/context'; +import type { Operator } from 'constants/operators'; +import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint'; +import { useConstraintInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput'; + +interface IConstraintAccordionEditProps { + constraint: IConstraint; + onCancel: () => void; + onSave: (constraint: IConstraint) => void; + onDelete?: () => void; + onAutoSave?: (constraint: IConstraint) => void; +} + +export const CANCEL = 'cancel'; +export const SAVE = 'save'; + +const resolveContextDefinition = ( + context: IUnleashContextDefinition[], + contextName: string, +): IUnleashContextDefinition => { + const definition = context.find( + (contextDef) => contextDef.name === contextName, + ); + + return ( + definition || { + name: '', + description: '', + createdAt: '', + sortOrder: 1, + stickiness: false, + } + ); +}; + +export const EditableConstraintWrapper = ({ + constraint, + onSave, + onDelete, + onAutoSave, +}: IConstraintAccordionEditProps) => { + const [localConstraint, setLocalConstraint] = useState( + cleanConstraint(constraint), + ); + const [constraintChanges, setConstraintChanges] = useState([ + cleanConstraint(constraint), + ]); + + const { context } = useUnleashContext(); + const [contextDefinition, setContextDefinition] = useState( + resolveContextDefinition(context, localConstraint.contextName), + ); + const { validateConstraint } = useFeatureApi(); + const [action, setAction] = useState(''); + + const { input, validator, setError, error } = useConstraintInput({ + contextDefinition, + localConstraint, + }); + + useEffect(() => { + setContextDefinition( + resolveContextDefinition(context, localConstraint.contextName), + ); + }, [localConstraint.contextName, context]); + + useEffect(() => { + setError(''); + }, [setError]); + + const onUndo = () => { + if (constraintChanges.length < 2) return; + const previousChange = constraintChanges[constraintChanges.length - 2]; + + setLocalConstraint(previousChange); + setConstraintChanges((prev) => prev.slice(0, prev.length - 1)); + autoSave(previousChange); + }; + + const autoSave = (localConstraint: IConstraint) => { + if (onAutoSave) { + onAutoSave(localConstraint); + } + }; + + const recordChange = (localConstraint: IConstraint) => { + setConstraintChanges((prev) => [...prev, localConstraint]); + autoSave(localConstraint); + }; + + const setContextName = useCallback((contextName: string) => { + setLocalConstraint((prev) => { + const localConstraint = cleanConstraint({ + ...prev, + contextName, + values: [], + value: '', + }); + + recordChange(localConstraint); + + return localConstraint; + }); + }, []); + + const setOperator = useCallback((operator: Operator) => { + setLocalConstraint((prev) => { + const localConstraint = cleanConstraint({ + ...prev, + operator, + values: [], + value: '', + }); + + recordChange(localConstraint); + + return localConstraint; + }); + }, []); + + const setValuesWithRecord = useCallback((values: string[]) => { + setLocalConstraint((prev) => { + const localConstraint = { ...prev, values }; + + recordChange(localConstraint); + + return localConstraint; + }); + }, []); + + const setValues = useCallback((values: string[]) => { + setLocalConstraint((prev) => { + const localConstraint = { ...prev, values }; + + return localConstraint; + }); + }, []); + + const setValue = useCallback((value: string) => { + setLocalConstraint((prev) => { + const localConstraint = { ...prev, value }; + + recordChange(localConstraint); + + return localConstraint; + }); + }, []); + + const setInvertedOperator = () => { + setLocalConstraint((prev) => { + const localConstraint = { ...prev, inverted: !prev.inverted }; + + recordChange(localConstraint); + + return localConstraint; + }); + }; + + const setCaseInsensitive = useCallback(() => { + setLocalConstraint((prev) => { + const localConstraint = { + ...prev, + caseInsensitive: !prev.caseInsensitive, + }; + + recordChange(localConstraint); + + return localConstraint; + }); + }, []); + + const removeValue = useCallback( + (index: number) => { + const valueCopy = [...localConstraint.values!]; + valueCopy.splice(index, 1); + + setValuesWithRecord(valueCopy); + }, + [localConstraint, setValuesWithRecord], + ); + + return ( + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx index e7f79838eb..aea4fc7f69 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList.tsx @@ -18,8 +18,6 @@ interface IConstraintAccordionListProps { constraints: IConstraint[]; setConstraints?: React.Dispatch>; showCreateButton?: boolean; - /* Add "constraints" title on the top - default `true` */ - showLabel?: boolean; } export const constraintAccordionListId = 'constraintAccordionListId';