cht-conf 3.11.1 → 3.12.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,3 +1,10 @@
1
+ # [3.12.0](https://github.com/medic/cht-conf/compare/v3.11.1...v3.12.0) (2022-06-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * **#484:** add actions for validating forms ([f2a6a8e](https://github.com/medic/cht-conf/commit/f2a6a8ef75561426ce2a6b542cdf1a255c869c6e)), closes [#484](https://github.com/medic/cht-conf/issues/484) [#481](https://github.com/medic/cht-conf/issues/481)
7
+
1
8
  ## [3.11.1](https://github.com/medic/cht-conf/compare/v3.11.0...v3.11.1) (2022-06-13)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "3.11.1",
3
+ "version": "3.12.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
package/src/cli/usage.js CHANGED
@@ -63,6 +63,9 @@ ${bold('OPTIONS')}
63
63
  --skip-translation-check
64
64
  Skips checking message translations
65
65
 
66
+ --skip-validate
67
+ Skips form validation
68
+
66
69
  --force
67
70
  CAN BE DANGEROUS! Passes yes to all commands and any where that would prompt to overwrite changes will overwrite automatically.
68
71
  `);
@@ -0,0 +1,12 @@
1
+ const validateForms = require('../lib/validate-forms');
2
+ const environment = require('../lib/environment');
3
+
4
+ const validateAppForms = (forms) => {
5
+ return validateForms(environment.pathToProject, 'app', { forms });
6
+ };
7
+
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ validateAppForms,
11
+ execute: () => validateAppForms(environment.extraArgs)
12
+ };
@@ -0,0 +1,12 @@
1
+ const validateForms = require('../lib/validate-forms');
2
+ const environment = require('../lib/environment');
3
+
4
+ const validateCollectForms = (forms) => {
5
+ return validateForms(environment.pathToProject, 'collect', { forms });
6
+ };
7
+
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ validateCollectForms,
11
+ execute: () => validateCollectForms(environment.extraArgs)
12
+ };
@@ -0,0 +1,12 @@
1
+ const validateForms = require('../lib/validate-forms');
2
+ const environment = require('../lib/environment');
3
+
4
+ const validateContactForms = (forms) => {
5
+ return validateForms(environment.pathToProject, 'contact', { forms });
6
+ };
7
+
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ validateContactForms,
11
+ execute: () => validateContactForms(environment.extraArgs)
12
+ };
@@ -5,6 +5,9 @@ const fs = require('fs');
5
5
  const { error, warn, info } = require('../lib/log');
6
6
  const Queue = require('queue-promise');
7
7
  const watcher = require('@parcel/watcher');
8
+ const { validateAppForms } = require('./validate-app-forms');
9
+ const { validateContactForms } = require('./validate-contact-forms');
10
+ const { validateCollectForms } = require('./validate-collect-forms');
8
11
  const { uploadAppForms } = require('./upload-app-forms');
9
12
  const { uploadContactForms } = require('./upload-contact-forms');
10
13
  const { uploadCollectForms } = require('./upload-collect-forms');
@@ -25,8 +28,17 @@ const watcherEvents = {
25
28
  UpdateEvent: 'update'
26
29
  };
27
30
 
31
+ const runValidation = (validation, forms) => {
32
+ if(environment.skipValidate) {
33
+ return;
34
+ }
35
+ return validation(forms);
36
+ };
37
+
28
38
  const uploadInitialState = async (api) => {
29
39
  await uploadResources();
40
+ await runValidation(validateAppForms, environment.extraArgs);
41
+ await runValidation(validateContactForms, environment.extraArgs);
30
42
  await uploadAppForms(environment.extraArgs);
31
43
  await uploadContactForms(environment.extraArgs);
32
44
  await uploadCustomTranslations();
@@ -82,6 +94,7 @@ const processAppForm = (eventType, fileName) => {
82
94
  let form = uploadForms.formFileMatcher(fileName);
83
95
  if (form) {
84
96
  eventQueue.enqueue(async () => {
97
+ await runValidation(validateAppForms, [form]);
85
98
  await uploadAppForms([form]);
86
99
  return fileName;
87
100
  });
@@ -103,6 +116,7 @@ const processAppFormMedia = (formMediaDir, fileName) => {
103
116
  const form = uploadForms.formMediaMatcher(formMediaDir);
104
117
  if (form) {
105
118
  eventQueue.enqueue(async () => {
119
+ await runValidation(validateAppForms,[form]);
106
120
  await uploadAppForms([form]);
107
121
  return fileName;
108
122
  });
@@ -127,6 +141,7 @@ const processContactForm = (eventType, fileName) => {
127
141
  form = uploadForms.formFileMatcher(fileName);
128
142
  if (form) {
129
143
  eventQueue.enqueue(async () => {
144
+ await runValidation(validateContactForms,[form]);
130
145
  await uploadContactForms([form]);
131
146
  return fileName;
132
147
  });
@@ -142,6 +157,7 @@ const processCollectForm = (eventType, fileName) => {
142
157
  let form = uploadForms.formFileMatcher(fileName);
143
158
  if (form) {
144
159
  eventQueue.enqueue(async () => {
160
+ await runValidation(validateCollectForms,[form]);
145
161
  await uploadCollectForms([form]);
146
162
  return fileName;
147
163
  });
@@ -12,7 +12,8 @@ const initialize = (
12
12
  extraArgs,
13
13
  apiUrl,
14
14
  force,
15
- skipTranslationCheck
15
+ skipTranslationCheck,
16
+ skipValidate
16
17
  ) => {
17
18
  if (state.initialized) {
18
19
  throw Error('environment is already initialized');
@@ -26,7 +27,8 @@ const initialize = (
26
27
  isArchiveMode,
27
28
  pathToProject,
28
29
  force,
29
- skipTranslationCheck
30
+ skipTranslationCheck,
31
+ skipValidate
30
32
  });
31
33
  };
32
34
 
@@ -51,6 +53,7 @@ module.exports = {
51
53
  get apiUrl() { return getState('apiUrl'); },
52
54
  get force() { return getState('force'); },
53
55
  get skipTranslationCheck() { return getState('skipTranslationCheck'); },
56
+ get skipValidate() { return getState('skipValidate'); },
54
57
 
55
58
  /**
56
59
  * Return `true` if the environment **seems** to be production.
package/src/lib/main.js CHANGED
@@ -21,6 +21,9 @@ const defaultActions = [
21
21
  'convert-app-forms',
22
22
  'convert-collect-forms',
23
23
  'convert-contact-forms',
24
+ 'validate-app-forms',
25
+ 'validate-collect-forms',
26
+ 'validate-contact-forms',
24
27
  'backup-all-forms',
25
28
  'delete-all-forms',
26
29
  'upload-app-forms',
@@ -38,6 +41,9 @@ const defaultArchiveActions = [
38
41
  'convert-app-forms',
39
42
  'convert-collect-forms',
40
43
  'convert-contact-forms',
44
+ 'validate-app-forms',
45
+ 'validate-collect-forms',
46
+ 'validate-contact-forms',
41
47
  'upload-app-forms',
42
48
  'upload-collect-forms',
43
49
  'upload-contact-forms',
@@ -123,19 +129,40 @@ module.exports = async (argv, env) => {
123
129
  // Build up actions
124
130
  //
125
131
  let actions = cmdArgs._;
126
- if (!actions.length) {
132
+ if (actions.length) {
133
+ const unsupported = actions.filter(a => !supportedActions.includes(a));
134
+ if(unsupported.length) {
135
+ throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
136
+ }
137
+ } else {
127
138
  actions = !cmdArgs.archive ? defaultActions : defaultArchiveActions;
128
139
  }
129
140
 
130
- const unsupported = actions.filter(a => !supportedActions.includes(a));
131
- if(unsupported.length) {
132
- throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
133
- }
134
-
135
141
  if (cmdArgs['skip-git-check']) {
136
142
  actions = actions.filter(a => a !== 'check-git');
137
143
  }
138
144
 
145
+ const skipValidate = cmdArgs['skip-validate'];
146
+ if(skipValidate) {
147
+ warn('Skipping all form validation.');
148
+ const validateActions = [
149
+ 'validate-app-forms',
150
+ 'validate-collect-forms',
151
+ 'validate-contact-forms'
152
+ ];
153
+ actions = actions.filter(action => !validateActions.includes(action));
154
+ } else {
155
+ const addFormValidationIfNecessary = (formType) => {
156
+ const updateFormsIndex = actions.indexOf(`upload-${formType}-forms`);
157
+ if (updateFormsIndex >= 0 && actions.indexOf(`validate-${formType}-forms`) < 0) {
158
+ actions.splice(updateFormsIndex, 0, `validate-${formType}-forms`);
159
+ }
160
+ };
161
+ addFormValidationIfNecessary('app');
162
+ addFormValidationIfNecessary('collect');
163
+ addFormValidationIfNecessary('contact');
164
+ }
165
+
139
166
  actions = actions.map(actionName => {
140
167
  const action = require(`../fn/${actionName}`);
141
168
 
@@ -157,9 +184,9 @@ module.exports = async (argv, env) => {
157
184
  //
158
185
  const projectName = fs.path.basename(pathToProject);
159
186
 
160
- let apiUrl;
161
- if (actions.some(action => action.requiresInstance)) {
162
- apiUrl = getApiUrl(cmdArgs, env);
187
+ const apiUrl = getApiUrl(cmdArgs, env);
188
+ const requiresInstance = actions.some(action => action.requiresInstance);
189
+ if (requiresInstance) {
163
190
  if (!apiUrl) {
164
191
  throw new Error('Failed to obtain a url to the API');
165
192
  }
@@ -177,10 +204,11 @@ module.exports = async (argv, env) => {
177
204
  extraArgs,
178
205
  apiUrl,
179
206
  cmdArgs.force,
180
- cmdArgs['skip-translation-check']
207
+ cmdArgs['skip-translation-check'],
208
+ skipValidate
181
209
  );
182
210
 
183
- if (apiUrl) {
211
+ if (requiresInstance && apiUrl) {
184
212
  await api().available();
185
213
  }
186
214
 
@@ -13,7 +13,6 @@ const {
13
13
  readTitleFrom,
14
14
  readIdFrom
15
15
  } = require('./forms-utils');
16
- const validateForms = require('./validate-forms');
17
16
 
18
17
  const SUPPORTED_PROPERTIES = ['context', 'icon', 'title', 'xml2sms', 'subject_key', 'hidden_fields'];
19
18
  const FORM_EXTENSTION = '.xml';
@@ -40,7 +39,6 @@ const formMediaMatcher = (formMediaDir) => {
40
39
  };
41
40
 
42
41
  const execute = async (projectDir, subDirectory, options) => {
43
- await validateForms(projectDir, subDirectory, options);
44
42
  const db = pouch();
45
43
  if (!options) options = {};
46
44
  const formsDir = getFormDir(projectDir, subDirectory);
@@ -1,64 +1,82 @@
1
- const api = require('./api');
2
1
  const argsFormFilter = require('./args-form-filter');
2
+ const environment = require('./environment');
3
3
  const fs = require('./sync-fs');
4
4
  const log = require('./log');
5
5
  const {
6
6
  getFormDir,
7
- getFormFilePaths,
8
- formHasInstanceId
7
+ getFormFilePaths
9
8
  } = require('./forms-utils');
10
9
 
10
+ const VALIDATIONS_PATH = fs.path.resolve(__dirname, './validation/form');
11
+ const validations = fs.readdir(VALIDATIONS_PATH)
12
+ .filter(name => name.endsWith('.js'))
13
+ .map(validationName => {
14
+ const validation = require(fs.path.join(VALIDATIONS_PATH, validationName));
15
+ validation.name = validationName;
16
+ if(!Object.hasOwnProperty.call(validation, 'requiresInstance')) {
17
+ validation.requiresInstance = true;
18
+ }
19
+ if(!Object.hasOwnProperty.call(validation, 'skipFurtherValidation')) {
20
+ validation.skipFurtherValidation = false;
21
+ }
22
+
23
+ return validation;
24
+ })
25
+ .sort((a, b) => {
26
+ if(a.skipFurtherValidation && !b.skipFurtherValidation) {
27
+ return -1;
28
+ }
29
+ if(!a.skipFurtherValidation && b.skipFurtherValidation) {
30
+ return 1;
31
+ }
32
+ return a.name.localeCompare(b.name);
33
+ });
34
+
11
35
  module.exports = async (projectDir, subDirectory, options={}) => {
12
36
 
13
37
  const formsDir = getFormDir(projectDir, subDirectory);
14
38
  if(!formsDir) {
15
- log.info(`Forms dir not found: ${formsDir}`);
39
+ log.info(`Forms dir not found: ${projectDir}/forms/${subDirectory}`);
16
40
  return;
17
41
  }
18
42
 
19
- let idValidationsPassed = true;
20
- let validateFormsPassed = true;
43
+ const instanceProvided = environment.apiUrl;
44
+ let validationSkipped = false;
21
45
 
22
46
  const fileNames = argsFormFilter(formsDir, '.xml', options);
23
- for (const fileName of fileNames) {
47
+
48
+ let errorFound = false;
49
+ for(const fileName of fileNames) {
24
50
  log.info(`Validating form: ${fileName}…`);
25
51
 
26
- const { xformPath, filePath } = getFormFilePaths(formsDir, fileName);
52
+ const { xformPath } = getFormFilePaths(formsDir, fileName);
27
53
  const xml = fs.read(xformPath);
28
54
 
29
- if(!formHasInstanceId(xml)) {
30
- log.error(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
31
- idValidationsPassed = false;
32
- continue;
33
- }
34
- try {
35
- await _formsValidate(xml);
36
- } catch (err) {
37
- log.error(`Error found while validating "${filePath}". Validation response: ${err.message}`);
38
- validateFormsPassed = false;
55
+ const valParams = { xformPath, xmlStr: xml };
56
+ for(const validation of validations) {
57
+ if(validation.requiresInstance && !instanceProvided) {
58
+ validationSkipped = true;
59
+ continue;
60
+ }
61
+
62
+ const output = await validation.execute(valParams);
63
+ if(output.warnings) {
64
+ output.warnings.forEach(warnMsg => log.warn(warnMsg));
65
+ }
66
+ if(output.errors && output.errors.length) {
67
+ output.errors.forEach(errorMsg => log.error(errorMsg));
68
+ errorFound = true;
69
+ if(validation.skipFurtherValidation) {
70
+ break;
71
+ }
72
+ }
39
73
  }
40
74
  }
41
- // Once all the fails were checked raise an exception if there were errors
42
- let errors = [];
43
- if (!validateFormsPassed) {
44
- errors.push('One or more forms appears to have errors found by the API validation endpoint.');
45
- }
46
- if (!idValidationsPassed) {
47
- errors.push('One or more forms appears to be missing <meta><instanceID/></meta> node.');
48
- }
49
- if (errors.length) {
50
- // the blank spaces are a trick to align the errors in the log ;)
51
- throw new Error(errors.join('\n '));
52
- }
53
- };
54
75
 
55
- let validateEndpointNotFoundLogged = false;
56
- const _formsValidate = async (xml) => {
57
- const resp = await api().formsValidate(xml);
58
- if (resp.formsValidateEndpointFound === false && !validateEndpointNotFoundLogged) {
59
- log.warn('Form validation endpoint not found in your version of CHT Core, ' +
60
- 'no form will be checked before push');
61
- validateEndpointNotFoundLogged = true; // Just log the message once
76
+ if(validationSkipped) {
77
+ log.warn('Some validations have been skipped because they require a CHT instance.');
78
+ }
79
+ if(errorFound) {
80
+ throw new Error('One or more forms have failed validation.');
62
81
  }
63
- return resp;
64
82
  };
@@ -0,0 +1,32 @@
1
+ # Form validations
2
+
3
+ Form validations should be added to this directory.
4
+
5
+ Their module should look something like this:
6
+
7
+ ```js
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ skipFurtherValidation: true,
11
+ execute: async ({ xformPath, xmlStr }) => {
12
+ ...
13
+ }
14
+ };
15
+ ```
16
+
17
+ ## Module Exports
18
+
19
+ | Field | Required | Notes |
20
+ |-------------------------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
21
+ | `requiresInstance` | Optional, defaults to `true` | The validation needs the user to have provided a instance location, e.g. via `--local` or `--instance` |
22
+ | `skipFurtherValidation` | Optional, defaults to `false` | When `true`, additional validations will not be performed on a form that fails the current validation. The goal is to avoid unnecessary noise in the validation log for forms that are grossly invalid (e.g. XML files that are not actually xForms at all). |
23
+ | `execute` | Required | The function that is run when the validation is executed. The provided input argument contains the form XML string. |
24
+
25
+ ## Result
26
+
27
+ The result has the following format:
28
+
29
+ | Field | Notes |
30
+ |----------|----------------------------------------------------------------------------------------------------|
31
+ | warnings | Array containing the descriptions of any warnings. |
32
+ | errors | Array containing the descriptions of any errors. If this is empty, the form has passed validation. |
@@ -0,0 +1,18 @@
1
+ const api = require('../../api');
2
+
3
+ module.exports = {
4
+ execute: async ({ xformPath, xmlStr }) => {
5
+ const warnings = [];
6
+ const errors = [];
7
+ try {
8
+ const resp = await api().formsValidate(xmlStr);
9
+ if (resp.formsValidateEndpointFound === false) {
10
+ warnings.push('Form validation endpoint not found in your version of CHT Core, no form will be checked before push');
11
+ }
12
+ } catch (err) {
13
+ errors.push(`Error found while validating "${xformPath}". Validation response: ${err.message}`);
14
+ }
15
+
16
+ return { errors, warnings };
17
+ }
18
+ };
@@ -0,0 +1,14 @@
1
+ const { formHasInstanceId } = require('../../forms-utils');
2
+
3
+ module.exports = {
4
+ requiresInstance: false,
5
+ skipFurtherValidation: true,
6
+ execute: async({ xformPath, xmlStr }) => {
7
+ const errors = [];
8
+ if(!formHasInstanceId(xmlStr)) {
9
+ errors.push(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
10
+ }
11
+
12
+ return { errors, warnings: [] };
13
+ }
14
+ };