apostrophe 3.18.1 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/.stylelintrc +2 -1
  2. package/CHANGELOG.md +17 -1
  3. package/lib/moog-require.js +7 -1
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  5. package/modules/@apostrophecms/asset/index.js +266 -19
  6. package/modules/@apostrophecms/asset/lib/webpack/apos/webpack.config.js +7 -0
  7. package/modules/@apostrophecms/asset/lib/webpack/src/webpack.config.js +7 -0
  8. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +10 -1
  9. package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
  10. package/modules/@apostrophecms/i18n/i18n/es.json +1 -0
  11. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +1 -0
  12. package/modules/@apostrophecms/i18n/i18n/sk.json +1 -0
  13. package/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue +2 -2
  14. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +5 -5
  15. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -2
  16. package/modules/@apostrophecms/schema/index.js +2 -1123
  17. package/modules/@apostrophecms/schema/lib/addFieldTypes.js +1139 -0
  18. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +115 -0
  19. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +21 -1
  20. package/modules/@apostrophecms/ui/ui/apos/components/AposSpinner.vue +7 -7
  21. package/modules/@apostrophecms/ui/ui/apos/components/AposToggle.vue +68 -0
  22. package/package.json +2 -3
  23. package/test/assets.js +462 -4
  24. package/test/schemas.js +25 -0
  25. package/test-lib/test.js +12 -0
  26. package/test-lib/util.js +4 -3
@@ -0,0 +1,1139 @@
1
+ const _ = require('lodash');
2
+ const dayjs = require('dayjs');
3
+ const tinycolor = require('tinycolor2');
4
+ const { klona } = require('klona');
5
+ const { stripIndents } = require('common-tags');
6
+ const joinr = require('./joinr');
7
+
8
+ const dateRegex = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/;
9
+
10
+ module.exports = (self) => {
11
+ self.addFieldType({
12
+ name: 'area',
13
+ async convert(req, field, data, destination) {
14
+ const _id = self.apos.launder.id(data[field.name] && data[field.name]._id) || self.apos.util.generateId();
15
+ if (typeof data[field.name] === 'string') {
16
+ destination[field.name] = self.apos.area.fromPlaintext(data[field.name]);
17
+ return;
18
+ }
19
+ if (Array.isArray(data[field.name])) {
20
+ data[field.name] = {
21
+ metaType: 'area',
22
+ items: data[field.name]
23
+ };
24
+ }
25
+ let items = [];
26
+ // accept either an array of items, or a complete
27
+ // area object
28
+ try {
29
+ items = data[field.name].items || [];
30
+ if (!Array.isArray(items)) {
31
+ items = [];
32
+ }
33
+ } catch (e) {
34
+ // Always recover graciously and import something reasonable, like an empty area
35
+ items = [];
36
+ }
37
+ items = await self.apos.area.sanitizeItems(req, items, field.options || {});
38
+ destination[field.name] = {
39
+ _id,
40
+ items,
41
+ metaType: 'area'
42
+ };
43
+ },
44
+ isEmpty: function (field, value) {
45
+ return self.apos.area.isEmpty({ area: value });
46
+ },
47
+ isEqual (req, field, one, two) {
48
+ if (self.apos.area.isEmpty({ area: one[field.name] }) && self.apos.area.isEmpty({ area: two[field.name] })) {
49
+ return true;
50
+ }
51
+ return _.isEqual(one[field.name], two[field.name]);
52
+ },
53
+ validate: function (field, options, warn, fail) {
54
+ if (field.options && field.options.widgets) {
55
+ for (const name of Object.keys(field.options.widgets)) {
56
+ if (!self.apos.modules[`${name}-widget`]) {
57
+ if (name.match(/-widget$/)) {
58
+ warn(stripIndents`
59
+ Do not include "-widget" in the name when configuring a widget in an area field.
60
+ Apostrophe will automatically add "-widget" when looking for the right module.
61
+ `);
62
+ } else {
63
+ warn(`Nonexistent widget type name ${name} in area field.`);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ });
70
+
71
+ self.addFieldType({
72
+ name: 'string',
73
+ convert: function (req, field, data, destination) {
74
+ destination[field.name] = self.apos.launder.string(data[field.name], field.def);
75
+
76
+ destination[field.name] = checkStringLength(destination[field.name], field.min, field.max);
77
+ // If field is required but empty (and client side didn't catch that)
78
+ // This is new and until now if JS client side failed, then it would
79
+ // allow the save with empty values -Lars
80
+ if (field.required && (_.isUndefined(data[field.name]) || !data[field.name].toString().length)) {
81
+ throw self.apos.error('required');
82
+ }
83
+ },
84
+ index: function (value, field, texts) {
85
+ const silent = field.silent === undefined ? true : field.silent;
86
+ texts.push({
87
+ weight: field.weight || 15,
88
+ text: value,
89
+ silent: silent
90
+ });
91
+ },
92
+ isEmpty: function (field, value) {
93
+ return !value.length;
94
+ },
95
+ addQueryBuilder: function (field, query) {
96
+ query.addBuilder(field.name, {
97
+ finalize: function () {
98
+ if (self.queryBuilderInterested(query, field.name)) {
99
+ const criteria = {};
100
+ criteria[field.name] = new RegExp(self.apos.util.regExpQuote(query.get(field.name)), 'i');
101
+ query.and(criteria);
102
+ }
103
+ },
104
+ launder: function (s) {
105
+ return self.apos.launder.string(s);
106
+ },
107
+ choices: async function () {
108
+ return self.sortedDistinct(field.name, query);
109
+ }
110
+ });
111
+ },
112
+ def: ''
113
+ });
114
+
115
+ self.addFieldType({
116
+ name: 'slug',
117
+ extend: 'string',
118
+ // if field.page is true, expect a page slug (slashes allowed,
119
+ // leading slash required). Otherwise, expect a object-style slug
120
+ // (no slashes at all)
121
+ convert: function (req, field, data, destination) {
122
+ const options = {
123
+ def: field.def
124
+ };
125
+ if (field.page) {
126
+ options.allow = '/';
127
+ }
128
+ destination[field.name] = self.apos.util.slugify(self.apos.launder.string(data[field.name], field.def), options);
129
+
130
+ if (field.page) {
131
+ if (!(destination[field.name].charAt(0) === '/')) {
132
+ destination[field.name] = '/' + destination[field.name];
133
+ }
134
+ // No runs of slashes
135
+ destination[field.name] = destination[field.name].replace(/\/+/g, '/');
136
+ // No trailing slashes (except for root)
137
+ if (destination[field.name] !== '/') {
138
+ destination[field.name] = destination[field.name].replace(/\/$/, '');
139
+ }
140
+ }
141
+ },
142
+ addQueryBuilder(field, query) {
143
+ query.addBuilder(field.name, {
144
+ finalize: function () {
145
+ if (self.queryBuilderInterested(query, field.name)) {
146
+ const criteria = {};
147
+ let slugifyOptions = {};
148
+ if (field.page) {
149
+ slugifyOptions = { allow: '/' };
150
+ }
151
+ criteria[field.name] = new RegExp(self.apos.util.regExpQuote(self.apos.util.slugify(query.get(field.name), slugifyOptions)));
152
+ query.and(criteria);
153
+ }
154
+ },
155
+ choices: async function () {
156
+ return self.sortedDistinct(field.name, query);
157
+ }
158
+ });
159
+ }
160
+ });
161
+
162
+ self.addFieldType({
163
+ name: 'boolean',
164
+ convert: function (req, field, data, destination) {
165
+ destination[field.name] = self.apos.launder.boolean(data[field.name], field.def);
166
+ },
167
+ isEmpty: function (field, value) {
168
+ return !value;
169
+ },
170
+ exporters: {
171
+ string: function (req, field, object, output) {
172
+ output[field.name] = self.apos.launder.boolean(object[field.name]).toString();
173
+ }
174
+ },
175
+ addQueryBuilder(field, query) {
176
+ let criteria;
177
+ query.addBuilder(field.name, {
178
+ finalize: function () {
179
+ if (query.get(field.name) === false) {
180
+ criteria = {};
181
+ criteria[field.name] = { $ne: true };
182
+ query.and(criteria);
183
+ } else if (query.get(field.name) === true) {
184
+ criteria = {};
185
+ criteria[field.name] = true;
186
+ query.and(criteria);
187
+ }
188
+ },
189
+ launder: function (b) {
190
+ return self.apos.launder.booleanOrNull(b);
191
+ },
192
+ choices: async function () {
193
+ const values = query.toDistinct(field.name);
194
+ const choices = [];
195
+ if (_.includes(values, true)) {
196
+ choices.push({
197
+ value: '1',
198
+ label: 'apostrophe:yes'
199
+ });
200
+ }
201
+ if (_.includes(values, true)) {
202
+ choices.push({
203
+ value: '0',
204
+ label: 'apostrophe:no'
205
+ });
206
+ }
207
+ return choices;
208
+ }
209
+ });
210
+ }
211
+ });
212
+
213
+ self.addFieldType({
214
+ name: 'color',
215
+ async convert(req, field, data, destination) {
216
+ destination[field.name] = self.apos.launder.string(data[field.name], field.def);
217
+
218
+ if (field.required && (_.isUndefined(destination[field.name]) || !destination[field.name].toString().length)) {
219
+ throw self.apos.error('required');
220
+ }
221
+
222
+ const test = tinycolor(destination[field.name]);
223
+ if (!tinycolor(test).isValid()) {
224
+ destination[field.name] = null;
225
+ }
226
+ },
227
+ isEmpty: function (field, value) {
228
+ return !value.length;
229
+ }
230
+ });
231
+
232
+ self.addFieldType({
233
+ name: 'checkboxes',
234
+ async convert(req, field, data, destination) {
235
+ if (typeof data[field.name] === 'string') {
236
+ data[field.name] = self.apos.launder.string(data[field.name]).split(',');
237
+
238
+ if (!Array.isArray(data[field.name])) {
239
+ destination[field.name] = [];
240
+ return;
241
+ }
242
+
243
+ destination[field.name] = _.filter(data[field.name], function (choice) {
244
+ return _.includes(_.map(field.choices, 'value'), choice);
245
+ });
246
+ } else {
247
+ if (!Array.isArray(data[field.name])) {
248
+ destination[field.name] = [];
249
+ } else {
250
+ destination[field.name] = _.filter(data[field.name], function (choice) {
251
+ return _.includes(_.map(field.choices, 'value'), choice);
252
+ });
253
+ }
254
+ }
255
+ },
256
+ index: function (value, field, texts) {
257
+ const silent = field.silent === undefined ? true : field.silent;
258
+ texts.push({
259
+ weight: field.weight || 15,
260
+ text: (value || []).join(' '),
261
+ silent: silent
262
+ });
263
+ },
264
+ addQueryBuilder(field, query) {
265
+ return query.addBuilder(field.name, {
266
+ finalize: function () {
267
+ if (self.queryBuilderInterested(query, field.name)) {
268
+ const criteria = {};
269
+ let v = query.get(field.name);
270
+ // Allow programmers to pass just one value too (sanitize doesn't apply to them)
271
+ if (!Array.isArray(v)) {
272
+ v = [ v ];
273
+ }
274
+ criteria[field.name] = { $in: v };
275
+ query.and(criteria);
276
+ }
277
+ },
278
+ launder: function (value) {
279
+ // Support one or many
280
+ if (Array.isArray(value)) {
281
+ return _.map(value, function (v) {
282
+ return self.apos.launder.select(v, field.choices, field.def);
283
+ });
284
+ } else {
285
+ return [ self.apos.launder.select(value, field.choices, field.def) ];
286
+ }
287
+ },
288
+ choices: async function () {
289
+ const values = await query.toDistinct(field.name);
290
+ const choices = _.map(values, function (value) {
291
+ const choice = _.find(field.choices, { value: value });
292
+ return {
293
+ value: value,
294
+ label: choice && (choice.label || value)
295
+ };
296
+ });
297
+ self.apos.util.insensitiveSortByProperty(choices, 'label');
298
+ return choices;
299
+ }
300
+ });
301
+ }
302
+ });
303
+
304
+ self.addFieldType({
305
+ name: 'select',
306
+ async convert(req, field, data, destination) {
307
+ let choices;
308
+ if ((typeof field.choices) === 'string') {
309
+ choices = await self.apos.modules[field.moduleName][field.choices](req);
310
+ } else {
311
+ choices = field.choices;
312
+ }
313
+ destination[field.name] = self.apos.launder.select(data[field.name], choices, field.def);
314
+ },
315
+ index: function (value, field, texts) {
316
+ const silent = field.silent === undefined ? true : field.silent;
317
+ texts.push({
318
+ weight: field.weight || 15,
319
+ text: value,
320
+ silent: silent
321
+ });
322
+ },
323
+ addQueryBuilder(field, query) {
324
+ return query.addBuilder(field.name, {
325
+ finalize: function () {
326
+ if (self.queryBuilderInterested(query, field.name)) {
327
+ const criteria = {};
328
+ let v = query.get(field.name);
329
+ // Allow programmers to pass just one value too (sanitize doesn't apply to them)
330
+ if (!Array.isArray(v)) {
331
+ v = [ v ];
332
+ }
333
+ criteria[field.name] = { $in: v };
334
+ query.and(criteria);
335
+ }
336
+ },
337
+ launder: function (value) {
338
+ // Support one or many
339
+ if (Array.isArray(value)) {
340
+ return _.map(value, function (v) {
341
+ return self.apos.launder.select(v, field.choices, null);
342
+ });
343
+ } else {
344
+ value = (typeof field.choices) === 'string'
345
+ ? self.apos.launder.string(value)
346
+ : self.apos.launder.select(value, field.choices, null);
347
+ if (value === null) {
348
+ return null;
349
+ }
350
+ return [ value ];
351
+ }
352
+ },
353
+ choices: async function () {
354
+ let allChoices;
355
+ const values = await query.toDistinct(field.name);
356
+ if ((typeof field.choices) === 'string') {
357
+ const req = self.apos.task.getReq();
358
+ allChoices = await self.apos.modules[field.moduleName][field.choices](req);
359
+ } else {
360
+ allChoices = field.choices;
361
+ }
362
+ const choices = _.map(values, function (value) {
363
+ const choice = _.find(allChoices, { value: value });
364
+ return {
365
+ value: value,
366
+ label: choice && (choice.label || value)
367
+ };
368
+ });
369
+ self.apos.util.insensitiveSortByProperty(choices, 'label');
370
+ return choices;
371
+ }
372
+ });
373
+ }
374
+ });
375
+
376
+ self.addFieldType({
377
+ name: 'radio',
378
+ extend: 'select'
379
+ });
380
+
381
+ self.addFieldType({
382
+ name: 'integer',
383
+ vueComponent: 'AposInputString',
384
+ async convert(req, field, data, destination) {
385
+ destination[field.name] = self.apos.launder.integer(data[field.name], field.def, field.min, field.max);
386
+ if (field.required && ((data[field.name] == null) || !data[field.name].toString().length)) {
387
+ throw self.apos.error('required');
388
+ }
389
+ if (data[field.name] && isNaN(parseFloat(data[field.name]))) {
390
+ throw self.apos.error('invalid');
391
+ }
392
+ // This makes it possible to have a field that is not required, but min / max defined.
393
+ // This allows the form to be saved and sets the value to null if no value was given by
394
+ // the user.
395
+ if (!data[field.name] && data[field.name] !== 0) {
396
+ destination[field.name] = null;
397
+ }
398
+ },
399
+ addQueryBuilder(field, query) {
400
+ return query.addBuilder(field.name, {
401
+ finalize: function () {
402
+ let criteria;
403
+ const value = query.get(field.name);
404
+ if (value !== undefined && value !== null) {
405
+ if (Array.isArray(value) && value.length === 2) {
406
+ criteria = {};
407
+ criteria[field.name] = {
408
+ $gte: value[0],
409
+ $lte: value[1]
410
+ };
411
+ } else {
412
+ criteria = {};
413
+ criteria[field.name] = self.apos.launder.integer(value);
414
+ query.and(criteria);
415
+ }
416
+ }
417
+ },
418
+ choices: async function () {
419
+ return self.sortedDistinct(field.name, query);
420
+ },
421
+ launder: function (s) {
422
+ return self.apos.launder.integer(s, null);
423
+ }
424
+ });
425
+ }
426
+ });
427
+
428
+ self.addFieldType({
429
+ name: 'float',
430
+ vueComponent: 'AposInputString',
431
+ async convert(req, field, data, destination) {
432
+ destination[field.name] = self.apos.launder.float(data[field.name], field.def, field.min, field.max);
433
+ if (field.required && (_.isUndefined(data[field.name]) || !data[field.name].toString().length)) {
434
+ throw self.apos.error('required');
435
+ }
436
+ if (data[field.name] && isNaN(parseFloat(data[field.name]))) {
437
+ throw self.apos.error('invalid');
438
+ }
439
+ if (!data[field.name] && data[field.name] !== 0) {
440
+ destination[field.name] = null;
441
+ }
442
+ },
443
+ addQueryBuilder(field, query) {
444
+ return query.addBuilder(field.name, {
445
+ finalize: function () {
446
+ let criteria;
447
+ const value = query.get(field.name);
448
+ if (value !== undefined && value !== null) {
449
+ if (Array.isArray(value) && value.length === 2) {
450
+ criteria = {};
451
+ criteria[field.name] = {
452
+ $gte: value[0],
453
+ $lte: value[1]
454
+ };
455
+ } else {
456
+ criteria = {};
457
+ criteria[field.name] = self.apos.launder.float(value);
458
+ query.and(criteria);
459
+ }
460
+ }
461
+ },
462
+ choices: async function () {
463
+ return self.sortedDistinct(field.name, query);
464
+ }
465
+ });
466
+ }
467
+ });
468
+
469
+ self.addFieldType({
470
+ name: 'email',
471
+ vueComponent: 'AposInputString',
472
+ convert: function (req, field, data, destination) {
473
+ destination[field.name] = self.apos.launder.string(data[field.name]);
474
+ if (!data[field.name]) {
475
+ if (field.required) {
476
+ throw self.apos.error('required');
477
+ }
478
+ } else {
479
+ // regex source: https://emailregex.com/
480
+ 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,}))$/);
481
+ if (!matches) {
482
+ throw self.apos.error('invalid');
483
+ }
484
+ }
485
+ destination[field.name] = data[field.name];
486
+ }
487
+ });
488
+
489
+ self.addFieldType({
490
+ name: 'url',
491
+ vueComponent: 'AposInputString',
492
+ async convert(req, field, data, destination) {
493
+ destination[field.name] = self.apos.launder.url(data[field.name], field.def, true);
494
+ },
495
+ diffable: function (value) {
496
+ // URLs are fine to diff and display
497
+ if (typeof value === 'string') {
498
+ return value;
499
+ }
500
+ // always return a valid string
501
+ return '';
502
+ },
503
+ addQueryBuilder(field, query) {
504
+ query.addBuilder(field.name, {
505
+ finalize: function () {
506
+ if (self.queryBuilderInterested(query, field.name)) {
507
+ const criteria = {};
508
+ criteria[field.name] = new RegExp(self.apos.util.regExpQuote(query.get(field.name)), 'i');
509
+ query.and(criteria);
510
+ }
511
+ },
512
+ launder: function (s) {
513
+ // Don't be too strict, just enough that we can safely pass it to
514
+ // regExpQuote, partial URLs are welcome
515
+ return self.apos.launder.string(s);
516
+ },
517
+ choices: async function () {
518
+ return self.sortDistinct(field.name, query);
519
+ }
520
+ });
521
+ }
522
+ });
523
+
524
+ self.addFieldType({
525
+ name: 'date',
526
+ vueComponent: 'AposInputString',
527
+ async convert(req, field, data, destination) {
528
+ const newDateVal = data[field.name];
529
+ if (!newDateVal && destination[field.name]) {
530
+ // Allow date fields to be unset.
531
+ destination[field.name] = null;
532
+ return;
533
+ }
534
+ if (field.min && newDateVal && (newDateVal < field.min)) {
535
+ // If the min requirement isn't met, leave as-is.
536
+ return;
537
+ }
538
+ if (field.max && newDateVal && (newDateVal > field.max)) {
539
+ // If the max requirement isn't met, leave as-is.
540
+ return;
541
+ }
542
+
543
+ destination[field.name] = self.apos.launder.date(newDateVal, field.def);
544
+ },
545
+ validate: function (field, options, warn, fail) {
546
+ if (field.max && !field.max.match(dateRegex)) {
547
+ fail('Property "max" must be in the date format, YYYY-MM-DD');
548
+ }
549
+ if (field.min && !field.min.match(dateRegex)) {
550
+ fail('Property "min" must be in the date format, YYYY-MM-DD');
551
+ }
552
+ },
553
+ addQueryBuilder(field, query) {
554
+ return query.addBuilder(field.name, {
555
+ finalize: function () {
556
+ if (self.queryBuilderInterested(query, field.name)) {
557
+ const value = query.get(field.name);
558
+ let criteria;
559
+ if (Array.isArray(value)) {
560
+ criteria = {};
561
+ criteria[field.name] = {
562
+ $gte: value[0],
563
+ $lte: value[1]
564
+ };
565
+ } else {
566
+ criteria = {};
567
+ criteria[field.name] = self.apos.launder.date(value);
568
+ query.and(criteria);
569
+ }
570
+ }
571
+ },
572
+ launder: function (value) {
573
+ if (Array.isArray(value) && value.length === 2) {
574
+ if (value[0] instanceof Date) {
575
+ value[0] = dayjs(value[0]).format('YYYY-MM-DD');
576
+ }
577
+ if (value[1] instanceof Date) {
578
+ value[1] = dayjs(value[1]).format('YYYY-MM-DD');
579
+ }
580
+ value[0] = self.apos.launder.date(value[0]);
581
+ value[1] = self.apos.launder.date(value[1]);
582
+ return value;
583
+ } else {
584
+ if (value instanceof Date) {
585
+ value = dayjs(value).format('YYYY-MM-DD');
586
+ }
587
+ return self.apos.launder.date(value, null);
588
+ }
589
+ },
590
+ choices: async function () {
591
+ return self.sortDistinct(field.name, query);
592
+ }
593
+ });
594
+ }
595
+ });
596
+
597
+ self.addFieldType({
598
+ name: 'time',
599
+ vueComponent: 'AposInputString',
600
+ async convert(req, field, data, destination) {
601
+ destination[field.name] = self.apos.launder.time(data[field.name], field.def);
602
+ }
603
+ });
604
+
605
+ self.addFieldType({
606
+ name: 'dateAndTime',
607
+ vueComponent: 'AposInputDateAndTime',
608
+ convert (req, field, data, destination) {
609
+ destination[field.name] = data[field.name]
610
+ ? self.apos.launder.date(data[field.name])
611
+ : null;
612
+ }
613
+ });
614
+
615
+ self.addFieldType({
616
+ name: 'password',
617
+ async convert(req, field, data, destination) {
618
+ // This is the only field type that we never update unless
619
+ // there is actually a new value — a blank password is not cool. -Tom
620
+ if (data[field.name]) {
621
+ destination[field.name] = self.apos.launder.string(data[field.name], field.def);
622
+
623
+ destination[field.name] = checkStringLength(destination[field.name], field.min, field.max);
624
+ }
625
+ }
626
+ });
627
+
628
+ self.addFieldType({
629
+ name: 'group' // visual grouping only
630
+ });
631
+
632
+ self.addFieldType({
633
+ name: 'range',
634
+ vueComponent: 'AposInputRange',
635
+ async convert(req, field, data, destination) {
636
+ destination[field.name] = self.apos.launder.float(data[field.name], field.def, field.min, field.max);
637
+ if (field.required && (_.isUndefined(data[field.name]) || !data[field.name].toString().length)) {
638
+ throw self.apos.error('required');
639
+ }
640
+ if (data[field.name] && isNaN(parseFloat(data[field.name]))) {
641
+ throw self.apos.error('invalid');
642
+ }
643
+ // Allow for ranges to go unset
644
+ // `min` here does not imply requirement, it is the minimum value the range UI will represent
645
+ if (
646
+ !data[field.name] ||
647
+ data[field.name] < field.min ||
648
+ data[field.name] > field.max
649
+ ) {
650
+ destination[field.name] = null;
651
+ }
652
+ },
653
+ validate: function (field, options, warn, fail) {
654
+ if (!field.min && field.min !== 0) {
655
+ fail('Property "min" must be set.');
656
+ }
657
+ if (!field.max && field.max !== 0) {
658
+ fail('Property "max" must be set.');
659
+ }
660
+ if (typeof field.max !== 'number') {
661
+ fail('Property "max" must be a number');
662
+ }
663
+ if (typeof field.min !== 'number') {
664
+ fail('Property "min" must be a number');
665
+ }
666
+ if (field.step && typeof field.step !== 'number') {
667
+ fail('Property "step" must be a number.');
668
+ }
669
+ if (field.unit && typeof field.unit !== 'string') {
670
+ fail('Property "unit" must be a string.');
671
+ }
672
+ }
673
+ });
674
+
675
+ self.addFieldType({
676
+ name: 'array',
677
+ async convert(req, field, data, destination) {
678
+ const schema = field.schema;
679
+ data = data[field.name];
680
+ if (!Array.isArray(data)) {
681
+ data = [];
682
+ }
683
+ const results = [];
684
+ if (field.limit && data.length > field.limit) {
685
+ data = data.slice(0, field.limit);
686
+ }
687
+ const errors = [];
688
+ for (const datum of data) {
689
+ const result = {};
690
+ result._id = self.apos.launder.id(datum._id) || self.apos.util.generateId();
691
+ result.metaType = 'arrayItem';
692
+ result.scopedArrayName = field.scopedArrayName;
693
+ try {
694
+ await self.convert(req, schema, datum, result);
695
+ } catch (e) {
696
+ if (Array.isArray(e)) {
697
+ for (const error of e) {
698
+ error.path = `${result._id}.${error.path}`;
699
+ errors.push(error);
700
+ }
701
+ } else {
702
+ throw e;
703
+ }
704
+ }
705
+ results.push(result);
706
+ }
707
+ destination[field.name] = results;
708
+ if (field.required && !results.length) {
709
+ throw self.apos.error('required');
710
+ }
711
+ if ((field.min !== undefined) && (results.length < field.min)) {
712
+ throw self.apos.error('min');
713
+ }
714
+ if ((field.max !== undefined) && (results.length > field.max)) {
715
+ throw self.apos.error('max');
716
+ }
717
+ if (errors.length) {
718
+ throw errors;
719
+ }
720
+ },
721
+ isEmpty: function (field, value) {
722
+ return (!Array.isArray(value)) || (!value.length);
723
+ },
724
+ index: function (value, field, texts) {
725
+ _.each(value || [], function (item) {
726
+ self.apos.schema.indexFields(field.schema, item, texts);
727
+ });
728
+ },
729
+ validate: function (field, options, warn, fail) {
730
+ for (const subField of field.schema || field.fields.add) {
731
+ self.validateField(subField, options);
732
+ }
733
+ },
734
+ register: function (metaType, type, field) {
735
+ const localArrayName = field.arrayName || field.name;
736
+ field.scopedArrayName = `${metaType}.${type}.${localArrayName}`;
737
+ self.arrayManagers[field.scopedArrayName] = {
738
+ schema: field.schema
739
+ };
740
+ self.register(metaType, type, field.schema);
741
+ },
742
+ isEqual(req, field, one, two) {
743
+ if (!(one[field.name] && two[field.name])) {
744
+ return !(one[field.name] || two[field.name]);
745
+ }
746
+ if (one[field.name].length !== two[field.name].length) {
747
+ return false;
748
+ }
749
+ for (let i = 0; (i < one.length); i++) {
750
+ if (!self.isEqual(req, field.schema, one[field.name][i], two[field.name][i])) {
751
+ return false;
752
+ }
753
+ }
754
+ return true;
755
+ },
756
+ def: []
757
+ });
758
+
759
+ self.addFieldType({
760
+ name: 'object',
761
+ async convert(req, field, data, destination) {
762
+ data = data[field.name];
763
+ const schema = field.schema;
764
+ const errors = [];
765
+ const result = {
766
+ _id: self.apos.launder.id(data && data._id) || self.apos.util.generateId()
767
+ };
768
+ if (data == null || typeof data !== 'object' || Array.isArray(data)) {
769
+ data = {};
770
+ }
771
+ try {
772
+ await self.convert(req, schema, data, result);
773
+ } catch (e) {
774
+ for (const error of e) {
775
+ errors.push({
776
+ path: error.path,
777
+ error: error.error
778
+ });
779
+ }
780
+ }
781
+ result.metaType = 'objectItem';
782
+ result.scopedObjectName = field.scopedObjectName;
783
+ destination[field.name] = result;
784
+ if (errors.length) {
785
+ throw errors;
786
+ }
787
+ },
788
+ register: function (metaType, type, field) {
789
+ const localObjectName = field.objectName || field.name;
790
+ field.scopedObjectName = `${metaType}.${type}.${localObjectName}`;
791
+ self.objectManagers[field.scopedObjectName] = {
792
+ schema: field.schema
793
+ };
794
+ self.register(metaType, type, field.schema);
795
+ },
796
+ validate: function (field, options, warn, fail) {
797
+ for (const subField of field.schema || field.fields.add) {
798
+ self.validateField(subField, options);
799
+ }
800
+ },
801
+ isEqual(req, field, one, two) {
802
+ if (one && (!two)) {
803
+ return false;
804
+ }
805
+ if (two && (!one)) {
806
+ return false;
807
+ }
808
+ if (!(one || two)) {
809
+ return true;
810
+ }
811
+ if (one[field.name] && (!two[field.name])) {
812
+ return false;
813
+ }
814
+ if (two[field.name] && (!one[field.name])) {
815
+ return false;
816
+ }
817
+ if (!(one[field.name] || two[field.name])) {
818
+ return true;
819
+ }
820
+ return self.isEqual(req, field.schema, one[field.name], two[field.name]);
821
+ },
822
+ def: {}
823
+ });
824
+
825
+ self.addFieldType({
826
+ name: 'relationship',
827
+ // Validate a relationship field, copying from `data[field.name]` to
828
+ // `object[field.name]`. If the relationship is named `_product`, then
829
+ // `data._product` should be an array of product docs.
830
+ // These doc objects must at least have an _id property.
831
+ //
832
+ // Alternatively, entries in `data._product` may simply be
833
+ // `_id` strings or `title` strings. Titles are compared in a
834
+ // tolerant manner. This is useful for CSV input. Strings may
835
+ // be mixed with actual docs in a single array.
836
+ //
837
+ // If the relationship field has a `fields` option, then each
838
+ // doc object may also have a `_fields` property which
839
+ // will be validated against the schema in `fields`.
840
+ //
841
+ // The result in `object[field.name]` will always be an array
842
+ // of zero or more related docs, containing only those that
843
+ // actually exist in the database and can be fetched by this user,
844
+ // in the same order specified in `data[field.name]`.
845
+ //
846
+ // Actual storage to the permanent idsStorage and fieldsStorage
847
+ // properties is handled at a lower level in a beforeSave
848
+ // handler of the doc-type module.
849
+
850
+ async convert(req, field, data, destination) {
851
+ const manager = self.apos.doc.getManager(field.withType);
852
+ if (!manager) {
853
+ throw Error('relationship with type ' + field.withType + ' unrecognized');
854
+ }
855
+ let input = data[field.name];
856
+ if (input == null) {
857
+ input = [];
858
+ }
859
+ if ((typeof input) === 'string') {
860
+ // Handy in CSV: allows titles or _ids
861
+ input = input.split(/\s*,\s*/);
862
+ }
863
+ if (field.min && field.min > input.length) {
864
+ throw self.apos.error('min', `Minimum ${field.withType} required not reached.`);
865
+ }
866
+ if (field.max && field.max < input.length) {
867
+ throw self.apos.error('max', `Maximum ${field.withType} required reached.`);
868
+ }
869
+ const ids = [];
870
+ const titlesOrIds = [];
871
+ for (const item of input) {
872
+ if ((typeof item) === 'string') {
873
+ titlesOrIds.push(item);
874
+ } else {
875
+ if (item && ((typeof item._id) === 'string')) {
876
+ ids.push(item._id);
877
+ }
878
+ }
879
+ }
880
+ const clauses = [];
881
+ if (titlesOrIds.length) {
882
+ clauses.push({
883
+ titleSortified: {
884
+ $in: titlesOrIds.map(titleOrId => self.apos.util.sortify(titleOrId))
885
+ }
886
+ });
887
+ }
888
+ if (ids.length) {
889
+ clauses.push({
890
+ _id: {
891
+ $in: ids
892
+ }
893
+ });
894
+ }
895
+ if (!clauses.length) {
896
+ destination[field.name] = [];
897
+ return;
898
+ }
899
+ const results = await manager.find(req, { $or: clauses }).relationships(false).toArray();
900
+ // Must maintain input order. Also discard things not actually found in the db
901
+ const actualDocs = [];
902
+ for (const item of input) {
903
+ if ((typeof item) === 'string') {
904
+ const result = results.find(result => (result.title === item) || (result._id === item));
905
+ if (result) {
906
+ actualDocs.push(result);
907
+ }
908
+ } else if ((item && ((typeof item._id) === 'string'))) {
909
+ const result = results.find(doc => (doc._id === item._id));
910
+ if (result) {
911
+ if (field.schema) {
912
+ result._fields = {};
913
+ if (item && ((typeof item._fields === 'object'))) {
914
+ await self.convert(req, field.schema, item._fields || {}, result._fields);
915
+ }
916
+ }
917
+ actualDocs.push(result);
918
+ }
919
+ }
920
+ }
921
+ destination[field.name] = actualDocs;
922
+ },
923
+
924
+ relate: async function (req, field, objects, options) {
925
+ return self.relationshipDriver(req, joinr.byArray, false, objects, field.idsStorage, field.fieldsStorage, field.name, options);
926
+ },
927
+
928
+ addQueryBuilder(field, query) {
929
+
930
+ addOperationQueryBuilder('', '$in');
931
+ addOperationQueryBuilder('And', '$all');
932
+ self.addRelationshipSlugQueryBuilder(field, query, '');
933
+ self.addRelationshipSlugQueryBuilder(field, query, 'And');
934
+
935
+ function addOperationQueryBuilder(suffix, operator) {
936
+ return query.addBuilder(field.name + suffix, {
937
+ finalize: function () {
938
+
939
+ if (!self.queryBuilderInterested(query, field.name + suffix)) {
940
+ return;
941
+ }
942
+
943
+ const value = query.get(field.name + suffix);
944
+ const criteria = {};
945
+ // Even programmers appreciate shortcuts, so it's not enough that the
946
+ // sanitizer (which doesn't apply to programmatic use) accepts these
947
+ if (Array.isArray(value)) {
948
+ criteria[field.idsStorage] = {};
949
+ criteria[field.idsStorage][operator] = value.map(self.apos.doc.toAposDocId);
950
+ } else if (value === 'none') {
951
+ criteria.$or = [];
952
+ let clause = {};
953
+ clause[field.idsStorage] = null;
954
+ criteria.$or.push(clause);
955
+ clause = {};
956
+ clause[field.idsStorage] = { $exists: 0 };
957
+ criteria.$or.push(clause);
958
+ clause = {};
959
+ clause[field.idsStorage + '.0'] = { $exists: 0 };
960
+ criteria.$or.push(clause);
961
+ } else {
962
+ criteria[field.idsStorage] = { $in: [ self.apos.doc.toAposDocId(value) ] };
963
+ }
964
+ query.and(criteria);
965
+ },
966
+ choices: self.relationshipQueryBuilderChoices(field, query, '_id'),
967
+ launder: relationshipQueryBuilderLaunder
968
+ });
969
+ }
970
+ },
971
+ validate: function (field, options, warn, fail) {
972
+ if (!field.name.match(/^_/)) {
973
+ 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.');
974
+ }
975
+ if (!field.idsStorage) {
976
+ // Supply reasonable value
977
+ field.idsStorage = field.name.replace(/^_/, '') + 'Ids';
978
+ }
979
+ if (!field.withType) {
980
+ // Try to supply reasonable value based on relationship name. Relationship name will be plural,
981
+ // so consider that too
982
+ const withType = field.name.replace(/^_/, '').replace(/s$/, '');
983
+ if (!_.find(self.apos.doc.managers, { name: withType })) {
984
+ 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.');
985
+ }
986
+ field.withType = withType;
987
+ }
988
+ if (!field.withType) {
989
+ fail('withType property is missing. Hint: it must match the name of a doc type module.');
990
+ }
991
+ if (Array.isArray(field.withType)) {
992
+ _.each(field.withType, function (type) {
993
+ lintType(type);
994
+ });
995
+ } else {
996
+ lintType(field.withType);
997
+ const withTypeManager = self.apos.doc.getManager(field.withType);
998
+ field.editor = field.editor || withTypeManager.options.relationshipEditor;
999
+ field.postprocessor = field.postprocessor || withTypeManager.options.relationshipPostprocessor;
1000
+ field.editorLabel = field.editorLabel || withTypeManager.options.relationshipEditorLabel;
1001
+ field.editorIcon = field.editorIcon || withTypeManager.options.relationshipEditorIcon;
1002
+
1003
+ if (!field.schema && !Array.isArray(field.withType)) {
1004
+ const fieldsOption = withTypeManager.options.relationshipFields;
1005
+ const fields = fieldsOption && fieldsOption.add;
1006
+ field.fields = fields && klona(fields);
1007
+ field.schema = self.fieldsToArray(`Relationship field ${field.name}`, field.fields);
1008
+ }
1009
+ }
1010
+ if (field.schema && !field.fieldsStorage) {
1011
+ field.fieldsStorage = field.name.replace(/^_/, '') + 'Fields';
1012
+ }
1013
+ if (field.schema && !Array.isArray(field.schema)) {
1014
+ fail('schema property should be an array if present at this stage');
1015
+ }
1016
+ if (field.filters) {
1017
+ fail('"filters" property should be changed to "builders" for 3.x');
1018
+ }
1019
+ if (field.builders && field.builders.projection) {
1020
+ fail('"projection" sub-property should be changed to "project" for 3.x');
1021
+ }
1022
+ function lintType(type) {
1023
+ type = self.apos.doc.normalizeType(type);
1024
+ if (!_.find(self.apos.doc.managers, { name: type })) {
1025
+ fail('withType property, ' + type + ', does not match the name of any piece or page type module.');
1026
+ }
1027
+ }
1028
+ },
1029
+ isEqual(req, field, one, two) {
1030
+ const ids1 = one[field.idsStorage] || [];
1031
+ const ids2 = two[field.idsStorage] || [];
1032
+ if (!_.isEqual(ids1, ids2)) {
1033
+ return false;
1034
+ }
1035
+ if (field.fieldsStorage) {
1036
+ const fields1 = one[field.fieldsStorage] || {};
1037
+ const fields2 = two[field.fieldsStorage] || {};
1038
+ if (!_.isEqual(fields1, fields2)) {
1039
+ return false;
1040
+ }
1041
+ }
1042
+ return true;
1043
+ }
1044
+ });
1045
+
1046
+ self.addFieldType({
1047
+ name: 'relationshipReverse',
1048
+ vueComponent: false,
1049
+ relate: async function (req, field, objects, options) {
1050
+ return self.relationshipDriver(req, joinr.byArrayReverse, true, objects, field.idsStorage, field.fieldsStorage, field.name, options);
1051
+ },
1052
+ validate: function (field, options, warn, fail) {
1053
+ let forwardRelationship;
1054
+ if (!field.name.match(/^_/)) {
1055
+ 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.');
1056
+ }
1057
+ if (!field.withType) {
1058
+ // Try to supply reasonable value based on relationship name
1059
+ const withType = field.name.replace(/^_/, '').replace(/s$/, '');
1060
+ if (!_.find(self.apos.doc.managers, { name: withType })) {
1061
+ 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.');
1062
+ }
1063
+ field.withType = withType;
1064
+ }
1065
+ const otherModule = _.find(self.apos.doc.managers, { name: self.apos.doc.normalizeType(field.withType) });
1066
+ if (!otherModule) {
1067
+ fail('withType property, ' + field.withType + ', does not match the name of a piece or page type module.');
1068
+ }
1069
+ if (!(field.reverseOf || field.idsStorage)) {
1070
+ self.validate(otherModule.schema, {
1071
+ type: 'doc type',
1072
+ subtype: otherModule.name
1073
+ });
1074
+ // Look for a relationship with our type in the other type
1075
+ forwardRelationship = _.find(otherModule.schema, { withType: options.subtype });
1076
+ if (forwardRelationship) {
1077
+ field.reverseOf = forwardRelationship.name;
1078
+ }
1079
+ }
1080
+ if (field.reverseOf) {
1081
+ forwardRelationship = _.find(otherModule.schema, {
1082
+ type: 'relationship',
1083
+ name: field.reverseOf
1084
+ });
1085
+ if (!forwardRelationship) {
1086
+ 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".');
1087
+ }
1088
+ // Make sure the other relationship has any missing fields auto-supplied before
1089
+ // trying to access them
1090
+ self.validate([ forwardRelationship ], {
1091
+ type: 'doc type',
1092
+ subtype: otherModule.name
1093
+ });
1094
+ field.idsStorage = forwardRelationship.idsStorage;
1095
+ field.fieldsStorage = forwardRelationship.fieldsStorage;
1096
+ }
1097
+ if (!field.idsStorage) {
1098
+ field.idsStorage = field.name.replace(/^_/, '') + 'Ids';
1099
+ }
1100
+ if (!forwardRelationship) {
1101
+ forwardRelationship = _.find(otherModule.schema, {
1102
+ type: 'relationship',
1103
+ idsStorage: field.idsStorage
1104
+ });
1105
+ if (!forwardRelationship) {
1106
+ 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.');
1107
+ }
1108
+ if (forwardRelationship.fieldsStorage) {
1109
+ field.fieldsStorage = forwardRelationship.fieldsStorage;
1110
+ }
1111
+ }
1112
+ }
1113
+ });
1114
+
1115
+ function checkStringLength (string, min, max) {
1116
+ if (string && min && string.length < min) {
1117
+ // Would be unpleasant, but shouldn't happen since the browser
1118
+ // also implements this. We're just checking for naughty scripts
1119
+ throw self.apos.error('min');
1120
+ }
1121
+ // If max is longer than allowed, trim the value down to the max length
1122
+ if (string && max && string.length > max) {
1123
+ return string.substr(0, max);
1124
+ }
1125
+
1126
+ return string;
1127
+ }
1128
+
1129
+ function relationshipQueryBuilderLaunder(v) {
1130
+ if (Array.isArray(v)) {
1131
+ return self.apos.launder.ids(v);
1132
+ } else if (typeof v === 'string' && v.length) {
1133
+ return [ self.apos.launder.id(v) ];
1134
+ } else if (v === 'none') {
1135
+ return 'none';
1136
+ }
1137
+ return undefined;
1138
+ }
1139
+ };