Skip to content
Snippets Groups Projects
Commit 628ca7d6 authored by Bálint Kléri's avatar Bálint Kléri Committed by Harumi Jang
Browse files

Issue #3463618 by balintbrews, bnjmnm, jessebaker: [PP-1] Include component props form in undo

parent bd8ee98e
No related branches found
No related tags found
1 merge request!180#3463618: Include component props form in undo
Pipeline #266747 failed
......@@ -29,3 +29,4 @@ unstorable
xbxb
vaul
cebe
uuidv
......@@ -83,4 +83,43 @@ describe('Undo/Redo functionality', { testIsolation: false }, () => {
},
);
});
it('Component props form values are included in Undo/Redo', () => {
cy.loadURLandWaitForXBLoaded();
// Click on our "hello, world!" hero component.
cy.getIframeBody().findByText('hello, world!').click();
// Add " one" to the heading field.
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.click()
.type(' one')
.wait(500); // Wait for debounce to finish to ensure undo history is updated.
// Add " two" to the heading field.
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.click()
.type(' two')
.wait(500); // Wait for debounce to finish to ensure undo history is updated.
// Click the Undo button, see if the value is "hello, world! one".
cy.get('button[aria-label="Undo"]').click();
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.should('have.value', 'hello, world! one');
// Click the Redo button, see if the value is "hello, world! one two".
cy.get('button[aria-label="Redo"]').click();
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.should('have.value', 'hello, world! one two');
// Click the Undo button twice, see if the value is "hello, world!".
cy.get('button[aria-label="Undo"]').click().click();
cy.findByTestId(/^xb-component-form-.*/)
.findByLabelText('Heading')
.should('have.value', 'hello, world!');
});
});
import type { Action, ThunkAction } from '@reduxjs/toolkit';
import type { Action, Middleware, ThunkAction } from '@reduxjs/toolkit';
import { combineSlices, configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { v4 as uuidv4 } from 'uuid';
import { uiSlice } from '@/features/ui/uiSlice';
import { addMenuSlice } from '@/features/ui/addMenuSlice';
import { componentApi } from '@/services/components';
import { layoutApi } from '@/services/layout';
import { previewApi } from '@/services/preview';
import undoable from 'redux-undo';
import undoable, { ActionCreators as UndoActionCreators } from 'redux-undo';
import { layoutModelReducer } from '@/features/layout/layoutModelSlice';
import { dummyPropsFormApi } from '@/services/dummyPropsForm';
import { configurationSlice } from '@/features/configuration/configurationSlice';
import { setLatestUndoRedoActionId } from '@/features/ui/uiSlice';
// `combineSlices` automatically combines the reducers using
// their `reducerPath`s, therefore we no longer need to call `combineReducers`.
......@@ -33,6 +35,25 @@ const rootReducer = combineSlices(
// Infer the `RootState` type from the root reducer
export type RootState = ReturnType<typeof rootReducer>;
// Middleware to add unique ID to undo/redo actions and store it.
const undoRedoActionIdMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
if (
(action as Action).type === UndoActionCreators.undo().type ||
(action as Action).type === UndoActionCreators.redo().type
) {
const id = uuidv4();
store.dispatch(setLatestUndoRedoActionId(id));
return next({
...(action as Action),
meta: {
id,
},
});
}
return next(action);
};
// The store setup is wrapped in `makeStore` to allow reuse
// when setting up tests that need the same store config
export const makeStore = (preloadedState?: Partial<RootState>) => {
......@@ -46,6 +67,7 @@ export const makeStore = (preloadedState?: Partial<RootState>) => {
layoutApi.middleware,
previewApi.middleware,
dummyPropsFormApi.middleware,
undoRedoActionIdMiddleware,
),
preloadedState,
});
......
......@@ -7,7 +7,10 @@ import twigToJSXComponentMap from '@/components/form/twig-to-jsx-component-map.j
import propsify from '@/local_packages/hyperscriptify/propsify/standard/index.js';
import { useAppSelector } from '@/app/hooks';
import { selectModel, selectLayout } from '@/features/layout/layoutModelSlice';
import { selectSelectedComponent } from '@/features/ui/uiSlice';
import {
selectSelectedComponent,
selectLatestUndoRedoActionId,
} from '@/features/ui/uiSlice';
import { useGetComponentsQuery } from '@/services/components';
import { findNodeByUuid } from '@/features/layout/layoutUtils';
......@@ -69,13 +72,17 @@ const DummyPropsEditFormRenderer: React.FC<DummyPropsEditFormRendererProps> = (
if (!xbDemoFieldElement?.content) {
return;
}
// While we have `selectedComponent` in the Redux store, we can't rely on it
// here, because if it's added as a dependency of this `useEffect` hook, it
// will cause a re-render using stale data from the Redux Toolkit Query hook
// — the API call. Instead we rely on fresh data from RTK Query to
// re-render, and we grab the selected component's ID from the arg that was
// passed to the API call which produced the current data.
// While we have `selectedComponent` and `latestUndoRedoActionId` in the
// Redux store, we can't rely on those values here, because if they are added
// as a dependency of this `useEffect` hook, they will cause a re-render
// using stale data from the Redux Toolkit Query hook — the API call.
// Instead we rely on fresh data from RTK Query to re-render, and we grab
// the values from the arg that was passed to the API call which produced
// the current data.
const componentId = new URLSearchParams(originalArgs).get('selected');
const latestUndoRedoActionId = new URLSearchParams(originalArgs).get(
'latestUndoRedoActionId',
);
setCurrentComponentId(componentId);
setJsxFormContent(
......@@ -86,7 +93,10 @@ const DummyPropsEditFormRenderer: React.FC<DummyPropsEditFormRendererProps> = (
// prop values are being updated by the user in the contextual panel,
// causing the form to lose focus.
// A `<div>` is used instead of `React.Fragment` so a test ID can be added.
<div key={componentId} data-testid={`xb-component-form-${componentId}`}>
<div
key={`${componentId}-${latestUndoRedoActionId}`}
data-testid={`xb-component-form-${componentId}`}
>
{hyperscriptify(
xbDemoFieldElement?.content as DocumentFragment,
React.createElement,
......@@ -131,6 +141,7 @@ const DummyPropsEditForm: React.FC<DummyPropsEditFormProps> = () => {
const { data: components, error } = useGetComponentsQuery();
const { showBoundary } = useErrorBoundary();
const selectedComponent = useAppSelector(selectSelectedComponent);
const latestUndoRedoActionId = useAppSelector(selectLatestUndoRedoActionId);
const [dynamicStaticCardQueryString, setDynamicStaticCardQueryString] =
useState('');
......@@ -212,9 +223,18 @@ const DummyPropsEditForm: React.FC<DummyPropsEditFormProps> = () => {
tree: JSON.stringify(tree),
props: JSON.stringify(preparedModel),
selected: selectedComponent,
latestUndoRedoActionId,
});
setDynamicStaticCardQueryString(`?${query.toString()}`);
}, [components, error, showBoundary, selectedComponent, layout, model]);
}, [
components,
error,
showBoundary,
selectedComponent,
latestUndoRedoActionId,
layout,
model,
]);
return (
dynamicStaticCardQueryString && (
......
......@@ -28,6 +28,7 @@ export interface uiSliceState {
hoveredComponent: string | undefined; //uuid of component
contextualPanelOpen: boolean;
canvasViewport: CanvasViewPort;
latestUndoRedoActionId: string;
}
type UpdateViewportPayload = {
......@@ -57,6 +58,7 @@ export const initialState: uiSliceState = {
y: 0,
scale: 1,
},
latestUndoRedoActionId: '',
};
interface ScaleValue {
......@@ -180,6 +182,11 @@ export const uiSlice = createAppSlice({
const prevIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : currentIndex;
state.canvasViewport.scale = scaleValues[prevIndex].scale;
}),
setLatestUndoRedoActionId: create.reducer(
(state, action: PayloadAction<string>) => {
state.latestUndoRedoActionId = action.payload;
},
),
}),
// You can define your selectors here. These selectors receive the slice
// state as their first argument.
......@@ -202,6 +209,9 @@ export const uiSlice = createAppSlice({
selectCanvasViewPort: (ui): CanvasViewPort => {
return ui.canvasViewport;
},
selectLatestUndoRedoActionId: (ui): string => {
return ui.latestUndoRedoActionId;
},
},
});
......@@ -222,6 +232,7 @@ export const {
canvasViewPortZoomIn,
canvasViewPortZoomOut,
canvasViewPortZoomDelta,
setLatestUndoRedoActionId,
} = uiSlice.actions;
// Selectors returned by `slice.selectors` take the root state as their first argument.
......@@ -232,4 +243,5 @@ export const {
selectHoveredComponent,
selectContextualPanelOpen,
selectCanvasViewPort,
selectLatestUndoRedoActionId,
} = uiSlice.selectors;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment