apostrophe 3.46.0 → 3.48.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 (29) hide show
  1. package/.github/workflows/main.yml +2 -2
  2. package/CHANGELOG.md +51 -5
  3. package/lib/moog-require.js +4 -0
  4. package/modules/@apostrophecms/any-page-type/index.js +5 -0
  5. package/modules/@apostrophecms/doc-type/index.js +42 -17
  6. package/modules/@apostrophecms/doc-type/lib/extendQueries.js +21 -0
  7. package/modules/@apostrophecms/image/index.js +8 -0
  8. package/modules/@apostrophecms/oembed-field/ui/apos/components/AposInputOembed.vue +15 -7
  9. package/modules/@apostrophecms/page-type/index.js +6 -0
  10. package/modules/@apostrophecms/piece-type/index.js +36 -0
  11. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +7 -1
  12. package/modules/@apostrophecms/rich-text-widget/index.js +1 -1
  13. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +3 -2
  14. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +32 -20
  15. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapAnchor.vue +0 -1
  16. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +0 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Image.js +76 -54
  18. package/modules/@apostrophecms/schema/index.js +1 -1
  19. package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +0 -1
  20. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue +0 -1
  21. package/modules/@apostrophecms/schema/ui/apos/components/AposInputObject.vue +0 -1
  22. package/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +6 -1
  23. package/modules/@apostrophecms/widget-type/index.js +4 -0
  24. package/package.json +4 -4
  25. package/test/pieces-children/pieces-malformed-child.js +32 -0
  26. package/test/pieces-malformed.js +33 -0
  27. package/test/queryBuilders.js +225 -0
  28. package/test/widgets-children/widgets-malformed-child.js +32 -0
  29. package/test/widgets-malformed.js +34 -0
@@ -20,8 +20,8 @@ jobs:
20
20
  runs-on: ubuntu-latest
21
21
  strategy:
22
22
  matrix:
23
- node-version: [14, 16, 18]
24
- mongodb-version: [4.2, 5.0]
23
+ node-version: [16, 18]
24
+ mongodb-version: [4.4, 5.0, 6.0]
25
25
 
26
26
  # Steps represent a sequence of tasks that will be executed as part of the job
27
27
  steps:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.48.0 (2023-05-26)
4
+
5
+ ### Adds
6
+
7
+ * For performance, add `apos.modules['piece-type']getManagerApiProjection` method to reduce the amount of data returned in the manager
8
+ modal. The projection will contain the fields returned in the method in addition to the existing manager modal
9
+ columns.
10
+ * Add `apos.schema.getRelationshipQueryBuilderChoicesProjection` method to set the projection used in
11
+ `apos.schema.relationshipQueryBuilderChoices`.
12
+ * Rich-text inline images now copies the `alt` attribute from the original image from the Media Library.
13
+
14
+ ### Changes
15
+
16
+ * Remove `stripPlaceholderBrs` and `restorePlaceholderBrs` from `AposRichTextWidgetEditor.vue` component.
17
+ * Change tiptap `Gapcursor` display to use a vertical blinking cursor instead of an horizontal cursor, which allow users to add text before and after inline images and tables.
18
+ * You can set `max-width` on `.apos-rich-text-toolbar__inner` to define the width of the rich-text toolbar. It will now
19
+ flow on multiple lines if needed.
20
+ * The `utilityRail` prop of `AposSchema` now defaults to `false`, removing
21
+ the need to explicitly pass it in almost all contexts.
22
+ * Mark `apos.modules['doc-type']` methods `getAutocompleteTitle`, `getAutocompleteProjection` and `autocomplete` as
23
+ deprecated. Our admin UI does not use them, it uses the `autocomplete('...')` query builder.
24
+ More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete'.
25
+ * Print a warning with a clear explanation if a module's `index.js` file contains
26
+ no `module.exports` object (often due to a typo), or it is empty.
27
+
28
+ ### Fixes
29
+
30
+ * Now errors and exits when a piece-type or widget-type module has a field object with the property `type`. Thanks to [NuktukDev](https://github.com/nuktukdev) for this contribution.
31
+ * Add a default page type value to prevent the dropdown from containing an empty value.
32
+
33
+ ## 3.47.0 (2023-05-05)
34
+
35
+ ### Changes
36
+
37
+ * Since Node 14 and MongoDB 4.2 have reached their own end-of-support dates,
38
+ we are **no longer supporting them for A3.** Note that our dependency on
39
+ `jsdom` 22 is incompatible with Node 14. Node 16 and Node 18 are both
40
+ still supported. However, because Node 16 reaches its
41
+ end-of-life date quite soon (September), testing and upgrading directly
42
+ to Node 18 is strongly recommended.
43
+ * Updated `sluggo` to version 1.0.0.
44
+ * Updated `jsdom` to version `22.0.0` to address an installation warning about the `word-wrap` module.
45
+
46
+ ### Fixes
47
+
48
+ * Fix `extendQueries` to use super pattern for every function in builders and methods (and override properties that are not functions).
49
+
3
50
  ## 3.46.0 (2023-05-03)
4
51
 
5
52
  ### Fixes
@@ -58,7 +105,11 @@ shouldn't close the link dialog etc.
58
105
 
59
106
  ### Fixes
60
107
 
108
+ * Fix various issues on conditional fields that were occurring when adding new widgets with default values or selecting a falsy value in a field that has a conditional field relying on it.
109
+ Populate new or existing doc instances with default values and add an empty `null` choice to select fields that do not have a default value (required or not) and to the ones configured with dynamic choices.
61
110
  * Rich text widgets save more reliably when many actions are taken quickly just before save.
111
+ * Fix an issue in the `oembed` field where the value was kept in memory after cancelling the widget editor, which resulted in saving the value if the widget was nested and the parent widget was saved.
112
+ Also improve the `oembed` field UX by setting the input as `readonly` rather than `disabled` when fetching the video metadata, in order to avoid losing its focus when typing.
62
113
 
63
114
  ## 3.44.0 (2023-04-13)
64
115
 
@@ -78,11 +129,6 @@ those writing mocha tests of Apostrophe modules.
78
129
  ### Fixes
79
130
  * Fix child page slug when title is deleted
80
131
 
81
- ### Fixes
82
-
83
- * Fix various issues on conditional fields that were occurring when adding new widgets with default values or selecting a falsy value in a field that has a conditional field relying on it.
84
- Populate new or existing doc instances with default values and add an empty `null` choice to select fields that do not have a default value (required or not) and to the ones configured with dynamic choices.
85
-
86
132
  ## 3.43.0 (2023-03-29)
87
133
 
88
134
  ### Adds
@@ -77,6 +77,10 @@ module.exports = function(options) {
77
77
  }
78
78
  if (fs.existsSync(projectLevelPath)) {
79
79
  projectLevelDefinition = importFresh(resolveFrom(path.dirname(self.root.filename), projectLevelPath));
80
+ if (Object.keys(projectLevelDefinition).length === 0) {
81
+ /* eslint-disable-next-line no-console */
82
+ console.warn(`⚠️ The file ${projectLevelPath}\ndoes not export anything, did you misspell or forget module.exports?\n`);
83
+ }
80
84
  }
81
85
 
82
86
  let relativeTo;
@@ -26,6 +26,11 @@ module.exports = {
26
26
  // titles from. The default behavior is to return the `title` property,
27
27
  // but since this is a page we are including the slug as well.
28
28
  getAutocompleteTitle(doc, query) {
29
+ // TODO Remove in next major version.
30
+ self.apos.util.warnDevOnce(
31
+ 'deprecate-get-autocomplete-title',
32
+ 'self.getAutocompleteTitle() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete'
33
+ );
29
34
  return doc.title + ' (' + doc.slug + ')';
30
35
  },
31
36
  getBrowserData(req) {
@@ -1,6 +1,7 @@
1
1
  const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
2
2
  const _ = require('lodash');
3
3
  const util = require('util');
4
+ const extendQueries = require('./lib/extendQueries');
4
5
 
5
6
  module.exports = {
6
7
  options: {
@@ -472,8 +473,9 @@ module.exports = {
472
473
  }
473
474
  }
474
475
  if (self.extendQueries[name]) {
475
- wrap(query.builders, self.extendQueries[name].builders || {});
476
- wrap(query.methods, self.extendQueries[name].methods || {});
476
+ const extendedQueries = self.extendQueries[name](self, query);
477
+ extendQueries(query.builders, extendedQueries.builders || {});
478
+ extendQueries(query.methods, extendedQueries.methods || {});
477
479
  }
478
480
  }
479
481
  Object.assign(query, query.methods);
@@ -506,6 +508,26 @@ module.exports = {
506
508
  //
507
509
  // `query.field` will contain the schema field definition for
508
510
  // the relationship the user is attempting to match titles from.
511
+ getRelationshipQueryBuilderChoicesProjection(query) {
512
+ const projection = self.getAutocompleteProjection(query);
513
+
514
+ return {
515
+ ...projection,
516
+ title: 1,
517
+ type: 1,
518
+ _id: 1,
519
+ _url: 1,
520
+ slug: 1
521
+ };
522
+ },
523
+ // Returns a MongoDB projection object to be used when querying
524
+ // for this type if all that is needed is a title for display
525
+ // in an autocomplete menu. Default behavior is to
526
+ // return only the `title`, `_id` and `slug` properties.
527
+ // Removing any of these three is not recommended.
528
+ //
529
+ // `query.field` will contain the schema field definition for
530
+ // the relationship the user is attempting to match titles from.
509
531
  getAutocompleteProjection(query) {
510
532
  return {
511
533
  title: 1,
@@ -522,6 +544,11 @@ module.exports = {
522
544
  // event start dates and similar information that helps the
523
545
  // user distinguish between docs.
524
546
  getAutocompleteTitle(doc, query) {
547
+ // TODO Remove in next major version.
548
+ self.apos.util.warnDevOnce(
549
+ 'deprecate-get-autocomplete-title',
550
+ 'self.getAutocompleteTitle() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete'
551
+ );
525
552
  return doc.title;
526
553
  },
527
554
  // Used by `@apostrophecms/version` to label changes that
@@ -613,6 +640,12 @@ module.exports = {
613
640
  //
614
641
  // We don't launder the input here, see the 'autocomplete' route.
615
642
  async autocomplete(req, query) {
643
+ // TODO Remove in next major version.
644
+ self.apos.util.warnDevOnce(
645
+ 'deprecate-autocomplete',
646
+ 'self.autocomplete() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete'
647
+ );
648
+
616
649
  const _query = query.find(req, {}).sort('search');
617
650
  if (query.extendAutocompleteQuery) {
618
651
  query.extendAutocompleteQuery(_query);
@@ -2322,8 +2355,14 @@ module.exports = {
2322
2355
  query.and({ $or });
2323
2356
  }
2324
2357
  }
2325
- }
2358
+ },
2326
2359
 
2360
+ viewContext: {
2361
+ def: null,
2362
+ launder(viewContext) {
2363
+ return [ 'manage', 'relationship' ].includes(viewContext) ? viewContext : null;
2364
+ }
2365
+ }
2327
2366
  },
2328
2367
 
2329
2368
  methods: {
@@ -3073,17 +3112,3 @@ module.exports = {
3073
3112
  };
3074
3113
  }
3075
3114
  };
3076
-
3077
- function wrap(context, extensions) {
3078
- for (const [ name, fn ] of extensions) {
3079
- if ((typeof fn) !== 'function') {
3080
- // Nested structure is allowed
3081
- context[name] = context[name] || {};
3082
- return wrap(context[name], fn);
3083
- }
3084
- const superMethod = context[name];
3085
- context[name] = function(...args) {
3086
- return fn(superMethod, ...args);
3087
- };
3088
- }
3089
- }
@@ -0,0 +1,21 @@
1
+ module.exports = extendQueries;
2
+
3
+ function extendQueries(queries, extensions) {
4
+ for (const [ name, fn ] of Object.entries(extensions)) {
5
+ if (typeof fn === 'object' && !Array.isArray(fn) && fn !== null) {
6
+ // Nested structure is allowed
7
+ queries[name] = queries[name] || {};
8
+ return extendQueries(queries[name], fn);
9
+ }
10
+
11
+ if (typeof fn !== 'function' || typeof queries[name] !== 'function') {
12
+ queries[name] = fn;
13
+ continue;
14
+ }
15
+
16
+ const superMethod = queries[name];
17
+ queries[name] = function(...args) {
18
+ return fn(superMethod, ...args);
19
+ };
20
+ }
21
+ };
@@ -462,6 +462,14 @@ module.exports = {
462
462
  },
463
463
  extendMethods(self) {
464
464
  return {
465
+ getRelationshipQueryBuilderChoicesProjection(_super, query) {
466
+ const projection = _super(query);
467
+
468
+ return {
469
+ ...projection,
470
+ attachment: 1
471
+ };
472
+ },
465
473
  getBrowserData(_super, req) {
466
474
  const data = _super(req);
467
475
  data.components.managerModal = 'AposMediaManager';
@@ -8,10 +8,14 @@
8
8
  <div class="apos-input-wrapper">
9
9
  <input
10
10
  :class="classes"
11
- v-model="next.url" type="url"
11
+ v-model="next.url"
12
+ type="url"
12
13
  :placeholder="$t(field.placeholder)"
13
- :disabled="field.readOnly" :required="field.required"
14
- :id="uid" :tabindex="tabindex"
14
+ :disabled="field.readOnly"
15
+ :readonly="tempReadOnly"
16
+ :required="field.required"
17
+ :id="uid"
18
+ :tabindex="tabindex"
15
19
  >
16
20
  <component
17
21
  v-if="icon"
@@ -43,10 +47,14 @@ export default {
43
47
  data () {
44
48
  return {
45
49
  next: (this.value && this.value.data)
46
- ? this.value.data : {},
50
+ ? { ...this.value.data } : {},
47
51
  oembedResult: {},
48
52
  dynamicRatio: '',
49
- oembedError: null
53
+ oembedError: null,
54
+
55
+ // This variable will set the input as readonly,
56
+ // not disabled, in order to avoid losing focus.
57
+ tempReadOnly: false
50
58
  };
51
59
  },
52
60
  computed: {
@@ -104,7 +112,7 @@ export default {
104
112
  this.validateAndEmit();
105
113
  },
106
114
  async loadOembed () {
107
- this.field.readOnly = true;
115
+ this.tempReadOnly = true;
108
116
  this.oembedResult = {};
109
117
  this.oembedError = null;
110
118
  this.dynamicRatio = '';
@@ -132,7 +140,7 @@ export default {
132
140
  this.next.title = '';
133
141
  this.next.thumbnail = '';
134
142
  } finally {
135
- this.field.readOnly = false;
143
+ this.tempReadOnly = false;
136
144
  }
137
145
  }
138
146
  }
@@ -22,6 +22,7 @@ module.exports = {
22
22
  type: 'select',
23
23
  label: 'apostrophe:type',
24
24
  required: true,
25
+ def: self.options.apos.page.typeChoices[0].name,
25
26
  choices: self.options.apos.page.typeChoices.map(function (type) {
26
27
  return {
27
28
  value: type.name,
@@ -297,6 +298,11 @@ module.exports = {
297
298
  // the `title` property, but since this is a page we are including
298
299
  // the slug as well.
299
300
  getAutocompleteTitle(doc, query) {
301
+ // TODO Remove in next major version.
302
+ self.apos.util.warnDevOnce(
303
+ 'deprecate-get-autocomplete-title',
304
+ 'self.getAutocompleteTitle() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete'
305
+ );
300
306
  return doc.title + ' (' + doc.slug + ')';
301
307
  },
302
308
  // `req` determines what the user is eligible to edit, `criteria`
@@ -26,6 +26,20 @@ module.exports = {
26
26
  // publicApiProjection: {
27
27
  // title: 1,
28
28
  // _url: 1,
29
+ // },
30
+ // By default the manager modal will get all the pieces fields below + all manager columns
31
+ // you can enable a projection using
32
+ // managerApiProjection: {
33
+ // _id: 1,
34
+ // _url: 1,
35
+ // aposDocId: 1,
36
+ // aposLocale: 1,
37
+ // aposMode: 1,
38
+ // docPermissions: 1,
39
+ // slug: 1,
40
+ // title: 1,
41
+ // type: 1,
42
+ // visibility: 1
29
43
  // }
30
44
  },
31
45
  fields: {
@@ -182,6 +196,10 @@ module.exports = {
182
196
  if (!self.options.name) {
183
197
  throw new Error('@apostrophecms/pieces require name option');
184
198
  }
199
+ const badFieldName = Object.keys(self.fields).indexOf('type') !== -1;
200
+ if (badFieldName) {
201
+ throw new Error(`The ${self.__meta.name} module contains a forbidden field property name: "type".`);
202
+ }
185
203
  if (!self.options.label) {
186
204
  // Englishify it
187
205
  self.options.label = _.startCase(self.options.name);
@@ -1063,7 +1081,24 @@ module.exports = {
1063
1081
  return self.apos.permission.can(req, batchOperation.permission, self.name);
1064
1082
  }
1065
1083
  return true;
1084
+
1085
+ });
1086
+ },
1087
+ getManagerApiProjection(req) {
1088
+ if (!self.options.managerApiProjection) {
1089
+ return null;
1090
+ }
1091
+
1092
+ const projection = { ...self.options.managerApiProjection };
1093
+ self.columns.forEach(({ name }) => {
1094
+ const column = (name.startsWith('draft:') || name.startsWith('published:'))
1095
+ ? name.replace(/^(draft|published):/, '')
1096
+ : name;
1097
+
1098
+ projection[column] = 1;
1066
1099
  });
1100
+
1101
+ return projection;
1067
1102
  }
1068
1103
  };
1069
1104
  },
@@ -1092,6 +1127,7 @@ module.exports = {
1092
1127
  editorModal: 'AposDocEditor',
1093
1128
  managerModal: 'AposDocsManager'
1094
1129
  });
1130
+ browserOptions.managerApiProjection = self.getManagerApiProjection(req);
1095
1131
 
1096
1132
  return browserOptions;
1097
1133
  },
@@ -146,7 +146,9 @@ export default {
146
146
  totalPages: 1,
147
147
  currentPage: 1,
148
148
  filterValues: {},
149
- queryExtras: {},
149
+ queryExtras: {
150
+ viewContext: this.relationshipField ? 'relationship' : 'manage'
151
+ },
150
152
  holdQueries: false,
151
153
  filterChoices: {},
152
154
  allPiecesSelection: {
@@ -314,6 +316,10 @@ export default {
314
316
  const {
315
317
  currentPage, pages, results, choices
316
318
  } = await this.request({
319
+ ...(
320
+ this.moduleOptions.managerApiProjection &&
321
+ { project: this.moduleOptions.managerApiProjection }
322
+ ),
317
323
  page: this.currentPage
318
324
  });
319
325
 
@@ -435,7 +435,7 @@ module.exports = {
435
435
  },
436
436
  {
437
437
  tag: 'img',
438
- attributes: [ 'src' ]
438
+ attributes: [ 'src', 'alt' ]
439
439
  }
440
440
  ]
441
441
  };
@@ -16,7 +16,6 @@
16
16
  :schema="schema"
17
17
  :trigger-validation="triggerValidation"
18
18
  v-model="docFields"
19
- :utility-rail="false"
20
19
  :modifiers="formModifiers"
21
20
  :key="lastSelectionTime"
22
21
  :generation="generation"
@@ -125,11 +124,13 @@ export default {
125
124
  }
126
125
  const image = this.docFields.data._image[0];
127
126
  this.docFields.data.imageId = image && image.aposDocId;
127
+ this.docFields.data.alt = image && image.alt;
128
128
  this.$emit('before-commands');
129
129
  this.editor.commands.setImage({
130
130
  imageId: this.docFields.data.imageId,
131
131
  caption: this.docFields.data.caption,
132
- style: this.docFields.data.style
132
+ style: this.docFields.data.style,
133
+ alt: this.docFields.data.alt
133
134
  });
134
135
  this.close();
135
136
  });
@@ -2,7 +2,7 @@
2
2
  <div>
3
3
  <bubble-menu
4
4
  class="bubble-menu"
5
- :tippy-options="{ duration: 100, zIndex: 2000 }"
5
+ :tippy-options="{ maxWidth: 'none', duration: 100, zIndex: 2000 }"
6
6
  :editor="editor"
7
7
  v-if="editor"
8
8
  >
@@ -27,7 +27,8 @@
27
27
  </bubble-menu>
28
28
  <floating-menu
29
29
  class="apos-rich-text-insert-menu" :should-show="showFloatingMenu"
30
- :editor="editor" :tippy-options="{ duration: 100, zIndex: 2000 }"
30
+ :editor="editor"
31
+ :tippy-options="{ duration: 100, zIndex: 2000 }"
31
32
  v-if="editor"
32
33
  >
33
34
  <div class="apos-rich-text-insert-menu-heading">
@@ -178,10 +179,10 @@ export default {
178
179
  },
179
180
  autofocus() {
180
181
  // Only true for a new rich text widget
181
- return !this.stripPlaceholderBrs(this.value.content).length;
182
+ return !this.value.content.length;
182
183
  },
183
184
  initialContent() {
184
- const content = this.transformNamedAnchors(this.stripPlaceholderBrs(this.value.content));
185
+ const content = this.transformNamedAnchors(this.value.content);
185
186
  if (content.length) {
186
187
  return content;
187
188
  }
@@ -343,26 +344,12 @@ export default {
343
344
  clearTimeout(this.pending);
344
345
  this.pending = null;
345
346
  }
346
- let content = this.editor.getHTML();
347
- content = this.restorePlaceholderBrs(content);
347
+ const content = this.editor.getHTML();
348
348
  const widget = this.docFields.data;
349
349
  widget.content = content;
350
350
  // ... removes need for deep watching in parent
351
351
  this.$emit('update', { ...widget });
352
352
  },
353
- // Restore placeholder BRs for empty paragraphs. ProseMirror adds these
354
- // temporarily so the editing experience doesn't break due to contenteditable
355
- // issues with empty paragraphs, but strips them on save; however
356
- // seeing them while editing creates a WYSIWYG expectation
357
- // on the user's part, so we must maintain them
358
- restorePlaceholderBrs(html) {
359
- return html.replace(/<(p[^>]*)>(\s*)<\/p>/gi, '<$1><br /></p>');
360
- },
361
- // Strip the placeholder BRs again when populating the editor.
362
- // Otherwise they get doubled by ProseMirror
363
- stripPlaceholderBrs(html) {
364
- return html.replace(/<(p[^>]*)>\s*<br \/>\s*<\/p>/gi, '<$1></p>');
365
- },
366
353
  // Legacy content may have `id` and `name` attributes on anchor tags
367
354
  // but our tiptap anchor extension needs them on a separate `span`, so nest
368
355
  // a span to migrate this content for each relevant anchor tag encountered
@@ -569,8 +556,10 @@ function traverseNextNode(node) {
569
556
 
570
557
  .apos-rich-text-toolbar__inner {
571
558
  display: flex;
559
+ flex-wrap: wrap;
572
560
  align-items: stretch;
573
- height: 35px;
561
+ max-width: 100%;
562
+ height: auto;
574
563
  background-color: var(--a-background-primary);
575
564
  color: var(--a-text-primary);
576
565
  border-radius: var(--a-border-radius);
@@ -710,4 +699,27 @@ function traverseNextNode(node) {
710
699
  .apos-rich-text-insert-menu-heading {
711
700
  color: var(--a-base-5);
712
701
  }
702
+
703
+ ::v-deep .ProseMirror {
704
+ > * + * {
705
+ margin-top: 0.75em;
706
+ }
707
+
708
+ > :last-child {
709
+ margin-bottom: 1.75em;
710
+ }
711
+ }
712
+
713
+ ::v-deep .ProseMirror-gapcursor {
714
+ position: relative;
715
+ display: block;
716
+ height: 20px;
717
+
718
+ &:after {
719
+ width: 1px;
720
+ height: 20px;
721
+ border-left: 1px solid #000;
722
+ border-top: 0 none;
723
+ }
724
+ }
713
725
  </style>
@@ -33,7 +33,6 @@
33
33
  :schema="schema"
34
34
  :trigger-validation="triggerValidation"
35
35
  v-model="docFields"
36
- :utility-rail="false"
37
36
  :modifiers="formModifiers"
38
37
  :key="lastSelectionTime"
39
38
  :generation="generation"
@@ -33,7 +33,6 @@
33
33
  :schema="schema"
34
34
  :trigger-validation="triggerValidation"
35
35
  v-model="docFields"
36
- :utility-rail="false"
37
36
  :modifiers="formModifiers"
38
37
  :key="lastSelectionTime"
39
38
  :generation="generation"
@@ -1,8 +1,10 @@
1
- import { Node } from '@tiptap/core';
1
+ import {
2
+ mergeAttributes,
3
+ Node
4
+ } from '@tiptap/core';
2
5
 
3
6
  export default options => {
4
7
  return Node.create({
5
-
6
8
  name: 'image',
7
9
 
8
10
  addOptions() {
@@ -12,22 +14,51 @@ export default options => {
12
14
  };
13
15
  },
14
16
 
15
- inline: true,
17
+ allowGapCursor: true,
18
+ atom: true,
19
+ selectable: true,
20
+
21
+ group: 'block',
16
22
 
17
- group: 'inline',
23
+ content: 'inline*',
18
24
 
19
25
  draggable: true,
20
26
 
27
+ isolating: false,
28
+
21
29
  addAttributes() {
22
30
  return {
23
31
  imageId: {
24
- default: null
32
+ default: null,
33
+ parseHTML: element => {
34
+ const src = element.querySelector('img')?.getAttribute('src');
35
+
36
+ const components = src.split('/');
37
+ if (components.length < 2) {
38
+ return false;
39
+ }
40
+
41
+ const routeName = components.pop();
42
+ if (routeName !== 'src') {
43
+ return false;
44
+ }
45
+
46
+ const imageId = components.pop();
47
+
48
+ return imageId;
49
+ }
25
50
  },
26
51
  caption: {
27
- default: ''
52
+ default: '',
53
+ parseHTML: element => element.querySelector('figcaption')?.innerText || ''
28
54
  },
29
55
  style: {
30
- default: null
56
+ default: null,
57
+ parseHTML: element => element.getAttribute('class')
58
+ },
59
+ alt: {
60
+ default: null,
61
+ parseHTML: element => element.querySelector('img')?.getAttribute('alt')
31
62
  }
32
63
  };
33
64
  },
@@ -35,72 +66,63 @@ export default options => {
35
66
  parseHTML() {
36
67
  // <figure>
37
68
  // <img src="/media/cc0-images/elephant-660-480.jpg"
38
- // alt="Elephant at sunset">
69
+ // alt="Elephant at sunset">
39
70
  // <figcaption>An elephant at sunset</figcaption>
40
71
  // </figure>
41
72
  return [
42
73
  {
43
74
  tag: 'figure',
44
- getAttrs: el => {
45
- const img = el.querySelector('img');
46
- const src = img.getAttribute('src');
47
- if (!img || !src) {
48
- return false;
49
- }
50
- const caption = el.querySelector('figcaption');
51
- const components = src.split('/');
52
- if (components.length < 2) {
53
- return false;
54
- }
55
- const routeName = components.pop();
56
- if (routeName !== 'src') {
57
- return false;
58
- }
59
- const imageId = components.pop();
60
- const style = el.getAttribute('class');
61
- if (!imageId) {
62
- return false;
63
- }
64
- const result = {
65
- imageId,
66
- style,
67
- caption: (caption && caption.innerText) || ''
68
- };
69
- return result;
70
- }
75
+ contentElement: 'figcaption'
71
76
  }
72
77
  ];
73
78
  },
74
79
 
75
- addCommands() {
76
- return {
77
- setImage: options => ({ commands }) => {
78
- return commands.insertContent({
79
- type: this.name,
80
- attrs: options
81
- });
82
- }
83
- };
84
- },
85
-
86
80
  renderHTML({ HTMLAttributes }) {
87
81
  return [
88
82
  'figure',
89
- {
90
- class: HTMLAttributes.style || this.options.HTMLAttributes.style
91
- },
92
- [
93
- 'img',
83
+ mergeAttributes(
84
+ this.options.HTMLAttributes,
94
85
  {
95
- src: `${apos.modules['@apostrophecms/image'].action}/${HTMLAttributes.imageId}/src`
86
+ class: HTMLAttributes.style
96
87
  }
88
+ ),
89
+ [
90
+ 'img',
91
+ mergeAttributes(
92
+ HTMLAttributes,
93
+ {
94
+ src: `${apos.modules['@apostrophecms/image'].action}/${HTMLAttributes.imageId}/src`,
95
+ alt: HTMLAttributes.alt,
96
+ draggable: false,
97
+ contenteditable: false
98
+ }
99
+ )
97
100
  ],
98
101
  [
99
102
  'figcaption',
100
- {},
101
- HTMLAttributes.caption
103
+ 0
102
104
  ]
103
105
  ];
106
+ },
107
+
108
+ addCommands() {
109
+ return {
110
+ setImage: (attrs) => ({ chain }) => {
111
+ return chain()
112
+ .insertContent({
113
+ type: this.name,
114
+ attrs,
115
+ content: attrs?.caption
116
+ ? [ {
117
+ type: 'text',
118
+ text: attrs.caption
119
+ } ]
120
+ : []
121
+ })
122
+ .createParagraphNear()
123
+ .run();
124
+ }
125
+ };
104
126
  }
105
127
  });
106
128
  };
@@ -1090,7 +1090,7 @@ module.exports = {
1090
1090
  const idsStorage = field.idsStorage;
1091
1091
  const ids = await query.toDistinct(idsStorage);
1092
1092
  const manager = self.apos.doc.getManager(field.withType);
1093
- const relationshipQuery = manager.find(query.req, { aposDocId: { $in: ids } }).project(manager.getAutocompleteProjection({ field: field }));
1093
+ const relationshipQuery = manager.find(query.req, { aposDocId: { $in: ids } }).project(manager.getRelationshipQueryBuilderChoicesProjection({ field: field }));
1094
1094
  if (field.builders) {
1095
1095
  relationshipQuery.applyBuilders(field.builders);
1096
1096
  }
@@ -64,7 +64,6 @@
64
64
  v-if="currentId"
65
65
  :schema="schema"
66
66
  :trigger-validation="triggerValidation"
67
- :utility-rail="false"
68
67
  :following-values="followingValues()"
69
68
  :conditional-fields="conditionalFields()"
70
69
  :value="currentDoc"
@@ -61,7 +61,6 @@
61
61
  :class="item.open && !alwaysExpand ? 'apos-input-array-inline-item--active' : null"
62
62
  v-model="item.schemaInput"
63
63
  :trigger-validation="triggerValidation"
64
- :utility-rail="false"
65
64
  :generation="generation"
66
65
  :modifiers="['small', 'inverted']"
67
66
  :doc-id="docId"
@@ -13,7 +13,6 @@
13
13
  <AposSchema
14
14
  :schema="field.schema"
15
15
  :trigger-validation="triggerValidation"
16
- :utility-rail="false"
17
16
  :generation="generation"
18
17
  :doc-id="docId"
19
18
  v-model="schemaInput"
@@ -104,7 +104,12 @@ export default {
104
104
  }
105
105
  },
106
106
  triggerValidation: Boolean,
107
- utilityRail: Boolean,
107
+ utilityRail: {
108
+ type: Boolean,
109
+ default() {
110
+ return false;
111
+ }
112
+ },
108
113
  docId: {
109
114
  type: String,
110
115
  default() {
@@ -103,6 +103,10 @@ module.exports = {
103
103
  placeholderClass: 'apos-placeholder'
104
104
  },
105
105
  init(self) {
106
+ const badFieldName = Object.keys(self.fields).indexOf('type') !== -1;
107
+ if (badFieldName) {
108
+ throw new Error(`The ${self.__meta.name} module contains a forbidden field property name: "type".`);
109
+ }
106
110
 
107
111
  self.enableBrowserData();
108
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.46.0",
3
+ "version": "3.48.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -17,7 +17,7 @@
17
17
  "url": "git@github.com:apostrophecms/apostrophe.git"
18
18
  },
19
19
  "engines": {
20
- "node": ">=12.0.0"
20
+ "node": ">=16.0.0"
21
21
  },
22
22
  "keywords": [
23
23
  "apostrophe",
@@ -80,7 +80,7 @@
80
80
  "i18next-http-middleware": "^3.1.5",
81
81
  "import-fresh": "^3.3.0",
82
82
  "is-wsl": "^2.2.0",
83
- "jsdom": "^17.0.0",
83
+ "jsdom": "^22.0.0",
84
84
  "klona": "^2.0.4",
85
85
  "launder": "^1.4.0",
86
86
  "lodash": "^4.17.20",
@@ -109,7 +109,7 @@
109
109
  "sass": "^1.52.3",
110
110
  "sass-loader": "^10.1.1",
111
111
  "server-destroy": "^1.0.1",
112
- "sluggo": "^0.3.0",
112
+ "sluggo": "^1.0.0",
113
113
  "tinycolor2": "^1.4.2",
114
114
  "tough-cookie": "^4.0.0",
115
115
  "underscore.string": "^3.3.4",
@@ -0,0 +1,32 @@
1
+ const t = require('../../test-lib/test.js');
2
+
3
+ const apiKey = 'this is a test api key';
4
+
5
+ (async function () {
6
+ await t.create({
7
+ root: module,
8
+
9
+ modules: {
10
+ '@apostrophecms/express': {
11
+ options: {
12
+ apiKeys: {
13
+ [apiKey]: {
14
+ role: 'admin'
15
+ }
16
+ }
17
+ }
18
+ },
19
+ malformed: {
20
+ extend: '@apostrophecms/piece-type',
21
+ fields: {
22
+ add: {
23
+ type: {
24
+ label: 'Foo',
25
+ type: 'string'
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ });
32
+ })();
@@ -0,0 +1,33 @@
1
+ const t = require('../test-lib/test.js');
2
+ const { spawn } = require('child_process');
3
+ const assert = require('assert');
4
+
5
+ describe('Malformed Pieces', function () {
6
+ this.timeout(t.timeout);
7
+ it('should fail to initialize with a schema containing a field named "type"', function (done) {
8
+ let throwsError = true;
9
+ const mochaProcess = spawn('node', [ './test/pieces-children/pieces-malformed-child.js' ]);
10
+
11
+ mochaProcess.stdout.on('data', (data) => {
12
+ console.log(`Mocha output: ${data}`);
13
+
14
+ });
15
+
16
+ mochaProcess.stderr.on('data', (data) => {
17
+ const errorMsg = data.toString();
18
+ const errorMatch = errorMsg.match(/(?<error>Error:.*\n)/);
19
+ if (errorMatch) {
20
+ throwsError = true;
21
+ assert.equal(errorMatch.groups.error, 'Error: The malformed module contains a forbidden field property name: "type".\n');
22
+ } else {
23
+ throwsError = false;
24
+ }
25
+ });
26
+
27
+ mochaProcess.on('close', (code) => {
28
+ assert.equal(code, 1, 'Mocha process exited with status code 0');
29
+ assert.ok(throwsError, 'Error message not found in stderr');
30
+ done();
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,225 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ describe('Query Builders', function() {
5
+ this.timeout(t.timeout);
6
+
7
+ let apos;
8
+ after(function() {
9
+ return t.destroy(apos);
10
+ });
11
+
12
+ before(async function () {
13
+ apos = await t.create({
14
+ root: module,
15
+ modules: {
16
+ young: {
17
+ options: {
18
+ alias: 'young'
19
+ },
20
+ extend: '@apostrophecms/piece-type',
21
+ fields: {
22
+ add: {
23
+ age: {
24
+ label: 'Age',
25
+ type: 'integer',
26
+ required: true
27
+ }
28
+ }
29
+ },
30
+ queries(self, query) {
31
+ return {
32
+ builders: {
33
+ age: {
34
+ launder(str) {
35
+ return [ 'children', 'adult' ].includes(str) ? str : null;
36
+ },
37
+ finalize() {
38
+ const age = query.get('age');
39
+
40
+ if ([ 'children', 'adults' ].includes(age)) {
41
+ const ageCriteria = age === 'children' ? { $lte: 18 } : { $gt: 18 };
42
+ query.and({ age: ageCriteria });
43
+ }
44
+ }
45
+ }
46
+ },
47
+ methods: {
48
+ async sortByAge() {
49
+ await query.finalize();
50
+
51
+ const pipeline = [
52
+ { $match: query.get('criteria') },
53
+ { $sort: { age: 1 } }
54
+ ];
55
+
56
+ const results = await self.apos.doc.db.aggregate(pipeline).toArray();
57
+
58
+ return results;
59
+ }
60
+ }
61
+ };
62
+ }
63
+ },
64
+ person: {
65
+ extend: 'young',
66
+ options: {
67
+ alias: 'person'
68
+ },
69
+ extendQueries(self, query) {
70
+ return {
71
+ builders: {
72
+ age: {
73
+ def: 'adult',
74
+ launder(_super, val) {
75
+ const laundered = _super();
76
+
77
+ if (laundered !== null) {
78
+ return laundered;
79
+ }
80
+
81
+ return val === 'senior' ? val : null;
82
+ },
83
+ async finalize(_super) {
84
+ await _super();
85
+
86
+ const age = query.get('age');
87
+
88
+ if (age === 'seniors') {
89
+ query.and({ age: { $gt: 60 } });
90
+ }
91
+ }
92
+ }
93
+ },
94
+ methods: {
95
+ async sortByAge(_super) {
96
+ assert(typeof _super === 'function');
97
+
98
+ await query.finalize();
99
+
100
+ const pipeline = [
101
+ { $match: query.get('criteria') },
102
+ { $sort: { age: -1 } }
103
+ ];
104
+
105
+ const results = await self.apos.doc.db.aggregate(pipeline).toArray();
106
+
107
+ return results;
108
+ }
109
+ }
110
+ };
111
+ }
112
+ }
113
+ }
114
+ });
115
+ });
116
+
117
+ it('should insert person pieces and verify age query builder is working', async function() {
118
+ const req = apos.task.getReq();
119
+ const persons = getPersons(apos.young);
120
+ const { insertedCount } = await apos.doc.db.insertMany(persons);
121
+
122
+ assert(insertedCount === 6);
123
+
124
+ const children = await apos.young.find(req).age('children').toArray();
125
+ const adults = await apos.young.find(req).age('adults').toArray();
126
+
127
+ assert(children.length === 2);
128
+ children.forEach((child) => {
129
+ assert(child.age <= 18);
130
+ });
131
+
132
+ assert(adults.length === 4);
133
+ adults.forEach((adult) => {
134
+ assert(adult.age > 18);
135
+ });
136
+ });
137
+
138
+ it('should insert seniors and verify the query builders have been properly extended', async function() {
139
+ const req = apos.task.getReq();
140
+ const persons = getPersons(apos.person, true);
141
+ const { insertedCount } = await apos.doc.db.insertMany(persons);
142
+
143
+ assert(insertedCount === 8);
144
+
145
+ const children = await apos.person.find(req).age('children').toArray();
146
+ const adults = await apos.person.find(req).age('adults').toArray();
147
+ const seniors = await apos.person.find(req).age('seniors').toArray();
148
+
149
+ assert(children.length === 2);
150
+ children.forEach((child) => {
151
+ assert(child.age <= 18);
152
+ });
153
+
154
+ assert(adults.length === 6);
155
+ adults.forEach((adult) => {
156
+ assert(adult.age > 18);
157
+ });
158
+
159
+ assert(seniors.length === 2);
160
+ seniors.forEach((senior) => {
161
+ assert(senior.age > 60);
162
+ });
163
+ });
164
+
165
+ it('should verify that query methods work and can be extende', async function() {
166
+ const req = apos.task.getReq();
167
+ const youngSorted = await apos.young.find(req).age('adults').sortByAge();
168
+ assert(youngSorted[0].age === 25);
169
+ assert(youngSorted[1].age === 32);
170
+ assert(youngSorted[2].age === 50);
171
+ assert(youngSorted[3].age === 58);
172
+
173
+ const personsSorted = await apos.person.find(req).age('adults').sortByAge();
174
+ assert(personsSorted[0].age === 80);
175
+ assert(personsSorted[1].age === 72);
176
+ assert(personsSorted[2].age === 58);
177
+ assert(personsSorted[3].age === 50);
178
+ assert(personsSorted[4].age === 32);
179
+ });
180
+ });
181
+
182
+ function getPersons(instance, withSeniors = false) {
183
+ const moduleName = instance.__meta.name;
184
+ return [
185
+ {
186
+ title: 'Jean',
187
+ age: 32
188
+ },
189
+ {
190
+ title: 'Julie',
191
+ age: 25
192
+ },
193
+ {
194
+ title: 'Victor',
195
+ age: 14
196
+ },
197
+ {
198
+ title: 'Marc',
199
+ age: 58
200
+ },
201
+ {
202
+ title: 'Hector',
203
+ age: 7
204
+ },
205
+ {
206
+ title: 'Marie',
207
+ age: 50
208
+ },
209
+ ...withSeniors ? [
210
+ {
211
+ title: 'Jules',
212
+ age: 72
213
+ },
214
+ {
215
+ title: 'Renée',
216
+ age: 80
217
+ }
218
+ ] : []
219
+ ].map((p, i) => ({
220
+ _id: `${moduleName}${i}`,
221
+ ...instance.newInstance(),
222
+ slug: `${moduleName}-${p.title.toLowerCase()}`,
223
+ ...p
224
+ }));
225
+ }
@@ -0,0 +1,32 @@
1
+ const t = require('../../test-lib/test.js');
2
+
3
+ const apiKey = 'this is a test api key';
4
+
5
+ (async function () {
6
+ await t.create({
7
+ root: module,
8
+
9
+ modules: {
10
+ '@apostrophecms/express': {
11
+ options: {
12
+ apiKeys: {
13
+ [apiKey]: {
14
+ role: 'admin'
15
+ }
16
+ }
17
+ }
18
+ },
19
+ malformed: {
20
+ extend: '@apostrophecms/widget-type',
21
+ fields: {
22
+ add: {
23
+ type: {
24
+ label: 'Foo',
25
+ type: 'string'
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ });
32
+ })();
@@ -0,0 +1,34 @@
1
+ const t = require('../test-lib/test.js');
2
+ const { spawn } = require('child_process');
3
+ const assert = require('assert');
4
+
5
+ describe('Malformed Widgets', function () {
6
+ this.timeout(t.timeout);
7
+
8
+ it('should fail to initialize with a schema containing a field named "type"', function (done) {
9
+ let throwsError = true;
10
+ const mochaProcess = spawn('node', [ './test/widgets-children/widgets-malformed-child.js' ]);
11
+
12
+ mochaProcess.stdout.on('data', (data) => {
13
+ console.log(`Mocha output: ${data}`);
14
+
15
+ });
16
+
17
+ mochaProcess.stderr.on('data', (data) => {
18
+ const errorMsg = data.toString();
19
+ const errorMatch = errorMsg.match(/(?<error>Error:.*\n)/);
20
+ if (errorMatch) {
21
+ throwsError = true;
22
+ assert.equal(errorMatch.groups.error, 'Error: The malformed module contains a forbidden field property name: "type".\n');
23
+ } else {
24
+ throwsError = false;
25
+ }
26
+ });
27
+
28
+ mochaProcess.on('close', (code) => {
29
+ assert.equal(code, 1, 'Mocha process exited with status code 0');
30
+ assert.ok(throwsError, 'Error message not found in stderr');
31
+ done();
32
+ });
33
+ });
34
+ });