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:
parent
e6bd9cd943
commit
6f5b50e932
38 changed files with 1233 additions and 181 deletions
|
@ -23,8 +23,10 @@
|
||||||
"i18next-xhr-backend": "^1.4.2",
|
"i18next-xhr-backend": "^1.4.2",
|
||||||
"immutable": "^3.8.1",
|
"immutable": "^3.8.1",
|
||||||
"moment": "^2.18.1",
|
"moment": "^2.18.1",
|
||||||
|
"moment-timezone": "^0.5.13",
|
||||||
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
|
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
|
||||||
"prop-types": "^15.5.10",
|
"prop-types": "^15.5.10",
|
||||||
|
"querystringify": "^1.0.0",
|
||||||
"react": "^15.6.1",
|
"react": "^15.6.1",
|
||||||
"react-ace": "^5.1.0",
|
"react-ace": "^5.1.0",
|
||||||
"react-day-picker": "^6.1.0",
|
"react-day-picker": "^6.1.0",
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow
|
withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow
|
||||||
} from '../lib/form';
|
} from '../lib/form';
|
||||||
import { withErrorHandling } from '../lib/error-handling';
|
import { withErrorHandling } from '../lib/error-handling';
|
||||||
import URL from 'url-parse';
|
import qs from 'querystringify';
|
||||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||||
import mailtrainConfig from 'mailtrainConfig';
|
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. */
|
as part of login response. Then we should integrate it in the mailtrainConfig global variable. */
|
||||||
|
|
||||||
if (submitSuccessful) {
|
if (submitSuccessful) {
|
||||||
const query = new URL(this.props.location.search, true).query;
|
const nextUrl = qs.parse(this.props.location.search).next || '/';
|
||||||
const nextUrl = query.next || '/';
|
|
||||||
|
|
||||||
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
|
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
|
||||||
window.location = nextUrl;
|
window.location = nextUrl;
|
||||||
|
|
|
@ -15,4 +15,16 @@ const axiosWrapper = {
|
||||||
delete: (...args) => axiosInst.delete(...args).catch(error => { throw interoperableErrors.deserialize(error.response.data) || error })
|
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 default axiosWrapper;
|
||||||
|
export {
|
||||||
|
HTTPMethod
|
||||||
|
}
|
7
client/src/lib/bootstrap-components.js
vendored
7
client/src/lib/bootstrap-components.js
vendored
|
@ -34,14 +34,15 @@ class DismissibleAlert extends Component {
|
||||||
|
|
||||||
class Icon extends Component {
|
class Icon extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
name: PropTypes.string,
|
icon: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string,
|
||||||
className: PropTypes.string
|
className: PropTypes.string
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const props = this.props;
|
const props = this.props;
|
||||||
|
|
||||||
return <span className={'glyphicon glyphicon-' + props.name + (props.className ? ' ' + props.className : '')}></span>;
|
return <span className={'glyphicon glyphicon-' + props.icon + (props.className ? ' ' + props.className : '')} title={props.title}></span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +76,7 @@ class Button extends Component {
|
||||||
|
|
||||||
let icon;
|
let icon;
|
||||||
if (props.icon) {
|
if (props.icon) {
|
||||||
icon = <Icon name={props.icon}/>
|
icon = <Icon icon={props.icon}/>
|
||||||
}
|
}
|
||||||
|
|
||||||
let iconSpacer;
|
let iconSpacer;
|
||||||
|
|
|
@ -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 (
|
|
||||||
<ModalDialog hidden={!this.props.visible} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
|
||||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
|
||||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
|
||||||
]}>
|
|
||||||
{t('Are you sure you want to delete "{{name}}"?', {name: owner.getFormValue('name')})}
|
|
||||||
</ModalDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export {
|
|
||||||
DeleteModalDialog
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import axios from './axios';
|
import axios, {HTTPMethod} from './axios';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
import { translate } from 'react-i18next';
|
import { translate } from 'react-i18next';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
@ -33,10 +33,7 @@ const FormState = {
|
||||||
Ready: 2
|
Ready: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormSendMethod = {
|
const FormSendMethod = HTTPMethod;
|
||||||
PUT: 0,
|
|
||||||
POST: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@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(
|
||||||
|
<div key={option.key} className="checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={selection.includes(option.key)} onChange={evt => this.onChange(option.key)}/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let className = 'form-control';
|
||||||
|
if (props.className) {
|
||||||
|
className += ' ' + props.className;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
||||||
|
<div>
|
||||||
|
{options}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div key={option.key} className="radio">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name={htmlId} checked={value === option.key} onChange={evt => owner.updateFormValue(id, option.key)}/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let className = 'form-control';
|
||||||
|
if (props.className) {
|
||||||
|
className += ' ' + props.className;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
||||||
|
<div>
|
||||||
|
{options}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TextArea extends Component {
|
class TextArea extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
|
@ -454,7 +561,6 @@ class Dropdown extends Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AlignedRow extends Component {
|
class AlignedRow extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
@ -848,12 +954,7 @@ function withForm(target) {
|
||||||
mutator(data);
|
mutator(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response;
|
const response = await axios.method(method, url, data);
|
||||||
if (method === FormSendMethod.PUT) {
|
|
||||||
response = await axios.put(url, data);
|
|
||||||
} else if (method === FormSendMethod.POST) {
|
|
||||||
response = await axios.post(url, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data || true;
|
return response.data || true;
|
||||||
|
|
||||||
|
@ -1077,6 +1178,8 @@ export {
|
||||||
StaticField,
|
StaticField,
|
||||||
InputField,
|
InputField,
|
||||||
CheckBox,
|
CheckBox,
|
||||||
|
CheckBoxGroup,
|
||||||
|
RadioGroup,
|
||||||
TextArea,
|
TextArea,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
|
102
client/src/lib/modals.js
Normal file
102
client/src/lib/modals.js
Normal file
|
@ -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 (
|
||||||
|
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={::this.hideModal} buttons={[
|
||||||
|
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideModal },
|
||||||
|
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
|
||||||
|
]}>
|
||||||
|
{this.props.message}
|
||||||
|
</ModalDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 <RestActionModalDialog
|
||||||
|
title={t('Confirm deletion')}
|
||||||
|
message={t('Are you sure you want to delete "{{name}}"?', {name: owner.getFormValue('name')})}
|
||||||
|
stateOwner={this.props.stateOwner}
|
||||||
|
visible={this.props.visible}
|
||||||
|
actionMethod={HTTPMethod.DELETE}
|
||||||
|
actionUrl={this.props.deleteUrl}
|
||||||
|
backUrl={this.props.cudUrl}
|
||||||
|
successUrl={this.props.listUrl}
|
||||||
|
actionInProgressMsg={this.props.deletingMsg}
|
||||||
|
actionDoneMsg={this.props.deletedMsg}
|
||||||
|
onErrorAsync={this.props.onErrorAsync}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
ModalDialog,
|
||||||
|
DeleteModalDialog,
|
||||||
|
RestActionModalDialog
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Dropdown, StaticField, CheckBox
|
Dropdown, StaticField, CheckBox
|
||||||
} from '../lib/form';
|
} from '../lib/form';
|
||||||
import { withErrorHandling } from '../lib/error-handling';
|
import { withErrorHandling } from '../lib/error-handling';
|
||||||
import { DeleteModalDialog } from '../lib/delete';
|
import { DeleteModalDialog } from '../lib/modals';
|
||||||
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
|
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
|
||||||
import { UnsubscriptionMode } from '../../../shared/lists';
|
import { UnsubscriptionMode } from '../../../shared/lists';
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'
|
||||||
import { Table } from '../lib/table';
|
import { Table } from '../lib/table';
|
||||||
import axios from '../lib/axios';
|
import axios from '../lib/axios';
|
||||||
import {Link} from "react-router-dom";
|
import {Link} from "react-router-dom";
|
||||||
|
import {Icon} from "../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -66,28 +67,28 @@ export default class List extends Component {
|
||||||
|
|
||||||
if (perms.includes('viewSubscriptions')) {
|
if (perms.includes('viewSubscriptions')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-user" aria-hidden="true" title="Subscribers"></span>,
|
label: <Icon icon="user" title="Subscribers"/>,
|
||||||
link: `/lists/${data[0]}/subscriptions`
|
link: `/lists/${data[0]}/subscriptions`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (perms.includes('edit')) {
|
if (perms.includes('edit')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/lists/${data[0]}/edit`
|
link: `/lists/${data[0]}/edit`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (perms.includes('manageFields')) {
|
if (perms.includes('manageFields')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-th-list" aria-hidden="true" title="Manage Fields"></span>,
|
label: <Icon icon="th-list" title={t('Manage Fields')}/>,
|
||||||
link: `/lists/${data[0]}/fields`
|
link: `/lists/${data[0]}/fields`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (perms.includes('share')) {
|
if (perms.includes('share')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
|
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||||
link: `/lists/${data[0]}/share`
|
link: `/lists/${data[0]}/share`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import {
|
||||||
Fieldset, Dropdown, AlignedRow, ACEEditor, StaticField
|
Fieldset, Dropdown, AlignedRow, ACEEditor, StaticField
|
||||||
} from '../../lib/form';
|
} from '../../lib/form';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
||||||
import {DeleteModalDialog} from "../../lib/delete";
|
import {DeleteModalDialog} from "../../lib/modals";
|
||||||
import { getFieldTypes } from './field-types';
|
import { getFieldTypes } from './helpers';
|
||||||
import interoperableErrors from '../../../../shared/interoperable-errors';
|
import interoperableErrors from '../../../../shared/interoperable-errors';
|
||||||
import validators from '../../../../shared/validators';
|
import validators from '../../../../shared/validators';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
|
@ -86,7 +86,7 @@ export default class CUD extends Component {
|
||||||
|
|
||||||
case 'radio-enum':
|
case 'radio-enum':
|
||||||
case 'dropdown-enum':
|
case 'dropdown-enum':
|
||||||
data.enumOptions = this.renderEnumOptions(data.settings.enumOptions);
|
data.enumOptions = this.renderEnumOptions(data.settings.options);
|
||||||
data.renderTemplate = data.settings.renderTemplate;
|
data.renderTemplate = data.settings.renderTemplate;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -151,7 +151,9 @@ export default class CUD extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValue = state.getIn(['default_value', 'value']);
|
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'));
|
state.setIn(['default_value', 'error'], t('Default value is not integer number'));
|
||||||
} else if (type === 'date' && !parseDate(state.getIn(['dateFormat', 'value']), defaultValue)) {
|
} else if (type === 'date' && !parseDate(state.getIn(['dateFormat', 'value']), defaultValue)) {
|
||||||
state.setIn(['default_value', 'error'], t('Default value is not a properly formatted date'));
|
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 {
|
} else {
|
||||||
state.setIn(['enumOptions', 'error'], null);
|
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'));
|
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) {
|
parseEnumOptions(text) {
|
||||||
const t = this.props.t;
|
const t = this.props.t;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const options = {};
|
const options = [];
|
||||||
|
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||||
|
@ -191,7 +193,7 @@ export default class CUD extends Component {
|
||||||
if (matches) {
|
if (matches) {
|
||||||
const key = matches[1].trim();
|
const key = matches[1].trim();
|
||||||
const label = matches[2].trim();
|
const label = matches[2].trim();
|
||||||
options[key] = label;
|
options.push({ key, label });
|
||||||
} else {
|
} else {
|
||||||
errors.push(t('Errror on line {{ line }}', { line: lineIdx + 1}));
|
errors.push(t('Errror on line {{ line }}', { line: lineIdx + 1}));
|
||||||
}
|
}
|
||||||
|
@ -210,7 +212,7 @@ export default class CUD extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEnumOptions(options) {
|
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 'radio-enum':
|
||||||
case 'dropdown-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;
|
data.settings.renderTemplate = data.renderTemplate;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,8 @@ import { translate } from 'react-i18next';
|
||||||
import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page';
|
import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page';
|
||||||
import { withErrorHandling } from '../../lib/error-handling';
|
import { withErrorHandling } from '../../lib/error-handling';
|
||||||
import { Table } from '../../lib/table';
|
import { Table } from '../../lib/table';
|
||||||
import { getFieldTypes } from './field-types';
|
import { getFieldTypes } from './helpers';
|
||||||
|
import {Icon} from "../../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -40,7 +41,7 @@ export default class List extends Component {
|
||||||
{ data: 3, title: t('Merge Tag') },
|
{ data: 3, title: t('Merge Tag') },
|
||||||
{
|
{
|
||||||
actions: data => [{
|
actions: data => [{
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/lists/${this.props.list.id}/fields/${data[0]}/edit`
|
link: `/lists/${this.props.list.id}/fields/${data[0]}/edit`
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '../../lib/form';
|
} from '../../lib/form';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
||||||
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
|
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
|
||||||
import {DeleteModalDialog} from "../../lib/delete";
|
import {DeleteModalDialog} from "../../lib/modals";
|
||||||
import mailtrainConfig from 'mailtrainConfig';
|
import mailtrainConfig from 'mailtrainConfig';
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} f
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
||||||
import { Table } from '../../lib/table';
|
import { Table } from '../../lib/table';
|
||||||
import axios from '../../lib/axios';
|
import axios from '../../lib/axios';
|
||||||
|
import {Icon} from "../../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -52,13 +53,13 @@ export default class List extends Component {
|
||||||
|
|
||||||
if (perms.includes('edit')) {
|
if (perms.includes('edit')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/lists/forms/${data[0]}/edit`
|
link: `/lists/forms/${data[0]}/edit`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (perms.includes('share')) {
|
if (perms.includes('share')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
|
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||||
link: `/lists/forms/${data[0]}/share`
|
link: `/lists/forms/${data[0]}/share`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import i18n from '../lib/i18n';
|
import i18n from '../lib/i18n';
|
||||||
|
import qs from 'querystringify';
|
||||||
|
|
||||||
import { Section } from '../lib/page';
|
import { Section } from '../lib/page';
|
||||||
import ListsList from './List';
|
import ListsList from './List';
|
||||||
|
@ -13,6 +14,7 @@ import FormsCUD from './forms/CUD';
|
||||||
import FieldsList from './fields/List';
|
import FieldsList from './fields/List';
|
||||||
import FieldsCUD from './fields/CUD';
|
import FieldsCUD from './fields/CUD';
|
||||||
import SubscriptionsList from './subscriptions/List';
|
import SubscriptionsList from './subscriptions/List';
|
||||||
|
import SubscriptionsCUD from './subscriptions/CUD';
|
||||||
import SegmentsList from './segments/List';
|
import SegmentsList from './segments/List';
|
||||||
import SegmentsCUD from './segments/CUD';
|
import SegmentsCUD from './segments/CUD';
|
||||||
import Share from '../shares/Share';
|
import Share from '../shares/Share';
|
||||||
|
@ -41,13 +43,35 @@ const getStructure = t => {
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
title: t('Subscribers'),
|
title: t('Subscribers'),
|
||||||
resolve: {
|
resolve: {
|
||||||
segments: params => `/rest/segments/${params.listId}`
|
segments: params => `/rest/segments/${params.listId}`,
|
||||||
},
|
},
|
||||||
extraParams: [':segmentId?'],
|
|
||||||
link: params => `/lists/${params.listId}/subscriptions`,
|
link: params => `/lists/${params.listId}/subscriptions`,
|
||||||
visible: resolved => resolved.list.permissions.includes('viewSubscriptions'),
|
visible: resolved => resolved.list.permissions.includes('viewSubscriptions'),
|
||||||
render: props => <SubscriptionsList list={props.resolved.list} segments={props.resolved.segments} segmentId={props.match.params.segmentId} />
|
render: props => <SubscriptionsList list={props.resolved.list} segments={props.resolved.segments} segmentId={qs.parse(props.location.search).segment} />,
|
||||||
},
|
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 => <SubscriptionsCUD action={props.match.params.action} entity={props.resolved.subscription} list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: t('Create'),
|
||||||
|
resolve: {
|
||||||
|
fieldsGrouped: params => `/rest/fields-grouped/${params.listId}`
|
||||||
|
},
|
||||||
|
render: props => <SubscriptionsCUD action="create" list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
|
||||||
|
}
|
||||||
|
} },
|
||||||
':action(edit|delete)': {
|
':action(edit|delete)': {
|
||||||
title: t('Edit'),
|
title: t('Edit'),
|
||||||
link: params => `/lists/${params.listId}/edit`,
|
link: params => `/lists/${params.listId}/edit`,
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {translate} from "react-i18next";
|
||||||
import {NavButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from "../../lib/page";
|
import {NavButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from "../../lib/page";
|
||||||
import {Button as FormButton, ButtonRow, Dropdown, Form, FormSendMethod, InputField, withForm} from "../../lib/form";
|
import {Button as FormButton, ButtonRow, Dropdown, Form, FormSendMethod, InputField, withForm} from "../../lib/form";
|
||||||
import {withAsyncErrorHandler, withErrorHandling} from "../../lib/error-handling";
|
import {withAsyncErrorHandler, withErrorHandling} from "../../lib/error-handling";
|
||||||
import {DeleteModalDialog} from "../../lib/delete";
|
import {DeleteModalDialog} from "../../lib/modals";
|
||||||
import interoperableErrors from "../../../../shared/interoperable-errors";
|
import interoperableErrors from "../../../../shared/interoperable-errors";
|
||||||
|
|
||||||
import styles from "./CUD.scss";
|
import styles from "./CUD.scss";
|
||||||
|
@ -15,7 +15,7 @@ import HTML5Backend from "react-dnd-html5-backend";
|
||||||
import TouchBackend from "react-dnd-touch-backend";
|
import TouchBackend from "react-dnd-touch-backend";
|
||||||
import SortableTree from "react-sortable-tree";
|
import SortableTree from "react-sortable-tree";
|
||||||
import {ActionLink, Button, Icon} from "../../lib/bootstrap-components";
|
import {ActionLink, Button, Icon} from "../../lib/bootstrap-components";
|
||||||
import {getRuleHelpers} from "./rule-helpers";
|
import {getRuleHelpers} from "./helpers";
|
||||||
import RuleSettingsPane from "./RuleSettingsPane";
|
import RuleSettingsPane from "./RuleSettingsPane";
|
||||||
|
|
||||||
// https://stackoverflow.com/a/4819886/1601953
|
// 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)) }
|
canDrop={ data => !data.nextParent || (ruleHelpers.isCompositeRuleType(data.nextParent.rule.type)) }
|
||||||
generateNodeProps={data => ({
|
generateNodeProps={data => ({
|
||||||
buttons: [
|
buttons: [
|
||||||
<ActionLink onClickAsync={async () => !this.state.ruleOptionsVisible && this.showRuleOptions(data.node.rule)} className={styles.ruleActionLink}><Icon name="edit"/></ActionLink>,
|
<ActionLink onClickAsync={async () => !this.state.ruleOptionsVisible && this.showRuleOptions(data.node.rule)} className={styles.ruleActionLink}><Icon icon="edit" title={t('Edit')}/></ActionLink>,
|
||||||
<ActionLink onClickAsync={async () => !this.state.ruleOptionsVisible && this.deleteRule(data.node.rule)} className={styles.ruleActionLink}><Icon name="remove"/></ActionLink>
|
<ActionLink onClickAsync={async () => !this.state.ruleOptionsVisible && this.deleteRule(data.node.rule)} className={styles.ruleActionLink}><Icon icon="remove" title={t('Delete')}/></ActionLink>
|
||||||
]
|
]
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { translate } from 'react-i18next';
|
||||||
import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page';
|
import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page';
|
||||||
import { withErrorHandling } from '../../lib/error-handling';
|
import { withErrorHandling } from '../../lib/error-handling';
|
||||||
import { Table } from '../../lib/table';
|
import { Table } from '../../lib/table';
|
||||||
|
import {Icon} from "../../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -32,7 +33,7 @@ export default class List extends Component {
|
||||||
{ data: 1, title: t('Name') },
|
{ data: 1, title: t('Name') },
|
||||||
{
|
{
|
||||||
actions: data => [{
|
actions: data => [{
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/lists/${this.props.list.id}/segments/${data[0]}/edit`
|
link: `/lists/${this.props.list.id}/segments/${data[0]}/edit`
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,8 @@ import {translate} from "react-i18next";
|
||||||
import {requiresAuthenticatedUser, withPageHelpers} from "../../lib/page";
|
import {requiresAuthenticatedUser, withPageHelpers} from "../../lib/page";
|
||||||
import {Button, ButtonRow, Dropdown, Form, TableSelect, withForm} from "../../lib/form";
|
import {Button, ButtonRow, Dropdown, Form, TableSelect, withForm} from "../../lib/form";
|
||||||
import {withErrorHandling} from "../../lib/error-handling";
|
import {withErrorHandling} from "../../lib/error-handling";
|
||||||
import {getRuleHelpers} from "./rule-helpers";
|
import {getRuleHelpers} from "./helpers";
|
||||||
import {getFieldTypes} from "../fields/field-types";
|
import {getFieldTypes} from "../fields/helpers";
|
||||||
|
|
||||||
import styles from "./CUD.scss";
|
import styles from "./CUD.scss";
|
||||||
|
|
||||||
|
|
|
@ -241,8 +241,8 @@ export function getRuleHelpers(t, fields) {
|
||||||
rule.value = parseInt(getter('value'));
|
rule.value = parseInt(getter('value'));
|
||||||
},
|
},
|
||||||
validate: state => {
|
validate: state => {
|
||||||
const value = state.getIn(['value', 'value']);
|
const value = state.getIn(['value', 'value']).trim();
|
||||||
if (!value) {
|
if (value === '') {
|
||||||
state.setIn(['value', 'error'], t('Value must not be empty'));
|
state.setIn(['value', 'error'], t('Value must not be empty'));
|
||||||
} else if (isNaN(value)) {
|
} else if (isNaN(value)) {
|
||||||
state.setIn(['value', 'error'], t('Value must be a number'));
|
state.setIn(['value', 'error'], t('Value must be a number'));
|
213
client/src/lists/subscriptions/CUD.js
Normal file
213
client/src/lists/subscriptions/CUD.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,9 @@ import {
|
||||||
Dropdown, Form,
|
Dropdown, Form,
|
||||||
withForm
|
withForm
|
||||||
} from '../../lib/form';
|
} from '../../lib/form';
|
||||||
|
import {Icon} from "../../lib/bootstrap-components";
|
||||||
|
import axios from '../../lib/axios';
|
||||||
|
import {getSubscriptionStatusLabels} from './helpers';
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withForm
|
@withForm
|
||||||
|
@ -23,19 +26,15 @@ export default class List extends Component {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
const t = props.t;
|
const t = props.t;
|
||||||
|
|
||||||
this.state = {};
|
this.state = {};
|
||||||
|
|
||||||
this.subscriptionStatusLabels = {
|
this.subscriptionStatusLabels = getSubscriptionStatusLabels(t);
|
||||||
[SubscriptionStatus.SUBSCRIBED]: t('Subscribed'),
|
|
||||||
[SubscriptionStatus.UNSUBSCRIBED]: t('Unubscribed'),
|
|
||||||
[SubscriptionStatus.BOUNCED]: t('Bounced'),
|
|
||||||
[SubscriptionStatus.COMPLAINED]: t('Complained'),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.initForm({
|
this.initForm({
|
||||||
onChange: {
|
onChange: {
|
||||||
segment: (newState, key, oldValue, value) => {
|
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);
|
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() {
|
render() {
|
||||||
const t = this.props.t;
|
const t = this.props.t;
|
||||||
const list = this.props.list;
|
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() : '' }
|
{ 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')) {
|
if (list.permissions.includes('manageSubscriptions')) {
|
||||||
columns.push({
|
columns.push({
|
||||||
actions: data => [{
|
actions: data => {
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
const actions = [];
|
||||||
link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit`
|
|
||||||
}]
|
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 = [
|
const segmentOptions = [
|
||||||
{key: '', label: t('All subscriptions')},
|
{key: '', label: t('All subscriptions')},
|
||||||
...segments.map(x => ({ key: x.id.toString(), label: x.name}))
|
...segments.map(x => ({ key: x.id.toString(), label: x.name}))
|
||||||
]
|
];
|
||||||
|
|
||||||
|
|
||||||
let dataUrl = '/rest/subscriptions-table/' + list.id;
|
let dataUrl = '/rest/subscriptions-table/' + list.id;
|
||||||
|
|
175
client/src/lists/subscriptions/helpers.js
Normal file
175
client/src/lists/subscriptions/helpers.js
Normal 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;
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button
|
||||||
import axios from '../lib/axios';
|
import axios from '../lib/axios';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||||
import {DeleteModalDialog} from "../lib/delete";
|
import {DeleteModalDialog} from "../lib/modals";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withForm
|
@withForm
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton }
|
||||||
import { TreeTable } from '../lib/tree';
|
import { TreeTable } from '../lib/tree';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||||
import axios from '../lib/axios';
|
import axios from '../lib/axios';
|
||||||
|
import {Icon} from "../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
|
@ -46,14 +47,14 @@ export default class List extends Component {
|
||||||
|
|
||||||
if (node.data.permissions.includes('edit')) {
|
if (node.data.permissions.includes('edit')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/namespaces/${node.key}/edit`
|
link: `/namespaces/${node.key}/edit`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.data.permissions.includes('share')) {
|
if (node.data.permissions.includes('share')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
|
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||||
link: `/namespaces/${node.key}/share`
|
link: `/namespaces/${node.key}/share`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import axios from '../lib/axios';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
|
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
|
||||||
import {DeleteModalDialog} from "../lib/delete";
|
import {DeleteModalDialog} from "../lib/modals";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withForm
|
@withForm
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import axios from '../lib/axios';
|
import axios from '../lib/axios';
|
||||||
import { ReportState } from '../../../shared/reports';
|
import { ReportState } from '../../../shared/reports';
|
||||||
|
import {Icon} from "../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
|
@ -88,11 +89,11 @@ export default class List extends Component {
|
||||||
|
|
||||||
if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) {
|
if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) {
|
||||||
viewContent = {
|
viewContent = {
|
||||||
label: <span className="glyphicon glyphicon-hourglass" aria-hidden="true" title="Processing"></span>,
|
label: <Icon icon="hourglass" title={t('Processing')}/>,
|
||||||
};
|
};
|
||||||
|
|
||||||
startStop = {
|
startStop = {
|
||||||
label: <span className="glyphicon glyphicon-stop" aria-hidden="true" title="Stop"></span>,
|
label: <Icon icon="stop" title={t('Stop')}/>,
|
||||||
action: (table) => this.stop(table, id)
|
action: (table) => this.stop(table, id)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -100,28 +101,28 @@ export default class List extends Component {
|
||||||
} else if (state === ReportState.FINISHED) {
|
} else if (state === ReportState.FINISHED) {
|
||||||
if (mimeType === 'text/html') {
|
if (mimeType === 'text/html') {
|
||||||
viewContent = {
|
viewContent = {
|
||||||
label: <span className="glyphicon glyphicon-eye-open" aria-hidden="true" title="View"></span>,
|
label: <Icon icon="eye-open" title={t('View')}/>,
|
||||||
link: `/reports/${id}/view`
|
link: `/reports/${id}/view`
|
||||||
};
|
};
|
||||||
} else if (mimeType === 'text/csv') {
|
} else if (mimeType === 'text/csv') {
|
||||||
viewContent = {
|
viewContent = {
|
||||||
label: <span className="glyphicon glyphicon-download-alt" aria-hidden="true" title="Download"></span>,
|
label: <Icon icon="download-alt" title={t('Download')}/>,
|
||||||
href: `/reports/${id}/download`
|
href: `/reports/${id}/download`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
startStop = {
|
startStop = {
|
||||||
label: <span className="glyphicon glyphicon-repeat" aria-hidden="true" title="Refresh report"></span>,
|
label: <Icon icon="repeat" title={t('Refresh report')}/>,
|
||||||
action: (table) => this.start(table, id)
|
action: (table) => this.start(table, id)
|
||||||
};
|
};
|
||||||
|
|
||||||
} else if (state === ReportState.FAILED) {
|
} else if (state === ReportState.FAILED) {
|
||||||
viewContent = {
|
viewContent = {
|
||||||
label: <span className="glyphicon glyphicon-thumbs-down" aria-hidden="true" title="Report generation failed"></span>,
|
label: <Icon icon="thumbs-down" title={t('Report generation failed')}/>,
|
||||||
};
|
};
|
||||||
|
|
||||||
startStop = {
|
startStop = {
|
||||||
label: <span className="glyphicon glyphicon-repeat" aria-hidden="true" title="Regenerate report"></span>,
|
label: <Icon icon="repeat" title={t('Regenerate report')}/>,
|
||||||
action: (table) => this.start(table, id)
|
action: (table) => this.start(table, id)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -133,7 +134,7 @@ export default class List extends Component {
|
||||||
if (perms.includes('viewOutput')) {
|
if (perms.includes('viewOutput')) {
|
||||||
actions.push(
|
actions.push(
|
||||||
{
|
{
|
||||||
label: <span className="glyphicon glyphicon-modal-window" aria-hidden="true" title="View console output"></span>,
|
label: <Icon icon="modal-window" title={t('View console output')}/>,
|
||||||
link: `/reports/${id}/output`
|
link: `/reports/${id}/output`
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -145,14 +146,14 @@ export default class List extends Component {
|
||||||
|
|
||||||
if (perms.includes('edit') && permsReportTemplate.includes('execute')) {
|
if (perms.includes('edit') && permsReportTemplate.includes('execute')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/reports/${id}/edit`
|
link: `/reports/${id}/edit`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (perms.includes('share')) {
|
if (perms.includes('share')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
|
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||||
link: `/reports/${id}/share`
|
link: `/reports/${id}/share`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../.
|
||||||
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../../lib/form';
|
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../../lib/form';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
||||||
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
|
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
|
||||||
import {DeleteModalDialog} from "../../lib/delete";
|
import {DeleteModalDialog} from "../../lib/modals";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withForm
|
@withForm
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { translate } from 'react-i18next';
|
import { translate } from 'react-i18next';
|
||||||
import { DropdownMenu } from '../../lib/bootstrap-components';
|
import {DropdownMenu, Icon} from '../../lib/bootstrap-components';
|
||||||
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
|
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
|
||||||
import { Table } from '../../lib/table';
|
import { Table } from '../../lib/table';
|
||||||
|
@ -56,14 +56,14 @@ export default class List extends Component {
|
||||||
|
|
||||||
if (mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && perms.includes('edit')) {
|
if (mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && perms.includes('edit')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/reports/templates/${data[0]}/edit`
|
link: `/reports/templates/${data[0]}/edit`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (perms.includes('share')) {
|
if (perms.includes('share')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
|
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||||
link: `/reports/templates/${data[0]}/share`
|
link: `/reports/templates/${data[0]}/share`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'
|
||||||
import { Table } from '../lib/table';
|
import { Table } from '../lib/table';
|
||||||
import axios from '../lib/axios';
|
import axios from '../lib/axios';
|
||||||
import mailtrainConfig from 'mailtrainConfig';
|
import mailtrainConfig from 'mailtrainConfig';
|
||||||
|
import {Icon} from "../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -42,6 +43,8 @@ export default class UserShares extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const t = this.props.t;
|
||||||
|
|
||||||
const renderSharesTable = (entityTypeId, title) => {
|
const renderSharesTable = (entityTypeId, title) => {
|
||||||
const columns = [
|
const columns = [
|
||||||
{ data: 0, title: t('Name') },
|
{ data: 0, title: t('Name') },
|
||||||
|
@ -54,7 +57,7 @@ export default class UserShares extends Component {
|
||||||
|
|
||||||
if (!autoGenerated && perms.includes('share')) {
|
if (!autoGenerated && perms.includes('share')) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: <span className="glyphicon glyphicon-remove" aria-hidden="true" title="Remove"></span>,
|
label: <Icon icon="remove" title={t('Remove')}/>,
|
||||||
action: () => this.deleteShare(entityTypeId, data[2])
|
action: () => this.deleteShare(entityTypeId, data[2])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -72,8 +75,6 @@ export default class UserShares extends Component {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = this.props.t;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title>{t('Shares for user "{{username}}"', {username: this.props.user.username})}</Title>
|
<Title>{t('Shares for user "{{username}}"', {username: this.props.user.username})}</Title>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import interoperableErrors from '../../../shared/interoperable-errors';
|
||||||
import passwordValidator from '../../../shared/password-validator';
|
import passwordValidator from '../../../shared/password-validator';
|
||||||
import mailtrainConfig from 'mailtrainConfig';
|
import mailtrainConfig from 'mailtrainConfig';
|
||||||
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
|
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
|
||||||
import {DeleteModalDialog} from "../lib/delete";
|
import {DeleteModalDialog} from "../lib/modals";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withForm
|
@withForm
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { translate } from 'react-i18next';
|
||||||
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
|
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
|
||||||
import { Table } from '../lib/table';
|
import { Table } from '../lib/table';
|
||||||
import mailtrainConfig from 'mailtrainConfig';
|
import mailtrainConfig from 'mailtrainConfig';
|
||||||
|
import {Icon} from "../lib/bootstrap-components";
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -34,11 +35,11 @@ export default class List extends Component {
|
||||||
columns.push({
|
columns.push({
|
||||||
actions: data => [
|
actions: data => [
|
||||||
{
|
{
|
||||||
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
|
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||||
link: `/users/${data[0]}/edit`
|
link: `/users/${data[0]}/edit`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <span className="glyphicon glyphicon-share" aria-hidden="true" title="Share"></span>,
|
label: <Icon icon="share" title={t('Share')}/>,
|
||||||
link: `/users/${data[0]}/shares`
|
link: `/users/${data[0]}/shares`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
107
models/fields.js
107
models/fields.js
|
@ -18,55 +18,82 @@ const hashKeys = allowedKeysCreate;
|
||||||
|
|
||||||
const fieldTypes = {};
|
const fieldTypes = {};
|
||||||
|
|
||||||
|
const Cardinality = {
|
||||||
|
SINGLE: 0,
|
||||||
|
MULTIPLE: 1
|
||||||
|
};
|
||||||
|
|
||||||
fieldTypes.text = fieldTypes.website = {
|
fieldTypes.text = fieldTypes.website = {
|
||||||
validate: entity => {},
|
validate: entity => {},
|
||||||
addColumn: (table, name) => table.string(name),
|
addColumn: (table, name) => table.string(name),
|
||||||
indexed: true,
|
indexed: true,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes.longtext = fieldTypes.gpg = {
|
fieldTypes.longtext = fieldTypes.gpg = {
|
||||||
validate: entity => {},
|
validate: entity => {},
|
||||||
addColumn: (table, name) => table.text(name),
|
addColumn: (table, name) => table.text(name),
|
||||||
indexed: false,
|
indexed: false,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes.json = {
|
fieldTypes.json = {
|
||||||
validate: entity => {},
|
validate: entity => {},
|
||||||
addColumn: (table, name) => table.json(name),
|
addColumn: (table, name) => table.json(name),
|
||||||
indexed: false,
|
indexed: false,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes.number = {
|
fieldTypes.number = {
|
||||||
validate: entity => {},
|
validate: entity => {},
|
||||||
addColumn: (table, name) => table.integer(name),
|
addColumn: (table, name) => table.integer(name),
|
||||||
indexed: true,
|
indexed: true,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes.checkbox = fieldTypes['radio-grouped'] = fieldTypes['dropdown-grouped'] = {
|
fieldTypes['checkbox-grouped'] = {
|
||||||
validate: entity => {},
|
validate: entity => {},
|
||||||
indexed: true,
|
indexed: true,
|
||||||
grouped: true
|
grouped: true,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.MULTIPLE
|
||||||
|
};
|
||||||
|
|
||||||
|
fieldTypes['radio-grouped'] = fieldTypes['dropdown-grouped'] = {
|
||||||
|
validate: entity => {},
|
||||||
|
indexed: true,
|
||||||
|
grouped: true,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes['radio-enum'] = fieldTypes['dropdown-enum'] = {
|
fieldTypes['radio-enum'] = fieldTypes['dropdown-enum'] = {
|
||||||
validate: entity => {
|
validate: entity => {
|
||||||
enforce(entity.settings.options, 'Options missing in settings');
|
enforce(entity.settings.options, 'Options missing in settings');
|
||||||
enforce(Object.keys(entity.settings.options).includes(entity.default_value), 'Default value not present in options');
|
enforce(entity.default_value === null || entity.settings.options.find(x => x.key === entity.default_value), 'Default value not present in options');
|
||||||
},
|
},
|
||||||
addColumn: (table, name) => table.string(name),
|
addColumn: (table, name) => table.string(name),
|
||||||
indexed: true,
|
indexed: true,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: true,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes.option = {
|
fieldTypes.option = {
|
||||||
validate: entity => {},
|
validate: entity => {},
|
||||||
addColumn: (table, name) => table.boolean(name),
|
addColumn: (table, name) => table.boolean(name),
|
||||||
indexed: true,
|
indexed: true,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldTypes['date'] = fieldTypes['birthday'] = {
|
fieldTypes['date'] = fieldTypes['birthday'] = {
|
||||||
|
@ -75,11 +102,17 @@ fieldTypes['date'] = fieldTypes['birthday'] = {
|
||||||
},
|
},
|
||||||
addColumn: (table, name) => table.dateTime(name),
|
addColumn: (table, name) => table.dateTime(name),
|
||||||
indexed: true,
|
indexed: true,
|
||||||
grouped: false
|
grouped: false,
|
||||||
|
enumerated: false,
|
||||||
|
cardinality: Cardinality.SINGLE
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupedTypes = Object.keys(fieldTypes).filter(key => fieldTypes[key].grouped);
|
const groupedTypes = Object.keys(fieldTypes).filter(key => fieldTypes[key].grouped);
|
||||||
|
|
||||||
|
function getFieldType(type) {
|
||||||
|
return fieldTypes[type];
|
||||||
|
}
|
||||||
|
|
||||||
function hash(entity) {
|
function hash(entity) {
|
||||||
return hasher.hash(filterObject(entity, hashKeys));
|
return hasher.hash(filterObject(entity, hashKeys));
|
||||||
}
|
}
|
||||||
|
@ -90,6 +123,8 @@ async function getById(context, listId, id) {
|
||||||
|
|
||||||
const entity = await tx('custom_fields').where({list: listId, id}).first();
|
const entity = await tx('custom_fields').where({list: listId, id}).first();
|
||||||
|
|
||||||
|
entity.settings = JSON.parse(entity.settings);
|
||||||
|
|
||||||
const orderFields = {
|
const orderFields = {
|
||||||
order_list: 'orderListBefore',
|
order_list: 'orderListBefore',
|
||||||
order_subscribe: 'orderSubscribeBefore',
|
order_subscribe: 'orderSubscribeBefore',
|
||||||
|
@ -114,16 +149,55 @@ async function getById(context, listId, id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listTx(tx, listId) {
|
async function listTx(tx, listId) {
|
||||||
return await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'key', 'column', 'order_list', 'order_subscribe', 'order_manage']).orderBy('id', 'asc');
|
return await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'key', 'column', 'order_list', 'settings', 'group', 'order_subscribe', 'order_manage']).orderBy(knex.raw('-order_list'), 'desc').orderBy('id', 'asc');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function list(context, listId) {
|
async function list(context, listId) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields');
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['manageFields', 'manageSegments']);
|
||||||
return await listTx(tx, listId);
|
return await listTx(tx, listId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listGroupedTx(tx, listId) {
|
||||||
|
const flds = await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'column', 'settings', 'group', 'default_value']).orderBy(knex.raw('-order_list'), 'desc').orderBy('id', 'asc');
|
||||||
|
|
||||||
|
const fldsById = {};
|
||||||
|
for (const fld of flds) {
|
||||||
|
fld.settings = JSON.parse(fld.settings);
|
||||||
|
|
||||||
|
fldsById[fld.id] = fld;
|
||||||
|
|
||||||
|
if (fieldTypes[fld.type].grouped) {
|
||||||
|
fld.settings.options = [];
|
||||||
|
fld.groupedOptions = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fld of flds) {
|
||||||
|
if (fld.group) {
|
||||||
|
const group = fldsById[fld.group];
|
||||||
|
group.settings.options.push({ key: fld.column, label: fld.name });
|
||||||
|
group.groupedOptions[fld.column] = fld;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedFlds = flds.filter(fld => !fld.group);
|
||||||
|
|
||||||
|
for (const fld of flds) {
|
||||||
|
delete fld.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedFlds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGrouped(context, listId) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['manageSubscriptions']);
|
||||||
|
return await listGroupedTx(tx, listId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function listByOrderListTx(tx, listId, extraColumns = []) {
|
async function listByOrderListTx(tx, listId, extraColumns = []) {
|
||||||
return await tx('custom_fields').where({list: listId}).whereNotNull('order_list').select(['name', ...extraColumns]).orderBy('order_list', 'asc');
|
return await tx('custom_fields').where({list: listId}).whereNotNull('order_list').select(['name', ...extraColumns]).orderBy('order_list', 'asc');
|
||||||
}
|
}
|
||||||
|
@ -241,7 +315,7 @@ async function _validateAndPreprocess(tx, listId, entity, isCreate) {
|
||||||
|
|
||||||
enforce(validators.mergeTagValid(entity.key), 'Merge tag is not valid.');
|
enforce(validators.mergeTagValid(entity.key), 'Merge tag is not valid.');
|
||||||
|
|
||||||
const existingWithKeyQuery = knex('custom_fields').where({
|
const existingWithKeyQuery = tx('custom_fields').where({
|
||||||
list: listId,
|
list: listId,
|
||||||
key: entity.key
|
key: entity.key
|
||||||
});
|
});
|
||||||
|
@ -349,6 +423,7 @@ async function updateWithConsistencyCheck(context, listId, entity) {
|
||||||
throw new interoperableErrors.NotFoundError();
|
throw new interoperableErrors.NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existing.settings = JSON.parse(existing.settings);
|
||||||
const existingHash = hash(existing);
|
const existingHash = hash(existing);
|
||||||
if (existingHash !== entity.originalHash) {
|
if (existingHash !== entity.originalHash) {
|
||||||
throw new interoperableErrors.ChangedError();
|
throw new interoperableErrors.ChangedError();
|
||||||
|
@ -357,7 +432,7 @@ async function updateWithConsistencyCheck(context, listId, entity) {
|
||||||
enforce(entity.type === existing.type, 'Field type cannot be changed');
|
enforce(entity.type === existing.type, 'Field type cannot be changed');
|
||||||
await _validateAndPreprocess(tx, listId, entity, false);
|
await _validateAndPreprocess(tx, listId, entity, false);
|
||||||
|
|
||||||
await tx('custom_fields').where('id', entity.id).update(filterObject(entity, allowedKeysUpdate));
|
await tx('custom_fields').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeysUpdate));
|
||||||
await _sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore);
|
await _sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -401,10 +476,14 @@ async function removeAllByListIdTx(tx, context, listId) {
|
||||||
|
|
||||||
// This is to handle circular dependency with segments.js
|
// This is to handle circular dependency with segments.js
|
||||||
Object.assign(module.exports, {
|
Object.assign(module.exports, {
|
||||||
|
Cardinality,
|
||||||
|
getFieldType,
|
||||||
hash,
|
hash,
|
||||||
getById,
|
getById,
|
||||||
list,
|
list,
|
||||||
listTx,
|
listTx,
|
||||||
|
listGrouped,
|
||||||
|
listGroupedTx,
|
||||||
listByOrderListTx,
|
listByOrderListTx,
|
||||||
listDTAjax,
|
listDTAjax,
|
||||||
listGroupedDTAjax,
|
listGroupedDTAjax,
|
||||||
|
|
|
@ -214,7 +214,7 @@ function hash(entity) {
|
||||||
|
|
||||||
async function listDTAjax(context, listId, params) {
|
async function listDTAjax(context, listId, params) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']);
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
|
||||||
|
|
||||||
return await dtHelpers.ajaxListTx(
|
return await dtHelpers.ajaxListTx(
|
||||||
tx,
|
tx,
|
||||||
|
@ -227,7 +227,7 @@ async function listDTAjax(context, listId, params) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function list(context, listId) {
|
async function listIdName(context, listId) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']);
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']);
|
||||||
|
|
||||||
|
@ -237,7 +237,7 @@ async function list(context, listId) {
|
||||||
|
|
||||||
async function getById(context, listId, id) {
|
async function getById(context, listId, id) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']);
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
|
||||||
const entity = await tx('segments').where({id, list: listId}).first();
|
const entity = await tx('segments').where({id, list: listId}).first();
|
||||||
entity.settings = JSON.parse(entity.settings);
|
entity.settings = JSON.parse(entity.settings);
|
||||||
return entity;
|
return entity;
|
||||||
|
@ -400,7 +400,7 @@ async function getQueryGeneratorTx(tx, listId, id) {
|
||||||
Object.assign(module.exports, {
|
Object.assign(module.exports, {
|
||||||
hash,
|
hash,
|
||||||
listDTAjax,
|
listDTAjax,
|
||||||
list,
|
listIdName,
|
||||||
getById,
|
getById,
|
||||||
create,
|
create,
|
||||||
updateWithConsistencyCheck,
|
updateWithConsistencyCheck,
|
||||||
|
|
|
@ -1,24 +1,149 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const knex = require('../lib/knex');
|
const knex = require('../lib/knex');
|
||||||
|
const hasher = require('node-object-hash')();
|
||||||
|
const shortid = require('shortid');
|
||||||
const dtHelpers = require('../lib/dt-helpers');
|
const dtHelpers = require('../lib/dt-helpers');
|
||||||
const interoperableErrors = require('../shared/interoperable-errors');
|
const interoperableErrors = require('../shared/interoperable-errors');
|
||||||
const shares = require('./shares');
|
const shares = require('./shares');
|
||||||
const fields = require('./fields');
|
const fields = require('./fields');
|
||||||
const { SubscriptionStatus } = require('../shared/lists');
|
const { SubscriptionStatus, getFieldKey } = require('../shared/lists');
|
||||||
const segments = require('./segments');
|
const segments = require('./segments');
|
||||||
|
const { enforce, filterObject } = require('../lib/helpers');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
|
const allowedKeysBase = new Set(['email', 'tz', 'is_test', 'status']);
|
||||||
|
|
||||||
|
function getTableName(listId) {
|
||||||
|
return `subscription__${listId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroupedFieldsMap(tx, listId) {
|
||||||
|
const groupedFields = await fields.listGroupedTx(tx, listId);
|
||||||
|
const result = {};
|
||||||
|
for (const fld of groupedFields) {
|
||||||
|
result[getFieldKey(fld)] = fld;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSubscription(groupedFieldsMap, entity) {
|
||||||
|
for (const fldKey in groupedFieldsMap) {
|
||||||
|
const fld = groupedFieldsMap[fldKey];
|
||||||
|
const fieldType = fields.getFieldType(fld.type);
|
||||||
|
|
||||||
|
if (fieldType.grouped) {
|
||||||
|
|
||||||
|
let value = null;
|
||||||
|
|
||||||
|
if (fieldType.cardinality === fields.Cardinality.SINGLE) {
|
||||||
|
for (const optionKey in fld.groupedOptions) {
|
||||||
|
const option = fld.groupedOptions[optionKey];
|
||||||
|
|
||||||
|
if (entity[option.column]) {
|
||||||
|
value = option.column;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete entity[option.column];
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
value = [];
|
||||||
|
for (const optionKey in fld.groupedOptions) {
|
||||||
|
const option = fld.groupedOptions[optionKey];
|
||||||
|
|
||||||
|
if (entity[option.column]) {
|
||||||
|
value.push(option.column);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete entity[option.column];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity[fldKey] = value;
|
||||||
|
|
||||||
|
} else if (fieldType.enumerated) {
|
||||||
|
// This is enum-xxx type. We just make sure that the options we give out match the field settings.
|
||||||
|
// If the field settings gets changed, there can be discrepancies between the field and the subscription data.
|
||||||
|
|
||||||
|
const allowedKeys = new Set(fld.settings.options.map(x => x.key));
|
||||||
|
|
||||||
|
if (!allowedKeys.has(entity[fldKey])) {
|
||||||
|
entity[fldKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ungroupSubscription(groupedFieldsMap, entity) {
|
||||||
|
for (const fldKey in groupedFieldsMap) {
|
||||||
|
const fld = groupedFieldsMap[fldKey];
|
||||||
|
|
||||||
|
const fieldType = fields.getFieldType(fld.type);
|
||||||
|
if (fieldType.grouped) {
|
||||||
|
|
||||||
|
if (fieldType.cardinality === fields.Cardinality.SINGLE) {
|
||||||
|
const value = entity[fldKey];
|
||||||
|
for (const optionKey in fld.groupedOptions) {
|
||||||
|
const option = fld.groupedOptions[optionKey];
|
||||||
|
entity[option.column] = option.column === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const values = entity[fldKey];
|
||||||
|
for (const optionKey in fld.groupedOptions) {
|
||||||
|
const option = fld.groupedOptions[optionKey];
|
||||||
|
entity[option.column] = values.includes(option.column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete entity[fldKey];
|
||||||
|
|
||||||
|
} else if (fieldType.enumerated) {
|
||||||
|
// This is enum-xxx type. We just make sure that the options we give out match the field settings.
|
||||||
|
// If the field settings gets changed, there can be discrepancies between the field and the subscription data.
|
||||||
|
|
||||||
|
const allowedKeys = new Set(fld.settings.options.map(x => x.key));
|
||||||
|
|
||||||
|
if (!allowedKeys.has(entity[fldKey])) {
|
||||||
|
entity[fldKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const allowedKeysBase = new Set(['cid', 'email']);
|
function getAllowedKeys(groupedFieldsMap) {
|
||||||
|
return new Set([
|
||||||
function hash(entity) {
|
...allowedKeysBase,
|
||||||
const allowedKeys = allowedKeysBase.slice();
|
...Object.keys(groupedFieldsMap)
|
||||||
|
]);
|
||||||
// TODO add keys from custom fields
|
}
|
||||||
|
|
||||||
|
function hashByAllowedKeys(allowedKeys, entity) {
|
||||||
return hasher.hash(filterObject(entity, allowedKeys));
|
return hasher.hash(filterObject(entity, allowedKeys));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hashByList(listId, entity) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||||
|
const allowedKeys = getAllowedKeys(groupedFieldsMap);
|
||||||
|
return hashByAllowedKeys(allowedKeys, entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(context, listId, id) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
|
||||||
|
|
||||||
|
const entity = await tx(getTableName(listId)).where('id', id).first();
|
||||||
|
|
||||||
|
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||||
|
groupSubscription(groupedFieldsMap, entity);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function listDTAjax(context, listId, segmentId, params) {
|
async function listDTAjax(context, listId, segmentId, params) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
|
@ -31,13 +156,14 @@ async function listDTAjax(context, listId, segmentId, params) {
|
||||||
tx,
|
tx,
|
||||||
params,
|
params,
|
||||||
builder => {
|
builder => {
|
||||||
const query = builder.from(`subscription__${listId}`);
|
const query = builder.from(getTableName(listId));
|
||||||
query.where(function() {
|
query.where(function() {
|
||||||
addSegmentQuery(this);
|
addSegmentQuery(this);
|
||||||
});
|
});
|
||||||
return query;
|
return query;
|
||||||
},
|
},
|
||||||
['id', 'cid', 'email', 'status', 'created', ...flds.map(fld => fld.column)]
|
['id', 'cid', 'email', 'status', 'created', ...flds.map(fld => fld.column)]
|
||||||
|
// FIXME - adapt data in custom columns to render them properly
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -46,12 +172,189 @@ async function list(context, listId) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
|
||||||
|
|
||||||
return await tx(`subscription__${listId}`);
|
const entities = await tx(getTableName(listId));
|
||||||
|
|
||||||
|
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
groupSubscription(groupedFieldsMap, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serverValidate(context, listId, data) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
const result = {};
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
|
||||||
|
|
||||||
|
if (data.email) {
|
||||||
|
const existingKeyQuery = tx(getTableName(listId)).where('email', data.email);
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
existingKeyQuery.whereNot('id', data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingKey = await existingKeyQuery.first();
|
||||||
|
result.key = {
|
||||||
|
exists: !!existingKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, isCreate) {
|
||||||
|
enforce(entity.email, 'Email must be set');
|
||||||
|
|
||||||
|
const existingWithKeyQuery = tx(getTableName(listId)).where('email', entity.email);
|
||||||
|
|
||||||
|
if (!isCreate) {
|
||||||
|
existingWithKeyQuery.whereNot('id', entity.id);
|
||||||
|
}
|
||||||
|
const existingWithKey = await existingWithKeyQuery.first();
|
||||||
|
if (existingWithKey) {
|
||||||
|
throw new interoperableErrors.DuplicitEmailError();
|
||||||
|
}
|
||||||
|
|
||||||
|
enforce(entity.status >= 0 && entity.status < SubscriptionStatus.MAX, 'Invalid status');
|
||||||
|
|
||||||
|
for (const key in groupedFieldsMap) {
|
||||||
|
const fld = groupedFieldsMap[key];
|
||||||
|
if (fld.type === 'date' || fld.type === 'birthday') {
|
||||||
|
entity[getFieldKey(fld)] = moment(entity[getFieldKey(fld)]).toDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(context, listId, entity) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
|
||||||
|
|
||||||
|
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||||
|
const allowedKeys = getAllowedKeys(groupedFieldsMap);
|
||||||
|
|
||||||
|
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, true);
|
||||||
|
|
||||||
|
const filteredEntity = filterObject(entity, allowedKeys);
|
||||||
|
filteredEntity.cid = shortid.generate();
|
||||||
|
filteredEntity.status_change = new Date();
|
||||||
|
|
||||||
|
ungroupSubscription(groupedFieldsMap, filteredEntity);
|
||||||
|
|
||||||
|
// FIXME - process:
|
||||||
|
// filteredEntity.opt_in_ip =
|
||||||
|
// filteredEntity.opt_in_country =
|
||||||
|
// filteredEntity.imported =
|
||||||
|
|
||||||
|
const ids = await tx(getTableName(listId)).insert(filteredEntity);
|
||||||
|
const id = ids[0];
|
||||||
|
|
||||||
|
if (entity.status === SubscriptionStatus.SUBSCRIBED) {
|
||||||
|
await tx('lists').where('id', listId).increment('subscribers', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateWithConsistencyCheck(context, listId, entity) {
|
||||||
|
await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
|
||||||
|
|
||||||
|
const existing = await tx(getTableName(listId)).where('id', entity.id).first();
|
||||||
|
if (!existing) {
|
||||||
|
throw new interoperableErrors.NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||||
|
const allowedKeys = getAllowedKeys(groupedFieldsMap);
|
||||||
|
|
||||||
|
groupSubscription(groupedFieldsMap, existing);
|
||||||
|
|
||||||
|
const existingHash = hashByAllowedKeys(allowedKeys, existing);
|
||||||
|
if (existingHash !== entity.originalHash) {
|
||||||
|
throw new interoperableErrors.ChangedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, false);
|
||||||
|
|
||||||
|
const filteredEntity = filterObject(entity, allowedKeys);
|
||||||
|
|
||||||
|
ungroupSubscription(groupedFieldsMap, filteredEntity);
|
||||||
|
|
||||||
|
if (existing.status !== entity.status) {
|
||||||
|
filteredEntity.status_change = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx(getTableName(listId)).where('id', entity.id).update(filteredEntity);
|
||||||
|
|
||||||
|
|
||||||
|
let countIncrement = 0;
|
||||||
|
if (existing.status === SubscriptionStatus.SUBSCRIBED && entity.status !== SubscriptionStatus.SUBSCRIBED) {
|
||||||
|
countIncrement = -1;
|
||||||
|
} else if (existing.status !== SubscriptionStatus.SUBSCRIBED && entity.status === SubscriptionStatus.SUBSCRIBED) {
|
||||||
|
countIncrement = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countIncrement) {
|
||||||
|
await tx('lists').where('id', listId).increment('subscribers', countIncrement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTx(tx, context, listId, id) {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
|
||||||
|
|
||||||
|
const existing = await tx(getTableName(listId)).where('id', id).first();
|
||||||
|
if (!existing) {
|
||||||
|
throw new interoperableErrors.NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx(getTableName(listId)).where('id', id).del();
|
||||||
|
|
||||||
|
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
|
||||||
|
await tx('lists').where('id', listId).decrement('subscribers', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(context, listId, id) {
|
||||||
|
await knex.transaction(async tx => {
|
||||||
|
await removeTx(tx, context, listId, id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribe(context, listId, id) {
|
||||||
|
await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
|
||||||
|
|
||||||
|
const existing = await tx(getTableName(listId)).where('id', id).first();
|
||||||
|
if (!existing) {
|
||||||
|
throw new interoperableErrors.NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
|
||||||
|
await tx(getTableName(listId)).where('id', id).update({
|
||||||
|
status: SubscriptionStatus.UNSUBSCRIBED
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx('lists').where('id', listId).decrement('subscribers', 1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
hashByList,
|
||||||
|
getById,
|
||||||
list,
|
list,
|
||||||
listDTAjax
|
listDTAjax,
|
||||||
|
serverValidate,
|
||||||
|
create,
|
||||||
|
updateWithConsistencyCheck,
|
||||||
|
remove,
|
||||||
|
unsubscribe
|
||||||
};
|
};
|
|
@ -25,6 +25,11 @@ router.getAsync('/fields/:listId', passport.loggedIn, async (req, res) => {
|
||||||
return res.json(rows);
|
return res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.getAsync('/fields-grouped/:listId', passport.loggedIn, async (req, res) => {
|
||||||
|
const rows = await fields.listGrouped(req.context, req.params.listId);
|
||||||
|
return res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
router.postAsync('/fields/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
router.postAsync('/fields/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
await fields.create(req.context, req.params.listId, req.body);
|
await fields.create(req.context, req.params.listId, req.body);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
|
@ -11,7 +11,7 @@ router.postAsync('/segments-table/:listId', passport.loggedIn, async (req, res)
|
||||||
});
|
});
|
||||||
|
|
||||||
router.getAsync('/segments/:listId', passport.loggedIn, async (req, res) => {
|
router.getAsync('/segments/:listId', passport.loggedIn, async (req, res) => {
|
||||||
return res.json(await segments.list(req.context, req.params.listId));
|
return res.json(await segments.listIdName(req.context, req.params.listId));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.getAsync('/segments/:listId/:segmentId', passport.loggedIn, async (req, res) => {
|
router.getAsync('/segments/:listId/:segmentId', passport.loggedIn, async (req, res) => {
|
||||||
|
|
|
@ -10,5 +10,38 @@ router.postAsync('/subscriptions-table/:listId/:segmentId?', passport.loggedIn,
|
||||||
return res.json(await subscriptions.listDTAjax(req.context, req.params.listId, req.params.segmentId, req.body));
|
return res.json(await subscriptions.listDTAjax(req.context, req.params.listId, req.params.segmentId, req.body));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.getAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, async (req, res) => {
|
||||||
|
const entity = await subscriptions.getById(req.context, req.params.listId, req.params.subscriptionId);
|
||||||
|
entity.hash = await subscriptions.hashByList(req.params.listId, entity);
|
||||||
|
return res.json(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/subscriptions/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
|
await subscriptions.create(req.context, req.params.listId, req.body);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.putAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
|
const entity = req.body;
|
||||||
|
entity.id = parseInt(req.params.subscriptionId);
|
||||||
|
|
||||||
|
await subscriptions.updateWithConsistencyCheck(req.context, req.params.listId, entity);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.deleteAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
|
await subscriptions.remove(req.context, req.params.listId, req.params.subscriptionId);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/subscriptions-validate/:listId', passport.loggedIn, async (req, res) => {
|
||||||
|
return res.json(await subscriptions.serverValidate(req.context, req.params.listId, req.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/subscriptions-unsubscribe/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||||
|
await subscriptions.unsubscribe(req.context, req.params.listId, req.params.subscriptionId);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
|
@ -15,9 +15,14 @@ const SubscriptionStatus = {
|
||||||
BOUNCED: 3,
|
BOUNCED: 3,
|
||||||
COMPLAINED: 4,
|
COMPLAINED: 4,
|
||||||
MAX: 5
|
MAX: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFieldKey(field) {
|
||||||
|
return field.column || 'grouped_' + field.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
UnsubscriptionMode,
|
UnsubscriptionMode,
|
||||||
SubscriptionStatus
|
SubscriptionStatus,
|
||||||
|
getFieldKey
|
||||||
};
|
};
|
Loading…
Reference in a new issue