apostrophe 3.32.0 → 3.33.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.33.0 (2022-11-28)
4
+
5
+ ### Adds
6
+
7
+ * 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.
8
+ * Batch feature for publishing pieces.
9
+
10
+ ### Fixes
11
+
12
+ * Prior to this release, widget templates that contained areas pulled in from related documents would break the ability to add another widget beneath.
13
+ * Validation of object fields now works properly on the browser side, in addition to server-side validation, resolving UX issues.
14
+ * 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.
15
+
3
16
  ## 3.32.0 (2022-11-09)
4
17
 
5
18
  ### 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;
@@ -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
@@ -283,11 +289,13 @@ export default {
283
289
  return false;
284
290
  }
285
291
 
286
- if (this.widgetHovered) {
287
- return this.widgetHovered !== this.widget._id;
288
- } else {
289
- return false;
290
- }
292
+ return !(this.hovered || this.nonForeignHovered);
293
+ },
294
+ hovered() {
295
+ return this.widgetHovered === this.widget._id;
296
+ },
297
+ nonForeignHovered() {
298
+ return this.nonForeignWidgetHovered === this.widget._id;
291
299
  },
292
300
  // Sets up all the interaction classes based on the current
293
301
  // state. If our widget is suppressed, return a blank UI state and reset
@@ -297,11 +305,11 @@ export default {
297
305
  controls: this.state.controls.show ? this.classes.show : null,
298
306
  labels: this.state.labels.show ? this.classes.show : null,
299
307
  container: this.state.container.focus ? this.classes.focus
300
- : (this.state.container.highlight ? this.classes.highlight : null),
308
+ : ((this.state.container.highlight || this.nonForeignHovered) ? this.classes.highlight : null),
301
309
  addTop: this.state.add.top.focus ? this.classes.focus
302
- : (this.state.add.top.show ? this.classes.show : null),
310
+ : ((this.state.add.top.show || this.nonForeignHovered) ? this.classes.show : null),
303
311
  addBottom: this.state.add.bottom.focus ? this.classes.focus
304
- : (this.state.add.bottom.show ? this.classes.show : null)
312
+ : ((this.state.add.bottom.show || this.nonForeignHovered) ? this.classes.show : null)
305
313
  };
306
314
 
307
315
  if (this.isSuppressed) {
@@ -356,9 +364,7 @@ export default {
356
364
 
357
365
  this.breadcrumbs.$lastEl = this.$el;
358
366
 
359
- if (!this.foreign) {
360
- this.getBreadcrumbs();
361
- }
367
+ this.getBreadcrumbs();
362
368
 
363
369
  if (this.widgetFocused) {
364
370
  // If another widget was in focus (because the user clicked the "add"
@@ -395,11 +401,18 @@ export default {
395
401
  if (this.focused) {
396
402
  return;
397
403
  }
398
- this.state.add.top.show = true;
399
- this.state.add.bottom.show = true;
404
+ if (!this.foreign) {
405
+ this.state.add.top.show = true;
406
+ this.state.add.bottom.show = true;
407
+ }
400
408
  this.state.container.highlight = true;
401
409
  this.state.labels.show = true;
402
- apos.bus.$emit('widget-hover', this.widget._id);
410
+ const closest = this.foreign && this.$el.closest('[data-apos-widget-foreign="0"]');
411
+ const closestId = closest && closest.getAttribute('data-apos-widget-id');
412
+ apos.bus.$emit('widget-hover', {
413
+ _id: this.widget._id,
414
+ nonForeignId: this.foreign ? closestId : null
415
+ });
403
416
  },
404
417
 
405
418
  mouseleave() {
@@ -412,6 +425,12 @@ export default {
412
425
  this.state.add.top.show = false;
413
426
  this.state.add.bottom.show = false;
414
427
  }
428
+ if (this.hovered) {
429
+ apos.bus.$emit('widget-hover', {
430
+ _id: null,
431
+ nonForeignId: null
432
+ });
433
+ }
415
434
  },
416
435
 
417
436
  focus(e) {
@@ -432,7 +451,7 @@ export default {
432
451
  this.focused = false;
433
452
  this.resetState();
434
453
  this.highlightable = false;
435
- document.removeEventListener('click', this.blurUnfocus);
454
+ document.removeEventListener('click', this.unfocus);
436
455
  apos.bus.$emit('widget-focus', null);
437
456
  }
438
457
  },
@@ -477,9 +496,11 @@ export default {
477
496
  widgetComponent(type) {
478
497
  return this.moduleOptions.components.widgets[type];
479
498
  },
499
+
480
500
  widgetEditorComponent(type) {
481
501
  return this.moduleOptions.components.widgetEditors[type];
482
502
  }
503
+
483
504
  }
484
505
  };
485
506
  </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',
@@ -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)",
@@ -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');
@@ -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;
@@ -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 & {
@@ -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.33.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
+ });