1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-27 00:19:39 +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:
Fredrik Strand Oseberg 2024-01-15 08:47:59 +01:00 committed by GitHub
parent 0ab42ab45c
commit f7b285d340
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 184 additions and 43 deletions

View File

@ -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) => ({
...prev,
contextName,
values: [],
value: '',
}));
setLocalConstraint((prev) => {
const localConstraint = cleanConstraint({
...prev,
contextName,
values: [],
value: '',
});
recordChange(localConstraint);
return localConstraint;
});
}, []);
const setOperator = useCallback((operator: Operator) => {
setLocalConstraint((prev) => ({
...prev,
operator,
values: [],
value: '',
}));
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) => ({
...prev,
caseInsensitive: !prev.caseInsensitive,
}));
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}

View File

@ -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}
/>

View File

@ -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={() => {}}
/>,

View File

@ -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 (

View File

@ -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>
);
};

View File

@ -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={

View File

@ -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();