WiP on admin interface for subscribers.

TODO:
- format data based on field info in listDTAjax
- integrate with the whole subscription machinery
This commit is contained in:
Tomas Bures 2017-08-20 23:44:33 +02:00
parent e6bd9cd943
commit 6f5b50e932
38 changed files with 1233 additions and 181 deletions

View file

@ -0,0 +1,213 @@
'use strict';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {HTTPMethod} from '../../lib/axios';
import { translate, Trans } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../../lib/page';
import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button,
Fieldset, Dropdown, AlignedRow, ACEEditor, StaticField, CheckBox
} from '../../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import {DeleteModalDialog, RestActionModalDialog} from "../../lib/modals";
import interoperableErrors from '../../../../shared/interoperable-errors';
import validators from '../../../../shared/validators';
import { parseDate, parseBirthday, DateFormat } from '../../../../shared/date';
import { SubscriptionStatus } from '../../../../shared/lists';
import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
import moment from 'moment-timezone';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {};
this.subscriptionStatusLabels = getSubscriptionStatusLabels(t);
this.fieldTypes = getFieldTypes(t);
this.initForm({
serverValidation: {
url: `/rest/subscriptions-validate/${this.props.list.id}`,
changed: ['email'],
extra: ['id']
},
});
}
static propTypes = {
action: PropTypes.string.isRequired,
list: PropTypes.object,
fieldsGrouped: PropTypes.array,
entity: PropTypes.object
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
data.status = data.status.toString();
data.tz = data.tz || '';
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].assignFormData(fld, data);
}
});
} else {
const data = {
email: '',
tz: '',
is_test: false,
status: SubscriptionStatus.SUBSCRIBED
};
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].initFormData(fld, data);
}
this.populateFormValues(data);
}
}
localValidateFormValues(state) {
const t = this.props.t;
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!state.getIn(['email', 'value'])) {
state.setIn(['email', 'error'], t('Email must not be empty'));
} else if (!emailServerValidation) {
state.setIn(['email', 'error'], t('Validation is in progress...'));
} else if (emailServerValidation.exists) {
state.setIn(['email', 'error'], t('Another subscription with the same email already exists.'));
} else {
state.setIn(['email', 'error'], null);
}
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].validate(fld, state);
}
}
async submitHandler() {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `/rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = `/rest/subscriptions/${this.props.list.id}`
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
data.status = parseInt(data.status);
data.tz = data.tz || null;
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].assignEntity(fld, data);
}
});
if (submitSuccessful) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('Susbscription saved'));
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.DuplicitEmailError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that another subscription with the same email has been created in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')}
</span>
);
return;
}
throw error;
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const fieldsGrouped = this.props.fieldsGrouped;
const statusOptions = Object.keys(this.subscriptionStatusLabels)
.map(key => ({key, label: this.subscriptionStatusLabels[key]}));
const tzOptions = [
{ key: '', label: t('Not selected') },
...moment.tz.names().map(tz => ({ key: tz.toLowerCase(), label: tz }))
];
const customFields = [];
for (const fld of this.props.fieldsGrouped) {
customFields.push(this.fieldTypes[fld.type].form(fld));
}
return (
<div>
{isEdit &&
<div>
<RestActionModalDialog
title={t('Confirm deletion')}
message={t('Are you sure you want to delete subscription for "{{email}}"?', {name: this.getFormValue('email')})}
stateOwner={this}
visible={this.props.action === 'delete'}
actionMethod={HTTPMethod.DELETE}
actionUrl={`/rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`}
backUrl={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/edit`}
successUrl={`/lists/${this.props.list.id}/subscriptions`}
actionInProgressMsg={t('Deleting subscription ...')}
actionDoneMsg={t('Subscription deleted')}/>
</div>
}
<Title>{isEdit ? t('Edit Subscription') : t('Create Subscription')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="email" label={t('Email')}/>
{customFields}
<hr />
<Dropdown id="tz" label={t('Timezone')} options={tzOptions}/>
<Dropdown id="status" label={t('Subscription status')} options={statusOptions}/>
<CheckBox id="is_test" text={t('Test user?')} help={t('If checked then this subscription can be used for previewing campaign messages')}/>
{!isEdit &&
<AlignedRow>
<p className="text-warning">
This person will not receive a confirmation email so make sure that you have permission to
email them.
</p>
</AlignedRow>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -12,6 +12,9 @@ import {
Dropdown, Form,
withForm
} from '../../lib/form';
import {Icon} from "../../lib/bootstrap-components";
import axios from '../../lib/axios';
import {getSubscriptionStatusLabels} from './helpers';
@translate()
@withForm
@ -23,19 +26,15 @@ export default class List extends Component {
super(props);
const t = props.t;
this.state = {};
this.subscriptionStatusLabels = {
[SubscriptionStatus.SUBSCRIBED]: t('Subscribed'),
[SubscriptionStatus.UNSUBSCRIBED]: t('Unubscribed'),
[SubscriptionStatus.BOUNCED]: t('Bounced'),
[SubscriptionStatus.COMPLAINED]: t('Complained'),
};
this.subscriptionStatusLabels = getSubscriptionStatusLabels(t);
this.initForm({
onChange: {
segment: (newState, key, oldValue, value) => {
this.navigateTo(`/lists/${this.props.list.id}/subscriptions` + (value ? '/' + value : ''));
this.navigateTo(`/lists/${this.props.list.id}/subscriptions` + (value ? '?segment=' + value : ''));
}
}
});
@ -61,6 +60,24 @@ export default class List extends Component {
this.updateSegmentSelection(nextProps);
}
@withAsyncErrorHandler
async deleteSubscription(id) {
await axios.delete(`/rest/subscriptions/${this.props.list.id}/${id}`);
this.subscriptionsTable.refresh();
}
@withAsyncErrorHandler
async unsubscribeSubscription(id) {
await axios.post(`/rest/subscriptions-unsubscribe/${this.props.list.id}/${id}`);
this.subscriptionsTable.refresh();
}
@withAsyncErrorHandler
async blacklistSubscription(id) {
await axios.post(`/rest/XXX/${this.props.list.id}/${id}`); // FIXME - add url one the blacklist functionality is in
this.subscriptionsTable.refresh();
}
render() {
const t = this.props.t;
const list = this.props.list;
@ -72,19 +89,53 @@ export default class List extends Component {
{ data: 4, title: t('Created'), render: data => data ? moment(data).fromNow() : '' }
];
let colIdx = 5;
for (const fld of list.listFields) {
columns.push({
data: colIdx,
title: fld.name
});
colIdx += 1;
}
if (list.permissions.includes('manageSubscriptions')) {
columns.push({
actions: data => [{
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit`
}]
actions: data => {
const actions = [];
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit`
});
if (data[3] === SubscriptionStatus.SUBSCRIBED) {
actions.push({
label: <Icon icon="off" title={t('Unsubscribe')}/>,
action: () => this.unsubscribeSubscription(data[0])
});
}
// FIXME - add condition here to show it only if not blacklisted already
actions.push({
label: <Icon icon="ban-circle" title={t('Blacklist')}/>,
action: () => this.blacklistSubscription(data[0])
});
actions.push({
label: <Icon icon="remove" title={t('Remove')}/>,
action: () => this.deleteSubscription(data[0])
});
return actions;
}
});
}
const segmentOptions = [
{key: '', label: t('All subscriptions')},
...segments.map(x => ({ key: x.id.toString(), label: x.name}))
]
];
let dataUrl = '/rest/subscriptions-table/' + list.id;

View file

@ -0,0 +1,175 @@
'use strict';
import React from "react";
import {SubscriptionStatus} from "../../../../shared/lists";
import {ACEEditor, CheckBoxGroup, DatePicker, Dropdown, InputField, RadioGroup, TextArea} from "../../lib/form";
import {formatBirthday, formatDate, parseBirthday, parseDate} from "../../../../shared/date";
import {getFieldKey} from '../../../../shared/lists';
export function getSubscriptionStatusLabels(t) {
const subscriptionStatusLabels = {
[SubscriptionStatus.SUBSCRIBED]: t('Subscribed'),
[SubscriptionStatus.UNSUBSCRIBED]: t('Unubscribed'),
[SubscriptionStatus.BOUNCED]: t('Bounced'),
[SubscriptionStatus.COMPLAINED]: t('Complained'),
};
return subscriptionStatusLabels;
}
export function getFieldTypes(t) {
const fieldTypes = {};
const stringFieldType = long => ({
form: field => long ? <TextArea key={getFieldKey(field)} id={getFieldKey(field)} label={field.name}/> : <InputField key={getFieldKey(field)} id={getFieldKey(field)} label={field.name}/>,
assignFormData: (field, data) => {},
initFormData: (field, data) => {
data[getFieldKey(field)] = '';
},
assignEntity: (field, data) => {},
validate: (field, state) => {}
});
const numberFieldType = {
form: field => <InputField key={getFieldKey(field)} id={getFieldKey(field)} label={field.name}/>,
assignFormData: (field, data) => {
const value = data[getFieldKey(field)];
data[getFieldKey(field)] = value ? value.toString() : '';
},
initFormData: (field, data) => {
data[getFieldKey(field)] = '';
},
assignEntity: (field, data) => {
data[getFieldKey(field)] = parseInt(data[getFieldKey(field)]);
},
validate: (field, state) => {
const value = state.getIn([getFieldKey(field), 'value']).trim();
if (value !== '' && isNaN(value)) {
state.setIn([getFieldKey(field), 'error'], t('Value must be a number'));
} else {
state.setIn([getFieldKey(field), 'error'], null);
}
}
};
const dateFieldType = {
form: field => <DatePicker key={getFieldKey(field)} id={getFieldKey(field)} label={field.name} dateFormat={field.settings.dateFormat} />,
assignFormData: (field, data) => {
const value = data[getFieldKey(field)];
data[getFieldKey(field)] = value ? formatDate(field.settings.dateFormat, value) : '';
},
initFormData: (field, data) => {
data[getFieldKey(field)] = '';
},
assignEntity: (field, data) => {
const date = parseDate(field.settings.dateFormat, data[getFieldKey(field)]);
data[getFieldKey(field)] = date;
},
validate: (field, state) => {
const value = state.getIn([getFieldKey(field), 'value']);
const date = parseDate(field.settings.dateFormat, value);
if (value !== '' && !date) {
state.setIn([getFieldKey(field), 'error'], t('Date is invalid'));
} else {
state.setIn([getFieldKey(field), 'error'], null);
}
}
};
const birthdayFieldType = {
form: field => <DatePicker key={getFieldKey(field)} id={getFieldKey(field)} label={field.name} dateFormat={field.settings.dateFormat} birthday />,
assignFormData: (field, data) => {
const value = data[getFieldKey(field)];
data[getFieldKey(field)] = value ? formatBirthday(field.settings.dateFormat, value) : '';
},
initFormData: (field, data) => {
data[getFieldKey(field)] = '';
},
assignEntity: (field, data) => {
const date = parseBirthday(field.settings.dateFormat, data[getFieldKey(field)]);
data[getFieldKey(field)] = date;
},
validate: (field, state) => {
const value = state.getIn([getFieldKey(field), 'value']);
const date = parseBirthday(field.settings.dateFormat, value);
if (value !== '' && !date) {
state.setIn([getFieldKey(field), 'error'], t('Date is invalid'));
} else {
state.setIn([getFieldKey(field), 'error'], null);
}
}
};
const jsonFieldType = {
form: field => <ACEEditor key={getFieldKey(field)} id={getFieldKey(field)} label={field.name} mode="json" height="300px"/>,
assignFormData: (field, data) => {},
initFormData: (field, data) => {
data[getFieldKey(field)] = '';
},
assignEntity: (field, data) => {},
validate: (field, state) => {}
};
const enumSingleFieldType = componentType => ({
form: field => React.createElement(componentType, { key: getFieldKey(field), id: getFieldKey(field), label: field.name, options: field.settings.options }, null),
assignFormData: (field, data) => {
if (data[getFieldKey(field)] === null) {
if (field.default_value) {
data[getFieldKey(field)] = field.default_value;
} else if (field.settings.options.length > 0) {
data[getFieldKey(field)] = field.settings.options[0].key;
} else {
data[getFieldKey(field)] = '';
}
}
},
initFormData: (field, data) => {
if (field.default_value) {
data[getFieldKey(field)] = field.default_value;
} else if (field.settings.options.length > 0) {
data[getFieldKey(field)] = field.settings.options[0].key;
} else {
data[getFieldKey(field)] = '';
}
},
assignEntity: (field, data) => {
},
validate: (field, state) => {}
});
const enumMultipleFieldType = componentType => ({
form: field => React.createElement(componentType, { key: getFieldKey(field), id: getFieldKey(field), label: field.name, options: field.settings.options }, null),
assignFormData: (field, data) => {
if (data[getFieldKey(field)] === null) {
data[getFieldKey(field)] = [];
}
},
initFormData: (field, data) => {
data[getFieldKey(field)] = [];
},
assignEntity: (field, data) => {},
validate: (field, state) => {}
});
fieldTypes.text = stringFieldType(false);
fieldTypes.website = stringFieldType(false);
fieldTypes.longtext = stringFieldType(true);
fieldTypes.gpg = stringFieldType(true);
fieldTypes.number = numberFieldType;
fieldTypes.date = dateFieldType;
fieldTypes.birthday = birthdayFieldType;
fieldTypes.json = jsonFieldType;
fieldTypes['dropdown-enum'] = enumSingleFieldType(Dropdown);
fieldTypes['radio-enum'] = enumSingleFieldType(RadioGroup);
// Here we rely on the fact the model/fields and model/subscriptions preprocess the field info and subscription
// such that the grouped entries behave the same as the enum entries
fieldTypes['checkbox-grouped'] = enumMultipleFieldType(CheckBoxGroup);
fieldTypes['radio-grouped'] = enumSingleFieldType(RadioGroup);
fieldTypes['dropdown-grouped'] = enumSingleFieldType(Dropdown);
return fieldTypes;
}