Skip to content

Commit

Permalink
Merge pull request #1214 from creative-commoners/pulls/5/always-inlin…
Browse files Browse the repository at this point in the history
…e-submit-elements

NEW Inline save all rendered element forms on parent form submit
  • Loading branch information
GuySartorelli authored Jul 24, 2024
2 parents ec9fe90 + 823cc53 commit fbbca6a
Show file tree
Hide file tree
Showing 17 changed files with 393 additions and 280 deletions.
16 changes: 8 additions & 8 deletions client/dist/js/bundle.js

Large diffs are not rendered by default.

43 changes: 32 additions & 11 deletions 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 && !doSaveElement) {
setDoSaveElement(true);
}
}, [props.saveElement, hasUnsavedChanges, props.increment]);

useEffect(() => {
if (props.connectDragPreview) {
// Use empty image as a drag preview so browsers don't draw it
Expand All @@ -52,17 +74,12 @@ const Element = (props) => {
captureDraggingState: true,
});
}
// 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) {
setFormHasRendered(true);
}
}, []);

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 +341,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 +355,7 @@ const Element = (props) => {
showSavedElementToast(title);
}
refetchElementalArea();
props.onAfterSubmitResponse(true);
};

const {
Expand Down Expand Up @@ -439,10 +459,6 @@ function mapStateToProps(state, ownProps) {
const tabSetName = tabSet && tabSet.id;
const uniqueFieldId = `element.${elementName}__${tabSetName}`;
const formDirty = state.unsavedForms.find((unsaved) => unsaved.name === `element.${elementName}`);
const formStateExists = state.form
&& state.form.formState
&& state.form.formState.element
&& state.form.formState.element.hasOwnProperty(elementName);

// Find name of the active tab in the tab set
// Only defined once an element form is expanded for the first time
Expand All @@ -455,7 +471,6 @@ function mapStateToProps(state, ownProps) {
tabSetName,
activeTab,
formDirty,
formStateExists,
};
}

Expand All @@ -467,6 +482,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 +519,11 @@ 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,
// Used to ensure form gets re-rendered on submission so it can be submitted again if there are validation errors
increment: PropTypes.number.isRequired,
};

Element.defaultProps = {
Expand Down
51 changes: 2 additions & 49 deletions client/src/components/ElementEditor/ElementEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ import PropTypes from 'prop-types';
import { inject } from 'lib/Injector';
import { compose } from 'redux';
import { elementTypeType } from 'types/elementTypeType';
import { connect } from 'react-redux';
import { loadElementFormStateName } from 'state/editor/loadElementFormStateName';
import { DropTarget } from 'react-dnd';
import sortBlockMutation from 'state/editor/sortBlockMutation';
import ElementDragPreview from 'components/ElementEditor/ElementDragPreview';
import withDragDropContext from 'lib/withDragDropContext';
import { createSelector } from 'reselect';

/**
* The ElementEditor is used in the CMS to manage a list or nested lists of
Expand Down Expand Up @@ -71,15 +68,14 @@ class ElementEditor extends PureComponent {

render() {
const {
fieldName,
formState,
ToolbarComponent,
ListComponent,
areaId,
elementTypes,
isDraggingOver,
connectDropTarget,
allowedElements,
sharedObject,
} = this.props;
const { dragTargetElementId, dragSpot } = this.state;

Expand All @@ -105,21 +101,15 @@ class ElementEditor extends PureComponent {
dragSpot={dragSpot}
isDraggingOver={isDraggingOver}
dragTargetElementId={dragTargetElementId}
sharedObject={sharedObject}
/>
<ElementDragPreview elementTypes={elementTypes} />
<input
name={fieldName}
type="hidden"
value={JSON.stringify(formState) || ''}
className="no-change-track"
/>
</div>
);
}
}

ElementEditor.propTypes = {
fieldName: PropTypes.string,
elementTypes: PropTypes.arrayOf(elementTypeType).isRequired,
allowedElements: PropTypes.arrayOf(PropTypes.string).isRequired,
areaId: PropTypes.number.isRequired,
Expand All @@ -128,50 +118,13 @@ ElementEditor.propTypes = {
}),
};

const defaultElementFormState = {};

// Use a memoization to prevent mapStateToProps() re-rendering on formstate changes
// Any formstate change, including unrelated ones such as from another FormBuilderLoader component
// will trigger the ElementalEditor to re-render
const elementFormSelector = createSelector([
(state) => {
const elementFormState = state.form.formState.element;

if (!elementFormState) {
// This needs to a reference to the defaultElementFormState variable rather than a new object
// or redux will think the state has changed and cause the component to re-render
return defaultElementFormState;
}

return elementFormState;
}], (elementFormState) => {
const formNamePattern = loadElementFormStateName('[0-9]+');

const filteredElementFormState = Object.keys(elementFormState)
.filter(key => key.match(formNamePattern))
.reduce((accumulator, key) => ({
...accumulator,
[key]: elementFormState[key].values
}), {});

return filteredElementFormState;
});

function mapStateToProps(state) {
// Memoize form state and value changes
const formState = elementFormSelector(state);

return { formState };
}

export { ElementEditor as Component };
export default compose(
withDragDropContext,
DropTarget('element', {}, (connector, monitor) => ({
connectDropTarget: connector.dropTarget(),
isDraggingOver: monitor.isOver(), // isDragging is not available on DropTargetMonitor
})),
connect(mapStateToProps),
inject(
['ElementToolbar', 'ElementList'],
(ToolbarComponent, ListComponent) => ({
Expand Down
120 changes: 116 additions & 4 deletions client/src/components/ElementEditor/ElementList.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,105 @@ 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 set to true in entwine.js in the 'onbeforesubmitform' "hook"
// which is triggered by LeftAndMain submitForm()
saveAllElements: false,
// increment is also set in entwine.js in the 'onbeforesubmitform' "hook"
increment: 0,
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) {
// Scenario: blocks props just changed after a graphql query response updated it
if (this.props.blocks !== prevProps.blocks) {
this.resetState(prevState, false);
return;
}
// Scenario Saving all elements and state has just updated because of a formSchema response from
// an inline save - see Element.js handleFormSchemaSubmitResponse()
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
const result = {
success: allValid,
reason: allValid ? '' : 'invalid',
};
this.props.sharedObject.entwineResolve(result);
this.resetState(prevState, allValid);
this.setState({ saveAllElements: false });
}
}
}

resetState(prevState, resetHasUnsavedChangesBlockIDs) {
// hasUnsavedChangesBlockIDs is the block dirty state and uses a boolean
const hasUnsavedChangesBlockIDs = {};
// validBlockIDs is the block validation state and uses a tri-state
// - null: not saved
// - true: saved, valid
// - false: attempted save, invalid
const validBlockIDs = {};
const blocks = this.props.blocks || [];
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 +149,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 +162,20 @@ 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)}
increment={this.state.increment}
/>
{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 Expand Up @@ -130,11 +237,16 @@ ElementList.propTypes = {
onDragOver: PropTypes.func,
onDragStart: PropTypes.func,
onDragEnd: PropTypes.func,
sharedObject: PropTypes.object.isRequired,
};

ElementList.defaultProps = {
blocks: [],
loading: false,
sharedObject: {
entwineResolve: () => {},
setState: null,
},
};

export { ElementList as Component };
Expand Down
5 changes: 5 additions & 0 deletions client/src/components/ElementEditor/tests/Element-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ function makeProps(obj = {}) {
connectDropTarget: (el) => el,
isDragging: false,
isOver: false,
onChangeHasUnsavedChanges: () => {},
onBeforeSubmitForm: () => {},
onAfterSubmitResponse: () => {},
saveElement: false,
increment: 0,
...obj,
};
}
Expand Down
Loading

0 comments on commit fbbca6a

Please sign in to comment.