Skip to content

Commit

Permalink
NEW Inline save all rendered element forms on parent form submit
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Jun 26, 2024
1 parent 9ee52c5 commit 027a700
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 19 deletions.
16 changes: 8 additions & 8 deletions client/dist/js/bundle.js

Large diffs are not rendered by default.

32 changes: 31 additions & 1 deletion client/src/components/ElementEditor/Element.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,30 @@ const Element = (props) => {
const [ensureFormRendered, setEnsureFormRendered] = useState(false);
const [formHasRendered, setFormHasRendered] = useState(false);
const [doDispatchAddFormChanged, setDoDispatchAddFormChanged] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [publishBlock] = useMutation(publishBlockMutation);

useEffect(() => {
// Note that formDirty from redux can be set to undefined after failed validation
// which is confusing as the block still has unsaved changes, hence why we create
// this state variable to track this instead
// props.formDirty is either undefined (when pristine) or an object (when dirty)
const formDirty = typeof props.formDirty !== 'undefined';
if (formDirty && !hasUnsavedChanges) {
setHasUnsavedChanges(true);
}
}, [props.formDirty]);

useEffect(() => {
props.onChangeHasUnsavedChanges(hasUnsavedChanges);
}, [hasUnsavedChanges]);

useEffect(() => {
if (props.saveElement && hasUnsavedChanges) {
setDoSaveElement(true);
}
}, [props.saveElement, hasUnsavedChanges]);

useEffect(() => {
if (props.connectDragPreview) {
// Use empty image as a drag preview so browsers don't draw it
Expand All @@ -52,6 +74,7 @@ const Element = (props) => {
captureDraggingState: true,
});
}
// TODO: probably get rid of this as we no longer will save elemental data with the page
// Check if formSchema state has already been loaded before opening a block
// This can happen if there was a validation error on a block after performing a Page save
if (props.formStateExists) {
Expand All @@ -62,7 +85,7 @@ const Element = (props) => {
useEffect(() => {
if (justClickedPublishButton && formHasRendered) {
setJustClickedPublishButton(false);
if (props.formDirty) {
if (hasUnsavedChanges) {
// Save the element first before publishing, which may trigger validation errors
props.submitForm();
setDoPublishElementAfterSave(true);
Expand Down Expand Up @@ -324,9 +347,11 @@ const Element = (props) => {
if (doPublishElementAfterSave) {
setDoPublishElementAfterSave(false);
}
props.onAfterSubmitResponse(false);
return;
}
// Form is valid
setHasUnsavedChanges(false);
setNewTitle(title);
if (doPublishElementAfterSave) {
setDoPublishElementAfterSave(false);
Expand All @@ -336,6 +361,7 @@ const Element = (props) => {
showSavedElementToast(title);
}
refetchElementalArea();
props.onAfterSubmitResponse(true);
};

const {
Expand Down Expand Up @@ -467,6 +493,7 @@ function mapDispatchToProps(dispatch, ownProps) {
dispatch(TabsActions.activateTab(`element.${elementName}__${tabSetName}`, activeTabName));
},
submitForm() {
ownProps.onBeforeSubmitForm(ownProps.element.id);
// Perform a redux-form remote-submit
dispatch(submit(`element.${elementName}`));
},
Expand Down Expand Up @@ -503,6 +530,9 @@ Element.propTypes = {
onDragOver: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
onDragEnd: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
onDragStart: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
saveElement: PropTypes.bool.isRequired,
onBeforeSubmitForm: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types
onAfterSubmitResponse: PropTypes.func.isRequired,
};

Element.defaultProps = {
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/ElementEditor/ElementEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class ElementEditor extends PureComponent {
isDraggingOver,
connectDropTarget,
allowedElements,
sharedObject,
} = this.props;
const { dragTargetElementId, dragSpot } = this.state;

Expand All @@ -105,6 +106,7 @@ class ElementEditor extends PureComponent {
dragSpot={dragSpot}
isDraggingOver={isDraggingOver}
dragTargetElementId={dragTargetElementId}
sharedObject={sharedObject}
/>
<ElementDragPreview elementTypes={elementTypes} />
<input
Expand All @@ -128,6 +130,10 @@ ElementEditor.propTypes = {
}),
};

ElementEditor.defaultProps = {
ancestorSaveAllElements: false,
};

const defaultElementFormState = {};

// Use a memoization to prevent mapStateToProps() re-rendering on formstate changes
Expand Down
108 changes: 104 additions & 4 deletions client/src/components/ElementEditor/ElementList.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,99 @@ import { getDragIndicatorIndex } from 'lib/dragHelpers';
import { getElementTypeConfig } from 'state/editor/elementConfig';

class ElementList extends Component {
constructor(props) {
super(props);
this.resetState = this.resetState.bind(this);
this.handleBeforeSubmitForm = this.handleBeforeSubmitForm.bind(this);
this.handleAfterSubmitResponse = this.handleAfterSubmitResponse.bind(this);
this.state = {
// saveAllElements will be to true in entwine.js in the 'onbeforesubmitform' "hook"
// which is triggered by LeftAndMain submitForm()
saveAllElements: false,
hasUnsavedChangesBlockIDs: {},
validBlockIDs: {},
};
// Update the sharedObject so that setState() can be called from entwine.js
this.props.sharedObject.setState = this.setState.bind(this);
}

componentDidUpdate(prevProps, prevState) {
// Number of blocks just changed after graphql response
if (this.props.blocks !== prevProps.blocks) {
this.resetState(prevState, false);
return;
}
// Saving all elements and state has just updated because of a formSchema response from inline save
if (this.state.saveAllElements) {
const unsavedChangesBlockIDs = this.props.blocks
.map(block => parseInt(block.id, 10))
.filter(blockID => this.state.hasUnsavedChangesBlockIDs[blockID]);
const allValidated = unsavedChangesBlockIDs.every(blockID => this.state.validBlockIDs[blockID] !== null);
if (allValidated) {
const allValid = unsavedChangesBlockIDs.every(blockID => this.state.validBlockIDs[blockID]);
// entwineResolve is bound in entwine.js
this.props.sharedObject.entwineResolve(allValid);
this.resetState(prevState, allValid);
this.setState({ saveAllElements: false });
}
}
}

resetState(prevState, resetHasUnsavedChangesBlockIDs) {
if (this.props.blocks === null) {
return;
}
// validBlockIDs (though not hasUnsavedChangesBlockIDs) uses a tri-state
// - null: not saved
// - true: saved, valid
// - false: attempted save, invalid
const hasUnsavedChangesBlockIDs = {};
const validBlockIDs = {};
this.props.blocks.forEach(block => {
const blockID = parseInt(block.id, 10);
if (resetHasUnsavedChangesBlockIDs) {
hasUnsavedChangesBlockIDs[blockID] = false;
} else if (prevState.hasUnsavedChangesBlockIDs.hasOwnProperty(blockID)) {
hasUnsavedChangesBlockIDs[blockID] = prevState.hasUnsavedChangesBlockIDs[blockID];
} else {
hasUnsavedChangesBlockIDs[blockID] = false;
}
validBlockIDs[blockID] = null;
});
this.setState({ hasUnsavedChangesBlockIDs, validBlockIDs });
}

handleChangeHasUnsavedChanges(elementID, hasUnsavedChanges) {
this.setState(prevState => ({
hasUnsavedChangesBlockIDs: {
...prevState.hasUnsavedChangesBlockIDs,
[elementID]: hasUnsavedChanges,
},
}));
}

handleBeforeSubmitForm(elementID) {
this.setState(prevState => ({
validBlockIDs: {
...prevState.validBlockIDs,
[elementID]: null,
},
}));
}

handleAfterSubmitResponse(elementID, valid) {
this.setState(prevState => ({
hasUnsavedChangesBlockIDs: {
...prevState.hasUnsavedChangesBlockIDs,
[elementID]: !valid,
},
validBlockIDs: {
...prevState.validBlockIDs,
[elementID]: valid,
},
}));
}

getDragIndicatorIndex() {
const { dragTargetElementId, draggedItem, blocks, dragSpot } = this.props;
return getDragIndicatorIndex(
Expand Down Expand Up @@ -50,8 +143,11 @@ class ElementList extends Component {
return <div>{i18n._t('ElementList.ADD_BLOCKS', 'Add blocks to place your content')}</div>;
}

let output = blocks.map((element) => (
<div key={element.id}>
let output = blocks.map(element => {
const saveElement = this.state.saveAllElements
&& this.state.hasUnsavedChangesBlockIDs[element.id]
&& this.state.validBlockIDs[element.id] === null;
return <div key={element.id}>
<ElementComponent
element={element}
areaId={areaId}
Expand All @@ -60,15 +156,19 @@ class ElementList extends Component {
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
saveElement={saveElement}
onChangeHasUnsavedChanges={(hasUnsavedChanges) => this.handleChangeHasUnsavedChanges(element.id, hasUnsavedChanges)}
onBeforeSubmitForm={() => this.handleBeforeSubmitForm(element.id)}
onAfterSubmitResponse={(valid) => this.handleAfterSubmitResponse(element.id, valid)}
/>
{isDraggingOver || <HoverBarComponent
key={`create-after-${element.id}`}
areaId={areaId}
elementId={element.id}
elementTypes={allowedElementTypes}
/>}
</div>
));
</div>;
});

// Add a insert point above the first block for consistency
if (!isDraggingOver) {
Expand Down
45 changes: 42 additions & 3 deletions client/src/legacy/ElementEditor/entwine.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,26 @@ jQuery.entwine('ss', ($) => {
$('.js-injector-boot .element-editor__container').entwine({
ReactRoot: null,

onmatch() {
// This object is shared between entwine.js and the ElementList react component. It allows:
// - entwine to call setState() on ElementList
// - ElementList to call entwineResolve() on entwine
SharedObject: {
entwineResolve: null,
setState: null,
},

render() {
const context = {};
const ElementEditorComponent = loadComponent('ElementEditor', context);
const schemaData = this.data('schema');
const elementTypes = getConfig().elementTypes;

const props = {
fieldName: this.attr('name'),
areaId: schemaData['elemental-area-id'],
allowedElements: schemaData['allowed-elements'],
elementTypes,
sharedObject: this.getSharedObject(),
};

let root = this.getReactRoot();
if (!root) {
root = createRoot(this[0]);
Expand All @@ -64,12 +71,20 @@ jQuery.entwine('ss', ($) => {
root.render(<ElementEditorComponent {...props} />);
},

onmatch() {
this.render();
},

onunmatch() {
// Reset the store if the user navigates to a different part of the CMS
// or after submission if there are no validation errors
if (!$('.cms-edit-form').data('hasValidationErrors')) {
resetStores();
}
this.unmountComponent();
},

unmountComponent() {
const root = this.getReactRoot();
if (root) {
root.unmount();
Expand All @@ -78,6 +93,30 @@ jQuery.entwine('ss', ($) => {
},

'from .cms-edit-form': {
onbeforesubmitform(event, data) {
if (!data) {
return;
}
// Create a promise and expose the resolve function
// The promise is added to the data object when is used in LeftAndMain submitForm()
// as a condition for submitting the form
// The resolve function is called from the ElementList react component once all
// dirty element forms have been validated and saved
let entwineResolve;
const entwinePromise = new Promise((resolve) => {
entwineResolve = resolve;
});
data.promises.push(entwinePromise);
data.onAjaxSuccessCallbacks.push(this.unmountComponent.bind(this));
const sharedObject = this.getSharedObject();
sharedObject.entwineResolve = entwineResolve;
// setState() is bound in the constructor of the ElementList react component
// setting saveAllElementst to true will trigger a re-render in the react component
sharedObject.setState({
saveAllElements: true
});
},

onaftersubmitform(event, data) {
const validationResultPjax = JSON.parse(data.xhr.responseText).ValidationResult;
const validationResult = JSON.parse(validationResultPjax.replace(/<\/?script[^>]*?>/g, ''));
Expand Down
21 changes: 18 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2646,7 +2646,7 @@ cacache@^16.0.0, cacache@^16.1.0, cacache@^16.1.3:
tar "^6.1.11"
unique-filename "^2.0.0"

call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==
Expand All @@ -2672,6 +2672,11 @@ camelcase@^6.2.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==

caniuse-lite@^1.0.30001565:
version "1.0.30001636"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz#b15f52d2bdb95fad32c2f53c0b68032b85188a78"
integrity sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==

caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599:
version "1.0.30001603"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001603.tgz#605046a5bdc95ba4a92496d67e062522dce43381"
Expand Down Expand Up @@ -3300,6 +3305,11 @@ duplexer@^0.1.2:
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==

electron-to-chromium@^1.4.601:
version "1.4.810"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.810.tgz#7dee01b090b9e048e6db752f7b30921790230654"
integrity sha512-Kaxhu4T7SJGpRQx99tq216gCq2nMxJo+uuT6uzz9l8TVN2stL7M06MIIXAtr9jsrLs2Glflgf2vMQRepxawOdQ==

electron-to-chromium@^1.4.668:
version "1.4.722"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.722.tgz#99ae3484c5fc0f387d39ad98d77e1f259b9f4074"
Expand Down Expand Up @@ -4045,7 +4055,7 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==

get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4:
get-intrinsic@^1.0.2, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
Expand Down Expand Up @@ -6163,6 +6173,11 @@ object-inspect@^1.13.1:
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==

object-inspect@^1.9.0:
version "1.13.2"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff"
integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==

object-is@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
Expand Down Expand Up @@ -7174,7 +7189,7 @@ set-blocking@^2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==

set-function-length@^1.1.1:
set-function-length@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
Expand Down

0 comments on commit 027a700

Please sign in to comment.