apostrophe
Version:
The Apostrophe Content Management System.
1,487 lines (1,422 loc) • 50.4 kB
JavaScript
const _ = require('lodash');
const dayjs = require('dayjs');
const { klona } = require('klona');
const { stripIndents } = require('common-tags');
const joinr = require('./joinr');
const dateRegex = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/;
module.exports = (self) => {
self.addFieldType({
name: 'area',
async convert(req, field, data, destination, { fetchRelationships = true } = {}) {
const options = { fetchRelationships };
const _id = self.apos.launder.id(
data[field.name] && data[field.name]._id
) || self.apos.util.generateId();
if (typeof data[field.name] === 'string') {
destination[field.name] = self.apos.area.fromPlaintext(data[field.name]);
return;
}
if (Array.isArray(data[field.name])) {
data[field.name] = {
metaType: 'area',
items: data[field.name]
};
}
let items = [];
// accept either an array of items, or a complete
// area object
try {
items = data[field.name].items || [];
if (!Array.isArray(items)) {
items = [];
}
} catch (e) {
// Always recover graciously and import something reasonable, like an
// empty area
items = [];
}
items = await self.apos.area.sanitizeItems(req, items, field.options, options);
destination[field.name] = {
_id,
items,
metaType: 'area'
};
},
isEmpty: function (field, value) {
return self.apos.area.isEmpty({ area: value });
},
isEqual (req, field, one, two) {
const oneArea = self.apos.util.clonePermanent(one[field.name] || {});
const twoArea = self.apos.util.clonePermanent(two[field.name] || {});
if (
self.apos.area.isEmpty({ area: oneArea }) &&
self.apos.area.isEmpty({ area: twoArea })
) {
return true;
}
return _.isEqual(oneArea, twoArea);
},
validate: function (field, options, warn, fail) {
if (field.widgets) {
warn(stripIndents`
Remember to nest "widgets" inside "options" when configuring an area field.
Otherwise, "widgets" has no effect.
`);
}
let widgets = (field.options && field.options.widgets) || {};
if (field.options && field.options.groups) {
for (const group of Object.keys(field.options.groups)) {
widgets = {
...widgets,
...group.widgets
};
}
}
for (const name of Object.keys(widgets)) {
check(name);
}
function check(name) {
if (!self.apos.modules[`${name}-widget`]) {
if (name.match(/-widget$/)) {
warn(stripIndents`
Do not include "-widget" in the name when configuring a widget in an area field.
Apostrophe will automatically add "-widget" when looking for the right module.
`);
} else {
warn(`Nonexistent widget type name ${name} in area field.`);
}
}
}
},
index: function (value, field, texts) {
for (const item of ((value && value.items) || [])) {
const manager = item.type && self.apos.area.getWidgetManager(item.type);
if (!manager) {
self.apos.area.warnMissingWidgetType(item.type);
return;
}
if (manager.addSearchTexts) {
manager.addSearchTexts(item, texts);
}
}
}
});
self.addFieldType({
name: 'string',
convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.string(data[field.name]);
destination[field.name] = checkStringLength(
destination[field.name],
field.min,
field.max
);
// If field is required but empty (and client side didn't catch that)
// This is new and until now if JS client side failed, then it would
// allow the save with empty values -Lars
if (
field.required &&
(_.isUndefined(data[field.name]) || !data[field.name].toString().length)
) {
throw self.apos.error('required');
}
if (field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(destination[field.name])) {
throw self.apos.error('invalid');
}
}
},
index(value, field, texts) {
const silent = field.silent === undefined ? true : field.silent;
texts.push({
weight: field.weight || 15,
text: value,
silent
});
},
isEmpty(field, value) {
return !value.length;
},
validate(field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
if (!field.pattern) {
return;
}
const isRegexInstance = field.pattern instanceof RegExp;
if (!isRegexInstance && typeof field.pattern !== 'string') {
fail('The pattern property must be a RegExp or a String');
}
field.pattern = isRegexInstance ? field.pattern.source : field.pattern;
},
addQueryBuilder(field, query) {
query.addBuilder(field.name, {
finalize: function () {
if (self.queryBuilderInterested(query, field.name)) {
const criteria = {};
criteria[field.name] = new RegExp(self.apos.util.regExpQuote(query.get(field.name)), 'i');
query.and(criteria);
}
},
launder: function (s) {
return self.apos.launder.string(s);
},
choices: async function () {
return self.sortedDistinct(field.name, query);
}
});
},
def: ''
});
self.addFieldType({
name: 'slug',
extend: 'string',
// if field.page is true, expect a page slug (slashes allowed,
// leading slash required). Otherwise, expect a object-style slug
// (no slashes at all)
convert (req, field, data, destination) {
const options = self.getSlugFieldOptions(field, data);
destination[field.name] = self.apos.util.slugify(
self.apos.launder.string(data[field.name]),
options
);
if (field.page) {
if (!(destination[field.name].charAt(0) === '/')) {
destination[field.name] = '/' + destination[field.name];
}
// No runs of slashes
destination[field.name] = destination[field.name].replace(/\/+/g, '/');
// No trailing slashes (except for root)
if (destination[field.name] !== '/') {
destination[field.name] = destination[field.name].replace(/\/$/, '');
}
}
},
addQueryBuilder(field, query) {
query.addBuilder(field.name, {
finalize: function () {
if (self.queryBuilderInterested(query, field.name)) {
const criteria = {};
let slugifyOptions = {};
if (field.page) {
slugifyOptions = { allow: '/' };
}
criteria[field.name] = new RegExp(
self.apos.util.regExpQuote(
self.apos.util.slugify(query.get(field.name),
slugifyOptions)
)
);
query.and(criteria);
}
},
launder: function (s) {
return self.apos.launder.string(s);
},
choices: async function () {
return self.sortedDistinct(field.name, query);
}
});
}
});
self.addFieldType({
name: 'boolean',
convert: function (req, field, data, destination) {
destination[field.name] = self.apos.launder.boolean(data[field.name]);
},
isEmpty: function (field, value) {
return !value && value !== false;
},
exporters: {
string: function (req, field, object, output) {
output[field.name] = self.apos.launder.boolean(object[field.name]).toString();
}
},
addQueryBuilder(field, query) {
let criteria;
query.addBuilder(field.name, {
finalize: function () {
if (query.get(field.name) === false) {
criteria = {};
criteria[field.name] = { $ne: true };
query.and(criteria);
} else if (query.get(field.name) === true) {
criteria = {};
criteria[field.name] = true;
query.and(criteria);
}
},
launder: function (b) {
return self.apos.launder.booleanOrNull(b);
},
choices: async function () {
const values = await query.toDistinct(field.name);
const choices = [];
if (_.includes(values, true)) {
choices.push({
value: '1',
label: 'apostrophe:yes'
});
}
if (_.includes(values, false)) {
choices.push({
value: '0',
label: 'apostrophe:no'
});
}
return choices;
}
});
}
});
self.addFieldType({
name: 'checkboxes',
dynamicChoices: true,
async convert(req, field, data, destination, { ancestors = [] } = {}) {
const choices = await self.getChoices(req, field, [ ...ancestors, destination ]);
if (typeof data[field.name] === 'string') {
data[field.name] = self.apos.launder.string(data[field.name]).split(',');
if (!Array.isArray(data[field.name])) {
destination[field.name] = [];
return;
}
destination[field.name] = _.filter(data[field.name], function (choice) {
return _.includes(_.map(choices, 'value'), choice);
});
} else {
if (!Array.isArray(data[field.name])) {
destination[field.name] = [];
} else {
destination[field.name] = _.filter(data[field.name], function (choice) {
return _.includes(_.map(choices, 'value'), choice);
});
}
}
if ((field.min !== undefined) && (destination[field.name].length < field.min)) {
throw self.apos.error('min');
}
if ((field.max !== undefined) && (destination[field.name].length > field.max)) {
throw self.apos.error('max');
}
},
index: function (value, field, texts) {
const silent = field.silent === undefined ? true : field.silent;
texts.push({
weight: field.weight || 15,
text: (value || []).join(' '),
silent
});
},
addQueryBuilder(field, query) {
return query.addBuilder(field.name, {
finalize: function () {
if (self.queryBuilderInterested(query, field.name)) {
const criteria = {};
let v = query.get(field.name);
// Allow programmers to pass just one value too
// (sanitize doesn't apply to them)
if (!Array.isArray(v)) {
v = [ v ];
}
criteria[field.name] = { $in: v };
query.and(criteria);
}
},
launder: function (value) {
// Support one or many
if (Array.isArray(value)) {
return _.map(value, function (v) {
return (typeof field.choices) === 'string'
? self.apos.launder.string(v, field.def)
: self.apos.launder.select(v, field.choices, field.def);
});
} else {
return (typeof field.choices) === 'string'
? self.apos.launder.string(value, field.def)
: self.apos.launder.select(value, field.choices, field.def);
}
},
choices: async function () {
return self.getChoicesForQueryBuilder(field, query);
}
});
},
validate: function (field, options, warn, fail) {
if (field.max && typeof field.max !== 'number') {
fail('Property "max" must be a number');
}
if (field.min && typeof field.min !== 'number') {
fail('Property "min" must be a number');
}
}
});
self.addFieldType({
name: 'select',
dynamicChoices: true,
async convert(req, field, data, destination, { ancestors = [] } = {}) {
const choices = await self.getChoices(req, field, [ ...ancestors, destination ]);
destination[field.name] = self.apos.launder.select(data[field.name], choices);
},
index: function (value, field, texts) {
const silent = field.silent === undefined ? true : field.silent;
texts.push({
weight: field.weight || 15,
text: value,
silent
});
},
addQueryBuilder(field, query) {
return query.addBuilder(field.name, {
finalize: function () {
if (self.queryBuilderInterested(query, field.name)) {
const criteria = {};
let v = query.get(field.name);
// Allow programmers to pass just one value too
// (sanitize doesn't apply to them)
if (!Array.isArray(v)) {
v = [ v ];
}
criteria[field.name] = { $in: v };
query.and(criteria);
}
},
launder: function (value) {
// Support one or many
if (Array.isArray(value)) {
return _.map(value, function (v) {
return (typeof field.choices) === 'string'
? self.apos.launder.string(v, null)
: self.apos.launder.select(v, field.choices, null);
});
} else {
value = (typeof field.choices) === 'string'
? self.apos.launder.string(value, null)
: self.apos.launder.select(value, field.choices, null);
if (value === null) {
return null;
}
return [ value ];
}
},
choices: async function () {
return self.getChoicesForQueryBuilder(field, query);
}
});
}
});
self.addFieldType({
name: 'radio',
extend: 'select'
});
self.addFieldType({
name: 'integer',
vueComponent: 'AposInputString',
async convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.integer(
data[field.name],
undefined,
field.min,
field.max
);
if (
field.required &&
((data[field.name] == null) || !data[field.name].toString().length)
) {
throw self.apos.error('required');
}
if (data[field.name] && isNaN(parseFloat(data[field.name]))) {
throw self.apos.error('invalid');
}
// This makes it possible to have a field that is not required,
// but min / max defined. This allows the form to be saved and
// sets the value to null if no value was given by
// the user.
if (!data[field.name] && data[field.name] !== 0) {
destination[field.name] = null;
}
},
validate(field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
},
addQueryBuilder(field, query) {
return query.addBuilder(field.name, {
finalize: function () {
let criteria;
const value = query.get(field.name);
if (value !== undefined && value !== null) {
if (Array.isArray(value) && value.length === 2) {
criteria = {};
criteria[field.name] = {
$gte: value[0],
$lte: value[1]
};
query.and(criteria);
} else {
criteria = {};
criteria[field.name] = self.apos.launder.integer(value);
query.and(criteria);
}
}
},
choices: async function () {
return self.sortedDistinct(field.name, query);
},
launder: function (value) {
const launderInteger = (v) => self.apos.launder.integer(v, null);
if (Array.isArray(value)) {
return value.map(launderInteger);
} else {
return launderInteger(value);
}
}
});
}
});
self.addFieldType({
name: 'float',
vueComponent: 'AposInputString',
async convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.float(
data[field.name],
undefined,
field.min,
field.max
);
if (
field.required &&
(_.isUndefined(data[field.name]) || !data[field.name].toString().length)
) {
throw self.apos.error('required');
}
if (data[field.name] && isNaN(parseFloat(data[field.name]))) {
throw self.apos.error('invalid');
}
if (!data[field.name] && data[field.name] !== 0) {
destination[field.name] = null;
}
},
validate(field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
},
addQueryBuilder(field, query) {
return query.addBuilder(field.name, {
finalize: function () {
let criteria;
const value = query.get(field.name);
if (value !== undefined && value !== null) {
if (Array.isArray(value) && value.length === 2) {
criteria = {};
criteria[field.name] = {
$gte: value[0],
$lte: value[1]
};
query.and(criteria);
} else {
criteria = {};
criteria[field.name] = self.apos.launder.float(value);
query.and(criteria);
}
}
},
choices: async function () {
return self.sortedDistinct(field.name, query);
},
launder: function(value) {
const launderFloat = (v) => self.apos.launder.float(v, null);
if (Array.isArray(value)) {
return value.map(launderFloat);
} else {
return launderFloat(value);
}
}
});
}
});
self.addFieldType({
name: 'email',
vueComponent: 'AposInputString',
convert: function (req, field, data, destination) {
destination[field.name] = self.apos.launder.string(data[field.name]);
if (!data[field.name]) {
if (field.required) {
throw self.apos.error('required');
}
} else {
// regex source: https://emailregex.com/
const matches = data[field.name].match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/);
if (!matches) {
throw self.apos.error('invalid');
}
}
destination[field.name] = data[field.name];
},
validate(field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
}
});
self.addFieldType({
name: 'url',
vueComponent: 'AposInputString',
async convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.url(data[field.name], undefined, true);
if (
field.required &&
(data[field.name] == null || !data[field.name].toString().length)
) {
throw self.apos.error('required');
}
if (field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(destination[field.name])) {
throw self.apos.error('invalid');
}
}
},
diffable: function (value) {
// URLs are fine to diff and display
if (typeof value === 'string') {
return value;
}
// always return a valid string
return '';
},
validate(field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
if (!field.pattern) {
return;
}
const isRegexInstance = field.pattern instanceof RegExp;
if (!isRegexInstance && typeof field.pattern !== 'string') {
fail('The pattern property must be a RegExp or a String');
}
field.pattern = isRegexInstance ? field.pattern.source : field.pattern;
},
addQueryBuilder(field, query) {
query.addBuilder(field.name, {
finalize: function () {
if (self.queryBuilderInterested(query, field.name)) {
const criteria = {};
criteria[field.name] = new RegExp(self.apos.util.regExpQuote(query.get(field.name)), 'i');
query.and(criteria);
}
},
launder: function (s) {
// Don't be too strict, just enough that we can safely pass it to
// regExpQuote, partial URLs are welcome
return self.apos.launder.string(s);
},
choices: async function () {
return self.sortDistinct(field.name, query);
}
});
}
});
self.addFieldType({
name: 'date',
vueComponent: 'AposInputString',
async convert(req, field, data, destination) {
const newDateVal = data[field.name];
if (!newDateVal && destination[field.name]) {
// Allow date fields to be unset.
destination[field.name] = null;
return;
}
if (!newDateVal && !field.def) {
// If no inputted date or default date, leave as empty
destination[field.name] = null;
return;
}
if (field.min && newDateVal && (newDateVal < field.min)) {
// If the min requirement isn't met, leave as-is.
return;
}
if (field.max && newDateVal && (newDateVal > field.max)) {
// If the max requirement isn't met, leave as-is.
return;
}
destination[field.name] = self.apos.launder.date(newDateVal);
},
validate: function (field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
if (field.max && !field.max.match(dateRegex)) {
fail('Property "max" must be in the date format, YYYY-MM-DD');
}
if (field.min && !field.min.match(dateRegex)) {
fail('Property "min" must be in the date format, YYYY-MM-DD');
}
},
addQueryBuilder(field, query) {
return query.addBuilder(field.name, {
finalize: function () {
if (self.queryBuilderInterested(query, field.name)) {
const value = query.get(field.name);
const criteria = {};
if (Array.isArray(value)) {
criteria[field.name] = {
$gte: value[0],
$lte: value[1]
};
} else {
criteria[field.name] = self.apos.launder.date(value);
}
query.and(criteria);
}
},
launder: function (value) {
if (Array.isArray(value) && value.length === 2) {
if (value[0] instanceof Date) {
value[0] = dayjs(value[0]).format('YYYY-MM-DD');
}
if (value[1] instanceof Date) {
value[1] = dayjs(value[1]).format('YYYY-MM-DD');
}
value[0] = self.apos.launder.date(value[0]);
value[1] = self.apos.launder.date(value[1]);
return value;
} else {
if (value instanceof Date) {
value = dayjs(value).format('YYYY-MM-DD');
}
return self.apos.launder.date(value, null);
}
},
choices: async function () {
return self.sortDistinct(field.name, query);
}
});
}
});
self.addFieldType({
name: 'time',
vueComponent: 'AposInputString',
async convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.time(data[field.name]);
}
});
self.addFieldType({
name: 'dateAndTime',
vueComponent: 'AposInputDateAndTime',
convert (req, field, data, destination) {
destination[field.name] = data[field.name]
? self.apos.launder.date(data[field.name])
: null;
},
validate: function (field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
}
});
self.addFieldType({
name: 'password',
async convert(req, field, data, destination) {
// This is the only field type that we never update unless
// there is actually a new value — a blank password is not cool. -Tom
if (data[field.name]) {
destination[field.name] = self.apos.launder.string(data[field.name]);
destination[field.name] = checkStringLength(
destination[field.name],
field.min,
field.max
);
}
},
validate: function (field, options, warn, fail) {
if (field.direction && !_.includes([ 'ltr', 'rtl' ], field.direction)) {
fail('The direction property must be "ltr" or "rtl" if specified');
}
},
def: ''
});
self.addFieldType({
name: 'group' // visual grouping only
});
self.addFieldType({
name: 'range',
vueComponent: 'AposInputRange',
async convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.float(
data[field.name],
field.def,
field.min,
field.max
);
if (
field.required &&
(_.isUndefined(data[field.name]) || !data[field.name].toString().length)
) {
throw self.apos.error('required');
}
if (data[field.name] && isNaN(parseFloat(data[field.name]))) {
throw self.apos.error('invalid');
}
// Allow for ranges to go unset `min` here does not imply requirement,
// it is the minimum value the range UI will represent
if (
typeof destination[field.name] !== 'number' ||
data[field.name] < field.min ||
data[field.name] > field.max
) {
destination[field.name] = null;
}
},
validate: function (field, options, warn, fail) {
if (!field.min && field.min !== 0) {
fail('Property "min" must be set.');
}
if (!field.max && field.max !== 0) {
fail('Property "max" must be set.');
}
if (typeof field.max !== 'number') {
fail('Property "max" must be a number');
}
if (typeof field.min !== 'number') {
fail('Property "min" must be a number');
}
if (field.step && typeof field.step !== 'number') {
fail('Property "step" must be a number.');
}
if (field.unit && typeof field.unit !== 'string') {
fail('Property "unit" must be a string.');
}
}
});
self.addFieldType({
name: 'array',
async convert(
req,
field,
data,
destination,
{
fetchRelationships = true,
ancestors = [],
rootConvert = true
} = {}
) {
const schema = field.schema;
data = data[field.name];
if (!Array.isArray(data)) {
data = [];
}
const results = [];
if (field.limit && data.length > field.limit) {
data = data.slice(0, field.limit);
}
const errors = [];
for (const datum of data) {
const _id = self.apos.launder.id(datum._id) || self.apos.util.generateId();
const [ found ] = destination[field.name]
?.filter?.(item => item._id === _id) || [];
const result = {
...(found || {}),
_id
};
result.metaType = 'arrayItem';
result.scopedArrayName = field.scopedArrayName;
try {
const options = {
fetchRelationships,
ancestors: [ ...ancestors, destination ],
rootConvert
};
await self.convert(req, schema, datum, result, options);
} catch (e) {
if (Array.isArray(e)) {
for (const error of e) {
error.path = `${result._id}.${error.path}`;
errors.push(error);
}
} else {
throw e;
}
}
results.push(result);
}
destination[field.name] = results;
if (field.required && !results.length) {
throw self.apos.error('required');
}
if ((field.min !== undefined) && (results.length < field.min)) {
throw self.apos.error('min');
}
if ((field.max !== undefined) && (results.length > field.max)) {
throw self.apos.error('max');
}
if (data.length && field.schema && field.schema.length) {
const { name: uniqueFieldName, label: uniqueFieldLabel } = field.schema
.find(subfield => subfield.unique) || [];
if (uniqueFieldName) {
const duplicates = data
.map(item => (Array.isArray(item[uniqueFieldName])
? item[uniqueFieldName][0]._id
: item[uniqueFieldName]))
.filter((item, index, array) => array.indexOf(item) !== index);
if (duplicates.length) {
throw self.apos.error('duplicate', `${req.t(uniqueFieldLabel)} in ${req.t(field.label)} must be unique`);
}
}
}
if (errors.length) {
throw errors;
}
},
isEmpty: function (field, value) {
return (!Array.isArray(value)) || (!value.length);
},
index: function (value, field, texts) {
_.each(value || [], function (item) {
self.apos.schema.indexFields(field.schema, item, texts);
});
},
validate: function (field, options, warn, fail) {
for (const subField of field.schema || field.fields.add) {
self.validateField(subField, options, field);
}
},
register: function (metaType, type, field) {
const localArrayName = field.arrayName || field.name;
field.scopedArrayName = `${metaType}.${type}.${localArrayName}`;
self.arrayManagers[field.scopedArrayName] = {
schema: field.schema
};
self.register(metaType, type, field.schema);
},
isEqual(req, field, one, two) {
if (!(one[field.name] && two[field.name])) {
return !(one[field.name] || two[field.name]);
}
if (one[field.name].length !== two[field.name].length) {
return false;
}
for (let i = 0; (i < one[field.name].length); i++) {
if (!self.isEqual(req, field.schema, one[field.name][i], two[field.name][i])) {
return false;
}
}
return true;
},
def: []
});
self.addFieldType({
name: 'object',
async convert(
req,
field,
data,
destination,
{
fetchRelationships = true,
ancestors = {},
rootConvert = true,
doc = {}
} = {}
) {
data = data[field.name];
const schema = field.schema;
const errors = [];
const result = {
...(destination[field.name] || {}),
_id: self.apos.launder.id(data && data._id) || self.apos.util.generateId()
};
const options = {
fetchRelationships,
ancestors: [ ...ancestors, destination ],
rootConvert
};
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
data = {};
}
try {
await self.convert(req, schema, data, result, options);
} catch (e) {
if (Array.isArray(e)) {
for (const error of e) {
errors.push(error);
}
} else {
throw e;
}
}
result.metaType = 'objectItem';
result.scopedObjectName = field.scopedObjectName;
destination[field.name] = result;
if (errors.length) {
throw errors;
}
},
register: function (metaType, type, field) {
const localObjectName = field.objectName || field.name;
field.scopedObjectName = `${metaType}.${type}.${localObjectName}`;
self.objectManagers[field.scopedObjectName] = {
schema: field.schema
};
self.register(metaType, type, field.schema);
},
validate: function (field, options, warn, fail) {
for (const subField of field.schema) {
self.validateField(subField, options, field);
}
},
isEqual(req, field, one, two) {
if (one && (!two)) {
return false;
}
if (two && (!one)) {
return false;
}
if (!(one || two)) {
return true;
}
if (one[field.name] && (!two[field.name])) {
return false;
}
if (two[field.name] && (!one[field.name])) {
return false;
}
if (!(one[field.name] || two[field.name])) {
return true;
}
return self.isEqual(req, field.schema, one[field.name], two[field.name]);
},
index: function (value, field, texts) {
if (value) {
self.apos.schema.indexFields(field.schema, value, texts);
}
},
def: {}
});
self.addFieldType({
name: 'relationship',
// Validate a relationship field, copying from `data[field.name]` to
// `object[field.name]`. If the relationship is named `_product`, then
// `data._product` should be an array of product docs.
// These doc objects must at least have an _id property.
//
// Alternatively, entries in `data._product` may simply be
// `_id` strings or `title` strings. Titles are compared in a
// tolerant manner. This is useful for CSV input. Strings may
// be mixed with actual docs in a single array.
//
// If the relationship field has a `fields` option, then each
// doc object may also have a `_fields` property which
// will be validated against the schema in `fields`.
//
// The result in `object[field.name]` will always be an array
// of zero or more related docs, containing only those that
// actually exist in the database and can be fetched by this user,
// in the same order specified in `data[field.name]`.
//
// Actual storage to the permanent idsStorage and fieldsStorage
// properties is handled at a lower level in a beforeSave
// handler of the doc-type module.
async convert(
req,
field,
data,
destination,
{
fetchRelationships = true,
rootConvert = true
} = {}
) {
const canEdit = () => self.apos.permission.can(
req,
field.editPermission.action,
field.editPermission.type
);
const canView = () => self.apos.permission.can(
req,
field.viewPermission.action,
field.viewPermission.type
);
const can =
(!field.withType && !field.editPermission && !field.viewPermission) ||
(field.withType && self.apos.permission.can(req, 'view', field.withType)) ||
(field.editPermission && canEdit()) ||
(field.viewPermission && canView()) ||
false;
if (!can) {
// Silently leave the relationship alone
return;
}
const options = {
fetchRelationships,
rootConvert
};
const manager = self.apos.doc.getManager(field.withType);
if (!manager) {
throw Error('relationship with type ' + field.withType + ' unrecognized');
}
let input = data[field.name];
if (input == null) {
input = [];
}
if ((typeof input) === 'string') {
// Handy in CSV: allows titles or _ids
input = input.split(/\s*,\s*/);
}
if (fetchRelationships === false) {
destination[field.name] = [];
for (const relation of input) {
if (typeof relation === 'string') {
destination[field.name].push({
_id: self.apos.launder.id(relation),
_fields: {}
});
continue;
}
const _fields = {};
if (field.schema?.length) {
await self.convert(
req,
field.schema,
relation._fields || {},
_fields,
options
);
}
destination[field.name].push({
_id: self.apos.launder.id(relation._id),
_fields
});
}
return;
}
const ids = [];
const titlesOrIds = [];
for (const item of input) {
if ((typeof item) === 'string') {
titlesOrIds.push(item);
} else {
if (item && ((typeof item._id) === 'string')) {
ids.push(item._id);
}
}
}
const clauses = [];
if (titlesOrIds.length) {
clauses.push({
titleSortified: {
$in: titlesOrIds.map(titleOrId => self.apos.util.sortify(titleOrId))
}
});
}
if (ids.length) {
clauses.push({
_id: {
$in: ids
}
});
}
if (!clauses.length) {
destination[field.name] = [];
} else {
const results = await manager
.find(req, { $or: clauses })
.relationships(false)
.toArray();
// Must maintain input order. Also discard things not actually found in
// the db
const actualDocs = [];
for (const item of input) {
if ((typeof item) === 'string') {
const result = results
.find(result => (result.title === item) || (result._id === item));
if (result) {
actualDocs.push(result);
}
} else if ((item && (typeof item._id === 'string'))) {
const result = results.find(doc => (doc._id === item._id));
if (result) {
if (field.schema) {
const destArray = Array.isArray(destination[field.name])
? destination[field.name]
: [];
const destItem = destArray.find((doc) => doc._id === item._id);
result._fields = {
...destItem?._fields || {}
};
if (item && ((typeof item._fields === 'object'))) {
await self.convert(
req,
field.schema,
item._fields || {},
result._fields,
options
);
}
}
actualDocs.push(result);
}
}
}
destination[field.name] = actualDocs;
}
// "min" and "required" are not enforced server-side for relationships because
// it is always possible for the related document to be removed independently
// at some point. This leads to too many edge cases and knock-on effects if enforced
if (field.max && field.max < destination[field.name].length) {
throw self.apos.error('max', `Maximum ${field.withType} required reached.`);
}
},
relate: async function (req, field, objects, options) {
if ((!self.apos.doc?.replicateReached) && (!field.idsStorage)) {
self.apos.util.warnDevOnce(
'premature-relationship-query',
'Database queries for types with relationships may fail if made before the @apostrophecms/doc:beforeReplicate event'
);
}
return self.relationshipDriver(
req,
joinr.byArray,
false,
objects,
field.idsStorage,
field.fieldsStorage,
field.name,
options
);
},
addQueryBuilder(field, query) {
addOperationQueryBuilder('', '$in');
addOperationQueryBuilder('And', '$all');
self.addRelationshipSlugQueryBuilder(field, query, '');
self.addRelationshipSlugQueryBuilder(field, query, 'And');
function addOperationQueryBuilder(suffix, operator) {
return query.addBuilder(field.name + suffix, {
finalize: function () {
if (!self.queryBuilderInterested(query, field.name + suffix)) {
return;
}
const value = query.get(field.name + suffix);
const criteria = {};
// Even programmers appreciate shortcuts, so it's not enough that
// the sanitizer (which doesn't apply to programmatic use) accepts
// these
if (Array.isArray(value)) {
criteria[field.idsStorage] = {};
criteria[field.idsStorage][operator] = value.map(self.apos.doc.toAposDocId);
} else if (value === 'none') {
criteria.$or = [];
let clause = {};
clause[field.idsStorage] = null;
criteria.$or.push(clause);
clause = {};
clause[field.idsStorage] = { $exists: 0 };
criteria.$or.push(clause);
clause = {};
clause[field.idsStorage + '.0'] = { $exists: 0 };
criteria.$or.push(clause);
} else {
criteria[field.idsStorage] = { $in: [ self.apos.doc.toAposDocId(value) ] };
}
query.and(criteria);
},
choices: self.relationshipQueryBuilderChoices(field, query, '_id'),
launder: relationshipQueryBuilderLaunder
});
}
},
validate: function (field, options, warn, fail) {
if (!field.name.match(/^_/)) {
warn('Name of relationship field does not start with _. This is permitted for bc but it will fill your database with duplicate outdated data. Please fix it.');
}
if (!field.idsStorage) {
// Supply reasonable value
field.idsStorage = field.name.replace(/^_/, '') + 'Ids';
}
if (!field.withType) {
// Try to supply reasonable value based on relationship name.
// Relationship name will be plural, so consider that too
const withType = field.name.replace(/^_/, '').replace(/s$/, '');
if (!_.find(self.apos.doc.managers, { name: withType })) {
fail('withType property is missing. Hint: it must match the name of a doc type module. Or omit it and give your relationship the same name as the other type, with a leading _ and optional trailing s.');
}
field.withType = withType;
}
if (!field.withType) {
fail('withType property is missing. Hint: it must match the name of a doc type module.');
}
if (Array.isArray(field.withType)) {
_.each(field.withType, function (type) {
lintType(type);
});
} else {
lintType(field.withType);
const withTypeManager = self.apos.doc.getManager(field.withType);
field.editor = field.editor || withTypeManager.options.relationshipEditor;
field.postprocessor = field.postprocessor ||
withTypeManager.options.relationshipPostprocessor;
field.editorLabel = field.editorLabel ||
withTypeManager.options.relationshipEditorLabel;
field.editorIcon = field.editorIcon ||
withTypeManager.options.relationshipEditorIcon;
field.suggestionLabel = field.suggestionLabel ||
withTypeManager.options.relationshipSuggestionLabel;
field.suggestionHelp = field.suggestionHelp ||
withTypeManager.options.relationshipSuggestionHelp;
field.suggestionLimit = field.suggestionLimit ||
withTypeManager.options.relationshipSuggestionLimit;
field.suggestionSort = field.suggestionSort ||
withTypeManager.options.relationshipSuggestionSort;
field.suggestionIcon = field.suggestionIcon ||
withTypeManager.options.relationshipSuggestionIcon;
field.suggestionFields = field.suggestionFields ||
withTypeManager.options.relationshipSuggestionFields;
if (!field.schema && !Array.isArray(field.withType)) {
const fieldsOption = withTypeManager.options.relationshipFields;
const fields = fieldsOption && fieldsOption.add;
field.fields = fields && klona(fields);
field.schema = self.fieldsToArray(`Relationship field ${field.name}`, field.fields);
}
}
validateSchema(field);
if (field.filters) {
fail('"filters" property should be changed to "builders" for 3.x');
}
if (field.builders && field.builders.projection) {
fail('"projection" sub-property should be changed to "project" for 3.x');
}
function lintType(type) {
type = self.apos.doc.normalizeType(type);
if (!_.find(self.apos.doc.managers, { name: type })) {
fail('withType property, ' + type + ', does not match the name of any piece or page type module.');
}
}
function validateSchema(_field) {
if (!_field.schema) {
return;
}
if (!Array.isArray(_field.schema)) {
fail('schema property should be an array if present at this stage');
}
self.validate(_field.schema, {
type: 'relationship',
subtype: _field.withType
}, _field);
if (!_field.fieldsStorage) {
_field.fieldsStorage = _field.name.replace(/^_/, '') + 'Fields';
}
}
},
isEqual(req, field, one, two) {
const ids1 = one[field.idsStorage] || [];
const ids2 = two[field.idsStorage] || [];
if (!_.isEqual(ids1, ids2)) {
return false;
}
if (field.fieldsStorage) {
const fields1 = one[field.fieldsStorage] || {};
const fields2 = two[field.fieldsStorage] || {};
if (!_.isEqual(fields1, fields2)) {
return false;
}
}
return true;
}
});
self.addFieldType({
name: 'relationshipReverse',
vueComponent: false,
relate: async function (req, field, objects, options) {
if ((!self.apos.doc?.replicateReached) && (!field.idsStorage)) {
self.apos.util.warnDevOnce(
'premature-relationship-query',
'Database queries for types with relationships may fail if made before the @apostrophecms/doc:beforeReplicate event'
);
}
return self.relationshipDriver(
req,
joinr.byArrayReverse,
true,
objects,
field.idsStorage,
field.fieldsStorage,
field.name,
options
);
},
validate: function (field, options, warn, fail) {
let forwardRelationship;
if (!field.name.match(/^_/)) {
warn('Name of relationship field does not start with _. This is permitted for bc but it will fill your database with duplicate outdated data. Please fix it.');
}
if (!field.withType) {
// Try to supply reasonable value based on relationship name
const withType = field.name.replace(/^_/, '').replace(/s$/, '');
if (!_.find(self.apos.doc.managers, { name: withType })) {
fail('withType property is missing. Hint: it must match the name of a piece or page type module. Or omit it and give your relationship the same name as the other type, with a leading _ and optional trailing s.');
}
field.withType = withType;
}
const otherModule = _.find(
self.apos.doc.managers,
{ name: self.apos.doc.normalizeType(field.withType) }
);
if (!otherModule) {
fail('withType property, ' + field.withType + ', does not match the name of a piece or page type module.');
}
if (!(field.reverseOf || field.idsStorage)) {
self.validate(otherModule.schema, {
type: 'doc type',
subtype: otherModule.name
});
// Look for a relationship with our type in the other type
forwardRelationship = _.find(otherModule.schema, { withType: options.subtype });
if (forwardRelationship) {
field.reverseOf = forwardRelationship.name;
}
}
if (field.reverseOf) {
forwardRelationship = _.find(otherModule.schema, {
type: 'relationship',
name: field.reverseOf
});
if (!forwardRelationship) {
fail('reverseOf property does not match the name property of any relationship in the schema for ' + field.withType + '. Hint: you are taking advantage of a relationship already being edited in the schema for that type, "reverse" must match "name".');
}
// Make sure the other relationship has any missing fields
// auto-supplied before trying to access them
self.validate([ forwardRelationship ], {
type: 'doc type',
subtype: otherModule.name
});
field.idsStorage = forwardRelationship.idsStorage;
field.fieldsStorage = forwardRelationship.fieldsStorage;
}
if (!field.idsStorage) {
field.idsStorage = field.name.replace(/^_/, '') + 'Ids';
}
if (!forwardRelationship) {
forwardRelationship = _.find(otherModule.schema, {
type: 'relationship',
idsStorage: field.idsStorage
});
if (!forwardRelationship) {
fail('idsStorage property does not match the idsStorage property of any relationship in the schema for ' + field.withType + '. Hint: you are taking advantage of a relationship already being edited in the schema for that type, your idsStorage must be the same to find the data there.');
}
if (forwardRelationship.fieldsStorage) {
field.fieldsStorage = forwardRelationship.fieldsStorage;
}
}
}
});
function checkStringLength (string, min, max) {
if (string && min && string.length < min) {
// Would be unpleasant, but shouldn't happen since the browser
// also implements this. We're just checking for naughty scripts
throw self.apos.error('min');
}
// If max is longer than allowed, trim th