Skip to content

Commit

Permalink
feat(#484): add actions for validating forms
Browse files Browse the repository at this point in the history
Add support for validating app, collect, and contact forms without needing to attempt uploading the forms to the server via the `upload-*-forms` actions.

New actions:

- `validate-app-forms`
- `validate-collect-forms`
- `validate-contact-forms`

Form validation will still be automatically run when uploading forms even if no validate actions are specified by the user.

Closes #481
  • Loading branch information
jkuester authored Jun 23, 2022
1 parent 21af87d commit f2a6a8e
Show file tree
Hide file tree
Showing 17 changed files with 476 additions and 104 deletions.
3 changes: 3 additions & 0 deletions src/cli/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ ${bold('OPTIONS')}
--skip-translation-check
Skips checking message translations
--skip-validate
Skips form validation
--force
CAN BE DANGEROUS! Passes yes to all commands and any where that would prompt to overwrite changes will overwrite automatically.
`);
Expand Down
12 changes: 12 additions & 0 deletions src/fn/validate-app-forms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const validateForms = require('../lib/validate-forms');
const environment = require('../lib/environment');

const validateAppForms = (forms) => {
return validateForms(environment.pathToProject, 'app', { forms });
};

module.exports = {
requiresInstance: false,
validateAppForms,
execute: () => validateAppForms(environment.extraArgs)
};
12 changes: 12 additions & 0 deletions src/fn/validate-collect-forms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const validateForms = require('../lib/validate-forms');
const environment = require('../lib/environment');

const validateCollectForms = (forms) => {
return validateForms(environment.pathToProject, 'collect', { forms });
};

module.exports = {
requiresInstance: false,
validateCollectForms,
execute: () => validateCollectForms(environment.extraArgs)
};
12 changes: 12 additions & 0 deletions src/fn/validate-contact-forms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const validateForms = require('../lib/validate-forms');
const environment = require('../lib/environment');

const validateContactForms = (forms) => {
return validateForms(environment.pathToProject, 'contact', { forms });
};

module.exports = {
requiresInstance: false,
validateContactForms,
execute: () => validateContactForms(environment.extraArgs)
};
16 changes: 16 additions & 0 deletions src/fn/watch-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const fs = require('fs');
const { error, warn, info } = require('../lib/log');
const Queue = require('queue-promise');
const watcher = require('@parcel/watcher');
const { validateAppForms } = require('./validate-app-forms');
const { validateContactForms } = require('./validate-contact-forms');
const { validateCollectForms } = require('./validate-collect-forms');
const { uploadAppForms } = require('./upload-app-forms');
const { uploadContactForms } = require('./upload-contact-forms');
const { uploadCollectForms } = require('./upload-collect-forms');
Expand All @@ -25,8 +28,17 @@ const watcherEvents = {
UpdateEvent: 'update'
};

const runValidation = (validation, forms) => {
if(environment.skipValidate) {
return;
}
return validation(forms);
};

const uploadInitialState = async (api) => {
await uploadResources();
await runValidation(validateAppForms, environment.extraArgs);
await runValidation(validateContactForms, environment.extraArgs);
await uploadAppForms(environment.extraArgs);
await uploadContactForms(environment.extraArgs);
await uploadCustomTranslations();
Expand Down Expand Up @@ -82,6 +94,7 @@ const processAppForm = (eventType, fileName) => {
let form = uploadForms.formFileMatcher(fileName);
if (form) {
eventQueue.enqueue(async () => {
await runValidation(validateAppForms, [form]);
await uploadAppForms([form]);
return fileName;
});
Expand All @@ -103,6 +116,7 @@ const processAppFormMedia = (formMediaDir, fileName) => {
const form = uploadForms.formMediaMatcher(formMediaDir);
if (form) {
eventQueue.enqueue(async () => {
await runValidation(validateAppForms,[form]);
await uploadAppForms([form]);
return fileName;
});
Expand All @@ -127,6 +141,7 @@ const processContactForm = (eventType, fileName) => {
form = uploadForms.formFileMatcher(fileName);
if (form) {
eventQueue.enqueue(async () => {
await runValidation(validateContactForms,[form]);
await uploadContactForms([form]);
return fileName;
});
Expand All @@ -142,6 +157,7 @@ const processCollectForm = (eventType, fileName) => {
let form = uploadForms.formFileMatcher(fileName);
if (form) {
eventQueue.enqueue(async () => {
await runValidation(validateCollectForms,[form]);
await uploadCollectForms([form]);
return fileName;
});
Expand Down
7 changes: 5 additions & 2 deletions src/lib/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const initialize = (
extraArgs,
apiUrl,
force,
skipTranslationCheck
skipTranslationCheck,
skipValidate
) => {
if (state.initialized) {
throw Error('environment is already initialized');
Expand All @@ -26,7 +27,8 @@ const initialize = (
isArchiveMode,
pathToProject,
force,
skipTranslationCheck
skipTranslationCheck,
skipValidate
});
};

Expand All @@ -51,6 +53,7 @@ module.exports = {
get apiUrl() { return getState('apiUrl'); },
get force() { return getState('force'); },
get skipTranslationCheck() { return getState('skipTranslationCheck'); },
get skipValidate() { return getState('skipValidate'); },

/**
* Return `true` if the environment **seems** to be production.
Expand Down
50 changes: 39 additions & 11 deletions src/lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const defaultActions = [
'convert-app-forms',
'convert-collect-forms',
'convert-contact-forms',
'validate-app-forms',
'validate-collect-forms',
'validate-contact-forms',
'backup-all-forms',
'delete-all-forms',
'upload-app-forms',
Expand All @@ -38,6 +41,9 @@ const defaultArchiveActions = [
'convert-app-forms',
'convert-collect-forms',
'convert-contact-forms',
'validate-app-forms',
'validate-collect-forms',
'validate-contact-forms',
'upload-app-forms',
'upload-collect-forms',
'upload-contact-forms',
Expand Down Expand Up @@ -123,19 +129,40 @@ module.exports = async (argv, env) => {
// Build up actions
//
let actions = cmdArgs._;
if (!actions.length) {
if (actions.length) {
const unsupported = actions.filter(a => !supportedActions.includes(a));
if(unsupported.length) {
throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
}
} else {
actions = !cmdArgs.archive ? defaultActions : defaultArchiveActions;
}

const unsupported = actions.filter(a => !supportedActions.includes(a));
if(unsupported.length) {
throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
}

if (cmdArgs['skip-git-check']) {
actions = actions.filter(a => a !== 'check-git');
}

const skipValidate = cmdArgs['skip-validate'];
if(skipValidate) {
warn('Skipping all form validation.');
const validateActions = [
'validate-app-forms',
'validate-collect-forms',
'validate-contact-forms'
];
actions = actions.filter(action => !validateActions.includes(action));
} else {
const addFormValidationIfNecessary = (formType) => {
const updateFormsIndex = actions.indexOf(`upload-${formType}-forms`);
if (updateFormsIndex >= 0 && actions.indexOf(`validate-${formType}-forms`) < 0) {
actions.splice(updateFormsIndex, 0, `validate-${formType}-forms`);
}
};
addFormValidationIfNecessary('app');
addFormValidationIfNecessary('collect');
addFormValidationIfNecessary('contact');
}

actions = actions.map(actionName => {
const action = require(`../fn/${actionName}`);

Expand All @@ -157,9 +184,9 @@ module.exports = async (argv, env) => {
//
const projectName = fs.path.basename(pathToProject);

let apiUrl;
if (actions.some(action => action.requiresInstance)) {
apiUrl = getApiUrl(cmdArgs, env);
const apiUrl = getApiUrl(cmdArgs, env);
const requiresInstance = actions.some(action => action.requiresInstance);
if (requiresInstance) {
if (!apiUrl) {
throw new Error('Failed to obtain a url to the API');
}
Expand All @@ -177,10 +204,11 @@ module.exports = async (argv, env) => {
extraArgs,
apiUrl,
cmdArgs.force,
cmdArgs['skip-translation-check']
cmdArgs['skip-translation-check'],
skipValidate
);

if (apiUrl) {
if (requiresInstance && apiUrl) {
await api().available();
}

Expand Down
2 changes: 0 additions & 2 deletions src/lib/upload-forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const {
readTitleFrom,
readIdFrom
} = require('./forms-utils');
const validateForms = require('./validate-forms');

const SUPPORTED_PROPERTIES = ['context', 'icon', 'title', 'xml2sms', 'subject_key', 'hidden_fields'];
const FORM_EXTENSTION = '.xml';
Expand All @@ -40,7 +39,6 @@ const formMediaMatcher = (formMediaDir) => {
};

const execute = async (projectDir, subDirectory, options) => {
await validateForms(projectDir, subDirectory, options);
const db = pouch();
if (!options) options = {};
const formsDir = getFormDir(projectDir, subDirectory);
Expand Down
96 changes: 57 additions & 39 deletions src/lib/validate-forms.js
Original file line number Diff line number Diff line change
@@ -1,64 +1,82 @@
const api = require('./api');
const argsFormFilter = require('./args-form-filter');
const environment = require('./environment');
const fs = require('./sync-fs');
const log = require('./log');
const {
getFormDir,
getFormFilePaths,
formHasInstanceId
getFormFilePaths
} = require('./forms-utils');

const VALIDATIONS_PATH = fs.path.resolve(__dirname, './validation/form');
const validations = fs.readdir(VALIDATIONS_PATH)
.filter(name => name.endsWith('.js'))
.map(validationName => {
const validation = require(fs.path.join(VALIDATIONS_PATH, validationName));
validation.name = validationName;
if(!Object.hasOwnProperty.call(validation, 'requiresInstance')) {
validation.requiresInstance = true;
}
if(!Object.hasOwnProperty.call(validation, 'skipFurtherValidation')) {
validation.skipFurtherValidation = false;
}

return validation;
})
.sort((a, b) => {
if(a.skipFurtherValidation && !b.skipFurtherValidation) {
return -1;
}
if(!a.skipFurtherValidation && b.skipFurtherValidation) {
return 1;
}
return a.name.localeCompare(b.name);
});

module.exports = async (projectDir, subDirectory, options={}) => {

const formsDir = getFormDir(projectDir, subDirectory);
if(!formsDir) {
log.info(`Forms dir not found: ${formsDir}`);
log.info(`Forms dir not found: ${projectDir}/forms/${subDirectory}`);
return;
}

let idValidationsPassed = true;
let validateFormsPassed = true;
const instanceProvided = environment.apiUrl;
let validationSkipped = false;

const fileNames = argsFormFilter(formsDir, '.xml', options);
for (const fileName of fileNames) {

let errorFound = false;
for(const fileName of fileNames) {
log.info(`Validating form: ${fileName}…`);

const { xformPath, filePath } = getFormFilePaths(formsDir, fileName);
const { xformPath } = getFormFilePaths(formsDir, fileName);
const xml = fs.read(xformPath);

if(!formHasInstanceId(xml)) {
log.error(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
idValidationsPassed = false;
continue;
}
try {
await _formsValidate(xml);
} catch (err) {
log.error(`Error found while validating "${filePath}". Validation response: ${err.message}`);
validateFormsPassed = false;
const valParams = { xformPath, xmlStr: xml };
for(const validation of validations) {
if(validation.requiresInstance && !instanceProvided) {
validationSkipped = true;
continue;
}

const output = await validation.execute(valParams);
if(output.warnings) {
output.warnings.forEach(warnMsg => log.warn(warnMsg));
}
if(output.errors && output.errors.length) {
output.errors.forEach(errorMsg => log.error(errorMsg));
errorFound = true;
if(validation.skipFurtherValidation) {
break;
}
}
}
}
// Once all the fails were checked raise an exception if there were errors
let errors = [];
if (!validateFormsPassed) {
errors.push('One or more forms appears to have errors found by the API validation endpoint.');
}
if (!idValidationsPassed) {
errors.push('One or more forms appears to be missing <meta><instanceID/></meta> node.');
}
if (errors.length) {
// the blank spaces are a trick to align the errors in the log ;)
throw new Error(errors.join('\n '));
}
};

let validateEndpointNotFoundLogged = false;
const _formsValidate = async (xml) => {
const resp = await api().formsValidate(xml);
if (resp.formsValidateEndpointFound === false && !validateEndpointNotFoundLogged) {
log.warn('Form validation endpoint not found in your version of CHT Core, ' +
'no form will be checked before push');
validateEndpointNotFoundLogged = true; // Just log the message once
if(validationSkipped) {
log.warn('Some validations have been skipped because they require a CHT instance.');
}
if(errorFound) {
throw new Error('One or more forms have failed validation.');
}
return resp;
};
Loading

0 comments on commit f2a6a8e

Please sign in to comment.