apostrophe 3.32.0 → 3.34.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.
@@ -38,6 +38,9 @@ jobs:
38
38
  with:
39
39
  mongodb-version: ${{ matrix.mongodb-version }}
40
40
 
41
+ # Exit status must be succesful in the end
42
+ - run: ( ( node --version | grep v14 ) && npm install -g npm@8 ) || echo "npm OK"
43
+
41
44
  - run: npm install
42
45
 
43
46
  - run: npm test
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.34.0 (2022-12-12)
4
+
5
+ ### Fixes
6
+
7
+ * Nested areas work properly in widgets that have the `initialModal: false` property.
8
+ * Apostrophe's search index now properly incorporates most string field types as in A2.
9
+
10
+ ### Adds
11
+
12
+ * Relationships load more quickly.
13
+ * Parked page checks at startup are faster.
14
+
15
+ ## 3.33.0 (2022-11-28)
16
+
17
+ ### Adds
18
+
19
+ * You can now set `inline: true` on schema fields of type `array`. This displays a simple editing interface in the context of the main dialog box for the document in question, avoiding the need to open an additional dialog box. Usually best for cases with just one field or just a few. If your array field has a large number of subfields the default behavior (`inline: false`) is more suitable for your needs. See the [array field](https://v3.docs.apostrophecms.org/reference/field-types/array.html) documentation for more information.
20
+ * Batch feature for publishing pieces.
21
+ * Add extensibility for `rich-text-widget` `defaultOptions`. Every key will now be used in the `AposRichTextWidgetEditor`.
22
+
23
+ ### Fixes
24
+
25
+ * Prior to this release, widget templates that contained areas pulled in from related documents would break the ability to add another widget beneath.
26
+ * Validation of object fields now works properly on the browser side, in addition to server-side validation, resolving UX issues.
27
+ * Provisions were added to prevent any possibility of a discrepancy in relationship loading results under high load. It is not clear whether this A2 bug was actually possible in A3.
28
+
3
29
  ## 3.32.0 (2022-11-09)
4
30
 
5
31
  ### Adds
package/CONTRIBUTING.md CHANGED
@@ -12,14 +12,17 @@ development updates, and help as you build with Apostrophe. There are also
12
12
  [Github Discussions](https://github.com/apostrophecms/apostrophe/discussions) and
13
13
  [Stack Overflow](https://stackoverflow.com/questions/tagged/apostrophe-cms).
14
14
 
15
- - [Code of Conduct](#code-of-conduct)
16
- - [How can I contribute?](#how-can-i-contribute)
17
- - [Reporting Bugs](#reporting-bugs)
18
- - [Suggesting a Feature or Enhancement](#suggesting-a-feature-or-enhancement)
19
- - [Fixing Bugs or Submitting Enhancements](#fixing-bugs-or-submitting-enhancements)
20
- - [Improving documentation](#improving-documentation)
21
- - [Share the Love](#share-the-love)
22
- - [Should I make a new npm module?](#should-i-make-a-new-npm-module)
15
+ - [ApostropheCMS Contribution Guide](#apostrophecms-contribution-guide)
16
+ - [Code of Conduct](#code-of-conduct)
17
+ - [How can I contribute?](#how-can-i-contribute)
18
+ - [Reporting Bugs](#reporting-bugs)
19
+ - [Suggesting a Feature or Enhancement](#suggesting-a-feature-or-enhancement)
20
+ - [Fixing Bugs or Submitting Enhancements](#fixing-bugs-or-submitting-enhancements)
21
+ - [Contributing to Apostrophe Core](#contributing-to-apostrophe-core)
22
+ - [**What to expect next**](#what-to-expect-next)
23
+ - [Improving documentation](#improving-documentation)
24
+ - [Share the Love](#share-the-love)
25
+ - [Should I make a new npm module?](#should-i-make-a-new-npm-module)
23
26
 
24
27
  ## Code of Conduct
25
28
 
@@ -95,6 +98,20 @@ were applicable. For Apostrophe core, that should be in the
95
98
  and for other modules, add it in their README files (unless the README directs
96
99
  you elsewhere).
97
100
 
101
+ ### Contributing to Apostrophe Core
102
+
103
+ This is assuming you are interacting with the ApostropheCMS repositories on the GitHub website. If you are using GitHub Desktop you can read about how to fork a repository in the [GitHub docs.](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/adding-and-cloning-repositories/cloning-and-forking-repositories-from-github-desktop)
104
+
105
+ 1. Fork the main Apostrophe repository to your own account by clicking on the fork button at the top of the [GitHub page.](https://github.com/apostrophecms/apostrophe) If you are contributing to the latest version of Apostrophe you can simply click on the "Create fork" button on the next screen. If you are contributing to Apostrophe 2, you will need to uncheck the "Copy the main branch only" selection before creating the fork.
106
+ 2. The forked version of the repository can be modified in any way you would like without impact on the original repository. As a best practice, we request that you create a branch with a short informative name for making code changes. This makes it easier for our team to track what you are contributing when you make your pull request (PR).
107
+ 3. Once you've completed your code changes (and updates to `CHANGELOG.md` if needed - see the notes above) you can push all of your changes to your repo. Navigating to your GitHub repository page you will see a banner for creating a PR. Click on the "Contribute" and then "Open a pull request" buttons.
108
+ 4. This will bring up a PR page. There are a number of sections to be filled out. Please read carefully and make selections where needed. Following this checklist is very helpful when our team reviews your request. If you need help, just ask!
109
+ 5. Finally, it is best to notify a team member on the [Discord channel](https://discord.com/channels/517772094482677790/701815369005924374) that you have submitted a PR. You can also find our contact addresses on our GitHub pages. We don't get automatic notifications from community-submitted PRs. We will see it in the queue eventually, notifying us will just speed up the process.
110
+
111
+ #### **What to expect next**
112
+
113
+ After we get your PR, we will assign someone from the team to review it. They will follow your testing recommendations and run any tests that you have included. They will also look to make sure your code passes all of our internal linting tests. If there are any issues, the reviewer will highlight the needed changes in their PR review. You can then respond to those suggestions with another round of code changes and submissions to your code branch. Once accepted, the reviewer will merge your changes into the proper repository branch. You can then give yourself a well deserved pat on the back - thanks! 🥳
114
+
98
115
  ### Improving documentation
99
116
 
100
117
  The [documentation repo](https://github.com/apostrophecms/a3-docs)
@@ -102,7 +119,13 @@ is public and we appreciate contributions. The core maintainers know Apostrophe
102
119
  very well, but that can make it hard to see where the gaps in documentation are.
103
120
  Please open issues there letting us know (nicely, please). Even better, submit a
104
121
  pull request documenting something and you'll be helping many developers going
105
- forward.
122
+ forward. The mechanism for creating a pull request for documentation is the same as the method described for contributing to the core. To summarize:
123
+ 1. Fork
124
+ 2. Clone and create branch
125
+ 3. Make changes and push to branch
126
+ 4. Make pull request (PR) and notify Apostrophe development team
127
+ 5. Respond to PR comments with any needed changes
128
+ 6. Enjoy your awesome contribution - thanks! 🎉
106
129
 
107
130
  Even typo fixes are great!
108
131
 
@@ -48,6 +48,7 @@
48
48
  :field-id="fieldId"
49
49
  :disabled="field && field.readOnly"
50
50
  :widget-hovered="hoveredWidget"
51
+ :non-foreign-widget-hovered="hoveredNonForeignWidget"
51
52
  :widget-focused="focusedWidget"
52
53
  :max-reached="maxReached"
53
54
  :rendering="rendering(widget)"
@@ -135,6 +136,7 @@ export default {
135
136
  areaId: cuid(),
136
137
  next: this.getValidItems(),
137
138
  hoveredWidget: null,
139
+ hoveredNonForeignWidget: null,
138
140
  focusedWidget: null,
139
141
  contextMenuOptions: {
140
142
  menu: this.choices
@@ -239,8 +241,9 @@ export default {
239
241
  apos.bus.$emit('widget-focus-parent', this.focusedWidget);
240
242
  }
241
243
  },
242
- updateWidgetHovered(widgetId) {
243
- this.hoveredWidget = widgetId;
244
+ updateWidgetHovered({ _id, nonForeignId }) {
245
+ this.hoveredWidget = _id;
246
+ this.hoveredNonForeignWidget = nonForeignId;
244
247
  },
245
248
  updateWidgetFocused(widgetId) {
246
249
  this.focusedWidget = widgetId;
@@ -443,9 +446,10 @@ export default {
443
446
  index
444
447
  });
445
448
  } else if (!this.widgetHasInitialModal(name)) {
449
+ const widget = this.newWidget(name);
446
450
  return this.insert({
447
451
  widget: {
448
- type: name,
452
+ ...widget,
449
453
  aposPlaceholder: this.widgetHasPlaceholder(name)
450
454
  },
451
455
  index
@@ -558,6 +562,26 @@ export default {
558
562
  }
559
563
  return window.apos.modules[`${item.type}-widget`];
560
564
  });
565
+ },
566
+ // Return a new widget object in which defaults are fully populated,
567
+ // especially valid sub-area objects, so that nested edits work on the page
568
+ newWidget(type) {
569
+ const widget = {
570
+ type
571
+ };
572
+ const schema = apos.modules[apos.area.widgetManagers[type]].schema;
573
+ schema.forEach(field => {
574
+ if (field.type === 'area') {
575
+ widget[field.name] = {
576
+ _id: cuid(),
577
+ metaType: 'area',
578
+ items: []
579
+ };
580
+ } else {
581
+ widget[field.name] = field.def ? klona(field.def) : field.def;
582
+ }
583
+ });
584
+ return widget;
561
585
  }
562
586
  }
563
587
  };
@@ -5,6 +5,8 @@
5
5
  :class="{'apos-area-widget-wrapper--foreign': foreign}"
6
6
  :data-area-widget="widget._id"
7
7
  :data-area-label="widgetLabel"
8
+ :data-apos-widget-foreign="foreign ? 1 : 0"
9
+ :data-apos-widget-id="widget._id"
8
10
  >
9
11
  <div
10
12
  class="apos-area-widget-inner"
@@ -152,6 +154,10 @@ export default {
152
154
  type: String,
153
155
  default: null
154
156
  },
157
+ nonForeignWidgetHovered: {
158
+ type: String,
159
+ default: null
160
+ },
155
161
  widgetFocused: {
156
162
  type: String,
157
163
  default: null
@@ -262,7 +268,12 @@ export default {
262
268
  };
263
269
  },
264
270
  widgetLabel() {
265
- return window.apos.modules[`${this.widget.type}-widget`].label;
271
+ const moduleName = `${this.widget.type}-widget`;
272
+ const module = window.apos.modules[moduleName];
273
+ if (!module) {
274
+ console.error(`No ${moduleName} module found for widget type ${this.widget.type}`);
275
+ }
276
+ return module.label;
266
277
  },
267
278
  widgetOptions() {
268
279
  return this.widgets[this.widget.type];
@@ -283,11 +294,13 @@ export default {
283
294
  return false;
284
295
  }
285
296
 
286
- if (this.widgetHovered) {
287
- return this.widgetHovered !== this.widget._id;
288
- } else {
289
- return false;
290
- }
297
+ return !(this.hovered || this.nonForeignHovered);
298
+ },
299
+ hovered() {
300
+ return this.widgetHovered === this.widget._id;
301
+ },
302
+ nonForeignHovered() {
303
+ return this.nonForeignWidgetHovered === this.widget._id;
291
304
  },
292
305
  // Sets up all the interaction classes based on the current
293
306
  // state. If our widget is suppressed, return a blank UI state and reset
@@ -297,11 +310,11 @@ export default {
297
310
  controls: this.state.controls.show ? this.classes.show : null,
298
311
  labels: this.state.labels.show ? this.classes.show : null,
299
312
  container: this.state.container.focus ? this.classes.focus
300
- : (this.state.container.highlight ? this.classes.highlight : null),
313
+ : ((this.state.container.highlight || this.nonForeignHovered) ? this.classes.highlight : null),
301
314
  addTop: this.state.add.top.focus ? this.classes.focus
302
- : (this.state.add.top.show ? this.classes.show : null),
315
+ : ((this.state.add.top.show || this.nonForeignHovered) ? this.classes.show : null),
303
316
  addBottom: this.state.add.bottom.focus ? this.classes.focus
304
- : (this.state.add.bottom.show ? this.classes.show : null)
317
+ : ((this.state.add.bottom.show || this.nonForeignHovered) ? this.classes.show : null)
305
318
  };
306
319
 
307
320
  if (this.isSuppressed) {
@@ -356,9 +369,7 @@ export default {
356
369
 
357
370
  this.breadcrumbs.$lastEl = this.$el;
358
371
 
359
- if (!this.foreign) {
360
- this.getBreadcrumbs();
361
- }
372
+ this.getBreadcrumbs();
362
373
 
363
374
  if (this.widgetFocused) {
364
375
  // If another widget was in focus (because the user clicked the "add"
@@ -395,11 +406,18 @@ export default {
395
406
  if (this.focused) {
396
407
  return;
397
408
  }
398
- this.state.add.top.show = true;
399
- this.state.add.bottom.show = true;
409
+ if (!this.foreign) {
410
+ this.state.add.top.show = true;
411
+ this.state.add.bottom.show = true;
412
+ }
400
413
  this.state.container.highlight = true;
401
414
  this.state.labels.show = true;
402
- apos.bus.$emit('widget-hover', this.widget._id);
415
+ const closest = this.foreign && this.$el.closest('[data-apos-widget-foreign="0"]');
416
+ const closestId = closest && closest.getAttribute('data-apos-widget-id');
417
+ apos.bus.$emit('widget-hover', {
418
+ _id: this.widget._id,
419
+ nonForeignId: this.foreign ? closestId : null
420
+ });
403
421
  },
404
422
 
405
423
  mouseleave() {
@@ -412,6 +430,12 @@ export default {
412
430
  this.state.add.top.show = false;
413
431
  this.state.add.bottom.show = false;
414
432
  }
433
+ if (this.hovered) {
434
+ apos.bus.$emit('widget-hover', {
435
+ _id: null,
436
+ nonForeignId: null
437
+ });
438
+ }
415
439
  },
416
440
 
417
441
  focus(e) {
@@ -432,7 +456,7 @@ export default {
432
456
  this.focused = false;
433
457
  this.resetState();
434
458
  this.highlightable = false;
435
- document.removeEventListener('click', this.blurUnfocus);
459
+ document.removeEventListener('click', this.unfocus);
436
460
  apos.bus.$emit('widget-focus', null);
437
461
  }
438
462
  },
@@ -477,9 +501,11 @@ export default {
477
501
  widgetComponent(type) {
478
502
  return this.moduleOptions.components.widgets[type];
479
503
  },
504
+
480
505
  widgetEditorComponent(type) {
481
506
  return this.moduleOptions.components.widgetEditors[type];
482
507
  }
508
+
483
509
  }
484
510
  };
485
511
  </script>
@@ -80,6 +80,7 @@ module.exports = {
80
80
  'trash-can-outline-icon': 'TrashCanOutline',
81
81
  'tune-icon': 'Tune',
82
82
  'undo-icon': 'UndoVariant',
83
+ 'unfold-less-horizontal-icon': 'UnfoldLessHorizontal',
83
84
  'unfold-more-horizontal-icon': 'UnfoldMoreHorizontal',
84
85
  'video-icon': 'Video',
85
86
  'view-column-icon': 'ViewColumn',
@@ -347,6 +347,15 @@ module.exports = {
347
347
  submitted: 1,
348
348
  aposLocale: 1
349
349
  });
350
+ await self.db.createIndex({
351
+ type: 1,
352
+ aposDocId: 1,
353
+ aposLocale: 1
354
+ });
355
+ await self.db.createIndex({
356
+ aposDocId: 1,
357
+ aposLocale: 1
358
+ });
350
359
  await self.createPathLevelIndex();
351
360
  },
352
361
  async createTextIndex() {
@@ -306,6 +306,9 @@
306
306
  "publish": "Publish",
307
307
  "publishBeforeUsingTooltip": "Publish this content before using it in a relationship",
308
308
  "published": "Published",
309
+ "publishType": "Publish {{ type }}",
310
+ "publishingBatchConfirmation": "Are you sure you want to publish {{ count }} {{ type }}?",
311
+ "publishingBatchConfirmationButton": "Yes, publish content.",
309
312
  "rawCssAndJs": "Raw CSS and JS",
310
313
  "rawHtml": "Raw HTML",
311
314
  "rawHtmlCode": "Raw HTML (Code)",
@@ -1867,7 +1867,12 @@ database.`);
1867
1867
  } else {
1868
1868
  parentSlug = item.parent;
1869
1869
  }
1870
- return self.findOneForEditing(req, { slug: parentSlug });
1870
+ return self.findOneForEditing(req, {
1871
+ slug: parentSlug
1872
+ }, {
1873
+ areas: false,
1874
+ relationships: false
1875
+ });
1871
1876
  }
1872
1877
  async function findExisting() {
1873
1878
  return self.findOneForEditing(req, { parkedId: item.parkedId });
@@ -105,6 +105,19 @@ module.exports = {
105
105
  utilityOperations: {},
106
106
  batchOperations: {
107
107
  add: {
108
+ publish: {
109
+ label: 'apostrophe:publish',
110
+ messages: {
111
+ progress: 'Publishing {{ type }}...',
112
+ completed: 'Published {{ count }} {{ type }}.'
113
+ },
114
+ icon: 'earth-icon',
115
+ modalOptions: {
116
+ title: 'apostrophe:publishType',
117
+ description: 'apostrophe:publishingBatchConfirmation',
118
+ confirmationButton: 'apostrophe:publishingBatchConfirmationButton'
119
+ }
120
+ },
108
121
  archive: {
109
122
  label: 'apostrophe:archive',
110
123
  messages: {
@@ -295,6 +308,31 @@ module.exports = {
295
308
  }
296
309
  return self.publish(req, draft);
297
310
  },
311
+ async publish (req) {
312
+ if (!Array.isArray(req.body._ids)) {
313
+ throw self.apos.error('invalid');
314
+ }
315
+
316
+ req.body._ids = req.body._ids.map(_id => {
317
+ return self.inferIdLocaleAndMode(req, _id);
318
+ });
319
+
320
+ return self.apos.modules['@apostrophecms/job'].runBatch(
321
+ req,
322
+ req.body._ids,
323
+ async function(req, id) {
324
+ const piece = await self.findOneForEditing(req, { _id: id });
325
+
326
+ if (!piece) {
327
+ throw self.apos.error('notfound');
328
+ }
329
+
330
+ await self.publish(req, piece);
331
+ }, {
332
+ action: 'publish'
333
+ }
334
+ );
335
+ },
298
336
  async archive (req) {
299
337
  if (!Array.isArray(req.body._ids)) {
300
338
  throw self.apos.error('invalid');
@@ -107,16 +107,20 @@ export default {
107
107
  editorOptions() {
108
108
  const activeOptions = Object.assign({}, this.options);
109
109
 
110
- // Allow toolbar option to pass through if `false`
111
- activeOptions.toolbar = (activeOptions.toolbar !== undefined)
112
- ? activeOptions.toolbar : this.defaultOptions.toolbar;
113
-
114
110
  activeOptions.styles = this.enhanceStyles(
115
111
  activeOptions.styles?.length
116
112
  ? activeOptions.styles
117
113
  : this.defaultOptions.styles
118
114
  );
119
115
 
116
+ // Allow default options to pass through if `false`
117
+ Object.keys(this.defaultOptions).forEach((option) => {
118
+ if (option !== 'styles') {
119
+ activeOptions[option] = (activeOptions[option] !== undefined)
120
+ ? activeOptions[option] : this.defaultOptions[option];
121
+ }
122
+ });
123
+
120
124
  activeOptions.className = (activeOptions.className !== undefined)
121
125
  ? activeOptions.className : this.moduleOptions.className;
122
126
 
@@ -661,9 +661,12 @@ module.exports = {
661
661
  let relationships = [];
662
662
 
663
663
  function findRelationships(schema, arrays) {
664
+ // Shallow clone of each relationship to allow
665
+ // for independent _dotPath and _arrays properties
666
+ // for different requests
664
667
  const _relationships = _.filter(schema, function (field) {
665
668
  return !!self.fieldTypes[field.type].relate;
666
- });
669
+ }).map(relationship => ({ ...relationship }));
667
670
  _.each(_relationships, function (relationship) {
668
671
  if (!arrays.length) {
669
672
  relationship._dotPath = relationship.name;
@@ -78,6 +78,18 @@ module.exports = (self) => {
78
78
  }
79
79
  }
80
80
  }
81
+ },
82
+ index: function (value, field, texts) {
83
+ for (const item of ((value && value.items) || [])) {
84
+ const manager = item.type && self.apos.area.getWidgetManager(item.type);
85
+ if (!manager) {
86
+ self.apos.area.warnMissingWidgetType(item.type);
87
+ return;
88
+ }
89
+ if (manager.addSearchTexts) {
90
+ manager.addSearchTexts(item, texts);
91
+ }
92
+ }
81
93
  }
82
94
  });
83
95
 
@@ -845,6 +857,11 @@ module.exports = (self) => {
845
857
  }
846
858
  return self.isEqual(req, field.schema, one[field.name], two[field.name]);
847
859
  },
860
+ index: function (value, field, texts) {
861
+ if (value) {
862
+ self.apos.schema.indexFields(field.schema, value, texts);
863
+ }
864
+ },
848
865
  def: {}
849
866
  });
850
867
 
@@ -11,7 +11,85 @@
11
11
  />
12
12
  </template>
13
13
  <template #body>
14
- <div class="apos-input-array">
14
+ <div v-if="field.inline">
15
+ <draggable
16
+ v-if="field.inline"
17
+ class="apos-input-array-inline"
18
+ tag="div"
19
+ role="list"
20
+ :list="items"
21
+ v-bind="dragOptions"
22
+ :id="listId"
23
+ >
24
+ <div
25
+ v-for="(item, index) in items"
26
+ :key="item._id"
27
+ class="apos-input-array-inline-item"
28
+ :class="item.open ? 'apos-input-array-inline-item--active' : null"
29
+ >
30
+ <div class="apos-input-array-inline-item-controls">
31
+ <AposIndicator
32
+ icon="drag-icon"
33
+ class="apos-drag-handle"
34
+ />
35
+ <AposButton
36
+ v-if="item.open && !alwaysExpand"
37
+ class="apos-input-array-inline-collapse"
38
+ :icon-size="20"
39
+ label="apostrophe:close"
40
+ icon="unfold-less-horizontal-icon"
41
+ type="subtle"
42
+ :modifiers="['inline', 'no-motion']"
43
+ :icon-only="true"
44
+ @click="closeInlineItem(item._id)"
45
+ />
46
+ </div>
47
+ <div class="apos-input-array-inline-content-wrapper">
48
+ <h3
49
+ class="apos-input-array-inline-label"
50
+ v-if="!item.open && !alwaysExpand"
51
+ @click="openInlineItem(item._id)"
52
+ >
53
+ {{ getLabel(item._id, index) }}
54
+ </h3>
55
+ <transition name="collapse">
56
+ <div
57
+ v-show="item.open"
58
+ class="apos-input-array-inline-schema-wrapper"
59
+ >
60
+ <AposSchema
61
+ :schema="field.schema"
62
+ v-model="item.schemaInput"
63
+ :trigger-validation="triggerValidation"
64
+ :utility-rail="false"
65
+ :generation="generation"
66
+ :modifiers="['small', 'inverted']"
67
+ />
68
+ </div>
69
+ </transition>
70
+ </div>
71
+ <div class="apos-input-array-inline-item-controls apos-input-array-inline-item-controls--remove">
72
+ <AposButton
73
+ label="apostrophe:removeItem"
74
+ icon="trash-can-outline-icon"
75
+ type="subtle"
76
+ :modifiers="['inline', 'danger', 'no-motion']"
77
+ :icon-only="true"
78
+ @click="remove(item._id)"
79
+ />
80
+ </div>
81
+ </div>
82
+ </draggable>
83
+ <AposButton
84
+ type="button"
85
+ label="apostrophe:addItem"
86
+ icon="plus-icon"
87
+ :disabled="disableAdd()"
88
+ :modifiers="[ 'block' ]"
89
+ @click="add"
90
+ />
91
+ </div>
92
+ <div v-else class="apos-input-array">
15
93
  <label class="apos-input-wrapper">
16
94
  <AposButton
17
95
  :label="editLabel"
@@ -27,27 +105,100 @@
27
105
 
28
106
  <script>
29
107
  import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin.js';
108
+ import cuid from 'cuid';
109
+ import { klona } from 'klona';
110
+ import draggable from 'vuedraggable';
30
111
 
31
112
  export default {
32
113
  name: 'AposInputArray',
114
+ components: { draggable },
33
115
  mixins: [ AposInputMixin ],
34
- data () {
35
- return {
36
- // Next should consistently be an array.
37
- next: (this.value && Array.isArray(this.value.data))
38
- ? this.value.data : (this.field.def || [])
116
+ props: {
117
+ generation: {
118
+ type: Number,
119
+ required: false,
120
+ default: null
121
+ }
122
+ },
123
+ data() {
124
+ const next = this.getNext();
125
+ const data = {
126
+ next,
127
+ items: modelItems(next, this.field)
39
128
  };
129
+ return data;
40
130
  },
41
131
  computed: {
42
- editLabel () {
132
+ alwaysExpand() {
133
+ return alwaysExpand(this.field);
134
+ },
135
+ listId() {
136
+ return `sortableList-${cuid()}`;
137
+ },
138
+ dragOptions() {
139
+ return {
140
+ disabled: this.field.readOnly || this.next.length <= 1,
141
+ ghostClass: 'apos-is-dragging',
142
+ handle: '.apos-drag-handle'
143
+ };
144
+ },
145
+ editLabel() {
43
146
  return {
44
147
  key: 'apostrophe:editType',
45
148
  type: this.$t(this.field.label)
46
149
  };
150
+ },
151
+ effectiveError() {
152
+ const error = this.error || this.serverError;
153
+ // Server-side errors behave differently
154
+ const name = error?.name || error;
155
+ if (name === 'invalid') {
156
+ // Always due to a subproperty which will display its own error,
157
+ // don't confuse the user
158
+ return false;
159
+ }
160
+ return error;
161
+ }
162
+ },
163
+ watch: {
164
+ generation() {
165
+ this.next = this.getNext();
166
+ this.items = modelItems(this.next, this.field);
167
+ },
168
+ items: {
169
+ deep: true,
170
+ handler() {
171
+ const erroneous = this.items.filter(item => item.schemaInput.hasErrors);
172
+ if (erroneous.length) {
173
+ erroneous.forEach(item => {
174
+ if (!item.open) {
175
+ // Make errors visible
176
+ item.open = true;
177
+ }
178
+ });
179
+ } else {
180
+ const next = this.items.map(item => ({
181
+ ...item.schemaInput.data,
182
+ _id: item._id,
183
+ metaType: 'arrayItem',
184
+ scopedArrayName: this.field.scopedArrayName
185
+ }));
186
+ this.next = next;
187
+ }
188
+ // Our validate method was called first before that of
189
+ // the subfields, so remedy that by calling again on any
190
+ // change to the subfield state during validation
191
+ if (this.triggerValidation) {
192
+ this.validateAndEmit();
193
+ }
194
+ }
47
195
  }
48
196
  },
49
197
  methods: {
50
- validate (value) {
198
+ validate(value) {
199
+ if (this.items.find(item => item.schemaInput.hasErrors)) {
200
+ return 'invalid';
201
+ }
51
202
  if (this.field.required && !value.length) {
52
203
  return 'required';
53
204
  }
@@ -59,10 +210,7 @@ export default {
59
210
  }
60
211
  return false;
61
212
  },
62
- update (items) {
63
- this.next = items;
64
- },
65
- async edit () {
213
+ async edit() {
66
214
  const result = await apos.modal.execute('AposArrayEditor', {
67
215
  field: this.field,
68
216
  items: this.next,
@@ -71,7 +219,139 @@ export default {
71
219
  if (result) {
72
220
  this.next = result;
73
221
  }
222
+ },
223
+ getNext() {
224
+ // Next should consistently be an array.
225
+ return (this.value && Array.isArray(this.value.data))
226
+ ? this.value.data : (this.field.def || []);
227
+ },
228
+ disableAdd() {
229
+ return this.field.max && (this.items.length >= this.field.max);
230
+ },
231
+ remove(_id) {
232
+ this.items = this.items.filter(item => item._id !== _id);
233
+ },
234
+ add() {
235
+ const _id = cuid();
236
+ this.items.push({
237
+ _id,
238
+ schemaInput: {
239
+ data: this.newInstance()
240
+ },
241
+ open: alwaysExpand(this.field)
242
+ });
243
+ this.openInlineItem(_id);
244
+ },
245
+ newInstance() {
246
+ const instance = {};
247
+ for (const field of this.field.schema) {
248
+ if (field.def !== undefined) {
249
+ instance[field.name] = klona(field.def);
250
+ }
251
+ }
252
+ return instance;
253
+ },
254
+ getLabel(id, index) {
255
+ const titleField = this.field.titleField || null;
256
+ const item = this.items.find(item => item._id === id);
257
+ return item.schemaInput.data[titleField] || `Item ${index + 1}`;
258
+ },
259
+ openInlineItem(id) {
260
+ this.items.forEach(item => {
261
+ item.open = (item._id === id) || this.alwaysExpand;
262
+ });
263
+ },
264
+ closeInlineItem(id) {
265
+ this.items.forEach(item => {
266
+ item.open = this.alwaysExpand;
267
+ });
74
268
  }
75
269
  }
76
270
  };
271
+
272
+ function modelItems(items, field) {
273
+ return items.map(item => {
274
+ const open = alwaysExpand(field);
275
+ return {
276
+ _id: item._id || cuid(),
277
+ schemaInput: {
278
+ data: item
279
+ },
280
+ open
281
+ };
282
+ });
283
+ }
284
+
285
+ function alwaysExpand(field) {
286
+ if (!field.inline) {
287
+ return false;
288
+ }
289
+ if (field.inline.alwaysExpand === undefined) {
290
+ return field.schema.length < 3;
291
+ }
292
+ return field.inline.alwaysExpand;
293
+ }
77
294
  </script>
295
+ <style lang="scss" scoped>
296
+ .apos-is-dragging {
297
+ opacity: 0.5;
298
+ background: var(--a-base-4);
299
+ }
300
+ .apos-input-array-inline-label {
301
+ transition: background-color 0.3s ease;
302
+ @include type-label;
303
+ margin: 0;
304
+ &:hover {
305
+ cursor: pointer;
306
+ }
307
+ }
308
+ .apos-input-array-inline-item {
309
+ position: relative;
310
+ transition: background-color 0.3s ease;
311
+ display: flex;
312
+ border-bottom: 1px solid var(--a-base-9);
313
+ &:hover {
314
+ background-color: var(--a-base-10);
315
+ }
316
+ }
317
+ .apos-input-array-inline-collapse {
318
+ position: absolute;
319
+ top: $spacing-quadruple;
320
+ left: 7.5px;
321
+ }
322
+
323
+ .apos-input-array-inline-item--active {
324
+ background-color: var(--a-base-10);
325
+ border-bottom: 1px solid var(--a-base-6);
326
+ .apos-input-array-inline-content-wrapper {
327
+ padding-top: $spacing-base;
328
+ padding-bottom: $spacing-base;
329
+ }
330
+ }
331
+ .apos-input-array-inline-item-controls {
332
+ padding: $spacing-base;
333
+ }
334
+
335
+ .apos-input-array-inline-label {
336
+ padding-top: $spacing-base;
337
+ padding-bottom: $spacing-base;
338
+ }
339
+
340
+ .apos-input-array-inline-content-wrapper {
341
+ flex-grow: 1;
342
+ }
343
+
344
+ .apos-input-array-inline-schema-wrapper {
345
+ max-height: 999px;
346
+ overflow: hidden;
347
+ transition: max-height 0.5s;
348
+ }
349
+
350
+ .collapse-enter, .collapse-leave-to {
351
+ max-height: 0;
352
+ }
353
+
354
+ .collapse-enter-to, .collapse-leave {
355
+ max-height: 999px;
356
+ }
357
+ </style>
@@ -49,8 +49,19 @@ export default {
49
49
  };
50
50
  },
51
51
  watch: {
52
- schemaInput() {
53
- this.next = this.schemaInput.data;
52
+ schemaInput: {
53
+ deep: true,
54
+ handler() {
55
+ if (!this.schemaInput.hasErrors) {
56
+ this.next = this.schemaInput.data;
57
+ }
58
+ // Our validate method was called first before that of
59
+ // the subfields, so remedy that by calling again on any
60
+ // change to the subfield state during validation
61
+ if (this.triggerValidation) {
62
+ this.validateAndEmit();
63
+ }
64
+ }
54
65
  },
55
66
  generation() {
56
67
  this.next = this.getNext();
@@ -330,10 +330,14 @@ export default {
330
330
  .apos-field {
331
331
  .apos-schema ::v-deep & {
332
332
  margin-bottom: $spacing-quadruple;
333
+ &.apos-field--small,
333
334
  &.apos-field--micro,
334
335
  &.apos-field--margin-micro {
335
336
  margin-bottom: $spacing-double;
336
337
  }
338
+ &.apos-field--margin-none {
339
+ margin-bottom: 0;
340
+ }
337
341
  }
338
342
 
339
343
  .apos-schema ::v-deep .apos-toolbar & {
@@ -68,6 +68,7 @@ module.exports = {
68
68
  self.options.suggestions.url = self.options.suggestions.url || self.action + '/suggest';
69
69
  self.dispatchAll();
70
70
  self.enableFilters();
71
+ self.addMigrations();
71
72
  },
72
73
  routes(self) {
73
74
  return {
@@ -119,6 +120,17 @@ module.exports = {
119
120
  }
120
121
  },
121
122
 
123
+ addMigrations() {
124
+ self.addIndexFixMigration();
125
+ },
126
+
127
+ addIndexFixMigration() {
128
+ // Search index lacked most text fields, correct that with a one-time migration
129
+ self.apos.migration.add('search-index-fix', async () => {
130
+ return self.indexTask();
131
+ });
132
+ },
133
+
122
134
  suggest(req, q) {
123
135
  return self.apos.doc.find(req).limit(self.options.suggestions && (self.options.suggestions.limit || 10)).search(q).project({
124
136
  _url: 1,
@@ -235,7 +247,6 @@ module.exports = {
235
247
  indexDoc(req, doc) {
236
248
 
237
249
  const texts = self.getSearchTexts(doc);
238
-
239
250
  _.each(texts, function (text) {
240
251
  if (text.text === undefined) {
241
252
  text.text = '';
@@ -316,29 +327,11 @@ module.exports = {
316
327
  text: doc.slug,
317
328
  silent: true
318
329
  });
319
-
320
- // Areas can be schemaless so find them automatically
321
- self.apos.area.walk(doc, function (area, dotPath) {
322
- // Do not examine areas accessed via temporarily
323
- // present information loaded via relationships, such as
324
- // snippets in a snippet widget. Allow those items to be found
325
- // on their own as search results, and avoid bloating the
326
- // search text up to the 16MB limit
327
- if (dotPath.match(/\._\w/)) {
328
- return;
329
- }
330
- _.each(area.items, function (item) {
331
- const manager = self.apos.area.getWidgetManager(item.type);
332
- if (!manager) {
333
- self.apos.area.warnMissingWidgetType(item.type);
334
- return;
335
- }
336
- if (manager.addSearchTexts) {
337
- manager.addSearchTexts(item, texts);
338
- }
339
- });
340
- });
341
-
330
+ const manager = self.apos.doc.getManager(doc.type);
331
+ if (manager) {
332
+ const schema = manager.schema;
333
+ self.apos.schema.indexFields(schema, doc, texts);
334
+ }
342
335
  return texts;
343
336
  },
344
337
 
@@ -358,6 +351,13 @@ module.exports = {
358
351
 
359
352
  docUnversionedFields(req, doc, fields) {
360
353
  fields.push('titleSortified', 'highSearchText', 'highSearchWords', 'lowSearchText', 'searchSummary');
354
+ },
355
+
356
+ async indexTask() {
357
+ const req = self.apos.task.getReq();
358
+ return self.apos.migration.eachDoc({}, doc => {
359
+ return self.indexTaskOne(req, doc);
360
+ });
361
361
  }
362
362
  };
363
363
  },
@@ -366,12 +366,11 @@ module.exports = {
366
366
  index: {
367
367
  usage: stripIndent`
368
368
  Rebuild the search index. Normally this happens automatically.
369
- This should only be needed if you have changed the"searchable" property
369
+ This should only be needed if you have changed the "searchable" property
370
370
  for various fields or types.
371
371
  `,
372
- task(argv) {
373
- const req = self.apos.task.getReq();
374
- return self.apos.migration.eachDoc({}, _.partial(self.indexTaskOne, req));
372
+ async task(argv) {
373
+ await self.indexTask();
375
374
  }
376
375
  }
377
376
  };
@@ -60,6 +60,10 @@ module.exports = {
60
60
  }
61
61
  },
62
62
 
63
+ batchOperations: {
64
+ remove: [ 'publish' ]
65
+ },
66
+
63
67
  handlers(self) {
64
68
  return {
65
69
  'apostrophe:modulesRegistered': {
@@ -512,6 +512,9 @@ export default {
512
512
  &[disabled].apos-button--busy {
513
513
  border: 1px solid var(--a-danger-button-disabled);
514
514
  }
515
+ &.apos-button--inline {
516
+ color: var(--a-danger-button-active);
517
+ }
515
518
  .apos-spinner__svg {
516
519
  color: var(--a-danger);
517
520
  }
@@ -42,6 +42,7 @@
42
42
 
43
43
  <script>
44
44
  import draggable from 'vuedraggable';
45
+ import cuid from 'cuid';
45
46
 
46
47
  export default {
47
48
  name: 'AposSlatList',
@@ -89,7 +90,7 @@ export default {
89
90
  },
90
91
  computed: {
91
92
  listId() {
92
- return `sortableList-${(Math.floor(Math.random() * Math.floor(10000)))}`;
93
+ return `sortableList-${cuid()}`;
93
94
  },
94
95
  dragOptions() {
95
96
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.32.0",
3
+ "version": "3.34.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,101 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+ const { klona } = require('klona');
4
+
5
+ describe('Concurrent Array Joins', function() {
6
+
7
+ after(async function() {
8
+ return t.destroy(apos);
9
+ });
10
+
11
+ this.timeout(t.timeout);
12
+
13
+ let apos;
14
+
15
+ // EXISTENCE
16
+
17
+ it('should be a property of the apos object', async function() {
18
+ apos = await t.create({
19
+ root: module,
20
+ modules: {
21
+ 'test-person': {
22
+ extend: '@apostrophecms/piece-type',
23
+ options: {
24
+ alias: 'person'
25
+ },
26
+ fields: {
27
+ add: {
28
+ hobbies: {
29
+ type: 'array',
30
+ fields: {
31
+ add: {
32
+ name: {
33
+ type: 'string'
34
+ },
35
+ _friends: {
36
+ type: 'relationship',
37
+ withType: 'test-person'
38
+ // builders: {
39
+ // project: {
40
+ // title: 1
41
+ // }
42
+ // }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ });
52
+ });
53
+
54
+ it('should be able to retrieve hobbies in parallel with all relationships', async function() {
55
+ const req = apos.task.getReq();
56
+ const hobbyists = [];
57
+ for (let i = 0; (i < 10); i++) {
58
+ hobbyists.push(await apos.person.insert(req, {
59
+ title: `Hobbyist ${i}`,
60
+ visibility: 'public'
61
+ }));
62
+ }
63
+ for (let i = 0; (i < 10); i++) {
64
+ await apos.person.update(req, {
65
+ ...hobbyists[i],
66
+ hobbies: [
67
+ {
68
+ name: `Hobby ${i}`,
69
+ _friends: [
70
+ // Deep clone to avoid infinite recursion during the save operation
71
+ // as 4 points to 5 and vice versa
72
+ klona(hobbyists[9 - i])
73
+ ]
74
+ }
75
+ ]
76
+ });
77
+ }
78
+ const promises = [];
79
+ for (let i = 0; (i < 100); i++) {
80
+ const req = apos.task.getReq();
81
+ promises.push(apos.person.find(req).toArray());
82
+ }
83
+ const results = await Promise.all(promises);
84
+ assert.strictEqual(results.length, 100);
85
+ for (const result of results) {
86
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare
87
+ result.sort((a, b) => a.title.localeCompare(b.title));
88
+ assert.strictEqual(result.length, 10);
89
+ for (let i = 0; (i < 10); i++) {
90
+ const person = result[i];
91
+ assert.strictEqual(person.title, `Hobbyist ${i}`);
92
+ console.log(person);
93
+ assert.strictEqual(person.hobbies.length, 1);
94
+ assert.strictEqual(person.hobbies[0].name, `Hobby ${i}`);
95
+ assert(person.hobbies[0]._friends);
96
+ assert(person.hobbies[0]._friends[0]);
97
+ assert.strictEqual(person.hobbies[0]._friends[0].title, `Hobbyist ${9 - i}`);
98
+ }
99
+ }
100
+ });
101
+ });