Rendering of custom fields in subscription list

This commit is contained in:
Tomas Bures 2017-08-22 08:15:13 +02:00
parent 6f5b50e932
commit c343e4efd3
6 changed files with 253 additions and 109 deletions

View file

@ -12,6 +12,7 @@ import { withErrorHandling } from '../lib/error-handling';
import { DeleteModalDialog } from '../lib/modals'; 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';
import styles from "../lib/styles.scss";
@translate() @translate()
@withForm @withForm
@ -162,7 +163,7 @@ export default class CUD extends Component {
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
{isEdit && {isEdit &&
<StaticField id="cid" label="List ID" help={t('This is the list ID displayed to the subscribers')}> <StaticField id="cid" className={styles.formDisabled} label="List ID" help={t('This is the list ID displayed to the subscribers')}>
{this.getFormValue('cid')} {this.getFormValue('cid')}
</StaticField> </StaticField>
} }

View file

@ -14,7 +14,7 @@ import {
} from '../../lib/form'; } from '../../lib/form';
import {Icon} from "../../lib/bootstrap-components"; import {Icon} from "../../lib/bootstrap-components";
import axios from '../../lib/axios'; import axios from '../../lib/axios';
import {getSubscriptionStatusLabels} from './helpers'; import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
@translate() @translate()
@withForm @withForm
@ -30,6 +30,7 @@ export default class List extends Component {
this.state = {}; this.state = {};
this.subscriptionStatusLabels = getSubscriptionStatusLabels(t); this.subscriptionStatusLabels = getSubscriptionStatusLabels(t);
this.fieldTypes = getFieldTypes(t);
this.initForm({ this.initForm({
onChange: { onChange: {
@ -90,10 +91,16 @@ export default class List extends Component {
]; ];
let colIdx = 5; let colIdx = 5;
for (const fld of list.listFields) { for (const fld of list.listFields) {
const indexable = this.fieldTypes[fld.type].indexable;
columns.push({ columns.push({
data: colIdx, data: colIdx,
title: fld.name title: fld.name,
sortable: indexable,
searchable: indexable
}); });
colIdx += 1; colIdx += 1;

View file

@ -20,156 +20,163 @@ export function getSubscriptionStatusLabels(t) {
export function getFieldTypes(t) { export function getFieldTypes(t) {
const fieldTypes = {}; const groupedFieldTypes = {};
const stringFieldType = long => ({ 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}/>, form: groupedField => long ? <TextArea key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name}/> : <InputField key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name}/>,
assignFormData: (field, data) => {}, assignFormData: (groupedField, data) => {},
initFormData: (field, data) => { initFormData: (groupedField, data) => {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
}, },
assignEntity: (field, data) => {}, assignEntity: (groupedField, data) => {},
validate: (field, state) => {} validate: (groupedField, state) => {},
indexable: true
}); });
const numberFieldType = { const numberFieldType = {
form: field => <InputField key={getFieldKey(field)} id={getFieldKey(field)} label={field.name}/>, form: groupedField => <InputField key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name}/>,
assignFormData: (field, data) => { assignFormData: (groupedField, data) => {
const value = data[getFieldKey(field)]; const value = data[getFieldKey(groupedField)];
data[getFieldKey(field)] = value ? value.toString() : ''; data[getFieldKey(groupedField)] = value ? value.toString() : '';
}, },
initFormData: (field, data) => { initFormData: (groupedField, data) => {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
}, },
assignEntity: (field, data) => { assignEntity: (groupedField, data) => {
data[getFieldKey(field)] = parseInt(data[getFieldKey(field)]); data[getFieldKey(groupedField)] = parseInt(data[getFieldKey(groupedField)]);
}, },
validate: (field, state) => { validate: (groupedField, state) => {
const value = state.getIn([getFieldKey(field), 'value']).trim(); const value = state.getIn([getFieldKey(groupedField), 'value']).trim();
if (value !== '' && isNaN(value)) { if (value !== '' && isNaN(value)) {
state.setIn([getFieldKey(field), 'error'], t('Value must be a number')); state.setIn([getFieldKey(groupedField), 'error'], t('Value must be a number'));
} else { } else {
state.setIn([getFieldKey(field), 'error'], null); state.setIn([getFieldKey(groupedField), 'error'], null);
} }
} },
indexable: true
}; };
const dateFieldType = { const dateFieldType = {
form: field => <DatePicker key={getFieldKey(field)} id={getFieldKey(field)} label={field.name} dateFormat={field.settings.dateFormat} />, form: groupedField => <DatePicker key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} />,
assignFormData: (field, data) => { assignFormData: (groupedField, data) => {
const value = data[getFieldKey(field)]; const value = data[getFieldKey(groupedField)];
data[getFieldKey(field)] = value ? formatDate(field.settings.dateFormat, value) : ''; data[getFieldKey(groupedField)] = value ? formatDate(groupedField.settings.dateFormat, value) : '';
}, },
initFormData: (field, data) => { initFormData: (groupedField, data) => {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
}, },
assignEntity: (field, data) => { assignEntity: (groupedField, data) => {
const date = parseDate(field.settings.dateFormat, data[getFieldKey(field)]); const date = parseDate(groupedField.settings.dateFormat, data[getFieldKey(groupedField)]);
data[getFieldKey(field)] = date; data[getFieldKey(groupedField)] = date;
}, },
validate: (field, state) => { validate: (groupedField, state) => {
const value = state.getIn([getFieldKey(field), 'value']); const value = state.getIn([getFieldKey(groupedField), 'value']);
const date = parseDate(field.settings.dateFormat, value); const date = parseDate(groupedField.settings.dateFormat, value);
if (value !== '' && !date) { if (value !== '' && !date) {
state.setIn([getFieldKey(field), 'error'], t('Date is invalid')); state.setIn([getFieldKey(groupedField), 'error'], t('Date is invalid'));
} else { } else {
state.setIn([getFieldKey(field), 'error'], null); state.setIn([getFieldKey(groupedField), 'error'], null);
} }
} },
indexable: true
}; };
const birthdayFieldType = { const birthdayFieldType = {
form: field => <DatePicker key={getFieldKey(field)} id={getFieldKey(field)} label={field.name} dateFormat={field.settings.dateFormat} birthday />, form: groupedField => <DatePicker key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} birthday />,
assignFormData: (field, data) => { assignFormData: (groupedField, data) => {
const value = data[getFieldKey(field)]; const value = data[getFieldKey(groupedField)];
data[getFieldKey(field)] = value ? formatBirthday(field.settings.dateFormat, value) : ''; data[getFieldKey(groupedField)] = value ? formatBirthday(groupedField.settings.dateFormat, value) : '';
}, },
initFormData: (field, data) => { initFormData: (groupedField, data) => {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
}, },
assignEntity: (field, data) => { assignEntity: (groupedField, data) => {
const date = parseBirthday(field.settings.dateFormat, data[getFieldKey(field)]); const date = parseBirthday(groupedField.settings.dateFormat, data[getFieldKey(groupedField)]);
data[getFieldKey(field)] = date; data[getFieldKey(groupedField)] = date;
}, },
validate: (field, state) => { validate: (groupedField, state) => {
const value = state.getIn([getFieldKey(field), 'value']); const value = state.getIn([getFieldKey(groupedField), 'value']);
const date = parseBirthday(field.settings.dateFormat, value); const date = parseBirthday(groupedField.settings.dateFormat, value);
if (value !== '' && !date) { if (value !== '' && !date) {
state.setIn([getFieldKey(field), 'error'], t('Date is invalid')); state.setIn([getFieldKey(groupedField), 'error'], t('Date is invalid'));
} else { } else {
state.setIn([getFieldKey(field), 'error'], null); state.setIn([getFieldKey(groupedField), 'error'], null);
} }
} },
indexable: true
}; };
const jsonFieldType = { const jsonFieldType = {
form: field => <ACEEditor key={getFieldKey(field)} id={getFieldKey(field)} label={field.name} mode="json" height="300px"/>, form: groupedField => <ACEEditor key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name} mode="json" height="300px"/>,
assignFormData: (field, data) => {}, assignFormData: (groupedField, data) => {},
initFormData: (field, data) => { initFormData: (groupedField, data) => {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
}, },
assignEntity: (field, data) => {}, assignEntity: (groupedField, data) => {},
validate: (field, state) => {} validate: (groupedField, state) => {},
indexable: false
}; };
const enumSingleFieldType = componentType => ({ const enumSingleFieldType = componentType => ({
form: field => React.createElement(componentType, { key: getFieldKey(field), id: getFieldKey(field), label: field.name, options: field.settings.options }, null), form: groupedField => React.createElement(componentType, { key: getFieldKey(groupedField), id: getFieldKey(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
assignFormData: (field, data) => { assignFormData: (groupedField, data) => {
if (data[getFieldKey(field)] === null) { if (data[getFieldKey(groupedField)] === null) {
if (field.default_value) { if (groupedField.default_value) {
data[getFieldKey(field)] = field.default_value; data[getFieldKey(groupedField)] = groupedField.default_value;
} else if (field.settings.options.length > 0) { } else if (groupedField.settings.options.length > 0) {
data[getFieldKey(field)] = field.settings.options[0].key; data[getFieldKey(groupedField)] = groupedField.settings.options[0].key;
} else { } else {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
} }
} }
}, },
initFormData: (field, data) => { initFormData: (groupedField, data) => {
if (field.default_value) { if (groupedField.default_value) {
data[getFieldKey(field)] = field.default_value; data[getFieldKey(groupedField)] = groupedField.default_value;
} else if (field.settings.options.length > 0) { } else if (groupedField.settings.options.length > 0) {
data[getFieldKey(field)] = field.settings.options[0].key; data[getFieldKey(groupedField)] = groupedField.settings.options[0].key;
} else { } else {
data[getFieldKey(field)] = ''; data[getFieldKey(groupedField)] = '';
} }
}, },
assignEntity: (field, data) => { assignEntity: (groupedField, data) => {
}, },
validate: (field, state) => {} validate: (groupedField, state) => {},
indexable: false
}); });
const enumMultipleFieldType = componentType => ({ const enumMultipleFieldType = componentType => ({
form: field => React.createElement(componentType, { key: getFieldKey(field), id: getFieldKey(field), label: field.name, options: field.settings.options }, null), form: groupedField => React.createElement(componentType, { key: getFieldKey(groupedField), id: getFieldKey(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
assignFormData: (field, data) => { assignFormData: (groupedField, data) => {
if (data[getFieldKey(field)] === null) { if (data[getFieldKey(groupedField)] === null) {
data[getFieldKey(field)] = []; data[getFieldKey(groupedField)] = [];
} }
}, },
initFormData: (field, data) => { initFormData: (groupedField, data) => {
data[getFieldKey(field)] = []; data[getFieldKey(groupedField)] = [];
}, },
assignEntity: (field, data) => {}, assignEntity: (groupedField, data) => {},
validate: (field, state) => {} validate: (groupedField, state) => {},
indexable: false
}); });
fieldTypes.text = stringFieldType(false); groupedFieldTypes.text = stringFieldType(false);
fieldTypes.website = stringFieldType(false); groupedFieldTypes.website = stringFieldType(false);
fieldTypes.longtext = stringFieldType(true); groupedFieldTypes.longtext = stringFieldType(true);
fieldTypes.gpg = stringFieldType(true); groupedFieldTypes.gpg = stringFieldType(true);
fieldTypes.number = numberFieldType; groupedFieldTypes.number = numberFieldType;
fieldTypes.date = dateFieldType; groupedFieldTypes.date = dateFieldType;
fieldTypes.birthday = birthdayFieldType; groupedFieldTypes.birthday = birthdayFieldType;
fieldTypes.json = jsonFieldType; groupedFieldTypes.json = jsonFieldType;
fieldTypes['dropdown-enum'] = enumSingleFieldType(Dropdown); groupedFieldTypes['dropdown-enum'] = enumSingleFieldType(Dropdown);
fieldTypes['radio-enum'] = enumSingleFieldType(RadioGroup); groupedFieldTypes['radio-enum'] = enumSingleFieldType(RadioGroup);
// Here we rely on the fact the model/fields and model/subscriptions preprocess the field info and subscription // Here we rely on the fact the model/groupedFields and model/subscriptions preprocess the groupedField info and subscription
// such that the grouped entries behave the same as the enum entries // such that the grouped entries behave the same as the enum entries
fieldTypes['checkbox-grouped'] = enumMultipleFieldType(CheckBoxGroup); groupedFieldTypes['checkbox-grouped'] = enumMultipleFieldType(CheckBoxGroup);
fieldTypes['radio-grouped'] = enumSingleFieldType(RadioGroup); groupedFieldTypes['radio-grouped'] = enumSingleFieldType(RadioGroup);
fieldTypes['dropdown-grouped'] = enumSingleFieldType(Dropdown); groupedFieldTypes['dropdown-grouped'] = enumSingleFieldType(Dropdown);
return fieldTypes; return groupedFieldTypes;
} }

View file

@ -67,7 +67,7 @@ async function ajaxListTx(tx, params, queryFun, columns, options) {
query.limit(limit); query.limit(limit);
} }
query.select(columnsSelect); query.select([...columnsSelect, ...options.extraColumns || [] ]);
for (const order of params.order) { for (const order of params.order) {
if (options.orderByBuilder) { if (options.orderByBuilder) {
@ -157,7 +157,8 @@ async function ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryF
} }
}, },
orderByBuilder: options.orderByBuilder orderByBuilder: options.orderByBuilder,
extraColumns: options.extraColumns
} }
); );
} }

View file

@ -149,7 +149,7 @@ 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', 'settings', 'group', 'order_subscribe', 'order_manage']).orderBy(knex.raw('-order_list'), 'desc').orderBy('id', 'asc'); return await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'key', 'column', 'settings', 'group', 'default_value', 'order_list', 'order_subscribe', 'order_manage']).orderBy(knex.raw('-order_list'), 'desc').orderBy('id', 'asc');
} }
async function list(context, listId) { async function list(context, listId) {
@ -160,7 +160,7 @@ async function list(context, listId) {
} }
async function listGroupedTx(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 flds = await listTx(tx, listId);
const fldsById = {}; const fldsById = {};
for (const fld of flds) { for (const fld of flds) {
@ -199,7 +199,7 @@ async function listGrouped(context, 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', 'type', ...extraColumns]).orderBy('order_list', 'asc');
} }
async function listDTAjax(context, listId, params) { async function listDTAjax(context, listId, params) {

View file

@ -11,9 +11,68 @@ const { SubscriptionStatus, getFieldKey } = require('../shared/lists');
const segments = require('./segments'); const segments = require('./segments');
const { enforce, filterObject } = require('../lib/helpers'); const { enforce, filterObject } = require('../lib/helpers');
const moment = require('moment'); const moment = require('moment');
const { formatDate, formatBirthday } = require('../shared/date');
const allowedKeysBase = new Set(['email', 'tz', 'is_test', 'status']); const allowedKeysBase = new Set(['email', 'tz', 'is_test', 'status']);
const fieldTypes = {};
const Cardinality = {
SINGLE: 0,
MULTIPLE: 1
};
function getOptionsMap(groupedField) {
const result = {};
for (const opt of groupedField.settings.options) {
result[opt.key] = opt.label;
}
return result;
}
fieldTypes.text = fieldTypes.website = fieldTypes.longtext = fieldTypes.gpg = fieldTypes.number = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => value
};
fieldTypes.json = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => value
};
fieldTypes['checkbox-grouped'] = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => {
const optMap = getOptionsMap(groupedField);
return value.map(x => optMap[x]).join(', ');
}
};
fieldTypes['radio-enum'] = fieldTypes['dropdown-enum'] = fieldTypes['radio-grouped'] = fieldTypes['dropdown-grouped'] = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => {
const optMap = getOptionsMap(groupedField);
return optMap[value];
}
};
fieldTypes.date = {
afterJSON: (groupedField, entity) => {
entity[getFieldKey(groupedField)] = moment(entity[getFieldKey(groupedField)]).toDate();
},
listRender: (groupedField, value) => formatDate(groupedField.settings.dateFormat, value)
};
fieldTypes.birthday = {
afterJSON: (groupedField, entity) => {
entity[getFieldKey(groupedField)] = moment(entity[getFieldKey(groupedField)]).toDate();
},
listRender: (groupedField, value) => formatBirthday(groupedField.settings.dateFormat, value)
};
function getTableName(listId) { function getTableName(listId) {
return `subscription__${listId}`; return `subscription__${listId}`;
} }
@ -149,7 +208,57 @@ async function listDTAjax(context, listId, segmentId, params) {
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');
const flds = await fields.listByOrderListTx(tx, listId, ['column']); // All the data transformation below is to reuse ajaxListTx and groupSubscription methods so as to keep the code DRY
// We first construct the columns to contain all which is supposed to be show and extraColumns which contain
// everything else that constitutes the subscription.
// Then in ajaxList's mapFunc, we construct the entity from the fields ajaxList retrieved and pass it to groupSubscription
// to group the fields. Then we copy relevant values form grouped subscription to ajaxList's data which then get
// returned to the client. During the copy, we also render the values.
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
const listFlds = await fields.listByOrderListTx(tx, listId, ['column', 'id']);
const columns = ['id', 'cid', 'email', 'status', 'created'];
const extraColumns = [];
let listFldIdx = columns.length;
const idxMap = {};
for (const listFld of listFlds) {
const fldKey = getFieldKey(listFld);
const fld = groupedFieldsMap[fldKey];
if (fld.column) {
columns.push(fld.column);
} else {
columns.push({
name: fldKey,
raw: 0
})
}
idxMap[fldKey] = listFldIdx;
listFldIdx += 1;
}
for (const fldKey in groupedFieldsMap) {
const fld = groupedFieldsMap[fldKey];
if (fld.column) {
if (!(fldKey in idxMap)) {
extraColumns.push(fld.column);
idxMap[fldKey] = listFldIdx;
listFldIdx += 1;
}
} else {
for (const optionColumn in fld.groupedOptions) {
extraColumns.push(optionColumn);
idxMap[optionColumn] = listFldIdx;
listFldIdx += 1;
}
}
}
const addSegmentQuery = segmentId ? await segments.getQueryGeneratorTx(tx, listId, segmentId) : () => {}; const addSegmentQuery = segmentId ? await segments.getQueryGeneratorTx(tx, listId, segmentId) : () => {};
return await dtHelpers.ajaxListTx( return await dtHelpers.ajaxListTx(
@ -162,8 +271,28 @@ async function listDTAjax(context, listId, segmentId, params) {
}); });
return query; return query;
}, },
['id', 'cid', 'email', 'status', 'created', ...flds.map(fld => fld.column)] columns,
// FIXME - adapt data in custom columns to render them properly {
mapFun: data => {
const entity = {};
for (const fldKey in idxMap) {
// This is a bit of hacking. We rely on the fact that if a field has a column, then the column is the field key.
// Then it has the group id with value 0. groupSubscription will be able to process the fields that have a column
// and it will assign values to the fields that don't have a value (i.e. those that currently have the group id and value 0).
entity[fldKey] = data[idxMap[fldKey]];
}
groupSubscription(groupedFieldsMap, entity);
for (const listFld of listFlds) {
const fldKey = getFieldKey(listFld);
const fld = groupedFieldsMap[fldKey];
data[idxMap[fldKey]] = fieldTypes[fld.type].listRender(fld, entity[fldKey]);
}
},
extraColumns
}
); );
}); });
} }
@ -223,9 +352,8 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, isCr
for (const key in groupedFieldsMap) { for (const key in groupedFieldsMap) {
const fld = groupedFieldsMap[key]; const fld = groupedFieldsMap[key];
if (fld.type === 'date' || fld.type === 'birthday') {
entity[getFieldKey(fld)] = moment(entity[getFieldKey(fld)]).toDate(); fieldTypes[fld.type].afterJSON(fld, entity);
}
} }
} }