AddToCampaign save message, Submitting indicator on FormAction button

This commit is contained in:
Christopher Joe 2016-08-15 14:48:03 +12:00 committed by Ingo Schommer
parent d7663e850e
commit b9624994ac
14 changed files with 291 additions and 137 deletions

View File

@ -22,7 +22,7 @@
} }
_jQuery2.default.entwine('ss', function ($) { _jQuery2.default.entwine('ss', function ($) {
$('.add-to-campaign-action, #add-to-campaign__action').entwine({ $('#add-to-campaign__dialog .add-to-campaign-action,' + '.cms-content-actions .add-to-campaign-action,' + '#add-to-campaign__action').entwine({
onclick: function onclick() { onclick: function onclick() {
var dialog = $('#add-to-campaign__dialog'); var dialog = $('#add-to-campaign__dialog');

View File

@ -2,24 +2,47 @@ import React from 'react';
import { Modal } from 'react-bootstrap-4'; import { Modal } from 'react-bootstrap-4';
import SilverStripeComponent from 'lib/SilverStripeComponent'; import SilverStripeComponent from 'lib/SilverStripeComponent';
import FormBuilder from 'components/FormBuilder/FormBuilder'; import FormBuilder from 'components/FormBuilder/FormBuilder';
import Config from 'lib/Config';
class AddToCampaignModal extends SilverStripeComponent { class AddToCampaignModal extends SilverStripeComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
response: null,
error: false,
};
} }
handleSubmit(event, fieldValues, submitFn) { handleSubmit(event, fieldValues, submitFn) {
let promise = null;
if (typeof this.props.handleSubmit === 'function') { if (typeof this.props.handleSubmit === 'function') {
this.props.handleSubmit(event, fieldValues, submitFn); promise = this.props.handleSubmit(event, fieldValues, submitFn);
return; } else {
event.preventDefault();
promise = submitFn();
} }
event.preventDefault(); if (promise) {
submitFn(); promise
.then((response) => {
// show response
if (typeof response === 'string' || response.response.ok) {
this.setState({
response,
error: false,
});
} else {
this.setState({
response: `${response.name}: ${response.message}`,
error: true,
});
}
return response;
});
}
return promise;
} }
getBody() { getBody() {
@ -28,25 +51,60 @@ class AddToCampaignModal extends SilverStripeComponent {
return <FormBuilder schemaUrl={schemaUrl} handleSubmit={this.handleSubmit} />; return <FormBuilder schemaUrl={schemaUrl} handleSubmit={this.handleSubmit} />;
} }
getResponse() {
if (!this.state.response) {
return null;
}
let className = 'add-to-campaign__response';
if (this.state.error) {
className += ' add-to-campaign__response--error';
} else {
className += ' add-to-campaign__response--good';
}
return (
<div className={className}>
<span>{this.state.response}</span>
</div>
);
}
clearResponse() {
// TODO to be used with "Try again" and other options later
this.setState({
response: null,
});
}
render() { render() {
const body = this.getBody(); const body = this.getBody();
const response = this.getResponse();
return <Modal show={this.props.show} onHide={this.props.handleHide} container={document.getElementsByClassName('cms-container')[0]}> return (
<Modal.Header closeButton> <Modal
<Modal.Title>{this.props.title + ' - Test'}</Modal.Title> show={this.props.show}
</Modal.Header> onHide={this.props.handleHide}
<Modal.Body> container={document.getElementsByClassName('cms-container')[0]}
{body} >
</Modal.Body> <Modal.Header closeButton>
</Modal>; <Modal.Title>{this.props.title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{body}
{response}
</Modal.Body>
</Modal>
);
} }
} }
AddToCampaignModal.propTypes = { AddToCampaignModal.propTypes = {
show: React.PropTypes.bool.isRequired, show: React.PropTypes.bool.isRequired,
title: React.PropTypes.string, title: React.PropTypes.string,
handleHide: React.PropTypes.func, handleHide: React.PropTypes.func,
schemaUrl: React.PropTypes.string, schemaUrl: React.PropTypes.string,
handleSubmit: React.PropTypes.func, handleSubmit: React.PropTypes.func,
}; };

View File

@ -0,0 +1,63 @@
/* global jest, describe, beforeEach, it, expect */
jest.unmock('react');
jest.unmock('react-addons-test-utils');
jest.unmock('../AddToCampaignModal');
import React from 'react';
import ReactTestUtils from 'react-addons-test-utils';
import AddToCampaignModal from '../AddToCampaignModal';
describe('AddToCampaignModal', () => {
let props;
beforeEach(() => {
props = {
title: '',
show: false,
handleHide: jest.genMockFunction(),
};
});
describe('getResponse()', () => {
let addToCampaignModal;
let response;
let message;
beforeEach(() => {
addToCampaignModal = ReactTestUtils.renderIntoDocument(
<AddToCampaignModal {...props} />
);
response = addToCampaignModal.getResponse();
message = 'My message';
});
it('should show no response initially', () => {
expect(response).toBeNull();
});
it('should show error message', () => {
message = 'This is an error';
addToCampaignModal.state = {
response: message,
error: true,
};
const responseDom = ReactTestUtils.renderIntoDocument(addToCampaignModal.getResponse());
expect(responseDom.classList.contains('add-to-campaign__response--error')).toBe(true);
expect(responseDom.textContent).toBe(message);
});
it('should show success message', () => {
message = 'This is a success';
addToCampaignModal.state = {
response: message,
error: false,
};
const responseDom = ReactTestUtils.renderIntoDocument(addToCampaignModal.getResponse());
expect(responseDom.classList.contains('add-to-campaign__response--good')).toBe(true);
expect(responseDom.textContent).toBe(message);
});
});
});

View File

@ -44,9 +44,9 @@ class FormAction extends SilverStripeComponent {
const buttonClasses = ['btn']; const buttonClasses = ['btn'];
// Add 'type' class // Add 'type' class
const bootstrapStyle = this.getBootstrapButtonStyle(); const style = this.getButtonStyle();
if (bootstrapStyle) { if (style) {
buttonClasses.push(`btn-${bootstrapStyle}`); buttonClasses.push(`btn-${style}`);
} }
// If there is no text // If there is no text
@ -82,13 +82,22 @@ class FormAction extends SilverStripeComponent {
* *
* @return {String} * @return {String}
*/ */
getBootstrapButtonStyle() { getButtonStyle() {
// Add 'type' class // Add 'type' class
if (typeof this.props.bootstrapButtonStyle !== 'undefined') { if (typeof this.props.data.buttonStyle !== 'undefined') {
return this.props.bootstrapButtonStyle; return this.props.data.buttonStyle;
} }
if (this.props.name === 'action_save') { const extraClasses = this.props.extraClass.split(' ');
// defined their own `btn-${something}` class
if (extraClasses.find((className) => className.indexOf('btn-') > -1)) {
return null;
}
if (this.props.name === 'action_save' ||
extraClasses.find((className) => className === 'ss-ui-action-constructive')
) {
return 'primary'; return 'primary';
} }
@ -134,9 +143,7 @@ class FormAction extends SilverStripeComponent {
if (typeof this.props.handleClick === 'function') { if (typeof this.props.handleClick === 'function') {
this.props.handleClick(event, this.props.name || this.props.id); this.props.handleClick(event, this.props.name || this.props.id);
} }
} }
} }
FormAction.propTypes = { FormAction.propTypes = {
@ -148,7 +155,12 @@ FormAction.propTypes = {
loading: React.PropTypes.bool, loading: React.PropTypes.bool,
icon: React.PropTypes.string, icon: React.PropTypes.string,
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
bootstrapButtonStyle: React.PropTypes.string, data: React.PropTypes.oneOfType([
React.PropTypes.array,
React.PropTypes.shape({
buttonStyle: React.PropTypes.string,
}),
]),
extraClass: React.PropTypes.string, extraClass: React.PropTypes.string,
attributes: React.PropTypes.object, attributes: React.PropTypes.object,
}; };
@ -156,6 +168,7 @@ FormAction.propTypes = {
FormAction.defaultProps = { FormAction.defaultProps = {
title: '', title: '',
icon: '', icon: '',
extraClass: '',
attributes: {}, attributes: {},
data: {}, data: {},
disabled: false, disabled: false,

View File

@ -172,9 +172,10 @@ export class FormBuilderComponent extends SilverStripeComponent {
} }
} }
handleAction(event, name) { handleAction(event, submitAction) {
this.props.formActions.setSubmitAction(this.getFormId(), submitAction);
if (typeof this.props.handleAction === 'function') { if (typeof this.props.handleAction === 'function') {
this.props.handleAction(event, name); this.props.handleAction(event, submitAction);
} }
} }
@ -227,12 +228,11 @@ export class FormBuilderComponent extends SilverStripeComponent {
); );
if (typeof this.props.handleSubmit !== 'undefined') { if (typeof this.props.handleSubmit !== 'undefined') {
this.props.handleSubmit(event, fieldValues, submitFn); return this.props.handleSubmit(event, fieldValues, submitFn);
return;
} }
event.preventDefault(); event.preventDefault();
submitFn(); return submitFn();
} }
buildComponent(field, extraProps = {}) { buildComponent(field, extraProps = {}) {
@ -288,9 +288,16 @@ export class FormBuilderComponent extends SilverStripeComponent {
* @return {Array} * @return {Array}
*/ */
mapActionsToComponents(actions) { mapActionsToComponents(actions) {
const form = this.props.form[this.getFormId()];
return actions.map((action) => { return actions.map((action) => {
const loading = (form && form.submitting && form.submitAction === action.name);
// Events // Events
const extraProps = { handleClick: this.handleAction }; const extraProps = {
handleClick: this.handleAction,
loading,
disabled: loading || action.disabled,
};
// Build child nodes // Build child nodes
if (action.children) { if (action.children) {

View File

@ -4,10 +4,6 @@ Generates a `<select><option></option></select>`
## Props ## Props
### leftTitle
The label text to display with the field.
### extraClass ### extraClass
Addition CSS classes to apply to the `<select>` element. Addition CSS classes to apply to the `<select>` element.
@ -22,20 +18,32 @@ Handler function called when the field's value changes.
### value ### value
The field's value. The field's selected value.
### source (required) ### source (required)
The list of possible values that could be selected The list of possible values that could be selected
### disabled #### value
A list of values within `source` that can be seen but not selected The value for this option.
### hasEmptyDefault #### title
The title or label displayed for users.
#### disabled
This option is shown but disabled from being selected.
### data
Additional field specific data
#### hasEmptyDefault
If true, create an empty value option first If true, create an empty value option first
### emptyString #### emptyString
When `hasEmptyDefault` is true, this sets the label for the option When `hasEmptyDefault` is true, this sets the label for the option

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import SilverStripeComponent from 'lib/SilverStripeComponent'; import SilverStripeComponent from 'lib/SilverStripeComponent';
//import fieldHolder from 'components/FieldHolder/FieldHolder';
import i18n from 'i18n';
class SingleSelectField extends SilverStripeComponent { class SingleSelectField extends SilverStripeComponent {
@ -10,10 +12,6 @@ class SingleSelectField extends SilverStripeComponent {
} }
render() { render() {
const labelText = this.props.leftTitle !== null
? this.props.leftTitle
: this.props.title;
let field = null; let field = null;
if (this.props.readOnly) { if (this.props.readOnly) {
field = this.getReadonlyField; field = this.getReadonlyField;
@ -21,22 +19,7 @@ class SingleSelectField extends SilverStripeComponent {
field = this.getSelectField(); field = this.getSelectField();
} }
// The extraClass property is defined on both the holder and element return field;
// for legacy reasons (same behaviour as PHP rendering)
const classNames = ['form-group', this.props.extraClass].join(' ');
return (
<div className={classNames}>
{labelText &&
<label className="form__field-label" htmlFor={`gallery_${this.props.name}`}>
{labelText}
</label>
}
<div className="form__field-holder">
{field}
</div>
</div>
);
} }
/** /**
@ -45,9 +28,9 @@ class SingleSelectField extends SilverStripeComponent {
* @returns ReactComponent * @returns ReactComponent
*/ */
getReadonlyField() { getReadonlyField() {
let label = this.props.source[this.props.value]; let label = this.props.source.find((item) => item.value === this.props.value);
label = label !== null label = label !== undefined
? label ? label
: this.props.value; : this.props.value;
@ -65,16 +48,23 @@ class SingleSelectField extends SilverStripeComponent {
if (this.props.hasEmptyDefault) { if (this.props.hasEmptyDefault) {
options.unshift({ options.unshift({
value: '', value: '',
title: this.props.emptyString title: this.props.emptyString,
disabled: false,
}); });
} }
return <select {...this.getInputProps()}> return (
{ options.map((item) => { <select {...this.getInputProps()}>
const key = `${this.props.name}-${item.value || 'null'}`; { options.map((item) => {
const key = `${this.props.name}-${item.value || 'null'}`;
return <option key={key} value={item.value} disabled={item.disabled}>{item.title}</option>; return (
}) } <option key={key} value={item.value} disabled={item.disabled}>
</select>; {item.title}
</option>
);
}) }
</select>
);
} }
/** /**
@ -87,10 +77,10 @@ class SingleSelectField extends SilverStripeComponent {
// The extraClass property is defined on both the holder and element // The extraClass property is defined on both the holder and element
// for legacy reasons (same behaviour as PHP rendering) // for legacy reasons (same behaviour as PHP rendering)
className: ['form-control', this.props.extraClass].join(' '), className: ['form-control', this.props.extraClass].join(' '),
id: this.props.id, id: this.props.id,
name: this.props.name, name: this.props.name,
onChange: this.handleChange, onChange: this.handleChange,
value: this.props.value, value: this.props.value,
}; };
} }
@ -104,25 +94,23 @@ class SingleSelectField extends SilverStripeComponent {
return; return;
} }
this.props.onChange(event, {id: this.props.id, value: event.target.value}); this.props.onChange(event, { id: this.props.id, value: event.target.value });
} }
} }
SingleSelectField.propTypes = { SingleSelectField.propTypes = {
leftTitle: React.PropTypes.string, id: React.PropTypes.string,
extraClass: React.PropTypes.string, name: React.PropTypes.string.isRequired,
id: React.PropTypes.string, onChange: React.PropTypes.func,
name: React.PropTypes.string.isRequired, value: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]),
onChange: React.PropTypes.func, readOnly: React.PropTypes.bool,
value: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]), source: React.PropTypes.arrayOf(React.PropTypes.shape({
readOnly: React.PropTypes.bool,
source: React.PropTypes.arrayOf(React.PropTypes.shape({
value: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]), value: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]),
title: React.PropTypes.string, title: React.PropTypes.string,
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
})), })),
hasEmptyDefault: React.PropTypes.bool, hasEmptyDefault: React.PropTypes.bool,
emptyString: React.PropTypes.string, emptyString: React.PropTypes.string,
}; };
SingleSelectField.defaultProps = { SingleSelectField.defaultProps = {

View File

@ -28,7 +28,7 @@ class TextField extends SilverStripeComponent {
* @returns Object properties * @returns Object properties
*/ */
getInputProps() { getInputProps() {
// @todo Merge with 'attributes' from formfield schema // TODO Merge with 'attributes' from formfield schema
return { return {
// The extraClass property is defined on both the holder and element // The extraClass property is defined on both the holder and element
// for legacy reasons (same behaviour as PHP rendering) // for legacy reasons (same behaviour as PHP rendering)
@ -56,8 +56,6 @@ class TextField extends SilverStripeComponent {
} }
TextField.propTypes = { TextField.propTypes = {
leftTitle: React.PropTypes.string,
title: React.PropTypes.string,
extraClass: React.PropTypes.string, extraClass: React.PropTypes.string,
id: React.PropTypes.string, id: React.PropTypes.string,
name: React.PropTypes.string.isRequired, name: React.PropTypes.string.isRequired,

View File

@ -1,63 +1,65 @@
import jQuery from 'jQuery'; import jQuery from 'jQuery';
jQuery.entwine('ss', ($) => { jQuery.entwine('ss', ($) => {
$('#add-to-campaign__dialog .add-to-campaign-action, .cms-content-actions .add-to-campaign-action, #add-to-campaign__action').entwine({ $('#add-to-campaign__dialog .add-to-campaign-action,' +
onclick() { '.cms-content-actions .add-to-campaign-action,' +
let dialog = $('#add-to-campaign__dialog'); '#add-to-campaign__action').entwine({
onclick() {
let dialog = $('#add-to-campaign__dialog');
if (dialog.length) { if (dialog.length) {
dialog.open(); dialog.open();
} else { } else {
dialog = $('<div id="add-to-campaign__dialog" class="add-to-campaign__dialog" />'); dialog = $('<div id="add-to-campaign__dialog" class="add-to-campaign__dialog" />');
$('body').append(dialog); $('body').append(dialog);
} }
if (dialog.children().length === 0) dialog.addClass('loading'); if (dialog.children().length === 0) dialog.addClass('loading');
const form = this.closest('form'); const form = this.closest('form');
const button = this; const button = this;
const formData = form.serializeArray(); const formData = form.serializeArray();
formData.push({ formData.push({
name: button.attr('name'), name: button.attr('name'),
value: '1', value: '1',
}); });
$.ajax({ $.ajax({
url: form.attr('action'), url: form.attr('action'),
data: formData, data: formData,
type: 'POST', type: 'POST',
global: false, global: false,
complete() { complete() {
dialog.removeClass('loading'); dialog.removeClass('loading');
}, },
success(data, status, xhr) { success(data, status, xhr) {
if (xhr.getResponseHeader('Content-Type').indexOf('text/plain') === 0) { if (xhr.getResponseHeader('Content-Type').indexOf('text/plain') === 0) {
const container = $(
'<div class="add-to-campaign__response add-to-campaign__response--good">' +
'<span></span></div>'
);
container.find('span').text(data);
dialog.append(container);
} else {
dialog.html(data);
}
},
error(xhr) {
const error = xhr.responseText
|| 'Something went wrong. Please try again in a few minutes.';
const container = $( const container = $(
'<div class="add-to-campaign__response add-to-campaign__response--good">' + '<div class="add-to-campaign__response add-to-campaign__response--error">' +
'<span></span></div>' '<span></span></div>'
); );
container.find('span').text(data); container.find('span').text(error);
dialog.append(container); dialog.append(container);
} else { },
dialog.html(data); });
}
},
error(xhr) {
const error = xhr.responseText
|| 'Something went wrong. Please try again in a few minutes.';
const container = $(
'<div class="add-to-campaign__response add-to-campaign__response--error">' +
'<span></span></div>'
);
container.find('span').text(error);
dialog.append(container);
},
});
return false; return false;
}, },
}); });
$('#add-to-campaign__dialog').entwine({ $('#add-to-campaign__dialog').entwine({
onadd() { onadd() {

View File

@ -5,4 +5,5 @@ export const ACTION_TYPES = {
SUBMIT_FORM_REQUEST: 'SUBMIT_FORM_REQUEST', SUBMIT_FORM_REQUEST: 'SUBMIT_FORM_REQUEST',
SUBMIT_FORM_SUCCESS: 'SUBMIT_FORM_SUCCESS', SUBMIT_FORM_SUCCESS: 'SUBMIT_FORM_SUCCESS',
UPDATE_FIELD: 'UPDATE_FIELD', UPDATE_FIELD: 'UPDATE_FIELD',
SET_SUBMIT_ACTION: 'SET_SUBMIT_ACTION',
}; };

View File

@ -83,3 +83,12 @@ export function submitForm(submitApi, formId, fieldValues) {
}); });
}; };
} }
export function setSubmitAction(formId, submitAction) {
return (dispatch) => {
dispatch({
type: ACTION_TYPES.SET_SUBMIT_ACTION,
payload: { formId, submitAction },
});
};
}

View File

@ -64,6 +64,11 @@ function formReducer(state = initialState, action) {
submitting: false, submitting: false,
})); }));
case ACTION_TYPES.SET_SUBMIT_ACTION:
return deepFreeze(updateForm(action.payload.formId, {
submitAction: action.payload.submitAction,
}));
default: default:
return state; return state;

View File

@ -21,7 +21,9 @@ class AddToCampaignHandler_FormAction extends FormAction
function __construct() function __construct()
{ {
parent::__construct('addtocampaign', _t('CAMPAIGNS.ADDTOCAMPAIGN', 'Add to campaign')); parent::__construct('addtocampaign', _t('CAMPAIGNS.ADDTOCAMPAIGN', 'Add to campaign'));
$this->setUseButtonTag(false);
$this->addExtraClass('add-to-campaign-action'); $this->addExtraClass('add-to-campaign-action');
$this->setValidationExempt(true); $this->setValidationExempt(true);
$this->addExtraClass('btn-primary');
} }
} }

View File

@ -45,7 +45,7 @@ class FormAction extends FormField {
/** /**
* Enables the use of <button> instead of <input> * Enables the use of <button> instead of <input>
* in {@link Field()} - for more customizeable styling. * in {@link Field()} - for more customisable styling.
* *
* @var boolean * @var boolean
*/ */