diff --git a/client/package.json b/client/package.json index ac7d1de3..b174dba0 100644 --- a/client/package.json +++ b/client/package.json @@ -23,8 +23,10 @@ "i18next-xhr-backend": "^1.4.2", "immutable": "^3.8.1", "moment": "^2.18.1", + "moment-timezone": "^0.5.13", "owasp-password-strength-test": "github:bures/owasp-password-strength-test", "prop-types": "^15.5.10", + "querystringify": "^1.0.0", "react": "^15.6.1", "react-ace": "^5.1.0", "react-day-picker": "^6.1.0", diff --git a/client/src/account/Login.js b/client/src/account/Login.js index 6d2c9770..8d00f464 100644 --- a/client/src/account/Login.js +++ b/client/src/account/Login.js @@ -8,7 +8,7 @@ import { withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow } from '../lib/form'; import { withErrorHandling } from '../lib/error-handling'; -import URL from 'url-parse'; +import qs from 'querystringify'; import interoperableErrors from '../../../shared/interoperable-errors'; import mailtrainConfig from 'mailtrainConfig'; @@ -63,8 +63,7 @@ export default class Login extends Component { as part of login response. Then we should integrate it in the mailtrainConfig global variable. */ if (submitSuccessful) { - const query = new URL(this.props.location.search, true).query; - const nextUrl = query.next || '/'; + const nextUrl = qs.parse(this.props.location.search).next || '/'; /* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */ window.location = nextUrl; diff --git a/client/src/lib/axios.js b/client/src/lib/axios.js index a4c25bd1..24bbc8fb 100644 --- a/client/src/lib/axios.js +++ b/client/src/lib/axios.js @@ -15,4 +15,16 @@ const axiosWrapper = { delete: (...args) => axiosInst.delete(...args).catch(error => { throw interoperableErrors.deserialize(error.response.data) || error }) }; +const HTTPMethod = { + GET: axiosWrapper.get, + PUT: axiosWrapper.put, + POST: axiosWrapper.post, + DELETE: axiosWrapper.delete +}; + +axiosWrapper.method = (method, ...args) => method(...args); + export default axiosWrapper; +export { + HTTPMethod +} \ No newline at end of file diff --git a/client/src/lib/bootstrap-components.js b/client/src/lib/bootstrap-components.js index aece2bb1..fecd7f26 100644 --- a/client/src/lib/bootstrap-components.js +++ b/client/src/lib/bootstrap-components.js @@ -34,14 +34,15 @@ class DismissibleAlert extends Component { class Icon extends Component { static propTypes = { - name: PropTypes.string, + icon: PropTypes.string.isRequired, + title: PropTypes.string, className: PropTypes.string } render() { const props = this.props; - return ; + return ; } } @@ -75,7 +76,7 @@ class Button extends Component { let icon; if (props.icon) { - icon = + icon = } let iconSpacer; diff --git a/client/src/lib/delete.js b/client/src/lib/delete.js deleted file mode 100644 index 5a2730df..00000000 --- a/client/src/lib/delete.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -import React, { Component } from 'react'; -import axios from './axios'; -import { translate } from 'react-i18next'; -import PropTypes from 'prop-types'; -import {ModalDialog} from "./bootstrap-components"; - -@translate() -class DeleteModalDialog extends Component { - static propTypes = { - stateOwner: PropTypes.object.isRequired, - visible: PropTypes.bool.isRequired, - deleteUrl: PropTypes.string.isRequired, - cudUrl: PropTypes.string.isRequired, - listUrl: PropTypes.string.isRequired, - deletingMsg: PropTypes.string.isRequired, - deletedMsg: PropTypes.string.isRequired, - onErrorAsync: PropTypes.func - } - - async hideDeleteModal() { - this.props.stateOwner.navigateTo(this.props.cudUrl); - } - - async performDelete() { - const t = this.props.t; - const owner = this.props.stateOwner; - - await this.hideDeleteModal(); - - try { - owner.disableForm(); - owner.setFormStatusMessage('info', this.props.deletingMsg); - await axios.delete(this.props.deleteUrl); - - owner.navigateToWithFlashMessage(this.props.listUrl, 'success', this.props.deletedMsg); - } catch (err) { - if (this.props.onErrorAsync) { - await this.props.onErrorAsync(err); - } else { - throw err; - } - } - } - - render() { - const t = this.props.t; - const owner = this.props.stateOwner; - - return ( - - ); - } -} - - -export { - DeleteModalDialog -} diff --git a/client/src/lib/form.js b/client/src/lib/form.js index 7e0ae075..76f304ea 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -1,7 +1,7 @@ 'use strict'; import React, { Component } from 'react'; -import axios from './axios'; +import axios, {HTTPMethod} from './axios'; import Immutable from 'immutable'; import { translate } from 'react-i18next'; import PropTypes from 'prop-types'; @@ -33,10 +33,7 @@ const FormState = { Ready: 2 }; -const FormSendMethod = { - PUT: 0, - POST: 1 -}; +const FormSendMethod = HTTPMethod; @translate() @withPageHelpers @@ -279,6 +276,116 @@ class CheckBox extends Component { } } +class CheckBoxGroup extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + options: PropTypes.array, + className: PropTypes.string, + format: PropTypes.string + } + + static contextTypes = { + formStateOwner: PropTypes.object.isRequired + } + + onChange(key) { + const id = this.props.id; + const owner = this.context.formStateOwner; + const existingSelection = owner.getFormValue(id); + + let newSelection; + if (existingSelection.includes(key)) { + newSelection = existingSelection.filter(x => x !== key); + } else { + newSelection = [key, ...existingSelection]; + } + owner.updateFormValue(id, newSelection.sort()); + } + + render() { + const props = this.props; + + const owner = this.context.formStateOwner; + const id = this.props.id; + const htmlId = 'form_' + id; + + const selection = owner.getFormValue(id); + + const options = []; + for (const option of props.options) { + options.push( +
+ +
+ ); + } + + let className = 'form-control'; + if (props.className) { + className += ' ' + props.className; + } + + return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, +
+ {options} +
+ ); + } +} + +class RadioGroup extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + options: PropTypes.array, + className: PropTypes.string, + format: PropTypes.string + } + + static contextTypes = { + formStateOwner: PropTypes.object.isRequired + } + + render() { + const props = this.props; + + const owner = this.context.formStateOwner; + const id = this.props.id; + const htmlId = 'form_' + id; + + const value = owner.getFormValue(id); + + const options = []; + for (const option of props.options) { + options.push( +
+ +
+ ); + } + + let className = 'form-control'; + if (props.className) { + className += ' ' + props.className; + } + + return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, +
+ {options} +
+ ); + } +} + class TextArea extends Component { static propTypes = { id: PropTypes.string.isRequired, @@ -454,7 +561,6 @@ class Dropdown extends Component { } } - class AlignedRow extends Component { static propTypes = { className: PropTypes.string, @@ -848,12 +954,7 @@ function withForm(target) { mutator(data); } - let response; - if (method === FormSendMethod.PUT) { - response = await axios.put(url, data); - } else if (method === FormSendMethod.POST) { - response = await axios.post(url, data); - } + const response = await axios.method(method, url, data); return response.data || true; @@ -1077,6 +1178,8 @@ export { StaticField, InputField, CheckBox, + CheckBoxGroup, + RadioGroup, TextArea, DatePicker, Dropdown, diff --git a/client/src/lib/modals.js b/client/src/lib/modals.js new file mode 100644 index 00000000..9227c705 --- /dev/null +++ b/client/src/lib/modals.js @@ -0,0 +1,102 @@ +'use strict'; + +import React, { Component } from 'react'; +import axios, { HTTPMethod } from './axios'; +import { translate } from 'react-i18next'; +import PropTypes from 'prop-types'; +import {ModalDialog} from "./bootstrap-components"; + +@translate() +class RestActionModalDialog extends Component { + static propTypes = { + title: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + stateOwner: PropTypes.object.isRequired, + visible: PropTypes.bool.isRequired, + actionMethod: PropTypes.func.isRequired, + actionUrl: PropTypes.string.isRequired, + backUrl: PropTypes.string.isRequired, + successUrl: PropTypes.string.isRequired, + actionInProgressMsg: PropTypes.string.isRequired, + actionDoneMsg: PropTypes.string.isRequired, + onErrorAsync: PropTypes.func + } + + async hideModal() { + this.props.stateOwner.navigateTo(this.props.backUrl); + } + + async performAction() { + const t = this.props.t; + const owner = this.props.stateOwner; + + await this.hideModal(); + + try { + owner.disableForm(); + owner.setFormStatusMessage('info', this.props.actionInProgressMsg); + await axios.method(this.props.actionMethod, this.props.actionUrl); + + owner.navigateToWithFlashMessage(this.props.successUrl, 'success', this.props.actionDoneMsg); + } catch (err) { + if (this.props.onErrorAsync) { + await this.props.onErrorAsync(err); + } else { + throw err; + } + } + } + + render() { + const t = this.props.t; + + return ( + + ); + } +} + +@translate() +class DeleteModalDialog extends Component { + static propTypes = { + stateOwner: PropTypes.object.isRequired, + visible: PropTypes.bool.isRequired, + deleteUrl: PropTypes.string.isRequired, + cudUrl: PropTypes.string.isRequired, + listUrl: PropTypes.string.isRequired, + deletingMsg: PropTypes.string.isRequired, + deletedMsg: PropTypes.string.isRequired, + onErrorAsync: PropTypes.func + } + + render() { + const t = this.props.t; + const owner = this.props.stateOwner; + + return + } +} + + +export { + ModalDialog, + DeleteModalDialog, + RestActionModalDialog +} diff --git a/client/src/lists/CUD.js b/client/src/lists/CUD.js index f2f32a26..5837bad0 100644 --- a/client/src/lists/CUD.js +++ b/client/src/lists/CUD.js @@ -9,7 +9,7 @@ import { Dropdown, StaticField, CheckBox } from '../lib/form'; import { withErrorHandling } from '../lib/error-handling'; -import { DeleteModalDialog } from '../lib/delete'; +import { DeleteModalDialog } from '../lib/modals'; import { validateNamespace, NamespaceSelect } from '../lib/namespace'; import { UnsubscriptionMode } from '../../../shared/lists'; diff --git a/client/src/lists/List.js b/client/src/lists/List.js index 34b48727..b3cdcab1 100644 --- a/client/src/lists/List.js +++ b/client/src/lists/List.js @@ -7,6 +7,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling' import { Table } from '../lib/table'; import axios from '../lib/axios'; import {Link} from "react-router-dom"; +import {Icon} from "../lib/bootstrap-components"; @translate() @withPageHelpers @@ -66,28 +67,28 @@ export default class List extends Component { if (perms.includes('viewSubscriptions')) { actions.push({ - label: , + label: , link: `/lists/${data[0]}/subscriptions` }); } if (perms.includes('edit')) { actions.push({ - label: , + label: , link: `/lists/${data[0]}/edit` }); } if (perms.includes('manageFields')) { actions.push({ - label: , + label: , link: `/lists/${data[0]}/fields` }); } if (perms.includes('share')) { actions.push({ - label: , + label: , link: `/lists/${data[0]}/share` }); } diff --git a/client/src/lists/fields/CUD.js b/client/src/lists/fields/CUD.js index 6f1230ca..20847cc4 100644 --- a/client/src/lists/fields/CUD.js +++ b/client/src/lists/fields/CUD.js @@ -9,8 +9,8 @@ import { Fieldset, Dropdown, AlignedRow, ACEEditor, StaticField } from '../../lib/form'; import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling'; -import {DeleteModalDialog} from "../../lib/delete"; -import { getFieldTypes } from './field-types'; +import {DeleteModalDialog} from "../../lib/modals"; +import { getFieldTypes } from './helpers'; import interoperableErrors from '../../../../shared/interoperable-errors'; import validators from '../../../../shared/validators'; import slugify from 'slugify'; @@ -86,7 +86,7 @@ export default class CUD extends Component { case 'radio-enum': case 'dropdown-enum': - data.enumOptions = this.renderEnumOptions(data.settings.enumOptions); + data.enumOptions = this.renderEnumOptions(data.settings.options); data.renderTemplate = data.settings.renderTemplate; break; @@ -151,7 +151,9 @@ export default class CUD extends Component { } const defaultValue = state.getIn(['default_value', 'value']); - if (type === 'number' && !/^[0-9]*$/.test(defaultValue.trim())) { + if (defaultValue === '') { + state.setIn(['default_value', 'error'], null); + } else if (type === 'number' && !/^[0-9]*$/.test(defaultValue.trim())) { state.setIn(['default_value', 'error'], t('Default value is not integer number')); } else if (type === 'date' && !parseDate(state.getIn(['dateFormat', 'value']), defaultValue)) { state.setIn(['default_value', 'error'], t('Default value is not a properly formatted date')); @@ -168,7 +170,7 @@ export default class CUD extends Component { } else { state.setIn(['enumOptions', 'error'], null); - if (defaultValue !== '' && !(defaultValue in enumOptions.options)) { + if (defaultValue !== '' && !(enumOptions.options.find(x => x.key === defaultValue))) { state.setIn(['default_value', 'error'], t('Default value is not one of the allowed options')); } } @@ -180,7 +182,7 @@ export default class CUD extends Component { parseEnumOptions(text) { const t = this.props.t; const errors = []; - const options = {}; + const options = []; const lines = text.split('\n'); for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { @@ -191,7 +193,7 @@ export default class CUD extends Component { if (matches) { const key = matches[1].trim(); const label = matches[2].trim(); - options[key] = label; + options.push({ key, label }); } else { errors.push(t('Errror on line {{ line }}', { line: lineIdx + 1})); } @@ -210,7 +212,7 @@ export default class CUD extends Component { } renderEnumOptions(options) { - return Object.keys(options).map(key => `${key}|${options[key]}`).join('\n'); + return options.map(opt => `${opt.key}|${opt.label}`).join('\n'); } @@ -250,7 +252,7 @@ export default class CUD extends Component { case 'radio-enum': case 'dropdown-enum': - data.settings.enumOptions = this.parseEnumOptions(data.enumOptions).options; + data.settings.options = this.parseEnumOptions(data.enumOptions).options; data.settings.renderTemplate = data.renderTemplate; break; diff --git a/client/src/lists/fields/List.js b/client/src/lists/fields/List.js index 04667ddf..36d2ed36 100644 --- a/client/src/lists/fields/List.js +++ b/client/src/lists/fields/List.js @@ -6,7 +6,8 @@ import { translate } from 'react-i18next'; import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page'; import { withErrorHandling } from '../../lib/error-handling'; import { Table } from '../../lib/table'; -import { getFieldTypes } from './field-types'; +import { getFieldTypes } from './helpers'; +import {Icon} from "../../lib/bootstrap-components"; @translate() @withPageHelpers @@ -40,7 +41,7 @@ export default class List extends Component { { data: 3, title: t('Merge Tag') }, { actions: data => [{ - label: , + label: , link: `/lists/${this.props.list.id}/fields/${data[0]}/edit` }] } diff --git a/client/src/lists/fields/field-types.js b/client/src/lists/fields/helpers.js similarity index 100% rename from client/src/lists/fields/field-types.js rename to client/src/lists/fields/helpers.js diff --git a/client/src/lists/forms/CUD.js b/client/src/lists/forms/CUD.js index f094364c..9f690d96 100644 --- a/client/src/lists/forms/CUD.js +++ b/client/src/lists/forms/CUD.js @@ -10,7 +10,7 @@ import { } from '../../lib/form'; import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling'; import { validateNamespace, NamespaceSelect } from '../../lib/namespace'; -import {DeleteModalDialog} from "../../lib/delete"; +import {DeleteModalDialog} from "../../lib/modals"; import mailtrainConfig from 'mailtrainConfig'; @translate() diff --git a/client/src/lists/forms/List.js b/client/src/lists/forms/List.js index 89cfdce9..9db4eea1 100644 --- a/client/src/lists/forms/List.js +++ b/client/src/lists/forms/List.js @@ -6,6 +6,7 @@ import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} f import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling'; import { Table } from '../../lib/table'; import axios from '../../lib/axios'; +import {Icon} from "../../lib/bootstrap-components"; @translate() @withPageHelpers @@ -52,13 +53,13 @@ export default class List extends Component { if (perms.includes('edit')) { actions.push({ - label: , + label: , link: `/lists/forms/${data[0]}/edit` }); } if (perms.includes('share')) { actions.push({ - label: , + label: , link: `/lists/forms/${data[0]}/share` }); } diff --git a/client/src/lists/root.js b/client/src/lists/root.js index 79301d11..4c2dabdd 100644 --- a/client/src/lists/root.js +++ b/client/src/lists/root.js @@ -4,6 +4,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nextProvider } from 'react-i18next'; import i18n from '../lib/i18n'; +import qs from 'querystringify'; import { Section } from '../lib/page'; import ListsList from './List'; @@ -13,6 +14,7 @@ import FormsCUD from './forms/CUD'; import FieldsList from './fields/List'; import FieldsCUD from './fields/CUD'; import SubscriptionsList from './subscriptions/List'; +import SubscriptionsCUD from './subscriptions/CUD'; import SegmentsList from './segments/List'; import SegmentsCUD from './segments/CUD'; import Share from '../shares/Share'; @@ -41,13 +43,35 @@ const getStructure = t => { subscriptions: { title: t('Subscribers'), resolve: { - segments: params => `/rest/segments/${params.listId}` + segments: params => `/rest/segments/${params.listId}`, }, - extraParams: [':segmentId?'], link: params => `/lists/${params.listId}/subscriptions`, visible: resolved => resolved.list.permissions.includes('viewSubscriptions'), - render: props => - }, + render: props => , + children: { + ':subscriptionId([0-9]+)': { + title: resolved => resolved.subscription.email, + resolve: { + subscription: params => `/rest/subscriptions/${params.listId}/${params.subscriptionId}`, + fieldsGrouped: params => `/rest/fields-grouped/${params.listId}` + }, + link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`, + navs: { + ':action(edit|delete)': { + title: t('Edit'), + link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`, + render: props => + } + } + }, + create: { + title: t('Create'), + resolve: { + fieldsGrouped: params => `/rest/fields-grouped/${params.listId}` + }, + render: props => + } + } }, ':action(edit|delete)': { title: t('Edit'), link: params => `/lists/${params.listId}/edit`, diff --git a/client/src/lists/segments/CUD.js b/client/src/lists/segments/CUD.js index 94b1c792..91d3066f 100644 --- a/client/src/lists/segments/CUD.js +++ b/client/src/lists/segments/CUD.js @@ -6,7 +6,7 @@ import {translate} from "react-i18next"; import {NavButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from "../../lib/page"; import {Button as FormButton, ButtonRow, Dropdown, Form, FormSendMethod, InputField, withForm} from "../../lib/form"; import {withAsyncErrorHandler, withErrorHandling} from "../../lib/error-handling"; -import {DeleteModalDialog} from "../../lib/delete"; +import {DeleteModalDialog} from "../../lib/modals"; import interoperableErrors from "../../../../shared/interoperable-errors"; import styles from "./CUD.scss"; @@ -15,7 +15,7 @@ import HTML5Backend from "react-dnd-html5-backend"; import TouchBackend from "react-dnd-touch-backend"; import SortableTree from "react-sortable-tree"; import {ActionLink, Button, Icon} from "../../lib/bootstrap-components"; -import {getRuleHelpers} from "./rule-helpers"; +import {getRuleHelpers} from "./helpers"; import RuleSettingsPane from "./RuleSettingsPane"; // https://stackoverflow.com/a/4819886/1601953 @@ -381,8 +381,8 @@ export default class CUD extends Component { canDrop={ data => !data.nextParent || (ruleHelpers.isCompositeRuleType(data.nextParent.rule.type)) } generateNodeProps={data => ({ buttons: [ - !this.state.ruleOptionsVisible && this.showRuleOptions(data.node.rule)} className={styles.ruleActionLink}>, - !this.state.ruleOptionsVisible && this.deleteRule(data.node.rule)} className={styles.ruleActionLink}> + !this.state.ruleOptionsVisible && this.showRuleOptions(data.node.rule)} className={styles.ruleActionLink}>, + !this.state.ruleOptionsVisible && this.deleteRule(data.node.rule)} className={styles.ruleActionLink}> ] })} /> diff --git a/client/src/lists/segments/List.js b/client/src/lists/segments/List.js index 7d6fff4e..e567d480 100644 --- a/client/src/lists/segments/List.js +++ b/client/src/lists/segments/List.js @@ -6,6 +6,7 @@ import { translate } from 'react-i18next'; import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page'; import { withErrorHandling } from '../../lib/error-handling'; import { Table } from '../../lib/table'; +import {Icon} from "../../lib/bootstrap-components"; @translate() @withPageHelpers @@ -32,7 +33,7 @@ export default class List extends Component { { data: 1, title: t('Name') }, { actions: data => [{ - label: , + label: , link: `/lists/${this.props.list.id}/segments/${data[0]}/edit` }] } diff --git a/client/src/lists/segments/RuleSettingsPane.js b/client/src/lists/segments/RuleSettingsPane.js index f24eb4b3..fde18742 100644 --- a/client/src/lists/segments/RuleSettingsPane.js +++ b/client/src/lists/segments/RuleSettingsPane.js @@ -6,8 +6,8 @@ import {translate} from "react-i18next"; import {requiresAuthenticatedUser, withPageHelpers} from "../../lib/page"; import {Button, ButtonRow, Dropdown, Form, TableSelect, withForm} from "../../lib/form"; import {withErrorHandling} from "../../lib/error-handling"; -import {getRuleHelpers} from "./rule-helpers"; -import {getFieldTypes} from "../fields/field-types"; +import {getRuleHelpers} from "./helpers"; +import {getFieldTypes} from "../fields/helpers"; import styles from "./CUD.scss"; diff --git a/client/src/lists/segments/rule-helpers.js b/client/src/lists/segments/helpers.js similarity index 97% rename from client/src/lists/segments/rule-helpers.js rename to client/src/lists/segments/helpers.js index a3c875c7..b5c348f7 100644 --- a/client/src/lists/segments/rule-helpers.js +++ b/client/src/lists/segments/helpers.js @@ -241,8 +241,8 @@ export function getRuleHelpers(t, fields) { rule.value = parseInt(getter('value')); }, validate: state => { - const value = state.getIn(['value', 'value']); - if (!value) { + const value = state.getIn(['value', 'value']).trim(); + if (value === '') { state.setIn(['value', 'error'], t('Value must not be empty')); } else if (isNaN(value)) { state.setIn(['value', 'error'], t('Value must be a number')); diff --git a/client/src/lists/subscriptions/CUD.js b/client/src/lists/subscriptions/CUD.js new file mode 100644 index 00000000..54f70a8f --- /dev/null +++ b/client/src/lists/subscriptions/CUD.js @@ -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', + + {t('Your updates cannot be saved.')}{' '} + {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.')} + + ); + 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 ( +
+ {isEdit && +
+ +
+ } + + {isEdit ? t('Edit Subscription') : t('Create Subscription')} + +
+ + + {customFields} + +
+ + + + + + + + {!isEdit && + +

+ This person will not receive a confirmation email so make sure that you have permission to + email them. +

+
+ } + +
+ ); + } +} diff --git a/client/src/lists/subscriptions/List.js b/client/src/lists/subscriptions/List.js index b68c81a1..51377d8e 100644 --- a/client/src/lists/subscriptions/List.js +++ b/client/src/lists/subscriptions/List.js @@ -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: , - link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit` - }] + actions: data => { + const actions = []; + + actions.push({ + label: , + link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit` + }); + + if (data[3] === SubscriptionStatus.SUBSCRIBED) { + actions.push({ + label: , + action: () => this.unsubscribeSubscription(data[0]) + }); + } + + // FIXME - add condition here to show it only if not blacklisted already + actions.push({ + label: , + action: () => this.blacklistSubscription(data[0]) + }); + + actions.push({ + label: , + 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; diff --git a/client/src/lists/subscriptions/helpers.js b/client/src/lists/subscriptions/helpers.js new file mode 100644 index 00000000..d3e03841 --- /dev/null +++ b/client/src/lists/subscriptions/helpers.js @@ -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 ?