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 ($) {
$('.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() {
var dialog = $('#add-to-campaign__dialog');

View File

@ -2,24 +2,47 @@ import React from 'react';
import { Modal } from 'react-bootstrap-4';
import SilverStripeComponent from 'lib/SilverStripeComponent';
import FormBuilder from 'components/FormBuilder/FormBuilder';
import Config from 'lib/Config';
class AddToCampaignModal extends SilverStripeComponent {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
response: null,
error: false,
};
}
handleSubmit(event, fieldValues, submitFn) {
let promise = null;
if (typeof this.props.handleSubmit === 'function') {
this.props.handleSubmit(event, fieldValues, submitFn);
return;
promise = this.props.handleSubmit(event, fieldValues, submitFn);
} else {
event.preventDefault();
promise = submitFn();
}
event.preventDefault();
submitFn();
if (promise) {
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() {
@ -28,25 +51,60 @@ class AddToCampaignModal extends SilverStripeComponent {
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() {
const body = this.getBody();
const response = this.getResponse();
return <Modal show={this.props.show} onHide={this.props.handleHide} container={document.getElementsByClassName('cms-container')[0]}>
<Modal.Header closeButton>
<Modal.Title>{this.props.title + ' - Test'}</Modal.Title>
</Modal.Header>
<Modal.Body>
{body}
</Modal.Body>
</Modal>;
return (
<Modal
show={this.props.show}
onHide={this.props.handleHide}
container={document.getElementsByClassName('cms-container')[0]}
>
<Modal.Header closeButton>
<Modal.Title>{this.props.title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{body}
{response}
</Modal.Body>
</Modal>
);
}
}
AddToCampaignModal.propTypes = {
show: React.PropTypes.bool.isRequired,
title: React.PropTypes.string,
show: React.PropTypes.bool.isRequired,
title: React.PropTypes.string,
handleHide: React.PropTypes.func,
schemaUrl: React.PropTypes.string,
schemaUrl: React.PropTypes.string,
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'];
// Add 'type' class
const bootstrapStyle = this.getBootstrapButtonStyle();
if (bootstrapStyle) {
buttonClasses.push(`btn-${bootstrapStyle}`);
const style = this.getButtonStyle();
if (style) {
buttonClasses.push(`btn-${style}`);
}
// If there is no text
@ -82,13 +82,22 @@ class FormAction extends SilverStripeComponent {
*
* @return {String}
*/
getBootstrapButtonStyle() {
getButtonStyle() {
// Add 'type' class
if (typeof this.props.bootstrapButtonStyle !== 'undefined') {
return this.props.bootstrapButtonStyle;
if (typeof this.props.data.buttonStyle !== 'undefined') {
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';
}
@ -134,9 +143,7 @@ class FormAction extends SilverStripeComponent {
if (typeof this.props.handleClick === 'function') {
this.props.handleClick(event, this.props.name || this.props.id);
}
}
}
FormAction.propTypes = {
@ -148,7 +155,12 @@ FormAction.propTypes = {
loading: React.PropTypes.bool,
icon: React.PropTypes.string,
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,
attributes: React.PropTypes.object,
};
@ -156,6 +168,7 @@ FormAction.propTypes = {
FormAction.defaultProps = {
title: '',
icon: '',
extraClass: '',
attributes: {},
data: {},
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') {
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') {
this.props.handleSubmit(event, fieldValues, submitFn);
return;
return this.props.handleSubmit(event, fieldValues, submitFn);
}
event.preventDefault();
submitFn();
return submitFn();
}
buildComponent(field, extraProps = {}) {
@ -288,9 +288,16 @@ export class FormBuilderComponent extends SilverStripeComponent {
* @return {Array}
*/
mapActionsToComponents(actions) {
const form = this.props.form[this.getFormId()];
return actions.map((action) => {
const loading = (form && form.submitting && form.submitAction === action.name);
// Events
const extraProps = { handleClick: this.handleAction };
const extraProps = {
handleClick: this.handleAction,
loading,
disabled: loading || action.disabled,
};
// Build child nodes
if (action.children) {

View File

@ -4,10 +4,6 @@ Generates a `<select><option></option></select>`
## Props
### leftTitle
The label text to display with the field.
### extraClass
Addition CSS classes to apply to the `<select>` element.
@ -22,20 +18,32 @@ Handler function called when the field's value changes.
### value
The field's value.
The field's selected value.
### source (required)
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
### emptyString
#### emptyString
When `hasEmptyDefault` is true, this sets the label for the option

View File

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

View File

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

View File

@ -1,63 +1,65 @@
import jQuery from 'jQuery';
jQuery.entwine('ss', ($) => {
$('#add-to-campaign__dialog .add-to-campaign-action, .cms-content-actions .add-to-campaign-action, #add-to-campaign__action').entwine({
onclick() {
let dialog = $('#add-to-campaign__dialog');
$('#add-to-campaign__dialog .add-to-campaign-action,' +
'.cms-content-actions .add-to-campaign-action,' +
'#add-to-campaign__action').entwine({
onclick() {
let dialog = $('#add-to-campaign__dialog');
if (dialog.length) {
dialog.open();
} else {
dialog = $('<div id="add-to-campaign__dialog" class="add-to-campaign__dialog" />');
$('body').append(dialog);
}
if (dialog.length) {
dialog.open();
} else {
dialog = $('<div id="add-to-campaign__dialog" class="add-to-campaign__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 button = this;
const form = this.closest('form');
const button = this;
const formData = form.serializeArray();
formData.push({
name: button.attr('name'),
value: '1',
});
const formData = form.serializeArray();
formData.push({
name: button.attr('name'),
value: '1',
});
$.ajax({
url: form.attr('action'),
data: formData,
type: 'POST',
global: false,
complete() {
dialog.removeClass('loading');
},
success(data, status, xhr) {
if (xhr.getResponseHeader('Content-Type').indexOf('text/plain') === 0) {
$.ajax({
url: form.attr('action'),
data: formData,
type: 'POST',
global: false,
complete() {
dialog.removeClass('loading');
},
success(data, status, xhr) {
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 = $(
'<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>'
);
container.find('span').text(data);
container.find('span').text(error);
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({
onadd() {

View File

@ -5,4 +5,5 @@ export const ACTION_TYPES = {
SUBMIT_FORM_REQUEST: 'SUBMIT_FORM_REQUEST',
SUBMIT_FORM_SUCCESS: 'SUBMIT_FORM_SUCCESS',
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,
}));
case ACTION_TYPES.SET_SUBMIT_ACTION:
return deepFreeze(updateForm(action.payload.formId, {
submitAction: action.payload.submitAction,
}));
default:
return state;

View File

@ -21,7 +21,9 @@ class AddToCampaignHandler_FormAction extends FormAction
function __construct()
{
parent::__construct('addtocampaign', _t('CAMPAIGNS.ADDTOCAMPAIGN', 'Add to campaign'));
$this->setUseButtonTag(false);
$this->addExtraClass('add-to-campaign-action');
$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>
* in {@link Field()} - for more customizeable styling.
* in {@link Field()} - for more customisable styling.
*
* @var boolean
*/