apostrophe 3.40.2-alpha → 3.41.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,6 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## UNRELEASED
3
+ ## 3.41.0 (2023-03-06)
4
+
5
+ ### Adds
6
+
7
+ * Handle external conditions to display fields according to the result of a module method, or multiple methods from different modules.
8
+ This can be useful for displaying fields according to the result of an external API or any business logic run on the server.
4
9
 
5
10
  ### Fixes
6
11
 
@@ -1014,7 +1014,7 @@ module.exports = {
1014
1014
  // }
1015
1015
  // All properties are required.
1016
1016
  // The only supported `context` for now is `update`.
1017
- // `action` is the operation idefntifier and should be globally unique.
1017
+ // `action` is the operation identifier and should be globally unique.
1018
1018
  // Overriding existing custom actions is possible (the last wins).
1019
1019
  // `modal` is the name of the modal component to be opened.
1020
1020
  // `label` is the menu label to be shown when expanding the context menu.
@@ -149,6 +149,7 @@
149
149
  "errorCount": "{{ count }} error remaining",
150
150
  "errorCount_plural": "{{ count }} errors remaining",
151
151
  "errorCreatingNewContent": "Error while creating new, empty content.",
152
+ "errorEvaluatingExternalCondition": "An error occurred while evaluating the external condition for the field \"{{ name }}\"",
152
153
  "errorFetchingTitleFieldChoicesByMethod": "An error occurred while fetching the choices for the titleField {{ name }}",
153
154
  "errorWhileRestoring": "An error occurred while restoring the previously published version.",
154
155
  "errorWhileUnpublishing": "An error occurred while unpublishing the document.",
@@ -24,7 +24,8 @@ export default {
24
24
  },
25
25
  serverErrors: null,
26
26
  restoreOnly: false,
27
- changed: []
27
+ changed: [],
28
+ externalConditionsResults: {}
28
29
  };
29
30
  },
30
31
 
@@ -43,7 +44,91 @@ export default {
43
44
  }
44
45
  },
45
46
 
47
+ watch: {
48
+ docType: {
49
+ // Evaluate external conditions found in current page-type's schema
50
+ async handler() {
51
+ if (this.moduleName === '@apostrophecms/page') {
52
+ await this.evaluateExternalConditions();
53
+ }
54
+ }
55
+ }
56
+ },
57
+
58
+ async created() {
59
+ await this.evaluateExternalConditions();
60
+ },
61
+
46
62
  methods: {
63
+ // Evaluate the external conditions found in each field
64
+ // via API calls -made in parallel for performance-
65
+ // and store their result for reusability.
66
+ async evaluateExternalConditions() {
67
+ const self = this;
68
+ for (const field of this.schema) {
69
+ if (field.if) {
70
+ const externalConditionKeys = Object
71
+ .entries(field.if)
72
+ .flatMap(getExternalConditionKeys)
73
+ .filter(Boolean);
74
+
75
+ const uniqExternalConditionKeys = [ ...new Set(externalConditionKeys) ];
76
+
77
+ let results = [];
78
+
79
+ try {
80
+ const docOrContextDocId = this.docId || this.docFields.data._docId;
81
+ const promises = uniqExternalConditionKeys
82
+ .map(key => this.externalConditionsResults[key] !== undefined
83
+ ? null
84
+ : this.evaluateExternalCondition(key, field._id, docOrContextDocId)
85
+ )
86
+ .filter(Boolean);
87
+
88
+ results = await Promise.all(promises);
89
+
90
+ this.externalConditionsResults = {
91
+ ...this.externalConditionsResults,
92
+ ...Object.fromEntries(results)
93
+ };
94
+ } catch (error) {
95
+ await apos.notify(this.$t('apostrophe:errorEvaluatingExternalCondition', { name: field.name }), {
96
+ type: 'danger',
97
+ icon: 'alert-circle-icon',
98
+ dismiss: true,
99
+ localize: false
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ function getExternalConditionKeys([ key, val ]) {
106
+ if (key === '$or') {
107
+ return val.flatMap(nested => Object.entries(nested).map(getExternalConditionKeys));
108
+ }
109
+ if (self.isExternalCondition(key)) {
110
+ return key;
111
+ }
112
+ return null;
113
+ }
114
+ },
115
+
116
+ async evaluateExternalCondition(conditionKey, fieldId, docId) {
117
+ const response = await apos.http.get(
118
+ `${apos.schema.action}/evaluate-external-condition`,
119
+ {
120
+ qs: {
121
+ fieldId,
122
+ docId,
123
+ conditionKey
124
+ },
125
+ busy: true
126
+ }
127
+ );
128
+
129
+ return [ conditionKey, response ];
130
+ },
131
+
47
132
  // followedByCategory may be falsy (all fields), "other" or "utility". The returned
48
133
  // object contains properties named for each field in that category that
49
134
  // follows other fields. For instance if followedBy is "utility" then in our
@@ -93,8 +178,22 @@ export default {
93
178
  // in that category, although they may be conditional upon fields in either
94
179
  // category.
95
180
 
96
- conditionalFields(followedByCategory) {
181
+ // Checking if key ends with a closing parenthesis here to throw later if any argument is passed.
182
+ isExternalCondition(conditionKey) {
183
+ if (!conditionKey.endsWith(')')) {
184
+ return false;
185
+ }
186
+
187
+ const [ methodDefinition ] = conditionKey.split('(');
188
+
189
+ if (!conditionKey.endsWith('()')) {
190
+ console.warn(`Warning in \`if\` definition: "${methodDefinition}()" should not be passed any argument.`);
191
+ }
97
192
 
193
+ return true;
194
+ },
195
+
196
+ conditionalFields(followedByCategory) {
98
197
  const self = this;
99
198
  const conditionalFields = {};
100
199
 
@@ -128,8 +227,26 @@ export default {
128
227
  let result = true;
129
228
  for (const [ key, val ] of Object.entries(clause)) {
130
229
  if (key === '$or') {
131
- return val.some(clause => evaluate(clause));
230
+ if (!val.some(clause => evaluate(clause))) {
231
+ result = false;
232
+ break;
233
+ }
234
+
235
+ // No need to go further here, the key is an "$or" condition...
236
+ continue;
132
237
  }
238
+
239
+ if (self.isExternalCondition(key)) {
240
+ if (self.externalConditionsResults[key] !== val) {
241
+ result = false;
242
+ break;
243
+ }
244
+
245
+ // Stop there, this is an external condition thus
246
+ // does not need to be checked against doc fields.
247
+ continue;
248
+ }
249
+
133
250
  if (conditionalFields[key] === false) {
134
251
  result = false;
135
252
  break;
@@ -145,7 +262,6 @@ export default {
145
262
  }
146
263
  return result;
147
264
  }
148
-
149
265
  },
150
266
 
151
267
  // Overridden by components that split the fields into several AposSchemas
@@ -487,18 +487,21 @@ module.exports = {
487
487
  throw new Error('convert invoked without a req, do you have one in your context?');
488
488
  }
489
489
 
490
- let errors = [];
490
+ const errors = [];
491
491
 
492
492
  for (const field of schema) {
493
493
  if (field.readOnly) {
494
494
  continue;
495
495
  }
496
+
496
497
  // Fields that are contextual are left alone, not blanked out, if
497
498
  // they do not appear at all in the data object.
498
499
  if (field.contextual && !_.has(data, field.name)) {
499
500
  continue;
500
501
  }
501
- const convert = self.fieldTypes[field.type].convert;
502
+
503
+ const { convert } = self.fieldTypes[field.type];
504
+
502
505
  if (convert) {
503
506
  try {
504
507
  await convert(req, field, data, destination);
@@ -520,15 +523,21 @@ module.exports = {
520
523
  }
521
524
  }
522
525
 
523
- errors = errors.filter(error => {
524
- if ((error.name === 'required' || error.name === 'mandatory') && !self.isVisible(schema, destination, error.path)) {
526
+ const errorsList = [];
527
+
528
+ for (const error of errors) {
529
+ const isVisible = await self.isVisible(req, schema, destination, error.path);
530
+
531
+ if ((error.name === 'required' || error.name === 'mandatory') && !isVisible) {
525
532
  // It is not reasonable to enforce required for
526
533
  // fields hidden via conditional fields
527
- return false;
534
+ continue;
528
535
  }
529
- return true;
530
- });
531
- if (errors.length) {
536
+
537
+ errorsList.push(error);
538
+ }
539
+
540
+ if (errorsList.length) {
532
541
  throw errors;
533
542
  }
534
543
  },
@@ -536,13 +545,13 @@ module.exports = {
536
545
  // Determine whether the given field is visible
537
546
  // based on `if` conditions of all fields
538
547
 
539
- isVisible(schema, object, name) {
548
+ async isVisible(req, schema, object, name) {
540
549
  const conditionalFields = {};
541
550
  while (true) {
542
551
  let change = false;
543
552
  for (const field of schema) {
544
553
  if (field.if) {
545
- const result = evaluate(field.if);
554
+ const result = await evaluate(field.if, field.name, field.moduleName);
546
555
  const previous = conditionalFields[field.name];
547
556
  if (previous !== result) {
548
557
  change = true;
@@ -559,12 +568,38 @@ module.exports = {
559
568
  } else {
560
569
  return true;
561
570
  }
562
- function evaluate(clause) {
571
+ async function evaluate(clause, fieldName, fieldModuleName) {
563
572
  let result = true;
564
573
  for (const [ key, val ] of Object.entries(clause)) {
565
574
  if (key === '$or') {
566
- return val.some(clause => evaluate(clause));
575
+ const results = await Promise.all(val.map(clause => evaluate(clause, fieldName, fieldModuleName)));
576
+
577
+ if (!results.some(({ value }) => value)) {
578
+ result = false;
579
+ break;
580
+ }
581
+
582
+ // No need to go further here, the key is an "$or" condition...
583
+ continue;
584
+ }
585
+
586
+ // Handle external conditions:
587
+ // - `if: { 'methodName()': true }`
588
+ // - `if: { 'moduleName:methodName()': 'expected value' }`
589
+ // Checking if key ends with a closing parenthesis here to throw later if any argument is passed.
590
+ if (key.endsWith(')')) {
591
+ const externalConditionResult = await self.evaluateExternalCondition(req, key, fieldName, fieldModuleName, object._id);
592
+
593
+ if (externalConditionResult !== val) {
594
+ result = false;
595
+ break;
596
+ };
597
+
598
+ // Stop there, this is an external condition thus
599
+ // does not need to be checked against doc fields.
600
+ continue;
567
601
  }
602
+
568
603
  if (conditionalFields[key] === false) {
569
604
  result = false;
570
605
  break;
@@ -578,6 +613,28 @@ module.exports = {
578
613
  }
579
614
  },
580
615
 
616
+ async evaluateExternalCondition(req, conditionKey, fieldName, fieldModuleName, docId = null) {
617
+ const [ methodDefinition ] = conditionKey.split('(');
618
+
619
+ if (!conditionKey.endsWith('()')) {
620
+ self.apos.util.warn(`Warning in the \`if\` definition of the "${fieldName}" field: "${methodDefinition}()" should not be passed any argument.`);
621
+ }
622
+
623
+ const [ methodName, moduleName = fieldModuleName ] = methodDefinition
624
+ .split(':')
625
+ .reverse();
626
+
627
+ const module = self.apos.modules[moduleName];
628
+
629
+ if (!module) {
630
+ throw new Error(`Error in the \`if\` definition of the "${fieldName}" field: "${moduleName}" module not found.`);
631
+ } else if (!module[methodName]) {
632
+ throw new Error(`Error in the \`if\` definition of the "${fieldName}" field: "${methodName}" method not found in "${moduleName}" module.`);
633
+ }
634
+
635
+ return module[methodName](req, { docId });
636
+ },
637
+
581
638
  // Driver invoked by the "relationship" methods of the standard
582
639
  // relationship field types.
583
640
  //
@@ -1493,6 +1550,20 @@ module.exports = {
1493
1550
  } else {
1494
1551
  throw self.apos.error('invalid', `The method ${field.choices} from the module ${field.moduleName} did not return an array`);
1495
1552
  }
1553
+ },
1554
+ async evaluateExternalCondition(req) {
1555
+ const fieldId = self.apos.launder.string(req.query.fieldId);
1556
+ const docId = self.apos.launder.string(req.query.docId, null);
1557
+ const conditionKey = self.apos.launder.string(req.query.conditionKey);
1558
+
1559
+ const field = self.getFieldById(fieldId);
1560
+
1561
+ try {
1562
+ const result = await self.evaluateExternalCondition(req, conditionKey, field.name, field.moduleName, docId);
1563
+ return result;
1564
+ } catch (error) {
1565
+ throw self.apos.error('invalid', error.message);
1566
+ }
1496
1567
  }
1497
1568
  }
1498
1569
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.40.2-alpha",
3
+ "version": "3.41.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/schemas.js CHANGED
@@ -235,6 +235,8 @@ const hasAreaWithoutWidgets = {
235
235
  ]
236
236
  };
237
237
 
238
+ const warnMessages = [];
239
+
238
240
  describe('Schemas', function() {
239
241
 
240
242
  this.timeout(t.timeout);
@@ -249,7 +251,31 @@ describe('Schemas', function() {
249
251
 
250
252
  it('should be a property of the apos object', async function() {
251
253
  apos = await t.create({
252
- root: module
254
+ root: module,
255
+ modules: {
256
+ '@apostrophecms/util': {
257
+ extendMethods() {
258
+ return {
259
+ warn(_super, ...args) {
260
+ warnMessages.push(...args);
261
+ return _super(...args);
262
+ }
263
+ };
264
+ }
265
+ },
266
+ 'external-condition': {
267
+ methods() {
268
+ return {
269
+ async externalCondition() {
270
+ return 'yes';
271
+ },
272
+ async externalCondition2(req, { docId }) {
273
+ return `yes - ${req.someReqAttr} - ${docId}`;
274
+ }
275
+ };
276
+ }
277
+ }
278
+ }
253
279
  });
254
280
  assert(apos.schema);
255
281
  apos.argv._ = [];
@@ -1805,6 +1831,163 @@ describe('Schemas', function() {
1805
1831
  }, 'age', 'required');
1806
1832
  });
1807
1833
 
1834
+ it('should ignore required property when external condition does not match', async function() {
1835
+ const req = apos.task.getReq();
1836
+ const schema = apos.schema.compose({
1837
+ addFields: [
1838
+ {
1839
+ name: 'age',
1840
+ type: 'integer',
1841
+ required: true,
1842
+ if: {
1843
+ 'external-condition:externalCondition()': 'no'
1844
+ }
1845
+ },
1846
+ {
1847
+ name: 'shoeSize',
1848
+ type: 'integer',
1849
+ required: false
1850
+ }
1851
+ ]
1852
+ });
1853
+ const output = {};
1854
+ await apos.schema.convert(req, schema, {
1855
+ shoeSize: 20
1856
+ }, output);
1857
+ assert(output.shoeSize === 20);
1858
+ });
1859
+
1860
+ it('should enforce required property when external condition matches', async function() {
1861
+ const schema = apos.schema.compose({
1862
+ addFields: [
1863
+ {
1864
+ name: 'age',
1865
+ type: 'integer',
1866
+ required: true,
1867
+ if: {
1868
+ 'external-condition:externalCondition()': 'yes'
1869
+ }
1870
+ }
1871
+ ]
1872
+ });
1873
+
1874
+ await testSchemaError(schema, {}, 'age', 'required');
1875
+ });
1876
+
1877
+ it('should use the field module name by default when the external condition key does not contain it', async function() {
1878
+ const req = apos.task.getReq();
1879
+ const conditionKey = 'externalCondition()';
1880
+ const fieldName = 'someField';
1881
+ const fieldModuleName = 'external-condition';
1882
+ const docId = 'some-doc-id';
1883
+
1884
+ const result = await apos.schema.evaluateExternalCondition(req, conditionKey, fieldName, fieldModuleName, docId);
1885
+
1886
+ assert(result === 'yes');
1887
+ });
1888
+
1889
+ it('should pass req and the doc ID to the external condition method', async function() {
1890
+ const someReqAttr = 'some-attribute-on-req';
1891
+ const req = apos.task.getReq({
1892
+ someReqAttr
1893
+ });
1894
+ const conditionKey = 'external-condition:externalCondition2()';
1895
+ const fieldName = 'someField';
1896
+ const fieldModuleName = 'external-condition';
1897
+ const docId = 'some-doc-id';
1898
+
1899
+ const result = await apos.schema.evaluateExternalCondition(req, conditionKey, fieldName, fieldModuleName, docId);
1900
+
1901
+ assert(result === `yes - ${someReqAttr} - ${docId}`);
1902
+ });
1903
+
1904
+ it('should warn when an argument is passed in the external condition key', async function() {
1905
+ const req = apos.task.getReq();
1906
+ const conditionKey = 'external-condition:externalCondition(letsNotArgue)';
1907
+ const fieldName = 'someField';
1908
+ const fieldModuleName = 'external-condition';
1909
+ const docId = 'some-doc-id';
1910
+
1911
+ const result = await apos.schema.evaluateExternalCondition(req, conditionKey, fieldName, fieldModuleName, docId);
1912
+
1913
+ assert(warnMessages.includes('Warning in the `if` definition of the "someField" field: "external-condition:externalCondition()" should not be passed any argument.'));
1914
+ assert(result === 'yes');
1915
+ });
1916
+
1917
+ it('should throw when the module defined in the external condition key is not found', async function() {
1918
+ const req = apos.task.getReq();
1919
+ const conditionKey = 'unknown-module:externalCondition()';
1920
+ const fieldName = 'someField';
1921
+ const fieldModuleName = 'unknown-module';
1922
+ const docId = 'some-doc-id';
1923
+
1924
+ try {
1925
+ await apos.schema.evaluateExternalCondition(req, conditionKey, fieldName, fieldModuleName, docId);
1926
+ } catch (error) {
1927
+ assert(error.message === 'Error in the `if` definition of the "someField" field: "unknown-module" module not found.');
1928
+ return;
1929
+ }
1930
+ throw new Error('should have thrown');
1931
+ });
1932
+
1933
+ it('should throw when the method defined in the external condition key is not found', async function() {
1934
+ const req = apos.task.getReq();
1935
+ const conditionKey = 'external-condition:unknownMethod()';
1936
+ const fieldName = 'someField';
1937
+ const fieldModuleName = 'external-condition';
1938
+ const docId = 'some-doc-id';
1939
+
1940
+ try {
1941
+ await apos.schema.evaluateExternalCondition(req, conditionKey, fieldName, fieldModuleName, docId);
1942
+ } catch (error) {
1943
+ assert(error.message === 'Error in the `if` definition of the "someField" field: "unknownMethod" method not found in "external-condition" module.');
1944
+ return;
1945
+ }
1946
+ throw new Error('should have thrown');
1947
+ });
1948
+
1949
+ it('should call the evaluate-external-condition API successfully', async function() {
1950
+ apos.schema.fieldsById['some-field-id'] = {
1951
+ name: 'someField',
1952
+ moduleName: 'external-condition'
1953
+ };
1954
+
1955
+ const res = await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=externalCondition()', {});
1956
+ assert(res === 'yes');
1957
+ });
1958
+
1959
+ it('should receive a clean error response when the evaluate-external-condition API call fails (module not found)', async function() {
1960
+ apos.schema.fieldsById['some-field-id'] = {
1961
+ name: 'someField',
1962
+ moduleName: 'unknown-module'
1963
+ };
1964
+
1965
+ try {
1966
+ await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=externalCondition()', {});
1967
+ } catch (error) {
1968
+ assert(error.status = 400);
1969
+ assert(error.body.message === 'Error in the `if` definition of the "someField" field: "unknown-module" module not found.');
1970
+ return;
1971
+ }
1972
+ throw new Error('should have thrown');
1973
+ });
1974
+
1975
+ it('should receive a clean error response when the evaluate-external-condition API call fails (external method not found)', async function() {
1976
+ apos.schema.fieldsById['some-field-id'] = {
1977
+ name: 'someField',
1978
+ moduleName: 'external-condition'
1979
+ };
1980
+
1981
+ try {
1982
+ await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=unknownMethod()', {});
1983
+ } catch (error) {
1984
+ assert(error.status = 400);
1985
+ assert(error.body.message === 'Error in the `if` definition of the "someField" field: "unknownMethod" method not found in "external-condition" module.');
1986
+ return;
1987
+ }
1988
+ throw new Error('should have thrown');
1989
+ });
1990
+
1808
1991
  it('should save date and time with the right format', async function () {
1809
1992
  const req = apos.task.getReq();
1810
1993
  const schema = apos.schema.compose({