Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW Inline save all rendered element forms on parent form submit #1214

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
}, []);

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,
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
};

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
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
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 };
}
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved

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;
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
});
this.setState({ hasUnsavedChangesBlockIDs, validBlockIDs });
}

handleChangeHasUnsavedChanges(elementID, hasUnsavedChanges) {
this.setState(prevState => ({
hasUnsavedChangesBlockIDs: {
...prevState.hasUnsavedChangesBlockIDs,
[elementID]: hasUnsavedChanges,
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
},
}));
}

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
Loading