mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: add undo (#5879)
This PR adds undo functionality so you can restore the state of your constraint if you make a mistake. We also amend the autosave functionality to only apply when values are changed and you have a valid value. See demo: https://www.loom.com/share/da704da8aee94ac18d4caae697426802
This commit is contained in:
		
							parent
							
								
									0ab42ab45c
								
							
						
					
					
						commit
						f7b285d340
					
				@ -81,7 +81,6 @@ const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
 | 
			
		||||
export const ConstraintAccordionEdit = ({
 | 
			
		||||
    constraint,
 | 
			
		||||
    compact,
 | 
			
		||||
    onCancel,
 | 
			
		||||
    onSave,
 | 
			
		||||
    onDelete,
 | 
			
		||||
    onAutoSave,
 | 
			
		||||
@ -89,6 +88,9 @@ export const ConstraintAccordionEdit = ({
 | 
			
		||||
    const [localConstraint, setLocalConstraint] = useState<IConstraint>(
 | 
			
		||||
        cleanConstraint(constraint),
 | 
			
		||||
    );
 | 
			
		||||
    const [constraintChanges, setConstraintChanges] = useState<IConstraint[]>([
 | 
			
		||||
        cleanConstraint(constraint),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const { context } = useUnleashContext();
 | 
			
		||||
    const [contextDefinition, setContextDefinition] = useState(
 | 
			
		||||
@ -98,40 +100,89 @@ export const ConstraintAccordionEdit = ({
 | 
			
		||||
    const [expanded, setExpanded] = useState(false);
 | 
			
		||||
    const [action, setAction] = useState('');
 | 
			
		||||
 | 
			
		||||
    const { input, validator, setError, error } = useConstraintInput({
 | 
			
		||||
        contextDefinition,
 | 
			
		||||
        localConstraint,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    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]);
 | 
			
		||||
 | 
			
		||||
    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));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const recordChange = (localConstraint: IConstraint) => {
 | 
			
		||||
        setConstraintChanges((prev) => [...prev, localConstraint]);
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
            onAutoSave &&
 | 
			
		||||
            localConstraint.values &&
 | 
			
		||||
            localConstraint.values.length > 0
 | 
			
		||||
        ) {
 | 
			
		||||
            onAutoSave(localConstraint);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (onAutoSave && localConstraint.value) {
 | 
			
		||||
            onAutoSave(localConstraint);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const setContextName = useCallback((contextName: string) => {
 | 
			
		||||
        setLocalConstraint((prev) => ({
 | 
			
		||||
        setLocalConstraint((prev) => {
 | 
			
		||||
            const localConstraint = cleanConstraint({
 | 
			
		||||
                ...prev,
 | 
			
		||||
                contextName,
 | 
			
		||||
                values: [],
 | 
			
		||||
                value: '',
 | 
			
		||||
        }));
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            recordChange(localConstraint);
 | 
			
		||||
 | 
			
		||||
            return localConstraint;
 | 
			
		||||
        });
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const setOperator = useCallback((operator: Operator) => {
 | 
			
		||||
        setLocalConstraint((prev) => ({
 | 
			
		||||
        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[]) => {
 | 
			
		||||
@ -143,18 +194,36 @@ export const ConstraintAccordionEdit = ({
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const setValue = useCallback((value: string) => {
 | 
			
		||||
        setLocalConstraint((prev) => ({ ...prev, value }));
 | 
			
		||||
        setLocalConstraint((prev) => {
 | 
			
		||||
            const localConstraint = { ...prev, value };
 | 
			
		||||
 | 
			
		||||
            recordChange(localConstraint);
 | 
			
		||||
 | 
			
		||||
            return localConstraint;
 | 
			
		||||
        });
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const setInvertedOperator = () => {
 | 
			
		||||
        setLocalConstraint((prev) => ({ ...prev, inverted: !prev.inverted }));
 | 
			
		||||
        setLocalConstraint((prev) => {
 | 
			
		||||
            const localConstraint = { ...prev, inverted: !prev.inverted };
 | 
			
		||||
 | 
			
		||||
            recordChange(localConstraint);
 | 
			
		||||
 | 
			
		||||
            return localConstraint;
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const setCaseInsensitive = useCallback(() => {
 | 
			
		||||
        setLocalConstraint((prev) => ({
 | 
			
		||||
        setLocalConstraint((prev) => {
 | 
			
		||||
            const localConstraint = {
 | 
			
		||||
                ...prev,
 | 
			
		||||
                caseInsensitive: !prev.caseInsensitive,
 | 
			
		||||
        }));
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            recordChange(localConstraint);
 | 
			
		||||
 | 
			
		||||
            return localConstraint;
 | 
			
		||||
        });
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const removeValue = useCallback(
 | 
			
		||||
@ -207,18 +276,6 @@ export const ConstraintAccordionEdit = ({
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const { input, validator, setError, error } = useConstraintInput({
 | 
			
		||||
        contextDefinition,
 | 
			
		||||
        localConstraint,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        setError('');
 | 
			
		||||
        setLocalConstraint((localConstraint) =>
 | 
			
		||||
            cleanConstraint(localConstraint),
 | 
			
		||||
        );
 | 
			
		||||
    }, [localConstraint.operator, localConstraint.contextName, setError]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledForm>
 | 
			
		||||
            <StyledAccordion
 | 
			
		||||
@ -243,6 +300,8 @@ export const ConstraintAccordionEdit = ({
 | 
			
		||||
                        setInvertedOperator={setInvertedOperator}
 | 
			
		||||
                        setCaseInsensitive={setCaseInsensitive}
 | 
			
		||||
                        onDelete={onDelete}
 | 
			
		||||
                        onUndo={onUndo}
 | 
			
		||||
                        constraintChanges={constraintChanges}
 | 
			
		||||
                    />
 | 
			
		||||
                </StyledAccordionSummary>
 | 
			
		||||
 | 
			
		||||
@ -257,6 +316,7 @@ export const ConstraintAccordionEdit = ({
 | 
			
		||||
                    >
 | 
			
		||||
                        <ResolveInput
 | 
			
		||||
                            setValues={setValues}
 | 
			
		||||
                            setValuesWithRecord={setValuesWithRecord}
 | 
			
		||||
                            setValue={setValue}
 | 
			
		||||
                            setError={setError}
 | 
			
		||||
                            localConstraint={localConstraint}
 | 
			
		||||
 | 
			
		||||
@ -26,6 +26,7 @@ interface IResolveInputProps {
 | 
			
		||||
    constraintValue: string;
 | 
			
		||||
    setValue: (value: string) => void;
 | 
			
		||||
    setValues: (values: string[]) => void;
 | 
			
		||||
    setValuesWithRecord: (values: string[]) => void;
 | 
			
		||||
    setError: React.Dispatch<React.SetStateAction<string>>;
 | 
			
		||||
    removeValue: (index: number) => void;
 | 
			
		||||
    input: Input;
 | 
			
		||||
@ -66,6 +67,7 @@ export const ResolveInput = ({
 | 
			
		||||
    localConstraint,
 | 
			
		||||
    setValue,
 | 
			
		||||
    setValues,
 | 
			
		||||
    setValuesWithRecord,
 | 
			
		||||
    setError,
 | 
			
		||||
    removeValue,
 | 
			
		||||
    error,
 | 
			
		||||
@ -83,6 +85,7 @@ export const ResolveInput = ({
 | 
			
		||||
                            )}
 | 
			
		||||
                            constraintValues={constraintValues}
 | 
			
		||||
                            values={localConstraint.values || []}
 | 
			
		||||
                            setValuesWithRecord={setValuesWithRecord}
 | 
			
		||||
                            setValues={setValues}
 | 
			
		||||
                            error={error}
 | 
			
		||||
                            setError={setError}
 | 
			
		||||
@ -143,7 +146,7 @@ export const ResolveInput = ({
 | 
			
		||||
                    <FreeTextInput
 | 
			
		||||
                        values={localConstraint.values || []}
 | 
			
		||||
                        removeValue={removeValue}
 | 
			
		||||
                        setValues={setValues}
 | 
			
		||||
                        setValues={setValuesWithRecord}
 | 
			
		||||
                        error={error}
 | 
			
		||||
                        setError={setError}
 | 
			
		||||
                    />
 | 
			
		||||
@ -154,7 +157,7 @@ export const ResolveInput = ({
 | 
			
		||||
                        <FreeTextInput
 | 
			
		||||
                            values={localConstraint.values || []}
 | 
			
		||||
                            removeValue={removeValue}
 | 
			
		||||
                            setValues={setValues}
 | 
			
		||||
                            setValues={setValuesWithRecord}
 | 
			
		||||
                            error={error}
 | 
			
		||||
                            setError={setError}
 | 
			
		||||
                        />
 | 
			
		||||
 | 
			
		||||
@ -14,6 +14,7 @@ test('should show alert when you have illegal legal values', async () => {
 | 
			
		||||
            constraintValues={fixedValues}
 | 
			
		||||
            values={localValues}
 | 
			
		||||
            setValues={() => {}}
 | 
			
		||||
            setValuesWithRecord={() => {}}
 | 
			
		||||
            error={''}
 | 
			
		||||
            setError={() => {}}
 | 
			
		||||
        />,
 | 
			
		||||
@ -43,6 +44,7 @@ test('Should remove illegal legal values from internal value state when mounting
 | 
			
		||||
            constraintValues={fixedValues}
 | 
			
		||||
            values={localValues}
 | 
			
		||||
            setValues={setValues}
 | 
			
		||||
            setValuesWithRecord={() => {}}
 | 
			
		||||
            error={''}
 | 
			
		||||
            setError={() => {}}
 | 
			
		||||
        />,
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ interface IRestrictiveLegalValuesProps {
 | 
			
		||||
    constraintValues: string[];
 | 
			
		||||
    values: string[];
 | 
			
		||||
    setValues: (values: string[]) => void;
 | 
			
		||||
    setValuesWithRecord: (values: string[]) => void;
 | 
			
		||||
    beforeValues?: JSX.Element;
 | 
			
		||||
    error: string;
 | 
			
		||||
    setError: React.Dispatch<React.SetStateAction<string>>;
 | 
			
		||||
@ -53,6 +54,7 @@ export const RestrictiveLegalValues = ({
 | 
			
		||||
    data,
 | 
			
		||||
    values,
 | 
			
		||||
    setValues,
 | 
			
		||||
    setValuesWithRecord,
 | 
			
		||||
    error,
 | 
			
		||||
    setError,
 | 
			
		||||
    constraintValues,
 | 
			
		||||
@ -96,11 +98,11 @@ export const RestrictiveLegalValues = ({
 | 
			
		||||
            const index = values.findIndex((value) => value === legalValue);
 | 
			
		||||
            const newValues = [...values];
 | 
			
		||||
            newValues.splice(index, 1);
 | 
			
		||||
            setValues(newValues);
 | 
			
		||||
            setValuesWithRecord(newValues);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setValues([...cleanDeletedLegalValues(values), legalValue]);
 | 
			
		||||
        setValuesWithRecord([...cleanDeletedLegalValues(values), legalValue]);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -36,6 +36,8 @@ interface IConstraintAccordionViewHeader {
 | 
			
		||||
    onDelete?: () => void;
 | 
			
		||||
    setInvertedOperator: () => void;
 | 
			
		||||
    setCaseInsensitive: () => void;
 | 
			
		||||
    onUndo: () => void;
 | 
			
		||||
    constraintChanges: IConstraint[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const StyledHeaderContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
@ -90,11 +92,13 @@ const StyledHeaderText = styled('p')(({ theme }) => ({
 | 
			
		||||
 | 
			
		||||
export const ConstraintAccordionEditHeader = ({
 | 
			
		||||
    compact,
 | 
			
		||||
    constraintChanges,
 | 
			
		||||
    localConstraint,
 | 
			
		||||
    setLocalConstraint,
 | 
			
		||||
    setContextName,
 | 
			
		||||
    setOperator,
 | 
			
		||||
    onDelete,
 | 
			
		||||
    onUndo,
 | 
			
		||||
    setInvertedOperator,
 | 
			
		||||
    setCaseInsensitive,
 | 
			
		||||
}: IConstraintAccordionViewHeader) => {
 | 
			
		||||
@ -221,7 +225,12 @@ export const ConstraintAccordionEditHeader = ({
 | 
			
		||||
                    </StyledHeaderText>
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
            <ConstraintAccordionHeaderActions onDelete={onDelete} disableEdit />
 | 
			
		||||
            <ConstraintAccordionHeaderActions
 | 
			
		||||
                onDelete={onDelete}
 | 
			
		||||
                onUndo={onUndo}
 | 
			
		||||
                constraintChanges={constraintChanges}
 | 
			
		||||
                disableEdit
 | 
			
		||||
            />
 | 
			
		||||
        </StyledHeaderContainer>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,14 @@
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { IconButton, styled, Tooltip } from '@mui/material';
 | 
			
		||||
import { Delete, Edit } from '@mui/icons-material';
 | 
			
		||||
import { Delete, Edit, Undo } from '@mui/icons-material';
 | 
			
		||||
import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender';
 | 
			
		||||
import { IConstraint } from 'interfaces/strategy';
 | 
			
		||||
 | 
			
		||||
interface ConstraintAccordionHeaderActionsProps {
 | 
			
		||||
    onDelete?: () => void;
 | 
			
		||||
    onEdit?: () => void;
 | 
			
		||||
    onUndo?: () => void;
 | 
			
		||||
    constraintChanges?: IConstraint[];
 | 
			
		||||
    disableEdit?: boolean;
 | 
			
		||||
    disableDelete?: boolean;
 | 
			
		||||
}
 | 
			
		||||
@ -21,6 +24,8 @@ const StyledHeaderActions = styled('div')(({ theme }) => ({
 | 
			
		||||
export const ConstraintAccordionHeaderActions = ({
 | 
			
		||||
    onEdit,
 | 
			
		||||
    onDelete,
 | 
			
		||||
    onUndo,
 | 
			
		||||
    constraintChanges = [],
 | 
			
		||||
    disableDelete = false,
 | 
			
		||||
    disableEdit = false,
 | 
			
		||||
}: ConstraintAccordionHeaderActionsProps) => {
 | 
			
		||||
@ -38,6 +43,13 @@ export const ConstraintAccordionHeaderActions = ({
 | 
			
		||||
            onDelete();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    const onUndoClick =
 | 
			
		||||
        onUndo &&
 | 
			
		||||
        ((event: React.SyntheticEvent) => {
 | 
			
		||||
            event.stopPropagation();
 | 
			
		||||
            onUndo();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledHeaderActions>
 | 
			
		||||
            <ConditionallyRender
 | 
			
		||||
@ -48,12 +60,28 @@ export const ConstraintAccordionHeaderActions = ({
 | 
			
		||||
                            type='button'
 | 
			
		||||
                            onClick={onEditClick}
 | 
			
		||||
                            disabled={disableEdit}
 | 
			
		||||
                            data-testid='EDIT_CONSTRAINT_BUTTON'
 | 
			
		||||
                        >
 | 
			
		||||
                            <Edit />
 | 
			
		||||
                        </IconButton>
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
            <ConditionallyRender
 | 
			
		||||
                condition={Boolean(onUndoClick) && constraintChanges.length > 1}
 | 
			
		||||
                show={
 | 
			
		||||
                    <Tooltip title='Undo last change' arrow>
 | 
			
		||||
                        <IconButton
 | 
			
		||||
                            type='button'
 | 
			
		||||
                            onClick={onUndoClick}
 | 
			
		||||
                            disabled={disableDelete}
 | 
			
		||||
                            data-testid='UNDO_CONSTRAINT_CHANGE_BUTTON'
 | 
			
		||||
                        >
 | 
			
		||||
                            <Undo />
 | 
			
		||||
                        </IconButton>
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
            <ConditionallyRender
 | 
			
		||||
                condition={Boolean(onDeleteClick) && !disableDelete}
 | 
			
		||||
                show={
 | 
			
		||||
 | 
			
		||||
@ -277,6 +277,43 @@ describe('NewFeatureStrategyCreate', () => {
 | 
			
		||||
        expect(screen.getByText(values[2])).toBeInTheDocument();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should undo changes made to constraints', 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 inputEl = screen.getByPlaceholderText(
 | 
			
		||||
            'value1, value2, value3...',
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        fireEvent.change(inputEl, {
 | 
			
		||||
            target: { value: '6, 7, 8' },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const addBtn = await screen.findByText('Add values');
 | 
			
		||||
        addBtn.click();
 | 
			
		||||
 | 
			
		||||
        expect(screen.queryByText('6')).toBeInTheDocument();
 | 
			
		||||
        expect(screen.queryByText('7')).toBeInTheDocument();
 | 
			
		||||
        expect(screen.queryByText('8')).toBeInTheDocument();
 | 
			
		||||
 | 
			
		||||
        const undoBtn = await screen.findByTestId(
 | 
			
		||||
            'UNDO_CONSTRAINT_CHANGE_BUTTON',
 | 
			
		||||
        );
 | 
			
		||||
        undoBtn.click();
 | 
			
		||||
 | 
			
		||||
        expect(screen.queryByText('6')).not.toBeInTheDocument();
 | 
			
		||||
        expect(screen.queryByText('7')).not.toBeInTheDocument();
 | 
			
		||||
        expect(screen.queryByText('8')).not.toBeInTheDocument();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Should remove constraint when no valid values are set and moving between tabs', async () => {
 | 
			
		||||
        setupComponent();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user