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:
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) => ({
|
||||
...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}
|
||||
|
@ -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