apostrophe 3.45.0 → 3.46.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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.46.0 (2023-05-03)
4
+
5
+ ### Fixes
6
+
7
+ * Adding or editing a piece no longer immediately refreshes the main content area if a widget editor is open. This prevents interruption of the widget editing process
8
+ when working with the `@apostrophecms/ai-helper` module, and also helps in other situations.
9
+ * Check that `e.doc` exists when handling `content-changed` event.
10
+ * Require updated `uploadfs` version with no dependency warnings.
11
+
12
+ ### Adds
13
+
14
+ * Allow sub-schema fields (array and object) to follow parent schema fields using the newly introduced `following: '<parentField'` syntax, where the starting `<` indicates the parent level. For example `<parentField` follows a field in the parent level, `<<grandParentField` follows a field in the grandparent level, etc. The change is fully backward compatible with the current syntax for following fields from the same schema level.
15
+
16
+ ### Changes
17
+
18
+ * Debounce search to prevent calling search on every key stroke in the manager modal.
19
+ * Various size and spacing adjustments in the expanded Add Content modal UI
20
+
21
+ ## 3.45.1 (2023-04-28)
22
+
23
+ ### Fixes
24
+
25
+ * Added missing styles to ensure consistent presentation of the rich text insert menu.
26
+ * Fixed a bug in which clicking on an image in the media manager would close the "insert
27
+ image" dialog box.
28
+ * Update `html-to-text` package to the latest major version.
29
+
3
30
  ## 3.45.0 (2023-04-27)
4
31
 
5
32
  ### Adds
@@ -363,6 +363,7 @@ export default {
363
363
  if (!this.widgetIsContextual(widget.type)) {
364
364
  const componentName = this.widgetEditorComponent(widget.type);
365
365
  apos.area.activeEditor = this;
366
+ apos.bus.$on('apos-refreshing', cancelRefresh);
366
367
  const result = await apos.modal.execute(componentName, {
367
368
  value: widget,
368
369
  options: this.widgetOptionsByType(widget.type),
@@ -370,6 +371,7 @@ export default {
370
371
  docId: this.docId
371
372
  });
372
373
  apos.area.activeEditor = null;
374
+ apos.bus.$off('apos-refreshing', cancelRefresh);
373
375
  if (result) {
374
376
  return this.update(result);
375
377
  }
@@ -593,6 +595,9 @@ export default {
593
595
  }
594
596
  };
595
597
 
598
+ function cancelRefresh(refreshOptions) {
599
+ refreshOptions.refresh = false;
600
+ }
596
601
  </script>
597
602
 
598
603
  <style lang="scss" scoped>
@@ -198,7 +198,7 @@ export default {
198
198
 
199
199
  .apos-widget-group {
200
200
  &:not(:last-of-type) {
201
- margin-bottom: 30px;
201
+ margin-bottom: 20px;
202
202
  }
203
203
 
204
204
  .apos-widget__preview {
@@ -206,8 +206,9 @@ export default {
206
206
  display: flex;
207
207
  justify-content: center;
208
208
  align-items: center;
209
+ overflow: hidden;
209
210
  height: 135px;
210
- border: 1px solid var(--a-base-7);
211
+ outline: 1px solid var(--a-base-7);
211
212
  border-radius: var(--a-border-radius);
212
213
  background-color: var(--a-base-10);
213
214
  }
@@ -221,7 +222,7 @@ export default {
221
222
  &--3-columns {
222
223
  display: grid;
223
224
  grid-template-columns: repeat(3, 1fr);
224
- gap: 10px;
225
+ gap: 15px 10px;
225
226
  .apos-widget__preview {
226
227
  height: 89px;
227
228
  }
@@ -230,7 +231,7 @@ export default {
230
231
  &--4-columns {
231
232
  display: grid;
232
233
  grid-template-columns: repeat(4, 1fr);
233
- gap: 5px;
234
+ gap: 15px 5px;
234
235
  .apos-widget__preview {
235
236
  height: 66px;
236
237
  }
@@ -289,8 +290,18 @@ export default {
289
290
  .apos-widget-group__label,
290
291
  .apos-widget__help {
291
292
  @include type-base;
293
+ margin-top: 0;
292
294
  line-height: var(--a-line-tall);
293
- color: var(--a-base-4);
294
295
  text-align: left;
295
296
  }
297
+
298
+ .apos-widget__help {
299
+ color: var(--a-base-2);
300
+ font-size: var(--a-type-smaller);
301
+ }
302
+
303
+ .apos-widget__label {
304
+ line-height: 1.2;
305
+ margin-bottom: 5px;
306
+ }
296
307
  </style>
@@ -775,7 +775,7 @@ export default {
775
775
  window.localStorage.setItem(this.savePreferenceName, pref);
776
776
  },
777
777
  onContentChanged(e) {
778
- if (this.original?._id !== e.doc._id) {
778
+ if (!e.doc || this.original?._id !== e.doc._id) {
779
779
  return;
780
780
  }
781
781
  if (e.doc.type !== this.docType) {
@@ -1,5 +1,5 @@
1
1
  const nodemailer = require('nodemailer');
2
- const htmlToText = require('html-to-text');
2
+ const { htmlToText } = require('html-to-text');
3
3
  const _ = require('lodash');
4
4
 
5
5
  // ## Options
@@ -26,18 +26,61 @@ module.exports = {
26
26
  async emailForModule(req, templateName, data, options, module) {
27
27
  const transport = self.getTransport();
28
28
  const html = await module.render(req, templateName, data);
29
- const text = htmlToText.fromString(html, {
30
- format: {
31
- heading: function (elem, fn, options) {
32
- const h = fn(elem.children, options);
33
- const split = h.split(/(\[.*?\])/);
34
- return split.map(function (s) {
35
- if (s.match(/^\[.*\]$/)) {
36
- return s;
37
- } else {
38
- return s.toUpperCase();
39
- }
40
- }).join('') + '\n';
29
+ const text = htmlToText(html, {
30
+ selectors: [
31
+ {
32
+ selector: 'a',
33
+ options: { hideLinkHrefIfSameAsText: true }
34
+ },
35
+ {
36
+ selector: 'h1',
37
+ format: 'customHeading'
38
+ },
39
+ {
40
+ selector: 'h2',
41
+ format: 'customHeading'
42
+ },
43
+ {
44
+ selector: 'h3',
45
+ format: 'customHeading'
46
+ },
47
+ {
48
+ selector: 'h4',
49
+ format: 'customHeading'
50
+ },
51
+ {
52
+ selector: 'h5',
53
+ format: 'customHeading'
54
+ },
55
+ {
56
+ selector: 'h6',
57
+ format: 'customHeading'
58
+ }
59
+ ],
60
+ formatters: {
61
+ // Custom heading formatter, based on the core heading but
62
+ // no uppercase inside "[ ]" blocks.
63
+ customHeading: function (elem, walk, builder, formatOptions) {
64
+ builder.openBlock({ leadingLineBreaks: formatOptions.leadingLineBreaks || 2 });
65
+ if (formatOptions.uppercase !== false) {
66
+ // Keep track of [ and ] (no nested support)
67
+ let ignoreUpper = false;
68
+ builder.pushWordTransform(str => {
69
+ if (str.trim().startsWith('[')) {
70
+ ignoreUpper = true;
71
+ }
72
+ const word = ignoreUpper ? str : str.toUpperCase();
73
+ if (str.trim().endsWith(']')) {
74
+ ignoreUpper = false;
75
+ }
76
+ return word;
77
+ });
78
+ walk(elem.children, builder);
79
+ builder.popWordTransform();
80
+ } else {
81
+ walk(elem.children, builder);
82
+ }
83
+ builder.closeBlock({ trailingLineBreaks: formatOptions.trailingLineBreaks || 2 });
41
84
  }
42
85
  }
43
86
  });
@@ -41,7 +41,20 @@ export default function() {
41
41
  // or el2 is not a modal, it is treated as its DOM
42
42
  // parent modal, or as `document`. If el1 has no
43
43
  // parent modal this method always returns false.
44
+ //
45
+ // If el1 is no longer connected to the DOM then it
46
+ // is also considered to be "on top" e.g. not something
47
+ // that should concern `v-click-outside-element` and
48
+ // similar functionality. This is necessary because
49
+ // sometimes Vue removes elements from the DOM before
50
+ // we can examine their relationships.
44
51
  onTopOf(el1, el2) {
52
+ if (!el1.isConnected) {
53
+ // If el1 is no longer in the DOM we can't make a proper determination,
54
+ // returning true prevents unwanted things like click-outside-element
55
+ // events from firing
56
+ return true;
57
+ }
45
58
  if (!el1.matches('[data-apos-modal]')) {
46
59
  el1 = el1.closest('[data-apos-modal]') || document;
47
60
  }
@@ -140,13 +140,21 @@ export default {
140
140
  const fields = this.getFieldsByCategory(followedByCategory);
141
141
 
142
142
  const followingValues = {};
143
+ const parentFollowing = {};
144
+ for (const [ key, val ] of Object.entries(this.parentFollowingValues || {})) {
145
+ parentFollowing[`<${key}`] = val;
146
+ }
143
147
 
144
148
  for (const field of fields) {
145
149
  if (field.following) {
146
150
  const following = Array.isArray(field.following) ? field.following : [ field.following ];
147
151
  followingValues[field.name] = {};
148
152
  for (const name of following) {
149
- followingValues[field.name][name] = this.getFieldValue(name);
153
+ if (name.startsWith('<')) {
154
+ followingValues[field.name][name] = parentFollowing[name];
155
+ } else {
156
+ followingValues[field.name][name] = this.getFieldValue(name);
157
+ }
150
158
  }
151
159
  }
152
160
  }
@@ -73,7 +73,7 @@
73
73
  :checked-count="checked.length"
74
74
  :batch-operations="moduleOptions.batchOperations"
75
75
  @select-click="selectAll"
76
- @search="search"
76
+ @search="onSearch"
77
77
  @page-change="updatePage"
78
78
  @filter="filter"
79
79
  @batch="handleBatchAction"
@@ -119,6 +119,7 @@
119
119
  import AposDocsManagerMixin from 'Modules/@apostrophecms/modal/mixins/AposDocsManagerMixin';
120
120
  import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin';
121
121
  import AposPublishMixin from 'Modules/@apostrophecms/ui/mixins/AposPublishMixin';
122
+ import { debounce } from 'Modules/@apostrophecms/ui/utils';
122
123
 
123
124
  export default {
124
125
  name: 'AposDocsManager',
@@ -209,6 +210,9 @@ export default {
209
210
  }
210
211
  },
211
212
  created() {
213
+ const DEBOUNCE_TIMEOUT = 500;
214
+ this.onSearch = debounce(this.search, DEBOUNCE_TIMEOUT);
215
+
212
216
  this.moduleOptions.filters.forEach(filter => {
213
217
  this.filterValues[filter.name] = filter.def;
214
218
  if (!filter.choices) {
@@ -678,6 +678,7 @@ function traverseNextNode(node) {
678
678
  border: 1px solid var(--a-base-8);
679
679
  color: var(--a-base-1);
680
680
  font-family: var(--a-family-default);
681
+ font-size: var(--a-type-base);
681
682
  }
682
683
 
683
684
  .apos-rich-text-insert-menu-item {
@@ -694,6 +695,11 @@ function traverseNextNode(node) {
694
695
  flex-direction: column;
695
696
  h4, p {
696
697
  margin: 4px;
698
+ font-family: var(--a-family-default);
699
+ font-size: var(--a-type-base);
700
+ }
701
+ h4 {
702
+ font-weight: bold;
697
703
  }
698
704
  }
699
705
  .apos-rich-text-insert-menu-icon {
@@ -1241,6 +1241,9 @@ module.exports = {
1241
1241
  if (fieldType.validate) {
1242
1242
  fieldType.validate(field, options, warn, fail);
1243
1243
  }
1244
+ // Ancestors hoisting should happen AFTER the validation recursion,
1245
+ // so that ancestors are processed as well.
1246
+ self.hoistFollowingFieldsToParent(field, parent);
1244
1247
  function fail(s) {
1245
1248
  throw new Error(format(s));
1246
1249
  }
@@ -1261,6 +1264,28 @@ module.exports = {
1261
1264
  }
1262
1265
  },
1263
1266
 
1267
+ // If a field has a following property and a parent,
1268
+ // hoist that property values to the parent,
1269
+ // if they start with `<`.
1270
+ hoistFollowingFieldsToParent(field, parent) {
1271
+ if (!parent || !field.following) {
1272
+ return;
1273
+ }
1274
+ const following = typeof field.following === 'string'
1275
+ ? [ field.following ]
1276
+ : field.following;
1277
+ const parentFollowing = typeof parent.following === 'string'
1278
+ ? [ parent.following ]
1279
+ : parent.following;
1280
+ const hoistFollowing = following
1281
+ .filter(f => f.startsWith('<'))
1282
+ .map(f => f.slice(1));
1283
+ parent.following = _.uniq([
1284
+ ...(parentFollowing || []),
1285
+ ...hoistFollowing
1286
+ ]);
1287
+ },
1288
+
1264
1289
  // Recursively register the given schema, giving each field an _id and making provision to be able to
1265
1290
  // fetch its definition via apos.schema.getFieldById().
1266
1291
  //
@@ -114,6 +114,10 @@ export default {
114
114
  docId: {
115
115
  type: String,
116
116
  default: null
117
+ },
118
+ parentFollowingValues: {
119
+ type: Object,
120
+ default: null
117
121
  }
118
122
  },
119
123
  emits: [ 'modal-result', 'safe-close' ],
@@ -141,6 +141,7 @@
141
141
 
142
142
  <script>
143
143
  import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin.js';
144
+ import AposInputFollowingMixin from 'Modules/@apostrophecms/schema/mixins/AposInputFollowingMixin.js';
144
145
  import cuid from 'cuid';
145
146
  import { klona } from 'klona';
146
147
  import { get } from 'lodash';
@@ -149,7 +150,7 @@ import draggable from 'vuedraggable';
149
150
  export default {
150
151
  name: 'AposInputArray',
151
152
  components: { draggable },
152
- mixins: [ AposInputMixin ],
153
+ mixins: [ AposInputMixin, AposInputFollowingMixin ],
153
154
  props: {
154
155
  generation: {
155
156
  type: Number,
@@ -288,7 +289,8 @@ export default {
288
289
  field: this.field,
289
290
  items: this.next,
290
291
  serverError: this.serverError,
291
- docId: this.docId
292
+ docId: this.docId,
293
+ parentFollowingValues: this.followingValues
292
294
  });
293
295
  if (result) {
294
296
  this.next = result;
@@ -341,18 +343,7 @@ export default {
341
343
  });
342
344
  },
343
345
  getFollowingValues(item) {
344
- const followingValues = {};
345
- for (const field of this.field.schema) {
346
- if (field.following) {
347
- const following = Array.isArray(field.following) ? field.following : [ field.following ];
348
- followingValues[field.name] = {};
349
- for (const name of following) {
350
- followingValues[field.name][name] = item.schemaInput.data[name];
351
- }
352
- }
353
- }
354
-
355
- return followingValues;
346
+ return this.computeFollowingValues(item.schemaInput.data);
356
347
  }
357
348
  }
358
349
  };
@@ -17,6 +17,7 @@
17
17
  :generation="generation"
18
18
  :doc-id="docId"
19
19
  v-model="schemaInput"
20
+ :following-values="followingValuesWithParent"
20
21
  ref="schema"
21
22
  />
22
23
  </div>
@@ -27,10 +28,11 @@
27
28
 
28
29
  <script>
29
30
  import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin.js';
31
+ import AposInputFollowingMixin from 'Modules/@apostrophecms/schema/mixins/AposInputFollowingMixin.js';
30
32
 
31
33
  export default {
32
34
  name: 'AposInputObject',
33
- mixins: [ AposInputMixin ],
35
+ mixins: [ AposInputMixin, AposInputFollowingMixin ],
34
36
  props: {
35
37
  generation: {
36
38
  type: Number,
@@ -56,6 +58,11 @@ export default {
56
58
  next
57
59
  };
58
60
  },
61
+ computed: {
62
+ followingValuesWithParent() {
63
+ return this.computeFollowingValues(this.schemaInput.data);
64
+ }
65
+ },
59
66
  watch: {
60
67
  schemaInput: {
61
68
  deep: true,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Provides followingValues computation for fields having
3
+ * sub-schema (array, object).
4
+ */
5
+
6
+ export default {
7
+ methods: {
8
+ // Accept a `data` object with field values and return an object
9
+ // of the following values for each field of the underlying schema.
10
+ computeFollowingValues(data) {
11
+ const followingValues = {};
12
+ const parentFollowing = {};
13
+ for (const [ key, val ] of Object.entries(this.followingValues || {})) {
14
+ parentFollowing[`<${key}`] = val;
15
+ }
16
+
17
+ for (const field of this.field.schema) {
18
+ if (field.following) {
19
+ const following = Array.isArray(field.following) ? field.following : [ field.following ];
20
+ followingValues[field.name] = {};
21
+ for (const name of following) {
22
+ if (name.startsWith('<')) {
23
+ followingValues[field.name][name] = parentFollowing[name];
24
+ } else {
25
+ followingValues[field.name][name] = data[name];
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ return followingValues;
32
+ }
33
+ }
34
+ };
@@ -64,6 +64,7 @@
64
64
  // Type Sizes
65
65
  --a-type-tiny: 9px;
66
66
  --a-type-small: 10px;
67
+ --a-type-smaller: 11px;
67
68
  --a-type-base: 12px;
68
69
  --a-type-label: 13px;
69
70
  --a-type-large: 14px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.45.0",
3
+ "version": "3.46.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -75,7 +75,7 @@
75
75
  "fs-extra": "^7.0.1",
76
76
  "glob": "^5.0.15",
77
77
  "he": "^0.5.0",
78
- "html-to-text": "^5.1.1",
78
+ "html-to-text": "^9.0.5",
79
79
  "i18next": "^20.3.2",
80
80
  "i18next-http-middleware": "^3.1.5",
81
81
  "import-fresh": "^3.3.0",
@@ -113,7 +113,7 @@
113
113
  "tinycolor2": "^1.4.2",
114
114
  "tough-cookie": "^4.0.0",
115
115
  "underscore.string": "^3.3.4",
116
- "uploadfs": "^1.17.1",
116
+ "uploadfs": "^1.22.1",
117
117
  "v-tooltip": "^2.0.3",
118
118
  "vue": "^2.6.14",
119
119
  "vue-advanced-cropper": "^1.10.1",
@@ -0,0 +1,30 @@
1
+ module.exports = {
2
+ html: '<h4>[test string] Resetting your [ another test string ] password on localhost</h4>\n' +
3
+ '<p>Hello cy_admin,</p>\n' +
4
+ '<p>You are receiving this email because a request to reset your password was made on localhost.</p>\n' +
5
+ '<p>If that is your wish, please follow this link to complete the password reset process:</p>\n' +
6
+ '<p><a href="http://localhost:3000/login?reset=clh0f780t0009ezh97wtu03d0&amp;email=admin%40example.com">http://localhost:3000/login?reset=clh0f780t0009ezh97wtu03d0&amp;email=admin%40example.com</a></p>\n' +
7
+ '<p>\n' +
8
+ ' If you did not request to reset your password, please delete and ignore this email. Someone else may have entered\n' +
9
+ ' your email address in error.\n' +
10
+ '</p>\n',
11
+ text: '[test string] RESETTING YOUR [ another test string ] PASSWORD ON LOCALHOST\n' +
12
+ '\n' +
13
+ 'Hello cy_admin,\n' +
14
+ '\n' +
15
+ 'You are receiving this email because a request to reset your password was made\n' +
16
+ 'on localhost.\n' +
17
+ '\n' +
18
+ 'If that is your wish, please follow this link to complete the password reset\n' +
19
+ 'process:\n' +
20
+ '\n' +
21
+ 'http://localhost:3000/login?reset=clh0f780t0009ezh97wtu03d0&email=admin%40example.com\n' +
22
+ // Improved link formatting via hideLinkHrefIfSameAsText option
23
+ // '[http://localhost:3000/login?reset=clh0f780t0009ezh97wtu03d0&email=admin%40example.com]\n' +
24
+ '\n' +
25
+ 'If you did not request to reset your password, please delete and ignore this\n' +
26
+ 'email. Someone else may have entered your email address in error.',
27
+ from: 'noreply@example.com',
28
+ to: 'admin@example.com',
29
+ subject: 'Your request to reset your password on localhost'
30
+ };
package/test/email.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const t = require('../test-lib/test.js');
2
- const assert = require('assert');
2
+ const assert = require('assert').strict;
3
3
  let apos;
4
4
 
5
5
  describe('Email', function() {
@@ -52,4 +52,35 @@ describe('Email', function() {
52
52
  assert(message.match(/To: recipient@example\.com/));
53
53
  assert(message.match(/\[http:\/\/example\.com\/\]/));
54
54
  });
55
+
56
+ it('should convert html to text', async function () {
57
+ await t.destroy(apos);
58
+ apos = await t.create({
59
+ root: module
60
+ });
61
+
62
+ const mockEmail = require('./data/fpw_email_mock.js');
63
+ const mockTransport = () => ({
64
+ sendMail: function (args) {
65
+ return Promise.resolve(args);
66
+ }
67
+ });
68
+ const mockModule = {
69
+ options: { email: { from: mockEmail.from } },
70
+ render: async () => mockEmail.html
71
+ };
72
+ apos.modules['@apostrophecms/email'].getTransport = mockTransport;
73
+
74
+ const result = await apos.modules['@apostrophecms/email'].emailForModule(
75
+ apos.task.getReq(), // req
76
+ 'anyTemplate', // templateName
77
+ {}, // data
78
+ {
79
+ to: mockEmail.to,
80
+ subject: mockEmail.subject
81
+ }, // options
82
+ mockModule // module
83
+ );
84
+ assert.equal(result.text, mockEmail.text);
85
+ });
55
86
  });
@@ -0,0 +1,165 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ describe('Schema - follow ancestor fields', function() {
5
+
6
+ let apos;
7
+ this.timeout(t.timeout);
8
+ this.slow(2000);
9
+
10
+ beforeEach(async function() {
11
+ return t.destroy(apos);
12
+ });
13
+
14
+ after(async function() {
15
+ return t.destroy(apos);
16
+ });
17
+
18
+ it('should follow a parent field in array and object', async function() {
19
+ apos = await t.create({
20
+ root: module,
21
+ modules: {
22
+ household: {
23
+ extend: '@apostrophecms/piece-type',
24
+ options: {
25
+ alias: 'household'
26
+ },
27
+ fields: {
28
+ add: {
29
+ petPreference: {
30
+ type: 'select',
31
+ label: 'Pet Preference',
32
+ choices: [
33
+ {
34
+ value: 'cats',
35
+ label: 'Cats'
36
+ },
37
+ {
38
+ value: 'dogs',
39
+ label: 'Dogs'
40
+ }
41
+ ],
42
+ required: true
43
+ },
44
+ pets: {
45
+ type: 'array',
46
+ label: 'Pets',
47
+ fields: {
48
+ add: {
49
+ name: {
50
+ type: 'string',
51
+ label: 'Name',
52
+ // Follow one level up
53
+ following: [ '<petPreference' ]
54
+ }
55
+ }
56
+ }
57
+ },
58
+ favoritePet: {
59
+ type: 'object',
60
+ label: 'Favorite Pet',
61
+ fields: {
62
+ add: {
63
+ name: {
64
+ type: 'string',
65
+ label: 'Name',
66
+ // Follow one level up
67
+ following: [ '<petPreference' ]
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ });
77
+ const schema = apos.modules.household.schema;
78
+ const petField = schema.find(field => field.name === 'pets');
79
+ const favPetField = schema.find(field => field.name === 'favoritePet');
80
+ assert(petField);
81
+ assert.deepEqual(petField.following, [ 'petPreference' ]);
82
+ assert(favPetField);
83
+ assert.deepEqual(favPetField.following, [ 'petPreference' ]);
84
+ });
85
+
86
+ it('should follow a grand parent field in array and object', async function() {
87
+ apos = await t.create({
88
+ root: module,
89
+ modules: {
90
+ household: {
91
+ extend: '@apostrophecms/piece-type',
92
+ options: {
93
+ alias: 'household'
94
+ },
95
+ fields: {
96
+ add: {
97
+ petPreference: {
98
+ type: 'select',
99
+ label: 'Pet Preference',
100
+ choices: [
101
+ {
102
+ value: 'cats',
103
+ label: 'Cats'
104
+ },
105
+ {
106
+ value: 'dogs',
107
+ label: 'Dogs'
108
+ }
109
+ ],
110
+ required: true
111
+ },
112
+ rooms: {
113
+ type: 'array',
114
+ label: 'Rooms',
115
+ fields: {
116
+ add: {
117
+ pets: {
118
+ type: 'array',
119
+ label: 'Pets',
120
+ fields: {
121
+ add: {
122
+ name: {
123
+ type: 'string',
124
+ label: 'Name',
125
+ // Follow two levels up
126
+ following: [ '<<petPreference' ]
127
+ }
128
+ }
129
+ }
130
+ },
131
+ favoritePet: {
132
+ type: 'object',
133
+ label: 'Favorite Pet',
134
+ fields: {
135
+ add: {
136
+ name: {
137
+ type: 'string',
138
+ label: 'Name',
139
+ // Follow two levels up
140
+ following: [ '<<petPreference' ]
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ });
153
+ const schema = apos.modules.household.schema;
154
+ const rooms = schema.find(field => field.name === 'rooms');
155
+ assert(rooms);
156
+ assert.deepEqual(rooms.following, [ 'petPreference' ]);
157
+
158
+ const petField = rooms.schema.find(field => field.name === 'pets');
159
+ const favPetField = rooms.schema.find(field => field.name === 'favoritePet');
160
+ assert(petField);
161
+ assert.deepEqual(petField.following, [ '<petPreference' ]);
162
+ assert(favPetField);
163
+ assert.deepEqual(favPetField.following, [ '<petPreference' ]);
164
+ });
165
+ });