UNPKG

apostrophe

Version:
1,487 lines (1,422 loc) 50.4 kB
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