diff --git a/client/package.json b/client/package.json index ee60f1ba..ac7d1de3 100644 --- a/client/package.json +++ b/client/package.json @@ -27,6 +27,7 @@ "prop-types": "^15.5.10", "react": "^15.6.1", "react-ace": "^5.1.0", + "react-day-picker": "^6.1.0", "react-dom": "^15.6.1", "react-i18next": "^4.6.1", "react-router-dom": "^4.1.1", diff --git a/client/src/lib/form.js b/client/src/lib/form.js index b1fd8547..3d07b5a4 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -19,6 +19,14 @@ import 'brace/mode/json'; import 'brace/mode/handlebars'; import 'brace/theme/github'; +import DayPicker from 'react-day-picker'; +import 'react-day-picker/lib/style.css'; +import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../shared/date'; + +import styles from "./styles.scss"; +import moment from "moment"; + + const FormState = { Loading: 0, LoadingWithNotice: 1, @@ -79,7 +87,7 @@ class Form extends Component { if (!owner.isFormReady()) { if (owner.isFormWithLoadingNotice()) { - return

{t('Loading ...')}

+ return

{t('Loading ...')}

} else { return
; } @@ -91,7 +99,7 @@ class Form extends Component { {statusMessageText && -

{statusMessageText}

+

{statusMessageText}

} @@ -295,6 +303,111 @@ class TextArea extends Component { } } + +class DatePicker extends Component { + constructor(props) { + super(props); + + this.state = { + opened: false + }; + } + + static propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + format: PropTypes.string, + birthday: PropTypes.bool, + dateFormat: PropTypes.string + } + + static defaultProps = { + dateFormat: DateFormat.INTL + } + + static contextTypes = { + formStateOwner: PropTypes.object.isRequired + } + + toggleDayPicker() { + this.setState({ + opened: !this.state.opened + }); + } + + daySelected(date) { + const owner = this.context.formStateOwner; + const id = this.props.id; + const props = this.props; + owner.updateFormValue(id, props.birthday ? formatBirthday(props.dateFormat, date) : formatDate(props.dateFormat, date)); + + this.setState({ + opened: false + }); + } + + render() { + const props = this.props; + const owner = this.context.formStateOwner; + const id = this.props.id; + const htmlId = 'form_' + id; + + function BirthdayPickerCaption({ date, localeUtils, onChange }) { + const months = localeUtils.getMonths(); + return ( +
+ {months[date.getMonth()]} +
+ ); + } + + let selectedDate, captionElement, fromMonth, toMonth, placeholder; + const selectedDateStr = owner.getFormValue(id) || ''; + if (props.birthday) { + selectedDate = parseBirthday(props.dateFormat, selectedDateStr); + if (!selectedDate) { + selectedDate = moment().set('year', birthdayYear).toDate(); + } + + captionElement = ; + fromMonth = new Date(birthdayYear, 0, 1); + toMonth = new Date(birthdayYear, 11, 31); + placeholder = getBirthdayFormatString(props.dateFormat); + + } else { + selectedDate = parseDate(props.dateFormat, selectedDateStr); + if (!selectedDate) { + selectedDate = moment().toDate(); + } + + placeholder = getDateFormatString(props.dateFormat); + } + + return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, +
+
+ owner.updateFormValue(id, evt.target.value)}/> + +
+ {this.state.opened && +
+ this.daySelected(date)} + selectedDays={selectedDate} + initialMonth={selectedDate} + fromMonth={fromMonth} + toMonth={toMonth} + captionElement={captionElement} + /> +
+ } +
+ ); + } +} + + class Dropdown extends Component { static propTypes = { id: PropTypes.string.isRequired, @@ -374,7 +487,7 @@ class ButtonRow extends Component { } render() { - let className = 'mt-button-row'; + let className = styles.buttonRow; if (this.props.className) { className += ' ' + this.props.className; } @@ -554,13 +667,13 @@ class TableSelect extends Component { if (props.dropdown) { return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
-
+
-
+
this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/> @@ -834,13 +947,29 @@ function withForm(target) { this.setState(previousState => { const oldValue = previousState.formState.getIn(['data', key, 'value']); + const onChangeBeforeValidationCallback = this.state.formSettings.onChangeBeforeValidation || {}; + + const formState = previousState.formState.withMutations(mutState => { + mutState.update('data', stateData => stateData.withMutations(mutStateData => { + if (typeof onChangeBeforeValidationCallback === 'object') { + if (onChangeBeforeValidationCallback[key]) { + onChangeBeforeValidationCallback[key](mutStateData, key, oldValue, value); + } + } else { + onChangeBeforeValidationCallback(mutStateData, key, oldValue, value); + } + + mutStateData.setIn([key, 'value'], value); + })); + + validateFormState(this, mutState); + }); + let newState = { - formState: previousState.formState.withMutations(mutState => { - mutState.setIn(['data', key, 'value'], value); - validateFormState(this, mutState); - }) + formState }; + const onChangeCallback = this.state.formSettings.onChange || {}; if (typeof onChangeCallback === 'object') { @@ -1000,6 +1129,7 @@ export { InputField, CheckBox, TextArea, + DatePicker, Dropdown, AlignedRow, ButtonRow, diff --git a/client/src/lib/page.css b/client/src/lib/page.css deleted file mode 100644 index e67794fa..00000000 --- a/client/src/lib/page.css +++ /dev/null @@ -1,75 +0,0 @@ -.mt-button-row > * { - margin-right: 15px; -} - -.mt-button-row > *:last-child { - margin-right: 0px; -} - -.mt-form-status { - padding-top: 5px; - padding-bottom: 5px; -} - -.mt-action-links > * { - margin-right: 8px; -} - -.mt-action-links > *:last-child { - margin-right: 0px; -} - -.form-horizontal .control-label { - display: block; -} - -.mt-form-disabled { - background-color: #eeeeee; - opacity: 1; -} - -.ace_editor { - border: 1px solid #ccc; -} - -.mt-tableselect-dropdown { - margin-bottom: 15px; -} - -.mt-tableselect-table.mt-tableselect-table-hidden { - visibility: hidden; - height: 0px; -} - -.mt-tableselect-dropdown input[readonly] { - background-color: white; -} - -h3.legend { - font-size: 21px; - margin-bottom: 20px; -} - -.mt-secondary-nav { - margin-top: 5px; - margin-right: 5px; - text-align: right; -} - -@media (max-width: 767px) { - .mt-secondary-nav { - margin: 0px; - background-color: #f5f5f5; - padding: 5px 5px; - border-radius: 4px; - } -} - -.mt-secondary-nav > li { - display: inline-block; - float: none; -} - -.mt-secondary-nav > li > a { - padding: 3px 10px; -} \ No newline at end of file diff --git a/client/src/lib/page.js b/client/src/lib/page.js index 53227edd..a911950c 100644 --- a/client/src/lib/page.js +++ b/client/src/lib/page.js @@ -5,12 +5,12 @@ import { translate } from 'react-i18next'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router'; import {BrowserRouter as Router, Route, Link, Switch, Redirect} from 'react-router-dom' -import './page.css'; import { withErrorHandling, withAsyncErrorHandler } from './error-handling'; import interoperableErrors from '../../../shared/interoperable-errors'; import { DismissibleAlert, Button } from './bootstrap-components'; import mailtrainConfig from 'mailtrainConfig'; import axios from '../lib/axios'; +import styles from "./styles.scss"; class Breadcrumb extends Component { @@ -133,7 +133,7 @@ class SecondaryNavBar extends Component { } if (renderedElems.length > 1) { - let className = 'mt-secondary-nav nav nav-pills'; + let className = styles.secondaryNav + ' nav nav-pills'; if (this.props.className) { className += ' ' + this.props.className; } @@ -482,7 +482,7 @@ class Toolbar extends Component { }; render() { - let className = 'pull-right mt-button-row'; + let className = 'pull-right ' + styles.buttonRow; if (this.props.className) { className += ' ' + this.props.className; } diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss new file mode 100644 index 00000000..bd746f95 --- /dev/null +++ b/client/src/lib/styles.scss @@ -0,0 +1,89 @@ +:global .DayPicker { + border-left: 1px solid lightgray; + border-right: 1px solid lightgray; + border-bottom: 1px solid lightgray; + border-radius: 4px; + padding: 15px; +} + +.dayPickerWrapper { + text-align: right; +} + + + +.buttonRow > * { + margin-right: 15px; +} + +.buttonRow > *:last-child { + margin-right: 0px; +} + +.formStatus { + padding-top: 5px; + padding-bottom: 5px; +} + +.actionLinks > * { + margin-right: 8px; +} + +.actionLinks > *:last-child { + margin-right: 0px; +} + +:global .form-horizontal .control-label { + display: block; +} + +.formDisabled { + background-color: #eeeeee; + opacity: 1; +} + +:global .ace_editor { + border: 1px solid #ccc; +} + +.tableSelectDropdown { + margin-bottom: 15px; +} + +.tableSelectTable.tableSelectTableHidden { + visibility: hidden; + height: 0px; +} + +.tableSelectDropdown input[readonly] { + background-color: white; +} + +:global h3.legend { + font-size: 21px; + margin-bottom: 20px; +} + +.secondaryNav { + margin-top: 5px; + margin-right: 5px; + text-align: right; +} + +@media (max-width: 767px) { + .secondaryNav { + margin: 0px; + background-color: #f5f5f5; + padding: 5px 5px; + border-radius: 4px; + } +} + +.secondaryNav > li { + display: inline-block; + float: none; +} + +.secondaryNav > li > a { + padding: 3px 10px; +} \ No newline at end of file diff --git a/client/src/lib/table.js b/client/src/lib/table.js index 351569b9..44ae9073 100644 --- a/client/src/lib/table.js +++ b/client/src/lib/table.js @@ -15,6 +15,7 @@ import axios from './axios'; import { withPageHelpers } from '../lib/page' import { withErrorHandling, withAsyncErrorHandler } from './error-handling'; +import styles from "./styles.scss"; //dtFactory(); //dtSelectFactory(); @@ -169,7 +170,7 @@ class Table extends Component { this.selectionMap = nextSelectionMap; - return updateDueToSelectionChange || this.props.data != nextProps.data || this.props.dataUrl != nextProps.dataUrl; + return updateDueToSelectionChange || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl; } componentDidMount() { @@ -179,7 +180,7 @@ class Table extends Component { for (const column of columns) { if (column.actions) { const createdCellFn = (td, data, rowData) => { - const linksContainer = jQuery(''); + const linksContainer = jQuery(``); let actions = column.actions(rowData); let options = {}; @@ -322,19 +323,20 @@ class Table extends Component { if (this.props.data) { this.table.clear(); this.table.rows.add(this.props.data); - } else { - const self = this; - this.table.rows().every(function() { - const key = this.data()[self.props.selectionKeyIndex]; - if (self.selectionMap.has(key)) { - jQuery(this.node()).addClass('selected'); - } else { - jQuery(this.node()).removeClass('selected'); - } - }); + this.refresh(); } + const self = this; + this.table.rows().every(function() { + const key = this.data()[self.props.selectionKeyIndex]; + if (self.selectionMap.has(key)) { + jQuery(this.node()).addClass('selected'); + } else { + jQuery(this.node()).removeClass('selected'); + } + }); + this.updateSelectInfo(); this.fetchAndNotifySelectionData(); } diff --git a/client/src/lib/tree.js b/client/src/lib/tree.js index bbca04c0..28ebe61b 100644 --- a/client/src/lib/tree.js +++ b/client/src/lib/tree.js @@ -14,6 +14,7 @@ import axios from './axios'; import { withPageHelpers } from '../lib/page' import { withErrorHandling, withAsyncErrorHandler } from './error-handling'; +import styles from "./styles.scss"; const TreeSelectMode = { NONE: 0, @@ -122,7 +123,7 @@ class TreeTable extends Component { } if (this.props.actions) { - const linksContainer = jQuery(''); + const linksContainer = jQuery(``); const actions = this.props.actions(node); for (const {label, link} of actions) { diff --git a/client/src/lists/fields/CUD.js b/client/src/lists/fields/CUD.js index 433697ed..6f1230ca 100644 --- a/client/src/lists/fields/CUD.js +++ b/client/src/lists/fields/CUD.js @@ -14,7 +14,8 @@ import { getFieldTypes } from './field-types'; import interoperableErrors from '../../../../shared/interoperable-errors'; import validators from '../../../../shared/validators'; import slugify from 'slugify'; -import { parseDate, parseBirthday } from '../../../../shared/fields'; +import { parseDate, parseBirthday, DateFormat } from '../../../../shared/date'; +import styles from "../../lib/styles.scss"; @translate() @withForm @@ -72,7 +73,7 @@ export default class CUD extends Component { } data.enumOptions = ''; - data.dateFormat = 'eur'; + data.dateFormat = DateFormat.EUR; data.renderTemplate = ''; switch (data.type) { @@ -374,8 +375,8 @@ export default class CUD extends Component {
Default value used when the field is empty.}/> @@ -387,8 +388,8 @@ export default class CUD extends Component {
Default value used when the field is empty.}/> @@ -445,7 +446,7 @@ export default class CUD extends Component { {isEdit ? - {(this.fieldTypes[this.getFormValue('type')] || {}).label} + {(this.fieldTypes[this.getFormValue('type')] || {}).label} : } diff --git a/client/src/lists/root.js b/client/src/lists/root.js index 312da384..79301d11 100644 --- a/client/src/lists/root.js +++ b/client/src/lists/root.js @@ -40,9 +40,13 @@ const getStructure = t => { navs: { subscriptions: { title: t('Subscribers'), + resolve: { + segments: params => `/rest/segments/${params.listId}` + }, + extraParams: [':segmentId?'], link: params => `/lists/${params.listId}/subscriptions`, visible: resolved => resolved.list.permissions.includes('viewSubscriptions'), - render: props => + render: props => }, ':action(edit|delete)': { title: t('Edit'), @@ -163,7 +167,7 @@ const getStructure = t => { export default function() { ReactDOM.render( -
, +
, document.getElementById('root') ); }; diff --git a/client/src/lists/segments/CUD.js b/client/src/lists/segments/CUD.js index b53a560c..52d2bd9a 100644 --- a/client/src/lists/segments/CUD.js +++ b/client/src/lists/segments/CUD.js @@ -93,6 +93,10 @@ export default class CUD extends Component { await this.getFormValuesFromURL(`/rest/segments/${this.props.list.id}/${this.props.entity.id}`, data => { data.rootRuleType = data.settings.rootRule.type; data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors + + this.setState({ + rulesTree: this.getTreeFromRules(data.settings.rootRule.rules) + }); }); } diff --git a/client/src/lists/segments/RuleSettingsPane.js b/client/src/lists/segments/RuleSettingsPane.js index f301fbfa..fc88bcf8 100644 --- a/client/src/lists/segments/RuleSettingsPane.js +++ b/client/src/lists/segments/RuleSettingsPane.js @@ -27,6 +27,7 @@ export default class CUD extends Component { this.state = {}; this.initForm({ + onChangeBeforeValidation: ::this.populateRuleDefaults, onChange: ::this.onFormChange }); } @@ -45,12 +46,12 @@ export default class CUD extends Component { const ruleHelpers = this.ruleHelpers; let data; - if (!ruleHelpers.isCompositeRuleType(rule.type)) { + if (!ruleHelpers.isCompositeRuleType(rule.type)) { // rule.type === null signifies primitive rule where the type has not been determined yet + data = ruleHelpers.primitiveRuleTypesFormDataDefaults; + const settings = ruleHelpers.getRuleTypeSettings(rule); if (settings) { - data = settings.getFormData(rule); - } else { - data = {}; // This handles the case of a new rule, which does not have a type and column yet + Object.assign(data, settings.getFormData(rule)); } data.type = rule.type || ''; // On '', we display label "--SELECT--" in the type dropdown. Null would not be accepted by React. @@ -89,10 +90,10 @@ export default class CUD extends Component { const ruleType = state.getIn(['type', 'value']); if (!ruleHelpers.isCompositeRuleType(ruleType)) { - const columnType = state.getIn(['column', 'value']); + const column = state.getIn(['column', 'value']); - if (columnType) { - const colType = ruleHelpers.getColumnType(columnType); + if (column) { + const colType = ruleHelpers.getColumnType(column); if (ruleType) { const settings = ruleHelpers.primitiveRuleTypes[colType][ruleType]; @@ -106,6 +107,27 @@ export default class CUD extends Component { } } + populateRuleDefaults(mutState) { + const ruleHelpers = this.ruleHelpers; + const type = mutState.getIn(['data','type','value']); + + if (!ruleHelpers.isCompositeRuleType(type)) { + const column = mutState.getIn(['data', 'column', 'value']); + + if (column) { + const colType = ruleHelpers.getColumnType(column); + + if (type) { + const settings = ruleHelpers.primitiveRuleTypes[colType][type]; + if (!settings) { + // The existing rule type does not fit the newly changed column. This resets the rule type chooser to "-- Select ---" + mutState.setIn(['data', 'type', 'value'], ''); + } + } + } + } + } + onFormChange(newState) { const noErrors = !newState.formState.get('data').find(attr => attr.get('error')); @@ -136,7 +158,6 @@ export default class CUD extends Component { } } - render() { const t = this.props.t; const rule = this.props.rule; @@ -153,7 +174,7 @@ export default class CUD extends Component { { data: 3, title: t('Merge Tag') } ]; - const ruleColumnOptions = ruleHelpers.fields.map(fld => [ fld.column, fld.name, this.fieldTypes[fld.type].label, fld.tag || '' ]); + const ruleColumnOptions = ruleHelpers.fields.map(fld => [ fld.column, fld.name, this.fieldTypes[fld.type].label, fld.key || '' ]); const ruleColumnSelect = ; let ruleTypeSelect = null; @@ -186,6 +207,7 @@ export default class CUD extends Component { } + return (

{t('Rule Options')}

@@ -198,6 +220,7 @@ export default class CUD extends Component {
); } diff --git a/client/src/lists/segments/rule-helpers.js b/client/src/lists/segments/rule-helpers.js index 0ce05069..49e4e606 100644 --- a/client/src/lists/segments/rule-helpers.js +++ b/client/src/lists/segments/rule-helpers.js @@ -1,14 +1,12 @@ 'use strict'; import React from 'react'; -import {InputField} from "../../lib/form"; +import {DatePicker, Dropdown, InputField} from "../../lib/form"; +import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../../shared/date'; + export function getRuleHelpers(t, fields) { - function formatDate(date) { - return date; // FIXME - } - const ruleHelpers = {}; ruleHelpers.compositeRuleTypes = { @@ -108,49 +106,72 @@ export function getRuleHelpers(t, fields) { } } - ruleHelpers.primitiveRuleTypes.date = ruleHelpers.primitiveRuleTypes.birthday = { + ruleHelpers.primitiveRuleTypes.date = { eq: { dropdownLabel: t('On'), - treeLabel: rule => t('Date in column "{{colName}}" is {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(rule.value)}), + treeLabel: rule => t('Date in column "{{colName}}" is {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}), }, lt: { dropdownLabel: t('Before'), - treeLabel: rule => t('Date in column "{{colName}}" is before {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(rule.value)}), + treeLabel: rule => t('Date in column "{{colName}}" is before {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}), }, le: { dropdownLabel: t('Before or on'), - treeLabel: rule => t('Date in column "{{colName}}" is before or on {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(rule.value)}), + treeLabel: rule => t('Date in column "{{colName}}" is before or on {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}), }, gt: { dropdownLabel: t('After'), - treeLabel: rule => t('Date in column "{{colName}}" is after {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(rule.value)}), + treeLabel: rule => t('Date in column "{{colName}}" is after {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}), }, ge: { dropdownLabel: t('After or on'), - treeLabel: rule => t('Date in column "{{colName}}" is after or on {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(rule.value)}), + treeLabel: rule => t('Date in column "{{colName}}" is after or on {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}), }, - eqNowPlusDays: { - dropdownLabel: t('On x-th day before/after now'), + eqTodayPlusDays: { + dropdownLabel: t('On x-th day before/after current date'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is'), }, - ltNowPlusDays: { - dropdownLabel: t('Before x-th day before/after now'), + ltTodayPlusDays: { + dropdownLabel: t('Before x-th day before/after current date'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before'), }, - leNowPlusDays: { - dropdownLabel: t('Before or on x-th day before/after now'), + leTodayPlusDays: { + dropdownLabel: t('Before or on x-th day before/after current date'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before or on'), }, - gtNowPlusDays: { - dropdownLabel: t('After x-th day before/after now'), + gtTodayPlusDays: { + dropdownLabel: t('After x-th day before/after current date'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after'), }, - geNowPlusDays: { - dropdownLabel: t('After or on x-th day before/after now'), + geTodayPlusDays: { + dropdownLabel: t('After or on x-th day before/after current date'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after or on'), } }; + ruleHelpers.primitiveRuleTypes.birthday = { + eq: { + dropdownLabel: t('On'), + treeLabel: rule => t('Date in column "{{colName}}" is {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}), + }, + lt: { + dropdownLabel: t('Before'), + treeLabel: rule => t('Date in column "{{colName}}" is before {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}), + }, + le: { + dropdownLabel: t('Before or on'), + treeLabel: rule => t('Date in column "{{colName}}" is before or on {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}), + }, + gt: { + dropdownLabel: t('After'), + treeLabel: rule => t('Date in column "{{colName}}" is after {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}), + }, + ge: { + dropdownLabel: t('After or on'), + treeLabel: rule => t('Date in column "{{colName}}" is after or on {{value}}', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}), + } + }; + ruleHelpers.primitiveRuleTypes.option = { isTrue: { dropdownLabel: t('Is selected'), @@ -194,7 +215,7 @@ export function getRuleHelpers(t, fields) { }; - const stringValueSettings = { + const stringValueSettings = allowEmpty => ({ form: , getFormData: rule => ({ value: rule.value @@ -203,13 +224,13 @@ export function getRuleHelpers(t, fields) { rule.value = getter('value'); }, validate: state => { - if (!state.getIn(['value', 'value'])) { + if (!allowEmpty && !state.getIn(['value', 'value'])) { state.setIn(['value', 'error'], t('Value must not be empty')); } else { state.setIn(['value', 'error'], null); } } - }; + }); const numberValueSettings = { form: , @@ -232,23 +253,79 @@ export function getRuleHelpers(t, fields) { }; const birthdayValueSettings = { - form: // FIXME - }; - - const birthdayRelativeValueSettings = { - form: // FIXME + form: , + getFormData: rule => ({ + value: formatBirthday(DateFormat.INTL, rule.value) + }), + assignRuleSettings: (rule, getter) => { + rule.value = parseBirthday(DateFormat.INTL, getter('value')).toISOString(); + }, + validate: state => { + const value = state.getIn(['value', 'value']); + const date = parseBirthday(DateFormat.INTL, value); + if (!value) { + state.setIn(['value', 'error'], t('Date must not be empty')); + } else if (!date) { + state.setIn(['value', 'error'], t('Date is invalid')); + } else { + state.setIn(['value', 'error'], null); + } + } }; const dateValueSettings = { - form: // FIXME + form: , + getFormData: rule => ({ + value: formatDate(DateFormat.INTL, rule.value) + }), + assignRuleSettings: (rule, getter) => { + rule.value = parseDate(DateFormat.INTL, getter('value')).toISOString(); + }, + validate: state => { + const value = state.getIn(['value', 'value']); + const date = parseDate(DateFormat.INTL, value); + if (!value) { + state.setIn(['value', 'error'], t('Date must not be empty')); + } else if (!date) { + state.setIn(['value', 'error'], t('Date is invalid')); + } else { + state.setIn(['value', 'error'], null); + } + } }; const dateRelativeValueSettings = { - form: // FIXME + form: +
+ + +
, + getFormData: rule => ({ + value: Math.abs(rule.value).toString(), + direction: rule.value >= 0 ? 'after' : 'before' + }), + assignRuleSettings: (rule, getter) => { + const direction = getter('direction'); + rule.value = parseInt(getter('value')) * (direction === 'before' ? -1 : 1); + }, + validate: state => { + const value = state.getIn(['value', 'value']); + if (!value) { + state.setIn(['value', 'error'], t('Value must not be empty')); + } else if (isNaN(value)) { + state.setIn(['value', 'error'], t('Value must be a number')); + } else { + state.setIn(['value', 'error'], null); + } + } }; const optionValueSettings = { form: null, + formDataDefaults: {}, getFormData: rule => ({}), assignRuleSettings: (rule, getter) => {}, validate: state => {} @@ -261,16 +338,24 @@ export function getRuleHelpers(t, fields) { } } - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, Object.keys(ruleHelpers.primitiveRuleTypes.text), stringValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.website, Object.keys(ruleHelpers.primitiveRuleTypes.website), stringValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.number, Object.keys(ruleHelpers.primitiveRuleTypes.number), numberValueSettings); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['eq', 'like', 're'], stringValueSettings(true)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['lt', 'le', 'gt', 'ge'], stringValueSettings(false)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.website, ['eq', 'like', 're'], stringValueSettings(true)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.number, ['eq', 'lt', 'le', 'gt', 'ge'], numberValueSettings); assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.birthday, ['eq', 'lt', 'le', 'gt', 'ge'], birthdayValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.birthday, ['eqNowPlusDays', 'ltNowPlusDays', 'leNowPlusDays', 'gtNowPlusDays', 'geNowPlusDays'], birthdayRelativeValueSettings); assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eq', 'lt', 'le', 'gt', 'ge'], dateValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eqNowPlusDays', 'ltNowPlusDays', 'leNowPlusDays', 'gtNowPlusDays', 'geNowPlusDays'], dateRelativeValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.option, Object.keys(ruleHelpers.primitiveRuleTypes.option), optionValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], Object.keys(ruleHelpers.primitiveRuleTypes['dropdown-enum']), stringValueSettings); - assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], Object.keys(ruleHelpers.primitiveRuleTypes['radio-enum']), stringValueSettings); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'], dateRelativeValueSettings); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.option, ['isTrue', 'isFalse'], optionValueSettings); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['eq', 'like', 're'], stringValueSettings(true)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['eq', 'like', 're'], stringValueSettings(true)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false)); + + ruleHelpers.primitiveRuleTypesFormDataDefaults = { + value: '', + direction: 'before' + }; + ruleHelpers.getCompositeRuleTypeOptions = () => { @@ -283,24 +368,22 @@ export function getRuleHelpers(t, fields) { text: ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'], website: ['eq', 'like', 're'], number: ['eq', 'lt', 'le', 'gt', 'ge'], - birthday: ['eq', 'lt', 'le', 'gt', 'ge', 'eqNowPlusDays', 'ltNowPlusDays', 'leNowPlusDays', 'gtNowPlusDays', 'geNowPlusDays'], - date: ['eq', 'lt', 'le', 'gt', 'ge', 'eqNowPlusDays', 'ltNowPlusDays', 'leNowPlusDays', 'gtNowPlusDays', 'geNowPlusDays'], + birthday: ['eq', 'lt', 'le', 'gt', 'ge'], + date: ['eq', 'lt', 'le', 'gt', 'ge', 'eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'], option: ['isTrue', 'isFalse'], - 'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'], - 'dropdown-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'] + 'dropdown-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'], + 'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'] }; return order[columnType].map(key => ({ key, label: ruleHelpers.primitiveRuleTypes[columnType][key].dropdownLabel })); }; - - const predefColumns = [ { column: 'email', name: t('Email address'), type: 'text', - tag: 'EMAIL' + key: 'EMAIL' }, { column: 'opt_in_country', @@ -329,7 +412,7 @@ export function getRuleHelpers(t, fields) { ...fields.filter(fld => fld.type in ruleHelpers.primitiveRuleTypes) ]; - ruleHelpers.fieldsByColumn = []; + ruleHelpers.fieldsByColumn = {}; for (const fld of ruleHelpers.fields) { ruleHelpers.fieldsByColumn[fld.column] = fld; } diff --git a/client/src/lists/subscriptions/List.js b/client/src/lists/subscriptions/List.js index 19b76b9f..b68c81a1 100644 --- a/client/src/lists/subscriptions/List.js +++ b/client/src/lists/subscriptions/List.js @@ -12,7 +12,6 @@ import { Dropdown, Form, withForm } from '../../lib/form'; -import axios from '../../lib/axios'; @translate() @withForm @@ -24,62 +23,48 @@ export default class List extends Component { super(props); const t = props.t; - this.state = { - segmentOptions: [ - {key: 'none', label: t('All subscriptions')} - ] - }; + this.state = {}; this.subscriptionStatusLabels = { [SubscriptionStatus.SUBSCRIBED]: t('Subscribed'), [SubscriptionStatus.UNSUBSCRIBED]: t('Unubscribed'), [SubscriptionStatus.BOUNCED]: t('Bounced'), [SubscriptionStatus.COMPLAINED]: t('Complained'), - } + }; this.initForm({ onChange: { - segment: ::this.onSegmentChange + segment: (newState, key, oldValue, value) => { + this.navigateTo(`/lists/${this.props.list.id}/subscriptions` + (value ? '/' + value : '')); + } } }); } static propTypes = { - list: PropTypes.object + list: PropTypes.object, + segments: PropTypes.array, + segmentId: PropTypes.string } - onSegmentChange(state, attr, oldValue, newValue) { - // TODO - - this.subscriptionsTable.refresh(); - } - - @withAsyncErrorHandler - async loadSegmentOptions() { - const t = this.props.t; - - const result = await axios.get(`/rest/segments/${this.props.list.id}`); - - this.setState({ - segmentOptions: [ - {key: 'none', label: t('All subscriptions')}, - ...result.data.map(x => ({ key: x.id.toString(), label: x.name})), - ] + updateSegmentSelection(props) { + this.populateFormValues({ + segment: props.segmentId || '' }); } componentDidMount() { - this.populateFormValues({ - segment: 'none' - }); - - this.loadSegmentOptions(); + this.updateSegmentSelection(this.props); } + componentWillReceiveProps(nextProps) { + this.updateSegmentSelection(nextProps); + } render() { const t = this.props.t; const list = this.props.list; + const segments = this.props.segments; const columns = [ { data: 2, title: t('Email') }, @@ -96,6 +81,17 @@ export default class List extends Component { }); } + const segmentOptions = [ + {key: '', label: t('All subscriptions')}, + ...segments.map(x => ({ key: x.id.toString(), label: x.name})) + ] + + + let dataUrl = '/rest/subscriptions-table/' + list.id; + if (this.props.segmentId) { + dataUrl += '/' + this.props.segmentId; + } + return (
@@ -110,12 +106,12 @@ export default class List extends Component {
- +
-
this.subscriptionsTable = node} withHeader dataUrl={`/rest/subscriptions-table/${list.id}`} columns={columns} /> +
this.subscriptionsTable = node} withHeader dataUrl={dataUrl} columns={columns} /> ); } diff --git a/lib/knex.js b/lib/knex.js index 0c0aebdc..fd0bfb5b 100644 --- a/lib/knex.js +++ b/lib/knex.js @@ -8,7 +8,7 @@ const knex = require('knex')({ migrations: { directory: __dirname + '/../setup/knex/migrations' } - // , debug: true + , debug: true }); module.exports = knex; diff --git a/models/fields.js b/models/fields.js index 7a059fd5..abecc638 100644 --- a/models/fields.js +++ b/models/fields.js @@ -113,10 +113,14 @@ async function getById(context, listId, id) { }); } +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'); +} + async function list(context, listId) { return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); - 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 listTx(tx, listId); }); } @@ -125,72 +129,76 @@ async function listByOrderListTx(tx, listId, extraColumns = []) { } async function listDTAjax(context, listId, params) { - return await dtHelpers.ajaxListWithPermissions( - context, - [{ entityTypeId: 'list', requiredOperations: ['manageFields'] }], - params, - builder => builder - .from('custom_fields') - .innerJoin('lists', 'custom_fields.list', 'lists.id') + return await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); - // This self join is to provide 'option' fields a reference to their parent grouped field. If the field is not an option, it refers to itself - // All this is to show options always below their group parent - .innerJoin('custom_fields AS parent_fields', function() { - this.on(function() { - this.on('custom_fields.type', '=', knex.raw('?', ['option'])) - .on('custom_fields.group', '=', 'parent_fields.id'); - }).orOn(function() { - this.on('custom_fields.type', '<>', knex.raw('?', ['option'])) - .on('custom_fields.id', '=', 'parent_fields.id'); - }); - }) - .where('custom_fields.list', listId), - [ 'custom_fields.id', 'custom_fields.name', 'custom_fields.type', 'custom_fields.key', 'custom_fields.order_list' ], - { - orderByBuilder: (builder, orderColumn, orderDir) => { - // We use here parent_fields to keep options always below their parent group - if (orderColumn === 'custom_fields.order_list') { - builder - .orderBy(knex.raw('-parent_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc') // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last - .orderBy('parent_fields.name', orderDir) - .orderBy(knex.raw('custom_fields.type = "option"'), 'asc') - } else { - const parentColumn = orderColumn.replace(/^custom_fields/, 'parent_fields'); - builder - .orderBy(parentColumn, orderDir) - .orderBy('parent_fields.name', orderDir) - .orderBy(knex.raw('custom_fields.type = "option"'), 'asc'); + return await dtHelpers.ajaxListTx( + tx, + params, + builder => builder + .from('custom_fields') + + // This self join is to provide 'option' fields a reference to their parent grouped field. If the field is not an option, it refers to itself + // All this is to show options always below their group parent + .innerJoin('custom_fields AS parent_fields', function() { + this.on(function() { + this.on('custom_fields.type', '=', knex.raw('?', ['option'])) + .on('custom_fields.group', '=', 'parent_fields.id'); + }).orOn(function() { + this.on('custom_fields.type', '<>', knex.raw('?', ['option'])) + .on('custom_fields.id', '=', 'parent_fields.id'); + }); + }) + .where('custom_fields.list', listId), + [ 'custom_fields.id', 'custom_fields.name', 'custom_fields.type', 'custom_fields.key', 'custom_fields.order_list' ], + { + orderByBuilder: (builder, orderColumn, orderDir) => { + // We use here parent_fields to keep options always below their parent group + if (orderColumn === 'custom_fields.order_list') { + builder + .orderBy(knex.raw('-parent_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc') // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last + .orderBy('parent_fields.name', orderDir) + .orderBy(knex.raw('custom_fields.type = "option"'), 'asc') + } else { + const parentColumn = orderColumn.replace(/^custom_fields/, 'parent_fields'); + builder + .orderBy(parentColumn, orderDir) + .orderBy('parent_fields.name', orderDir) + .orderBy(knex.raw('custom_fields.type = "option"'), 'asc'); + } } } - } - ); + ); + }); } async function listGroupedDTAjax(context, listId, params) { - return await dtHelpers.ajaxListWithPermissions( - context, - [{ entityTypeId: 'list', requiredOperations: ['manageFields'] }], - params, - builder => builder - .from('custom_fields') - .innerJoin('lists', 'custom_fields.list', 'lists.id') - .where('custom_fields.list', listId) - .whereIn('custom_fields.type', groupedTypes), - [ 'custom_fields.id', 'custom_fields.name', 'custom_fields.type', 'custom_fields.key', 'custom_fields.order_list' ], - { - orderByBuilder: (builder, orderColumn, orderDir) => { - if (orderColumn === 'custom_fields.order_list') { - builder - .orderBy(knex.raw('-custom_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc') // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last - .orderBy('custom_fields.name', orderDir); - } else { - builder - .orderBy(orderColumn, orderDir) - .orderBy('custom_fields.name', orderDir); + return await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); + + return await dtHelpers.ajaxListTx( + tx, + params, + builder => builder + .from('custom_fields') + .where('custom_fields.list', listId) + .whereIn('custom_fields.type', groupedTypes), + ['custom_fields.id', 'custom_fields.name', 'custom_fields.type', 'custom_fields.key', 'custom_fields.order_list'], + { + orderByBuilder: (builder, orderColumn, orderDir) => { + if (orderColumn === 'custom_fields.order_list') { + builder + .orderBy(knex.raw('-custom_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc') // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last + .orderBy('custom_fields.name', orderDir); + } else { + builder + .orderBy(orderColumn, orderDir) + .orderBy('custom_fields.name', orderDir); + } } } - } - ); + ); + }); } async function serverValidate(context, listId, data) { @@ -374,7 +382,7 @@ async function removeTx(tx, context, listId, id) { table.dropColumn(existing.column); }); - await segments.removeRulesByFieldIdTx(tx, context, listId, id); + await segments.removeRulesByColumnTx(tx, context, listId, existing.column); } } @@ -391,11 +399,12 @@ async function removeAllByListIdTx(tx, context, listId) { } } - -module.exports = { +// This is to handle circular dependency with segments.js +Object.assign(module.exports, { hash, getById, list, + listTx, listByOrderListTx, listDTAjax, listGroupedDTAjax, @@ -404,4 +413,4 @@ module.exports = { remove, removeAllByListIdTx, serverValidate -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/models/segments.js b/models/segments.js index 019988fd..be0fb9f8 100644 --- a/models/segments.js +++ b/models/segments.js @@ -6,16 +6,215 @@ const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); const { enforce, filterObject } = require('../lib/helpers'); const hasher = require('node-object-hash')(); +const moment = require('moment'); +const fields = require('./fields'); + +const { parseDate, parseBirthday, DateFormat } = require('../shared/date'); const allowedKeys = new Set(['name', 'settings']); + + +const predefColumns = [ + { + column: 'email', + type: 'text' + }, + { + column: 'opt_in_country', + type: 'text' + }, + { + column: 'created', + type: 'date' + }, + { + column: 'latest_open', + type: 'date' + }, + { + column: 'latest_click', + type: 'date' + } +]; + + +const compositeRuleTypes = { + all: { + addQuery: (query, rules, addSubQuery) => { + for (const rule of rules) { + query.where(function() { + addSubQuery(this, rule); + }); + } + } + }, + some: { + addQuery: (query, rules, addSubQuery) => { + for (const rule of rules) { + query.orWhere(function() { + addSubQuery(this, rule); + }); + } + } + }, + none: { + addQuery: (query, rules, addSubQuery) => { + for (const rule of rules) { + query.whereNot(function() { + addSubQuery(this, rule); + }); + } + } + }, +}; + + + +const primitiveRuleTypes = { + text: {}, + website: {}, + number: {}, + date: {}, + birthday: {}, + option: {}, + 'dropdown-enum': {}, + 'radio-enum': {} +}; + + +function stringValueSettings(sqlOperator, allowEmpty) { + return { + validate: rule => { + enforce(typeof rule.value === 'string', 'Invalid value type in rule'); + enforce(allowEmpty || rule.value, 'Value in rule must not be empty'); + }, + addQuery: (query, rule) => query.where(rule.column, sqlOperator, rule.value) + }; +} + +function numberValueSettings(sqlOperator) { + return { + validate: rule => { + enforce(typeof rule.value === 'number', 'Invalid value type in rule'); + }, + addQuery: (query, rule) => query.where(rule.column, sqlOperator, rule.value) + }; +} + +function dateValueSettings(thisDaySqlOperator, nextDaySqlOperator) { + return { + validate: rule => { + const date = moment.utc(rule.value); + enforce(date.isValid(), 'Invalid date value'); + }, + addQuery: (query, rule) => { + const thisDay = moment.utc(rule.value).startOf('day'); + const nextDay = moment(thisDay).add(1, 'days'); + + if (thisDaySqlOperator) { + query.where(rule.column, thisDaySqlOperator, thisDay.toDate()) + } + + if (nextDaySqlOperator) { + query.where(rule.column, nextDaySqlOperator, nextDay.toDate()); + } + } + }; +} + +function dateRelativeValueSettings(todaySqlOperator, tomorrowSqlOperator) { + return { + validate: rule => { + enforce(typeof rule.value === 'number', 'Invalid value type in rule'); + }, + addQuery: (query, rule) => { + const todayWithOffset = moment.utc().startOf('day').add(rule.value, 'days'); + const tomorrowWithOffset = moment(todayWithOffset).add(1, 'days'); + + if (todaySqlOperator) { + query.where(rule.column, todaySqlOperator, todayWithOffset.toDate()) + } + + if (tomorrowSqlOperator) { + query.where(rule.column, tomorrowSqlOperator, tomorrowWithOffset.toDate()); + } + } + }; +} + +function optionValueSettings(value) { + return { + validate: rule => {}, + addQuery: (query, rule) => query.where(rule.column, value) + }; +} + + +primitiveRuleTypes.text.eq = stringValueSettings('=', true); +primitiveRuleTypes.text.like = stringValueSettings('LIKE', true); +primitiveRuleTypes.text.re = stringValueSettings('REGEXP', true); +primitiveRuleTypes.text.lt = stringValueSettings('<', false); +primitiveRuleTypes.text.le = stringValueSettings('<=', false); +primitiveRuleTypes.text.gt = stringValueSettings('>', false); +primitiveRuleTypes.text.ge = stringValueSettings('>=', false); + +primitiveRuleTypes.website.eq = stringValueSettings('=', true); +primitiveRuleTypes.website.like = stringValueSettings('LIKE', true); +primitiveRuleTypes.website.re = stringValueSettings('REGEXP', true); + +primitiveRuleTypes.number.eq = numberValueSettings('='); +primitiveRuleTypes.number.lt = numberValueSettings('<'); +primitiveRuleTypes.number.le = numberValueSettings('<='); +primitiveRuleTypes.number.gt = numberValueSettings('>'); +primitiveRuleTypes.number.ge = numberValueSettings('>='); + +primitiveRuleTypes.date.eq = dateValueSettings('>=', '<'); +primitiveRuleTypes.date.lt = dateValueSettings('<', null); +primitiveRuleTypes.date.le = dateValueSettings(null, '<'); +primitiveRuleTypes.date.gt = dateValueSettings(null, '>='); +primitiveRuleTypes.date.ge = dateValueSettings('>=', null); + +primitiveRuleTypes.date.eqTodayPlusDays = dateRelativeValueSettings('>=', '<'); +primitiveRuleTypes.date.ltTodayPlusDays = dateRelativeValueSettings('<', null); +primitiveRuleTypes.date.leTodayPlusDays = dateRelativeValueSettings(null, '<'); +primitiveRuleTypes.date.gtTodayPlusDays = dateRelativeValueSettings(null, '>='); +primitiveRuleTypes.date.geTodayPlusDays = dateRelativeValueSettings('>=', null); + +primitiveRuleTypes.birthday.eq = dateValueSettings('>=', '<'); +primitiveRuleTypes.birthday.lt = dateValueSettings('<', null); +primitiveRuleTypes.birthday.le = dateValueSettings(null, '<'); +primitiveRuleTypes.birthday.gt = dateValueSettings(null, '>='); +primitiveRuleTypes.birthday.ge = dateValueSettings('>=', null); + +primitiveRuleTypes.option.isTrue = optionValueSettings(true); +primitiveRuleTypes.option.isFalse = optionValueSettings(false); + +primitiveRuleTypes['dropdown-enum'].eq = stringValueSettings('=', true); +primitiveRuleTypes['dropdown-enum'].like = stringValueSettings('LIKE', true); +primitiveRuleTypes['dropdown-enum'].re = stringValueSettings('REGEXP', true); +primitiveRuleTypes['dropdown-enum'].lt = stringValueSettings('<', false); +primitiveRuleTypes['dropdown-enum'].le = stringValueSettings('<=', false); +primitiveRuleTypes['dropdown-enum'].gt = stringValueSettings('>', false); +primitiveRuleTypes['dropdown-enum'].ge = stringValueSettings('>=', false); + +primitiveRuleTypes['radio-enum'].eq = stringValueSettings('=', true); +primitiveRuleTypes['radio-enum'].like = stringValueSettings('LIKE', true); +primitiveRuleTypes['radio-enum'].re = stringValueSettings('REGEXP', true); +primitiveRuleTypes['radio-enum'].lt = stringValueSettings('<', false); +primitiveRuleTypes['radio-enum'].le = stringValueSettings('<=', false); +primitiveRuleTypes['radio-enum'].gt = stringValueSettings('>', false); +primitiveRuleTypes['radio-enum'].ge = stringValueSettings('>=', false); + + + function hash(entity) { return hasher.hash(filterObject(entity, allowedKeys)); } async function listDTAjax(context, listId, params) { return await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']); return await dtHelpers.ajaxListTx( tx, @@ -30,7 +229,7 @@ async function listDTAjax(context, listId, params) { async function list(context, listId) { return await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']); return await tx('segments').select(['id', 'name']).where('list', listId).orderBy('name', 'asc'); }); @@ -38,18 +237,53 @@ async function list(context, listId) { async function getById(context, listId, id) { return await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageSegments']); const entity = await tx('segments').where({id, list: listId}).first(); entity.settings = JSON.parse(entity.settings); return entity; }); } +async function _validateAndPreprocess(tx, listId, entity, isCreate) { + enforce(entity.name, 'Name must be present'); + enforce(entity.settings, 'Settings must be present'); + enforce(entity.settings.rootRule, 'Root rule must be present in setting'); + enforce(entity.settings.rootRule.type in compositeRuleTypes, 'Root rule must be composite'); + + + const flds = await fields.listTx(tx, listId); + const allowedFlds = [ + ...predefColumns, + ...flds.filter(fld => fld.type in primitiveRuleTypes) + ]; + + const fieldsByColumn = {}; + for (const fld of allowedFlds) { + fieldsByColumn[fld.column] = fld; + } + + function validateRule(rule) { + if (rule.type in compositeRuleTypes) { + for (const childRule of rule.rules) { + validateRule(childRule); + } + } else { + const colType = fieldsByColumn[rule.column].type; + primitiveRuleTypes[colType][rule.type].validate(rule); + } + } + + + validateRule(entity.settings.rootRule); + + entity.settings = JSON.stringify(entity.settings); +} + async function create(context, listId, entity) { return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments'); - entity.settings = JSON.stringify(entity.settings); + await _validateAndPreprocess(tx, listId, entity, true); const filteredEntity = filterObject(entity, allowedKeys); filteredEntity.list = listId; @@ -77,9 +311,9 @@ async function updateWithConsistencyCheck(context, listId, entity) { throw new interoperableErrors.ChangedError(); } - entity.settings = JSON.stringify(entity.settings); + await _validateAndPreprocess(tx, listId, entity, false); - await tx('segments').where('id', entity.id).update(filterObject(entity, allowedKeys)); + await tx('segments').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeys)); }); } @@ -88,7 +322,7 @@ async function removeTx(tx, context, listId, id) { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments'); // The listId "where" is here to prevent deleting segment of a list for which a user does not have permission - await tx('segments').where({list: listId, id: id}).del(); + await tx('segments').where({list: listId, id}).del(); } async function remove(context, listId, id) { @@ -100,15 +334,70 @@ async function remove(context, listId, id) { async function removeAllByListIdTx(tx, context, listId) { const entities = await tx('segments').where('list', listId).select(['id']); for (const entity of entities) { - await removeTx(tx, context, entity.id); + await removeTx(tx, context, listId, entity.id); } } -async function removeRulesByFieldIdTx(tx, context, listId, fieldId) { - // FIXME +async function removeRulesByColumnTx(tx, context, listId, column) { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments'); + + function pruneChildRules(rule) { + if (rule.type in compositeRuleTypes) { + + const newRules = []; + + for (const childRule of rule.rules) { + if (childRule.column !== column) { + pruneChildRules(childRule); + newRules.push(childRule); + } + } + + rule.rules = newRules; + } + } + + const entities = await tx('segments').where({list: listId}); + for (const entity of entities) { + const settings = JSON.parse(entity.settings); + + pruneChildRules(settings.rootRule); + + await tx('segments').where({list: listId, id: entity.id}).update('settings', JSON.stringify(settings)); + } } -module.exports = { +async function getQueryGeneratorTx(tx, listId, id) { + const flds = await fields.listTx(tx, listId); + const allowedFlds = [ + ...predefColumns, + ...flds.filter(fld => fld.type in primitiveRuleTypes) + ]; + + const fieldsByColumn = {}; + for (const fld of allowedFlds) { + fieldsByColumn[fld.column] = fld; + } + + const entity = await tx('segments').where({id, list: listId}).first(); + const settings = JSON.parse(entity.settings); + + function processRule(query, rule) { + if (rule.type in compositeRuleTypes) { + compositeRuleTypes[rule.type].addQuery(query, rule.rules, (subQuery, childRule) => { + processRule(subQuery, childRule); + }); + } else { + const colType = fieldsByColumn[rule.column].type; + primitiveRuleTypes[colType][rule.type].addQuery(query, rule); + } + } + + return query => processRule(query, settings.rootRule); +} + +// This is to handle circular dependency with fields.js +Object.assign(module.exports, { hash, listDTAjax, list, @@ -117,5 +406,6 @@ module.exports = { updateWithConsistencyCheck, remove, removeAllByListIdTx, - removeRulesByFieldIdTx -}; \ No newline at end of file + removeRulesByColumnTx, + getQueryGeneratorTx +}); \ No newline at end of file diff --git a/models/subscriptions.js b/models/subscriptions.js index ce056e09..21ac7592 100644 --- a/models/subscriptions.js +++ b/models/subscriptions.js @@ -6,6 +6,8 @@ const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); const fields = require('./fields'); const { SubscriptionStatus } = require('../shared/lists'); +const segments = require('./segments'); + const allowedKeysBase = new Set(['cid', 'email']); @@ -18,16 +20,23 @@ function hash(entity) { } -async function listDTAjax(context, listId, params) { +async function listDTAjax(context, listId, segmentId, params) { return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); const flds = await fields.listByOrderListTx(tx, listId, ['column']); + const addSegmentQuery = segmentId ? await segments.getQueryGeneratorTx(tx, listId, segmentId) : () => {}; return await dtHelpers.ajaxListTx( tx, params, - builder => builder.from(`subscription__${listId}`), + builder => { + const query = builder.from(`subscription__${listId}`); + query.where(function() { + addSegmentQuery(this); + }); + return query; + }, ['id', 'cid', 'email', 'status', 'created', ...flds.map(fld => fld.column)] ); }); diff --git a/routes/rest/subscriptions.js b/routes/rest/subscriptions.js index 38275a67..81215922 100644 --- a/routes/rest/subscriptions.js +++ b/routes/rest/subscriptions.js @@ -6,8 +6,8 @@ const subscriptions = require('../../models/subscriptions'); const router = require('../../lib/router-async').create(); -router.postAsync('/subscriptions-table/:listId', passport.loggedIn, async (req, res) => { - return res.json(await subscriptions.listDTAjax(req.context, req.params.listId, req.body)); +router.postAsync('/subscriptions-table/:listId/:segmentId?', passport.loggedIn, async (req, res) => { + return res.json(await subscriptions.listDTAjax(req.context, req.params.listId, req.params.segmentId, req.body)); }); diff --git a/setup/knex/migrations/20170814174051_upgrade_segments.js b/setup/knex/migrations/20170814174051_upgrade_segments.js index fe17e02d..f94e1544 100644 --- a/setup/knex/migrations/20170814174051_upgrade_segments.js +++ b/setup/knex/migrations/20170814174051_upgrade_segments.js @@ -77,26 +77,50 @@ exports.up = (knex, Promise) => (async() => { } break; case 'birthday': - case 'date': - if (oldSettings.relativeRange) { + if (oldSettings.range) { if (oldSettings.start && oldSettings.end) { if (type === 'all') { - rules.push({ type: 'geNowPlusDays', column: oldRule.column, value: oldSettings.start}); - rules.push({ type: 'leNowPlusDays', column: oldRule.column, value: oldSettings.end}); + rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start}); + rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end}); } else { rules.push({ type: 'all', rules: [ - { type: 'geNowPlusDays', column: oldRule.column, value: oldSettings.start}, - { type: 'leNowPlusDays', column: oldRule.column, value: oldSettings.end} + { type: 'ge', column: oldRule.column, value: oldSettings.start}, + { type: 'le', column: oldRule.column, value: oldSettings.end} ] }); } } else if (oldSettings.start) { - rules.push({ type: 'geNowPlusDays', column: oldRule.column, value: oldSettings.startDirection ? oldSettings.start : -oldSettings.start }); + rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start }); } if (oldSettings.end) { - rules.push({ type: 'leNowPlusDays', column: oldRule.column, value: oldSettings.endDirection ? oldSettings.end : -oldSettings.end }); + rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end }); + } + } else { + rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value }); + } + break; + case 'date': + if (oldSettings.relativeRange) { + if (oldSettings.start && oldSettings.end) { + if (type === 'all') { + rules.push({ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.start}); + rules.push({ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.end}); + } else { + rules.push({ + type: 'all', + rules: [ + { type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.start}, + { type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.end} + ] + }); + } + } else if (oldSettings.start) { + rules.push({ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.startDirection ? oldSettings.start : -oldSettings.start }); + } + if (oldSettings.end) { + rules.push({ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.endDirection ? oldSettings.end : -oldSettings.end }); } } else if (oldSettings.range) { if (oldSettings.start && oldSettings.end) { diff --git a/shared/date.js b/shared/date.js new file mode 100644 index 00000000..af5c1c23 --- /dev/null +++ b/shared/date.js @@ -0,0 +1,67 @@ +'use strict'; + +const moment = require('moment'); + +const birthdayYear = 2000; + +const DateFormat = { + US: 'us', + EU: 'eur', + INTL: 'intl' +}; + +const dateFormatStrings = { + 'us': 'MM/DD/YYYY', + 'eur': 'DD/MM/YYYY', + 'intl': 'YYYY-MM-DD' +}; + +const birthdayFormatStrings = { + 'us': 'MM/DD', + 'eur': 'DD/MM', + 'intl': 'MM/DD' +}; + +function parseDate(format, text) { + const date = moment.utc(text, dateFormatStrings[format]); + + if (date.isValid()) { + return date.toDate(); + } +} + +function parseBirthday(format, text) { + const fullDateStr = format === DateFormat.INTL ? birthdayYear + '-' + text : text + '-' + birthdayYear; + const date = moment.utc(fullDateStr, dateFormatStrings[format]); + + if (date.isValid()) { + return date.toDate(); + } +} + +function formatDate(format, date) { + return moment.utc(date).format(dateFormatStrings[format]); +} + +function formatBirthday(format, date) { + return moment.utc(date).format(birthdayFormatStrings[format]); +} + +function getDateFormatString(format) { + return dateFormatStrings[format]; +} + +function getBirthdayFormatString(format) { + return birthdayFormatStrings[format]; +} + +module.exports = { + DateFormat, + birthdayYear, + parseDate, + parseBirthday, + formatDate, + formatBirthday, + getDateFormatString, + getBirthdayFormatString +}; \ No newline at end of file diff --git a/shared/fields.js b/shared/fields.js deleted file mode 100644 index d283a339..00000000 --- a/shared/fields.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -function parseDate(type, text) { - const isUs = type === 'us'; - const trimmedText = text.trim(); - - // try international format first YYYY-MM-DD - const parts = trimmedText.match(/^(\d{4})\D+(\d{2})(?:\D+(\d{2})\b)$/); - let day, month, year; - let value; - - if (parts) { - year = Number(parts[1]) || 2000; - month = Number(parts[2]) || 0; - day = Number(parts[3]) || 0; - value = new Date(Date.UTC(year, month - 1, day)); - } else { - const parts = trimmedText.match(/^(\d+)\D+(\d+)(?:\D+(\d+)\b)$/); - if (!parts) { - value = null; - } else { - day = Number(parts[isUs ? 2 : 1]); - month = Number(parts[isUs ? 1 : 2]); - year = Number(parts[3]); - - if (!day || !month) { - value = null; - } else { - value = new Date(Date.UTC(year, month - 1, day)); - } - } - } - - return value; -} - -function parseBirthday(type, text) { - const isUs = type === 'us'; - const trimmedText = text.trim(); - - let day, month, year; - let value; - - const parts = trimmedText.match(/^(\d+)\D+(\d+)$/); - if (!parts) { - value = null; - } else { - day = Number(parts[isUs ? 2 : 1]); - month = Number(parts[isUs ? 1 : 2]); - - if (!day || !month) { - value = null; - } else { - value = new Date(Date.UTC(2000, month - 1, day)); - } - } - console.log(value); - - return value; -} - -module.exports = { - parseDate, - parseBirthday -}; \ No newline at end of file