From eb584691c1d04f98f0326f72821e982cfe5d9017 Mon Sep 17 00:00:00 2001 From: Martijn van de Rijdt Date: Fri, 1 Dec 2023 14:50:23 -0500 Subject: [PATCH 01/20] added: OC customized enketo-express-oc files (leaving app in broken state) --- packages/enketo-express/CUSTOMIZATIONS.md | 70 + packages/enketo-express/Gruntfile.js | 42 + packages/enketo-express/README.md | 29 +- .../app/controllers/account-controller.js | 133 ++ .../app/controllers/api-v1-controller.js | 7 +- .../app/controllers/api-v2-controller.js | 8 +- .../app/controllers/dev-controller.js | 11 + .../app/controllers/error-handler.js | 4 +- .../controllers/fieldsubmission-controller.js | 154 ++ .../app/controllers/oc-api-v1-controller.js | 777 +++++++ .../app/controllers/submission-controller.js | 64 +- .../app/controllers/survey-controller.js | 188 +- .../controllers/transformation-controller.js | 54 +- .../enketo-express/app/lib/communicator.js | 5 +- .../app/lib/headless-browser.js | 39 + packages/enketo-express/app/lib/headless.js | 70 + packages/enketo-express/app/lib/pdf.js | 66 +- .../enketo-express/app/lib/router-utils.js | 66 + packages/enketo-express/app/lib/url-oc.js | 28 + .../app/models/account-model.js | 300 ++- .../enketo-express/app/models/user-model.js | 8 + packages/enketo-express/app/views/layout.pug | 5 +- .../app/views/styles/common.scss | 2 - .../views/styles/component/_common-oc.scss | 482 +++++ .../views/styles/component/_common-ui.scss | 1 + .../app/views/styles/component/_grid-oc.scss | 40 + .../app/views/styles/component/_modal.scss | 2 +- .../app/views/styles/component/_print-oc.scss | 166 ++ .../views/styles/component/_variables.scss | 5 + .../theme-formhub/theme-formhub.print.scss | 2 + .../views/styles/theme-grid/_form-grid.scss | 1 + .../views/styles/theme-grid/_variables.scss | 2 + .../styles/theme-grid/theme-grid.print.scss | 2 + .../views/styles/theme-grid/theme-grid.scss | 4 + .../styles/theme-kobo/theme-kobo.print.scss | 2 + .../app/views/styles/theme-oc/_variables.scss | 12 + .../views/styles/theme-oc/theme-oc.print.scss | 4 + .../app/views/styles/theme-oc/theme-oc.scss | 50 + .../styles/theme-plain/theme-plain.print.scss | 2 + .../views/surveys/component/_enketo-power.pug | 10 +- .../views/surveys/component/_form-footer.pug | 43 +- .../views/surveys/component/_form-header.pug | 2 +- .../views/surveys/component/_side-slider.pug | 4 +- .../enketo-express/app/views/surveys/dev.pug | 53 + .../app/views/surveys/webform.pug | 14 +- packages/enketo-express/config/build.js | 2 +- .../enketo-express/config/default-config.json | 17 +- packages/enketo-express/config/express.js | 1 + ...linica-fieldsubmission-1.0.0-resolved.json | 618 ++++++ ...linica-fieldsubmission-2.0.0-resolved.json | 978 +++++++++ .../locales/src/de/translation-additions.json | 536 +++++ .../locales/src/en/translation-additions.json | 185 ++ .../locales/src/en/translation.json | 6 + packages/enketo-express/package.json | 8 +- .../enketo-express/public/images/favicon.ico | Bin 5430 -> 77907 bytes .../public/images/icon_180x180.png | Bin 7913 -> 7597 bytes .../public/js/src/enketo-webform-oc.js | 369 ++++ .../public/js/src/enketo-webform.js | 29 +- .../public/js/src/module/calculate.js | 20 + .../public/js/src/module/connection.js | 10 +- .../js/src/module/controller-webform-oc.js | 1676 +++++++++++++++ .../js/src/module/controller-webform.js | 2 + .../public/js/src/module/custom.js | 41 + .../public/js/src/module/download-utils.js | 11 + .../public/js/src/module/event.js | 32 + .../js/src/module/field-submission-queue.js | 336 +++ .../public/js/src/module/file-manager.js | 13 + .../public/js/src/module/form-model.js | 101 + .../public/js/src/module/form.js | 423 ++++ .../public/js/src/module/gui.js | 217 +- .../public/js/src/module/input.js | 13 + .../public/js/src/module/nodeset.js | 45 + .../public/js/src/module/page.js | 127 ++ .../public/js/src/module/radio-tab.js | 22 + .../public/js/src/module/reasons.js | 220 ++ .../public/js/src/module/relevant.js | 236 +++ .../public/js/src/module/repeat.js | 45 + .../public/js/src/module/required.js | 72 + .../public/js/src/module/settings.js | 63 +- .../public/js/src/module/utils.js | 22 + .../js/src/module/xpath-evaluator-binding.js | 13 + .../test/client/config/karma.conf.js | 3 +- .../test/client/connection.spec.js | 3 +- .../test/client/dn-widget.spec.js | 398 ++++ .../test/client/fieldsubmission.spec.js | 136 ++ .../test/client/form-extensions.spec.js | 44 + .../test/client/form-model-extensions.spec.js | 78 + .../enketo-express/test/client/forms/forms.js | 10 + .../forms/relevant_constraint_required.xml | 41 + .../test/client/forms/relevant_group.xml | 49 + .../test/client/helpers/test-widget.js | 250 +++ .../test/client/relevant-extensions.spec.js | 289 +++ .../test/client/widget.analog-scale.spec.js | 92 + .../client/widget.signature-external.spec.js | 56 + .../test/server/account-controller-oc.spec.js | 303 +++ .../test/server/account-model-oc.spec.js | 230 ++ .../test/server/account-model.spec.js | 1 + .../test/server/config-model.spec.js | 2 +- .../test/server/oc-api-controller.spec.js | 1098 ++++++++++ .../test/server/survey-controller.spec.js | 1 - .../enketo-express/test/server/url-oc.spec.js | 23 + .../enketo-express/tutorials/10-configure.md | 11 + .../tutorials/38-iframe-postmessage.md | 6 + .../tutorials/advanced-comment-widgets.md | 184 ++ packages/enketo-express/tutorials/oc-api.md | 287 +++ .../widget/analog-scale/analog-scalepicker.js | 225 ++ .../analog-scale/analog-scalepicker.scss | 246 +++ .../widget/discrepancy-note/dn-widget.js | 1858 +++++++++++++++++ .../widget/discrepancy-note/dn-widget.scss | 869 ++++++++ .../signature-external/signature-external.js | 18 + .../widget/strict-class/strict-class.js | 37 + .../widget/strict-class/strict-class.scss | 22 + 112 files changed, 16265 insertions(+), 147 deletions(-) create mode 100644 packages/enketo-express/CUSTOMIZATIONS.md create mode 100644 packages/enketo-express/app/controllers/account-controller.js create mode 100644 packages/enketo-express/app/controllers/dev-controller.js create mode 100644 packages/enketo-express/app/controllers/fieldsubmission-controller.js create mode 100644 packages/enketo-express/app/controllers/oc-api-v1-controller.js create mode 100644 packages/enketo-express/app/lib/headless-browser.js create mode 100644 packages/enketo-express/app/lib/headless.js create mode 100644 packages/enketo-express/app/lib/url-oc.js create mode 100644 packages/enketo-express/app/views/styles/component/_common-oc.scss create mode 100644 packages/enketo-express/app/views/styles/component/_grid-oc.scss create mode 100644 packages/enketo-express/app/views/styles/component/_print-oc.scss create mode 100644 packages/enketo-express/app/views/styles/theme-grid/_variables.scss create mode 100644 packages/enketo-express/app/views/styles/theme-oc/_variables.scss create mode 100644 packages/enketo-express/app/views/styles/theme-oc/theme-oc.print.scss create mode 100644 packages/enketo-express/app/views/styles/theme-oc/theme-oc.scss create mode 100644 packages/enketo-express/app/views/surveys/dev.pug create mode 100644 packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-1.0.0-resolved.json create mode 100644 packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-2.0.0-resolved.json create mode 100644 packages/enketo-express/locales/src/de/translation-additions.json create mode 100644 packages/enketo-express/locales/src/en/translation-additions.json create mode 100644 packages/enketo-express/public/js/src/enketo-webform-oc.js create mode 100644 packages/enketo-express/public/js/src/module/calculate.js create mode 100644 packages/enketo-express/public/js/src/module/controller-webform-oc.js create mode 100644 packages/enketo-express/public/js/src/module/custom.js create mode 100644 packages/enketo-express/public/js/src/module/download-utils.js create mode 100644 packages/enketo-express/public/js/src/module/field-submission-queue.js create mode 100644 packages/enketo-express/public/js/src/module/form-model.js create mode 100644 packages/enketo-express/public/js/src/module/form.js create mode 100644 packages/enketo-express/public/js/src/module/input.js create mode 100644 packages/enketo-express/public/js/src/module/nodeset.js create mode 100644 packages/enketo-express/public/js/src/module/page.js create mode 100644 packages/enketo-express/public/js/src/module/radio-tab.js create mode 100644 packages/enketo-express/public/js/src/module/reasons.js create mode 100644 packages/enketo-express/public/js/src/module/relevant.js create mode 100644 packages/enketo-express/public/js/src/module/repeat.js create mode 100644 packages/enketo-express/public/js/src/module/required.js create mode 100644 packages/enketo-express/public/js/src/module/xpath-evaluator-binding.js create mode 100644 packages/enketo-express/test/client/dn-widget.spec.js create mode 100644 packages/enketo-express/test/client/fieldsubmission.spec.js create mode 100644 packages/enketo-express/test/client/form-extensions.spec.js create mode 100644 packages/enketo-express/test/client/form-model-extensions.spec.js create mode 100644 packages/enketo-express/test/client/forms/forms.js create mode 100644 packages/enketo-express/test/client/forms/relevant_constraint_required.xml create mode 100644 packages/enketo-express/test/client/forms/relevant_group.xml create mode 100644 packages/enketo-express/test/client/helpers/test-widget.js create mode 100644 packages/enketo-express/test/client/relevant-extensions.spec.js create mode 100644 packages/enketo-express/test/client/widget.analog-scale.spec.js create mode 100644 packages/enketo-express/test/client/widget.signature-external.spec.js create mode 100644 packages/enketo-express/test/server/account-controller-oc.spec.js create mode 100644 packages/enketo-express/test/server/account-model-oc.spec.js create mode 100644 packages/enketo-express/test/server/oc-api-controller.spec.js create mode 100644 packages/enketo-express/test/server/url-oc.spec.js create mode 100644 packages/enketo-express/tutorials/advanced-comment-widgets.md create mode 100644 packages/enketo-express/tutorials/oc-api.md create mode 100644 packages/enketo-express/widget/analog-scale/analog-scalepicker.js create mode 100644 packages/enketo-express/widget/analog-scale/analog-scalepicker.scss create mode 100644 packages/enketo-express/widget/discrepancy-note/dn-widget.js create mode 100644 packages/enketo-express/widget/discrepancy-note/dn-widget.scss create mode 100644 packages/enketo-express/widget/signature-external/signature-external.js create mode 100644 packages/enketo-express/widget/strict-class/strict-class.js create mode 100644 packages/enketo-express/widget/strict-class/strict-class.scss diff --git a/packages/enketo-express/CUSTOMIZATIONS.md b/packages/enketo-express/CUSTOMIZATIONS.md new file mode 100644 index 000000000..3bfcb4a22 --- /dev/null +++ b/packages/enketo-express/CUSTOMIZATIONS.md @@ -0,0 +1,70 @@ +# Customizations + +This is an overview of the customizations that were made in this fork. + +### Discrepancy Note Widget + +Multi-threaded widget that builds upon Enketo's basic comment widget. The widget enables users to discuss data, add queries or annotations (2 different note types) to individual questions, and view a log of various events such as value changes. Ability to Close or Reopen a query thread is restricted to specific user roles. +We automatically update all constraint and required logic (after Pyxform conversion) to include clauses for the item's query status. If an item has New, Updated, or Closed Query status, the constraint/required error message is suppressed. We added a new status (closed-modified) that is automatically assigned to a close Query thread when the item value changes to ensure that the error message can be displayed again in the future after the query has been closed. + +[add screenshot] + +### Handling Non-relevant questions that have values + +As a rule, items with user-entered data are never hidden due to being irrelevant to ensure that users know what data exists in the record and do not mistakenly clear a section of data due to an errant click. If an item evaluates as non-relevant and is not null, a special error message is shown for the item. Calculated values can be cleared when an item/group becomes non-relevant under the assumption that it will be recalculated again if the item/group becomes relevant again. + +### Strict required and strict constraints + +Two additional 'strict' required and constraint types were added. +If a value is entered and it causes the item to have a strict constraint/required fire, the value is rejected before being added to the model and an error popup appears. The item remains at its previous value. If a value is entered and it causes a different item to have a strict constraint/required fire, that other item displays an error message with a different background color. That error cannot be suppressed by adding queries. While a strict constraint/required error is active, the user is prevented from navigating forward in the form or using the Complete button. +These allow for better assurance that critical bad data/missing data cannot be submitted while our query model allows for standard validation checks to be resolved by adding a query. + +### Multiple constraints + +Support for multiple constraints to be added shortly. +Separate constraint logic can be added (each with its own constraint message). These additional constraints cannot be defined as strict. The goal is to allow distinct constraint messages for each constraint condition to be defined directly as part of the item definition. +Longer term, we are also planning to link different query threads for an item to each constraint. This would allow the system to automatically reopen an existing query thread if needed if the constraint related to that thread fires again in the future. Eventually, it would also allow for individual query threads to be auto-closed if the associated constraint logic is no longer firing. + +### User confirmation before deleting a repeat + +Users are prompted to confirm their action when they click the button to remove a repeating group entry so that data is not deleted accidentally. + +### Required '\*' visibility + +When an item is currently required and currently blank, a red asterisk is displayed for that item. When the required condition is not met or the item has a non-null value, the asterisk is not displayed. This identifies required items that still need values to be entered without showing the required validation message. + +### API + +This fork is using an alternative API (/oc/api/v1) that returns the specific 'fieldsubmission' and headless views without interfering with the standard Enketo webform views. See [add link to doc]. + +### Fieldsubmissions + +Whenever an individual field changes a submission of that field (XML fragment) is submitted. The whole XML record is never submitted. The server-side API (implemented in OC's backend is documented here [ add link]). +Submitting individual values in real time ensures that data is not lost if a user loses their connection or times out. Several other features described here are at least somewhat driven by the presence of the field submission feature. + +#### Close vs Complete + +In the normal view, each form has a Close button on each page and a Complete button on the last page. The Complete button is used to signify that data entry is finished for the form. Any form can be closed. If validation errors are present for any items (excluding non-strict required errors) the user will be prompted to go back and fix them or proceed and let the system submit autoqueries for each item with an error. +Once a form has been marked as complete, future changes will require the user to enter a Reason for Change. Users are prevented from marking a form as complete if the following error conditions are present: strict required, strict constraint, and relevant errors. Currently any non-strict required or constraint error will also prevent marking a form complete, but we plan to change this and allow the user to add autoqueries if only these validation errors are present so that they can proceed through forms more efficiently. +For forms opened as already complete, only the Close button is present. When the user closes the form, they will be prompted to fix any errors or allow the system to add autoqueries. + +### Reason for Change + +When a form is opened as Complete, any data change will require a Reason for Change. This displays a message at each item changed and causes a Reason for Change section to appear at the bottom of the page. The user cannot leave the page or close the form without supplying a Reason or Change for each changed item (or entering a reason and choosing Apply to All). + +### Add Next Form + +Forms can be opened with an optional parameter to indicate the name of the next form to be opened in sequence. If present, a checkbox is displayed above the Close button on the last page to allow the user to decide whether to navigate directly to the next form or return to the primary OC UI. The status of the checkbox is sent back on Close or Complete so that OC can redirect the user appropriately. + +### Headless functionality + +API endpoints were added that can process imported data headlessly, run calculations and submit the results, validate the data and add autoqueries to discrepancy note questions as if the form were opened by a user and the user closed the form and chose to have the system add autoqueries. +This is used for many cases, such as when data was imported from an external source and when data originally entered on version 1 of a form is migrated to version 2 of the form. Headless mode allows calculations and queries to be performed as if a user had opened the form manually. + +### Theme and styling + +An OpenClinica theme was added that slightly tweaks the Formhub/Kobo themes. In addition, several styling changes are made across themes, but primarily to deal with requirements of the customizations described in this document. + +### Translations + +A mechanism existing in the standard Enketo Express is used to add additional translation strings to the standard strings without augmenting the original files. diff --git a/packages/enketo-express/Gruntfile.js b/packages/enketo-express/Gruntfile.js index 523944d77..6bf3190b1 100644 --- a/packages/enketo-express/Gruntfile.js +++ b/packages/enketo-express/Gruntfile.js @@ -314,6 +314,47 @@ module.exports = (grunt) => { ); }); + grunt.registerTask('transforms', 'Creating forms.js', function () { + const forms = {}; + const done = this.async(); + const formsJsPath = 'test/client/forms/forms.js'; + const xformsPaths = grunt.file.expand({}, 'test/client/forms/*.xml'); + const transformer = require('enketo-transformer'); + grunt.log.write('Transforming XForms '); + xformsPaths + .reduce( + (prevPromise, filePath) => + prevPromise.then(() => { + const xformStr = grunt.file.read(filePath); + grunt.log.write('.'); + + return transformer + .transform({ + xform: xformStr, + openclinica: true, + }) + .then((result) => { + forms[ + filePath.substring( + filePath.lastIndexOf('/') + 1 + ) + ] = { + html_form: result.form, + xml_model: result.model, + }; + }); + }), + Promise.resolve() + ) + .then(() => { + grunt.file.write( + formsJsPath, + `export default ${JSON.stringify(forms, null, 4)};` + ); + done(); + }); + }); + grunt.registerTask('widgets', 'generate widget reference files', () => { const WIDGETS_JS_LOC = 'public/js/build/'; const WIDGETS_JS = `${WIDGETS_JS_LOC}widgets.js`; @@ -391,6 +432,7 @@ module.exports = (grunt) => { grunt.registerTask('js', ['widgets', 'shell:build']); grunt.registerTask('test', [ 'env:test', + 'transforms', 'js', 'sass', 'shell:nyc', diff --git a/packages/enketo-express/README.md b/packages/enketo-express/README.md index 72c29c64e..b11a092a2 100644 --- a/packages/enketo-express/README.md +++ b/packages/enketo-express/README.md @@ -1,7 +1,18 @@ -# Enketo Express +# Enketo Express fork for OpenClinica _The [Enketo Smart Paper](https://enketo.org) web application._ It can be used directly by form servers or used as inspiration for building applications that wrap [Enketo Core](https://github.com/enketo/enketo/packages/enketo-core). See [this diagram](https://enketo.org/develop/) for a summary of how the different Enketo components are related. +--- + +This is a fork of [enketo/enketo-express](https://github.com/enketo/enketo-express) that has the following additions: + +1. An [account manager](https://swaggerhub.com/api/Enketo/enketo-express-oc-account-manager) to use multiple accounts with a single Enketo installation. +2. A [fieldsubmission](./doc/fieldsubmission.md) webform view that uses [OpenClinica's Fieldsubmission API](https://swaggerhub.com/api/martijnr/openclinica-fieldsubmission). +3. An OpenClinica theme: [theme-oc](https://github.com/OpenClinica/enketo-express-oc/tree/master/app/views/styles/theme-oc). +4. [Advanced comment widgets](./doc/advanced-comment-widgets.md): [discrepancy note widget](./doc/advanced-comment-widgets.md#discrepancy-notes-widget) + +--- + ## Browser support See [this faq](https://enketo.org/faq/#browsers). @@ -20,6 +31,22 @@ You can use environment variables instead of a `config/config.json` file. If the The default production configuration includes 2 redis instances: one for caching form transformations (see [Enketo Transformer](../../packages/enketo-transformer)) and one for persistent data like associations between form server URLs and Enketo form IDs. You can **greatly simplify installation by using 1 redis instance** instead (for development usage). To do this set the redis.cache.port to 6379 (same as redis.main.port). +---- + +OpenClinica users, in addition to the configuration documentation linked above, may want to take special note of the following recommended settings: + +0. Set a secret value for `"account manager api key"` (or set it to `false` if OC's custom Account Manager is not used). +1. The `"linked form and data server"` object should not have `"server url"` and `"api key"` properties (if OC's custom Account Manager API is used). +2. Set `"disable save as draft": true` +3. Set `"repeat ordinals": true`. [This feature](./doc/ordinals.md) is required for the fieldsubmission webform views. +4. Set `"query parameter to pass to submission": "ecid"` +5. Set `"validate continuously": true` +6. Set `"validate page": false` (though some applications may wish to use `true`) +7. Set `"default theme": "oc"` +8. Set `"text field character limit": 3999` + +---- + For development usages, it is helpful to set "linked form and data server" -> "server url" to `""`, so you can use any OpenRosa server with your local Enketo Express. For detailed guidance on each configuration item, see [the configuration tutorial](./tutorials/10-configure.md). diff --git a/packages/enketo-express/app/controllers/account-controller.js b/packages/enketo-express/app/controllers/account-controller.js new file mode 100644 index 000000000..db19a35d7 --- /dev/null +++ b/packages/enketo-express/app/controllers/account-controller.js @@ -0,0 +1,133 @@ +const auth = require('basic-auth'); +const express = require('express'); +const account = require('../models/account-model'); + +const router = express.Router(); +// const debug = require( 'debug' )( 'account-controller' ); + +module.exports = (app) => { + app.use('/accounts/api/v1', router); +}; + +router + .all('*', (req, res, next) => { + // set content-type to json to provide appropriate json Error responses + res.set('Content-Type', 'application/json'); + next(); + }) + .all('*', authCheck) + .get('/account', getExistingAccount) + .post('/account', getNewOrExistingAccount) + .put('/account', updateExistingAccount) + .delete('/account', removeAccount) + .get('/list', getList) + .post('/list', getList) + .all('*', (req, res, next) => { + const error = new Error('Not allowed'); + error.status = 405; + next(error); + }); + +function authCheck(req, res, next) { + // check authentication and account + let error; + + const creds = auth(req); + const key = creds ? creds.name : undefined; + + if (!key || key !== req.app.get('account manager api key')) { + error = new Error('Not Allowed. Invalid API key.'); + error.status = 401; + res.status(error.status).set( + 'WWW-Authenticate', + 'Basic realm="Enter valid API key as user name"' + ); + next(error); + } else { + next(); + } +} + +function getExistingAccount(req, res, next) { + return account + .get({ + linkedServer: req.query.server_url, + key: req.query.api_key, + }) + .then((account) => { + _render(200, account, res); + }) + .catch(next); +} + +function getNewOrExistingAccount(req, res, next) { + return account + .set({ + linkedServer: req.body.server_url || req.query.server_url, + key: req.body.api_key || req.query.api_key, + }) + .then((account) => { + _render(account.status || 201, account, res); + }) + .catch(next); +} + +function updateExistingAccount(req, res, next) { + return account + .update({ + linkedServer: req.body.server_url || req.query.server_url, + key: req.body.api_key || req.query.api_key, + }) + .then((account) => { + _render(account.status || 201, account, res); + }) + .catch(next); +} + +function removeAccount(req, res, next) { + return account + .remove({ + linkedServer: req.body.server_url || req.query.server_url, + key: req.body.api_key || req.query.api_key, + }) + .then(() => { + _render(204, null, res); + }) + .catch(next); +} + +function getList(req, res, next) { + return account + .getList() + .then((list) => { + _render(200, list, res); + }) + .catch(next); +} + +function _render(status, body, res) { + if (status === 204) { + // send 204 response without a body + res.status(status).end(); + } else { + body = body || {}; + if (typeof body === 'string') { + body = { + message: body, + }; + } else if (Array.isArray(body)) { + body = body.map((account) => _renameProps(account)); + } else if (typeof body === 'object') { + body = _renameProps(body); + } + body.code = status; + res.status(status).json(body); + } +} + +function _renameProps(account) { + return { + server_url: account.linkedServer, + api_key: account.key, + }; +} diff --git a/packages/enketo-express/app/controllers/api-v1-controller.js b/packages/enketo-express/app/controllers/api-v1-controller.js index e6dfa3abf..efa07642f 100644 --- a/packages/enketo-express/app/controllers/api-v1-controller.js +++ b/packages/enketo-express/app/controllers/api-v1-controller.js @@ -10,6 +10,8 @@ const account = require('../models/account-model'); const router = express.Router(); const quotaErrorMessage = 'Forbidden. No quota left'; +const keys = require('../lib/router-utils').idEncryptionKeys; +const utils = require('../lib/utils'); // var debug = require( 'debug' )( 'api-controller-v1' ); module.exports = (app) => { @@ -398,12 +400,13 @@ function _generateWebformUrls(id, req) { 'base path' )}/`; const offline = req.app.get('offline enabled'); + const idPartPreview = utils.insecureAes192Encrypt(id, keys.preview); req.webformType = req.webformType || 'default'; switch (req.webformType) { case 'preview': - obj.preview_url = `${baseUrl}preview/${iframePart}${id}`; + obj.preview_url = `${baseUrl}preview/${iframePart}${idPartPreview}`; break; case 'edit': queryString = _generateQueryString([ @@ -415,7 +418,7 @@ function _generateWebformUrls(id, req) { case 'all': // non-iframe views obj.url = offline ? `${baseUrl}x/${id}` : baseUrl + id; - obj.preview_url = `${baseUrl}preview/${id}`; + obj.preview_url = `${baseUrl}preview/${idPartPreview}`; // iframe views obj.iframe_url = baseUrl + IFRAMEPATH + id; obj.preview_iframe_url = `${baseUrl}preview/${IFRAMEPATH}${id}`; diff --git a/packages/enketo-express/app/controllers/api-v2-controller.js b/packages/enketo-express/app/controllers/api-v2-controller.js index 4c9eee9ed..c107cee7c 100644 --- a/packages/enketo-express/app/controllers/api-v2-controller.js +++ b/packages/enketo-express/app/controllers/api-v2-controller.js @@ -598,6 +598,8 @@ function _generateWebformUrls(id, req) { )}/`; const idPartOnce = `${utils.insecureAes192Encrypt(id, keys.singleOnce)}`; const idPartView = `${utils.insecureAes192Encrypt(id, keys.view)}`; + const idPartPreview = utils.insecureAes192Encrypt(id, keys.preview); + let queryParts; req.webformType = req.webformType || 'default'; @@ -610,7 +612,7 @@ function _generateWebformUrls(id, req) { ]); obj[ `preview${iframePart ? '_iframe' : ''}_url` - ] = `${baseUrl}preview/${iframePart}${id}${queryString}${hash}`; + ] = `${baseUrl}preview/${iframePart}${idPartPreview}${queryString}${hash}`; // Keep in a bug since apps probably started relying on this. if (iframePart) { @@ -672,7 +674,7 @@ function _generateWebformUrls(id, req) { obj.single_url = `${baseUrl}single/${id}${queryString}`; obj.single_once_url = `${baseUrl}single/${idPartOnce}${queryString}`; obj.offline_url = baseUrl + OFFLINEPATH + id; - obj.preview_url = `${baseUrl}preview/${id}${queryString}`; + obj.preview_url = `${baseUrl}preview/${idPartPreview}${queryString}`; // iframe views queryString = _generateQueryString([ req.defaultsQueryParam, @@ -681,7 +683,7 @@ function _generateWebformUrls(id, req) { obj.iframe_url = baseUrl + IFRAMEPATH + id + queryString; obj.single_iframe_url = `${baseUrl}single/${IFRAMEPATH}${id}${queryString}`; obj.single_once_iframe_url = `${baseUrl}single/${IFRAMEPATH}${idPartOnce}${queryString}`; - obj.preview_iframe_url = `${baseUrl}preview/${IFRAMEPATH}${id}${queryString}`; + obj.preview_iframe_url = `${baseUrl}preview/${IFRAMEPATH}${idPartPreview}${queryString}`; // rest obj.enketo_id = id; break; diff --git a/packages/enketo-express/app/controllers/dev-controller.js b/packages/enketo-express/app/controllers/dev-controller.js new file mode 100644 index 000000000..48881a2e7 --- /dev/null +++ b/packages/enketo-express/app/controllers/dev-controller.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const router = express.Router(); + +module.exports = (app) => { + app.use(`${app.get('base path')}/dev`, router); +}; + +router.get('/', (req, res) => { + res.render('surveys/dev', { src: req.query.iframe }); +}); diff --git a/packages/enketo-express/app/controllers/error-handler.js b/packages/enketo-express/app/controllers/error-handler.js index 7493ccc87..5a0d5bb23 100644 --- a/packages/enketo-express/app/controllers/error-handler.js +++ b/packages/enketo-express/app/controllers/error-handler.js @@ -35,7 +35,7 @@ module.exports = { * @param {Function} next - Express callback */ // Express uses arguments length to determine whether a callback is an error handler. - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line no-unused-vars production(err, req, res, next) { // eslint-disable-line no-unused-vars const body = { @@ -57,7 +57,7 @@ module.exports = { * @param {Function} next - Express callback */ // Express uses arguments length to determine whether a callback is an error handler. - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line no-unused-vars development(err, req, res, next) { // eslint-disable-line no-unused-vars const body = { diff --git a/packages/enketo-express/app/controllers/fieldsubmission-controller.js b/packages/enketo-express/app/controllers/fieldsubmission-controller.js new file mode 100644 index 000000000..25d75529e --- /dev/null +++ b/packages/enketo-express/app/controllers/fieldsubmission-controller.js @@ -0,0 +1,154 @@ +const request = require('request'); +const express = require('express'); +const communicator = require('../lib/communicator'); +const surveyModel = require('../models/survey-model'); +const userModel = require('../models/user-model'); +const routerUtils = require('../lib/router-utils'); +const { getSubmissionUrlAPI1, getSubmissionUrlAPI2 } = require('../lib/url-oc'); + +const router = express.Router(); +// const debug = require( 'debug' )( 'fieldsubmission-controller' ); + +module.exports = (app) => { + app.use(`${app.get('base path')}/fieldsubmission`, router); +}; + +router.param('enketo_id', routerUtils.enketoId); +router.param( + 'encrypted_enketo_id_view_dn', + routerUtils.encryptedEnketoIdViewDn +); +router.param( + 'encrypted_enketo_id_view_dn_c', + routerUtils.encryptedEnketoIdViewDnC +); +router.param('encrypted_enketo_id_fs_c', routerUtils.encryptedEnketoIdFsC); +router.param( + 'encrypted_enketo_id_participant', + routerUtils.encryptedEnketoIdFsParticipant +); +router.param('encrypted_enketo_id_rfc', routerUtils.encryptedEnketoIdEditRfc); +router.param( + 'encrypted_enketo_id_rfc_c', + routerUtils.encryptedEnketoIdEditRfcC +); +router.param( + 'encrypted_enketo_id_headless', + routerUtils.encryptedEnketoIdEditHeadless +); + +router + .all('*', (req, res, next) => { + res.set('Content-Type', 'application/json'); + next(); + }) + .post('/:enketo_id', submit) + .post('/:encrypted_enketo_id_fs_c', submit) + .post('/:encrypted_enketo_id_participant', submit) + .post('/complete/:enketo_id', complete) + .post('/complete/:encrypted_enketo_id_fs_c', complete) + .post('/:encrypted_enketo_id_view_dn', submit) + .post('/:encrypted_enketo_id_view_dn_c', submit) + .post('/:encrypted_enketo_id_rfc', submit) + .post('/:encrypted_enketo_id_rfc_c', submit) + .post('/:encrypted_enketo_id_headless', submit) + .delete('/:enketo_id/*', del) // fieldsubmission API 2.0.0 + .delete('/:encrypted_enketo_id_fs_c/*', del) // fieldsubmission API 2.0.0 + .delete('/:encrypted_enketo_id_rfc/*', del) // fieldsubmission API 2.0.0 + .delete('/:encrypted_enketo_id_rfc_c/*', del) // fieldsubmission API 2.0.0 + .delete('/:encrypted_enketo_id_headless/*', del) // fieldsubmission API 2.0.0 + .delete('/:encrypted_enketo_id_participant/*', del) // fieldsubmission API 2.0.0 + .all('/*', (req, res, next) => { + const error = new Error('Not allowed'); + error.status = 405; + next(error); + }); + +function complete(req, res, next) { + _request('complete', req, res, next); +} + +function submit(req, res, next) { + _request('field', req, res, next); +} + +function del(req, res, next) { + _request('delete', req, res, next); +} + +/** + * Simply pipes well-formed request to the OpenRosa server and + * copies the response received. + * + * @param type + * @param {[type]} req - [description] + * @param {[type]} res - [description] + * @param {Function} next - [description] + * @return {[type]} [description] + */ +function _request(type, req, res, next) { + let submissionUrl; + surveyModel + .get(req.enketoId) + .then((survey) => { + if (type === 'delete') { + submissionUrl = getSubmissionUrlAPI2( + survey.openRosaServer, + req.originalUrl + ); + } else { + const ecidValue = req.query.ecid; + const query = ecidValue ? `?ecid=${ecidValue}` : ''; + submissionUrl = + getSubmissionUrlAPI1(survey.openRosaServer, type) + query; + } + + const credentials = userModel.getCredentials(req); + + return communicator.getAuthHeader(submissionUrl, credentials); + }) + .then((authHeader) => { + const options = { + url: submissionUrl, + headers: authHeader + ? { + Authorization: authHeader, + } + : {}, + timeout: req.app.get('timeout') + 500, + }; + + // pipe the request + req.pipe(request(options)) + .on('response', (orResponse) => { + if (orResponse.statusCode === 201) { + // TODO: Do we really want to log all field submissions? It's a huge amount. + // _logSubmission( id, instanceId, deprecatedId ); + } else if (orResponse.statusCode === 401) { + // replace the www-authenticate header to avoid browser built-in authentication dialog + orResponse.headers[ + 'WWW-Authenticate' + ] = `enketo${orResponse.headers['WWW-Authenticate']}`; + } + }) + .pipe(res); + }) + .catch(next); +} + +/* +function _logSubmission( id, instanceId, deprecatedId ) { + submissionModel.isNew( id, instanceId ) + .then( function( notRecorded ) { + if ( notRecorded ) { + // increment number of submissions + surveyModel.incrementSubmissions( id ); + // store/log instanceId + submissionModel.add( id, instanceId, deprecatedId ); + } + } ) + .catch( function( error ) { + console.error( error ); + } ); +} +*/ diff --git a/packages/enketo-express/app/controllers/oc-api-v1-controller.js b/packages/enketo-express/app/controllers/oc-api-v1-controller.js new file mode 100644 index 000000000..4d8297307 --- /dev/null +++ b/packages/enketo-express/app/controllers/oc-api-v1-controller.js @@ -0,0 +1,777 @@ +const auth = require('basic-auth'); +const express = require('express'); +const surveyModel = require('../models/survey-model'); +const instanceModel = require('../models/instance-model'); +const cacheModel = require('../models/cache-model'); +const account = require('../models/account-model'); +const pdf = require('../lib/pdf'); +const headless = require('../lib/headless'); +const utils = require('../lib/utils'); +const keys = require('../lib/router-utils').idEncryptionKeys; + +const router = express.Router(); +const quotaErrorMessage = 'Forbidden. No quota left'; +// const debug = require( 'debug' )( 'oc-api-controller-v1' ); + +module.exports = (app) => { + app.use(`${app.get('base path')}/oc/api/v1`, router); +}; + +// TODO: use URL object and searchParams.append to build URLs + +router + .get('/', (req, res) => { + res.redirect( + 'https://github.com/OpenClinica/enketo-express-oc/blob/master/doc/oc-api.md' + ); + }) + .get('/version', getVersion) + .post('/version', getVersion) + .post('*', authCheck) + .delete('*', authCheck) + .post('*', _setQuotaUsed) + .post('/survey/preview*', (req, res, next) => { + req.webformType = 'preview'; + next(); + }) + .post('/instance(/*)?', (req, res, next) => { + req.webformType = 'edit'; + next(); + }) + .post('*/c', (req, res, next) => { + req.dnClose = true; + next(); + }) + .post('*/incomplete/*', (req, res, next) => { + req.incomplete = true; + next(); + }) + .post('/survey/view(/*)?', (req, res, next) => { + req.webformType = 'view'; + next(); + }) + .post('/instance/view(/*)?', (req, res, next) => { + req.webformType = 'view-instance'; + next(); + }) + .post('*/pdf', (req, res, next) => { + req.webformType = 'pdf'; + next(); + }) + .post('*/headless(/*)?', (req, res, next) => { + req.webformType = 'headless'; + next(); + }) + .post('*/rfc(/*)?', (req, res, next) => { + req.rfc = true; + next(); + }) + .post('/instance/note(/*)?', (req, res, next) => { + req.webformType = 'note-instance'; + next(); + }) + .post('*/full/*', (req, res, next) => { + req.webformType = 'single-full'; + next(); + }) + .post('*/full/offline/*', (req, res, next) => { + if (req.app.get('offline enabled')) { + req.webformType = 'full-offline'; + next(); + } else { + const error = new Error( + 'Not Allowed. Offline capability is not enabled.' + ); + error.status = 405; + next(error); + } + }) + .post('*/participant(/*)?', (req, res, next) => { + req.participant = true; + next(); + }) + .delete('/survey/cache', emptySurveyCache) + .delete('/instance/', removeInstance) + // check and set parameters, return error if required parameter is missing + .post('*', _setDefaultsQueryParam) + .post('*', _setLangQueryParam) + .post('*', _setReturnQueryParam) // is this actually used by OC? + .post('*', _setGoTo) + .post('*', _setParentWindow) + .post('*', _setNextPrompt) + .post(/\/(survey|instance)\/(collect|edit|view|note|headless)/, _setEcid) // excl preview + .post( + /\/(survey|instance)\/(collect|edit|preview)(?!\/participant)/, + _setJini + ) // excl view, note, and participant + .post( + /\/(survey|instance)\/(collect|edit|view|note)(?!\/participant)/, + _setPid + ) // excl preview, and participant + .post(/\/(view|note)/, _setLoadWarning) + .post('*/pdf', _setPage) + .post('/survey/preview', getNewOrExistingSurvey) + .post('/survey/preview/participant', getNewOrExistingSurvey) + .post('/survey/view', getNewOrExistingSurvey) + .post('/survey/view/pdf', getNewOrExistingSurvey) + .post('/survey/collect', getNewOrExistingSurvey) + .post('/survey/collect/c', getNewOrExistingSurvey) + .post('/survey/collect/rfc', getNewOrExistingSurvey) + .post('/survey/collect/rfc/c', getNewOrExistingSurvey) + .post('/survey/collect/participant', getNewOrExistingSurvey) + .post('/survey/collect/full/participant', getNewOrExistingSurvey) + .post('/survey/collect/full/offline/participant', getNewOrExistingSurvey) + .post('/instance/*', _setInterfaceQueryParam) + .post('/instance/view', cacheInstance) + .post('/instance/view/pdf', cacheInstance) + .post('/instance/edit', cacheInstance) + .post('/instance/edit/c', cacheInstance) + .post('/instance/edit/rfc', cacheInstance) + .post('/instance/edit/rfc/c', cacheInstance) + .post('/instance/edit/incomplete/rfc', cacheInstance) + .post('/instance/edit/incomplete/rfc/c', cacheInstance) + .post('/instance/note', cacheInstance) + .post('/instance/note/c', cacheInstance) + .post('/instance/edit/participant', cacheInstance) + .post('/instance/headless', cacheInstance) + .post('/instance/headless/rfc', cacheInstance) + .all('*', (req, res, next) => { + const error = new Error('Not allowed.'); + error.status = 405; + next(error); + }); + +function getVersion(req, res) { + const version = req.app.get('version'); + _render(200, { version }, res); +} + +// API uses Basic authentication with just the username +function authCheck(req, res, next) { + // check authentication and account + let error; + const creds = auth(req); + const key = creds ? creds.name : undefined; + const server = req.body.server_url; + + // set content-type to json to provide appropriate json Error responses + res.set('Content-Type', 'application/json'); + + account + .get(server) + .then((account) => { + if (!key || key !== account.key) { + error = new Error('Not Allowed. Invalid API key.'); + error.status = 401; + res.status(error.status).set( + 'WWW-Authenticate', + 'Basic realm="Enter valid API key as user name"' + ); + next(error); + } else { + req.account = account; + next(); + } + }) + .catch(next); +} + +function getNewOrExistingSurvey(req, res, next) { + let status; + const survey = { + openRosaServer: req.body.server_url, + openRosaId: req.body.form_id, + theme: req.body.theme, + }; + + if (req.account.quota < req.account.quotaUsed) { + return _render(403, quotaErrorMessage, res); + } + + return surveyModel + .getId(survey) // will return id only for existing && active surveys + .then((id) => { + if (!id && req.account.quota <= req.account.quotaUsed) { + return _render(403, quotaErrorMessage, res); + } + status = id ? 200 : 201; + + // even if id was found still call .set() method to update any properties + return surveyModel.set(survey).then((id) => { + if (id) { + if (req.webformType === 'pdf') { + _renderPdf(status, id, req, res); + } else { + _render(status, _generateWebformUrls(id, req), res); + } + } else { + _render(404, 'Survey not found.', res); + } + }); + }) + .catch(next); +} + +function emptySurveyCache(req, res, next) { + return cacheModel + .flush({ + openRosaServer: req.body.server_url, + openRosaId: req.body.form_id, + }) + .then(() => { + _render(204, null, res); + }) + .catch(next); +} + +function cacheInstance(req, res, next) { + let survey; + let enketoId; + + if (req.account.quota < req.account.quotaUsed) { + return _render(403, quotaErrorMessage, res); + } + + survey = { + openRosaServer: req.body.server_url, + openRosaId: req.body.form_id, + instance: req.body.instance, + instanceId: req.body.instance_id, + returnUrl: req.body.return_url, + instanceAttachments: req.body.instance_attachments, + }; + + return surveyModel + .getId(survey) + .then((id) => { + if (!id && req.account.quota <= req.account.quotaUsed) { + return _render(403, quotaErrorMessage, res); + } + // Create a new enketo ID. + if (!id) { + return surveyModel.set(survey); + } + + // Do not update properties if ID was found to avoid overwriting theme. + return id; + }) + .then((id) => { + enketoId = id; + // If the API call is for /instance/edit/*, make sure + // to not allow caching if it is already cached as some lame + // protection against multiple people edit the same record simultaneously + const protect = + !req.webformType.startsWith('view') && + !req.webformType.startsWith('pdf'); + + return instanceModel.set(survey, protect); + }) + .then(() => { + const status = 201; + if (req.webformType === 'pdf') { + _renderPdf(status, enketoId, req, res); + } else if ( + req.webformType === 'headless' || + req.webformType === 'headless-rfc' + ) { + _renderHeadless(status, enketoId, req, res); + } else { + _render(status, _generateWebformUrls(enketoId, req), res); + } + }) + .catch(next); +} + +function removeInstance(req, res, next) { + return instanceModel + .remove({ + openRosaServer: req.body.server_url, + openRosaId: req.body.form_id, + instanceId: req.body.instance_id, + }) + .then((instanceId) => { + if (instanceId) { + _render(204, null, res); + } else { + _render(404, 'Record not found.', res); + } + }) + .catch(next); +} + +function _setQuotaUsed(req, res, next) { + surveyModel + .getNumber(req.account.linkedServer) + .then((number) => { + req.account.quotaUsed = number; + next(); + }) + .catch(next); +} + +function _setPage(req, res, next) { + req.page = {}; + req.page.format = req.body.format || req.query.format; + if ( + req.page.format && + !/^(Letter|Legal|Tabloid|Ledger|A0|A1|A2|A3|A4|A5|A6)$/.test( + req.page.format + ) + ) { + const error = new Error('Format parameter is not valid.'); + error.status = 400; + throw error; + } + req.page.landscape = req.body.landscape || req.query.landscape; + if (req.page.landscape && !/^(true|false)$/.test(req.page.landscape)) { + const error = new Error('Landscape parameter is not valid.'); + error.status = 400; + throw error; + } + // convert to boolean + req.page.landscape = req.page.landscape === 'true'; + req.page.margin = req.body.margin || req.query.margin; + if (req.page.margin && !/^\d+(\.\d+)?(in|cm|mm)$/.test(req.page.margin)) { + const error = new Error('Margin parameter is not valid.'); + error.status = 400; + throw error; + } + /* + TODO: scale has not been enabled yet, as it is not supported by Enketo Core's Grid print JS processing function. + req.page.scale = req.body.scale || req.query.scale; + if ( req.page.scale && !/^\d+$/.test( req.page.scale ) ) { + const error = new Error( 'Scale parameter is not valid.' ); + error.status = 400; + throw error; + } + // convert to number + req.page.scale = Number( req.page.scale ); + */ + next(); +} + +function _setDefaultsQueryParam(req, res, next) { + let queryParam = ''; + const map = req.body.defaults; + + if (map) { + for (const prop in map) { + if (Object.prototype.hasOwnProperty.call(map, prop)) { + const paramKey = `d[${decodeURIComponent(prop)}]`; + queryParam += `${encodeURIComponent( + paramKey + )}=${encodeURIComponent(decodeURIComponent(map[prop]))}&`; + } + } + req.defaultsQueryParam = queryParam.substring(0, queryParam.length - 1); + } + + next(); +} + +function _setLangQueryParam(req, res, next) { + const { lang } = req.body; + + if (lang) { + req.langQueryParam = `lang=${encodeURIComponent(lang)}`; + } else { + req.langQueryParam = ``; + } + + next(); +} + +function _setInterfaceQueryParam(req, res, next) { + if (req.body.interface) { + if (!['default', 'queries', 'sdv'].includes(req.body.interface)) { + const error = new Error('Invalid value for interface parameter.'); + error.status = 400; + next(error); + } else { + req.interfaceQueryParam = `interface=${req.body.interface}`; + } + } else { + req.interfaceQueryParam = ''; + } + next(); +} + +function _setGoTo(req, res, next) { + const goTo = req.body.go_to; + req.goTo = goTo ? `#${encodeURIComponent(goTo)}` : ''; + const goToErrorUrl = req.body.go_to_error_url; + req.goToErrorUrl = + goTo && goToErrorUrl + ? `go_to_error_url=${encodeURIComponent(goToErrorUrl)}` + : ''; + next(); +} + +function _setEcid(req, res, next) { + const { ecid } = req.body; + if (!ecid) { + const error = new Error('Bad request. Ecid parameter required'); + error.status = 400; + next(error); + } else { + req.ecid = `ecid=${encodeURIComponent(ecid)}`; + next(); + } +} + +function _setPid(req, res, next) { + const { pid } = req.body; + if (pid) { + req.pid = `PID=${encodeURIComponent(pid)}`; + } + next(); +} + +function _setJini(req, res, next) { + if (req.app.get('jini')['style url'] && req.app.get('jini')['script url']) { + const { jini } = req.body; + if (jini) { + req.jini = `jini=${encodeURIComponent(jini)}`; + } + } + next(); +} + +function _setLoadWarning(req, res, next) { + const warning = req.body.load_warning; + req.loadWarning = warning + ? `load_warning=${encodeURIComponent(warning)}` + : ''; + next(); +} + +function _setParentWindow(req, res, next) { + const parentWindowOrigin = req.body.parent_window_origin; + + if (parentWindowOrigin) { + req.parentWindowOriginParam = `parent_window_origin=${encodeURIComponent( + parentWindowOrigin + )}`; + } + next(); +} + +function _setNextPrompt(req, res, next) { + const nextPrompt = req.body.next_prompt; + + if (nextPrompt) { + req.nextPromptParam = `next_prompt=${encodeURIComponent(nextPrompt)}`; + } + next(); +} + +function _setReturnQueryParam(req, res, next) { + const returnUrl = req.body.return_url; + + if (returnUrl) { + req.returnQueryParam = `return_url=${encodeURIComponent(returnUrl)}`; + } + next(); +} + +function _generateQueryString(params = []) { + let paramsJoined; + + paramsJoined = params.filter((part) => part && part.length > 0).join('&'); + + return paramsJoined ? `?${paramsJoined}` : ''; +} + +function _generateWebformUrls(id, req) { + const IFRAMEPATH = 'i/'; + const FSPATH = 'fs/'; + const OFFLINEPATH = 'x/'; + const incompletePart = req.incomplete ? 'inc/' : ''; + const dnClosePart = req.dnClose ? 'c/' : ''; + const rfcPart = req.rfc ? 'rfc/' : ''; + const hash = req.goTo; + const protocol = req.headers['x-forwarded-proto'] || req.protocol; + const BASEURL = `${protocol}://${req.headers.host}${req.app.get( + 'base path' + )}/`; + const idView = `${utils.insecureAes192Encrypt(id, keys.view)}`; + const idViewDn = `${utils.insecureAes192Encrypt(id, keys.viewDn)}`; + const idEditRfc = `${utils.insecureAes192Encrypt(id, keys.editRfc)}`; + const idEditRfcC = `${utils.insecureAes192Encrypt(id, keys.editRfcC)}`; + const idViewDnC = `${utils.insecureAes192Encrypt(id, keys.viewDnC)}`; + const idFsC = `${utils.insecureAes192Encrypt(id, keys.fsC)}`; + const idFsParticipant = `${utils.insecureAes192Encrypt( + id, + keys.fsParticipant + )}`; + const idFullParticipant = `${utils.insecureAes192Encrypt( + id, + keys.fullParticipant + )}`; + const idEditHeadless = `${utils.insecureAes192Encrypt( + id, + keys.editHeadless + )}`; + const idPreview = utils.insecureAes192Encrypt(id, keys.preview); + const idIncompleteRfc = utils.insecureAes192Encrypt(id, keys.incRfc); + const idIncompleteRfcC = utils.insecureAes192Encrypt(id, keys.incRfcC); + + let url; + + const type = [req.webformType || 'single'] + .concat( + ['participant', 'incomplete', 'rfc'].filter((prop) => req[prop]) + ) + .join('-'); + + switch (type) { + case 'preview': { + const queryString = _generateQueryString([ + req.defaultsQueryParam, + req.parentWindowOriginParam, + req.goToErrorUrl, + req.jini, + req.nextPromptParam, + req.langQueryParam, + ]); + url = `${BASEURL}preview/${IFRAMEPATH}${idPreview}${queryString}${hash}`; + break; + } + case 'preview-participant': { + const queryString = _generateQueryString([ + req.defaultsQueryParam, + req.parentWindowOriginParam, + req.goToErrorUrl, + req.langQueryParam, + ]); + url = `${BASEURL}preview/participant/${IFRAMEPATH}${idFsParticipant}${queryString}${hash}`; + break; + } + case 'edit': + case 'edit-rfc': + case 'edit-incomplete-rfc': { + let idToUse; + if (!req.rfc) { + idToUse = req.dnClose ? idFsC : id; + } else if (req.incomplete) { + idToUse = req.dnClose ? idIncompleteRfcC : idIncompleteRfc; + } else { + idToUse = req.dnClose ? idEditRfcC : idEditRfc; + } + // TODO add tests that check that the Enketo ID generated is the correct one + const queryString = _generateQueryString([ + req.ecid, + req.pid, + `instance_id=${req.body.instance_id}`, + req.parentWindowOriginParam, + req.returnQueryParam, + req.goToErrorUrl, + req.interfaceQueryParam, + req.jini, + req.langQueryParam, + ]); + url = `${BASEURL}edit/${FSPATH}${incompletePart}${rfcPart}${dnClosePart}${IFRAMEPATH}${idToUse}${queryString}${hash}`; + break; + } + case 'headless': + case 'headless-rfc': { + const queryString = _generateQueryString([ + req.ecid, + `instance_id=${req.body.instance_id}`, + req.interfaceQueryParam, + req.langQueryParam, + ]); + url = `${BASEURL}edit/${FSPATH}${rfcPart}headless/${idEditHeadless}${queryString}`; + break; + } + case 'single': + case 'single-rfc': { + let idToUse; + if (req.rfc) { + idToUse = req.dnClose ? idIncompleteRfcC : idIncompleteRfc; + } else { + idToUse = req.dnClose ? idFsC : id; + } + + const queryString = _generateQueryString([ + req.ecid, + req.pid, + req.defaultsQueryParam, + req.returnQueryParam, + req.parentWindowOriginParam, + req.jini, + req.nextPromptParam, + req.langQueryParam, + ]); + url = `${BASEURL}single/${FSPATH}${rfcPart}${dnClosePart}${IFRAMEPATH}${idToUse}${queryString}`; + break; + } + case 'single-participant': { + const queryString = _generateQueryString([ + req.ecid, + req.pid, + req.defaultsQueryParam, + req.returnQueryParam, + req.parentWindowOriginParam, + req.jini, + req.langQueryParam, + ]); + url = `${BASEURL}single/${FSPATH}participant/${IFRAMEPATH}${idFsParticipant}${queryString}`; + break; + } + case 'edit-participant': { + const queryString = _generateQueryString([ + req.ecid, + req.pid, + `instance_id=${req.body.instance_id}`, + req.defaultsQueryParam, + req.returnQueryParam, + req.parentWindowOriginParam, + req.interfaceQueryParam, + req.langQueryParam, + ]); + url = `${BASEURL}edit/${FSPATH}participant/${IFRAMEPATH}${idFsParticipant}${queryString}${hash}`; + break; + } + case 'single-full-participant': { + const queryString = _generateQueryString([ + req.ecid, + req.pid, + req.defaultsQueryParam, + req.returnQueryParam, + req.parentWindowOriginParam, + req.jini, + req.langQueryParam, + ]); + url = `${BASEURL}single/full/participant/${idFullParticipant}${queryString}`; + break; + } + case 'full-offline-participant': { + const queryString = _generateQueryString([ + req.ecid, + req.pid, + req.defaultsQueryParam, + req.returnQueryParam, + req.parentWindowOriginParam, + req.jini, + req.langQueryParam, + ]); + url = `${BASEURL}${OFFLINEPATH}full/participant/${idFullParticipant}${queryString}`; + break; + } + case 'view': + case 'view-instance': { + const queryParts = [ + req.ecid, + req.pid, + req.parentWindowOriginParam, + req.returnQueryParam, + req.loadWarning, + req.goToErrorUrl, + req.interfaceQueryParam, + req.langQueryParam, + ]; + if (req.webformType === 'view-instance') { + queryParts.unshift(`instance_id=${req.body.instance_id}`); + } + const queryString = _generateQueryString(queryParts); + url = `${BASEURL}view/${FSPATH}${IFRAMEPATH}${idView}${queryString}${hash}`; + break; + } + case 'note-instance': { + const viewId = dnClosePart ? idViewDnC : idViewDn; + const queryString = _generateQueryString([ + req.ecid, + req.pid, + `instance_id=${req.body.instance_id}`, + req.parentWindowOriginParam, + req.returnQueryParam, + req.loadWarning, + req.goToErrorUrl, + req.interfaceQueryParam, + req.langQueryParam, + ]); + url = `${BASEURL}edit/${FSPATH}dn/${dnClosePart}${IFRAMEPATH}${viewId}${queryString}${hash}`; + break; + } + case 'pdf': { + // We use the view-instance view because it is: + // - has optional instance support + // - has protection against accidental fieldsubmissions (extra layer of security) + // - for now OC is planning to not add DN questions to the XForm if it doesn't want those printed + const queryParts = [ + req.ecid, + req.pid, + 'print=true', + req.interfaceQueryParam, + req.langQueryParam, + ]; + if (req.body.instance_id) { + queryParts.push(`instance_id=${req.body.instance_id}`); + } + const queryString = _generateQueryString(queryParts); + url = `${BASEURL}view/${FSPATH}${idView}${queryString}`; + break; + } + default: + url = 'Could not generate a webform URL. Unknown webform type.'; + break; + } + + return { url }; +} + +function _render(status, body, res) { + if (status === 204) { + // send 204 response without a body + res.status(status).end(); + } else { + body = body || {}; + if (typeof body === 'string') { + body = { + message: body, + }; + } + // body.code = status; + res.status(status).json(body); + } +} + +function _renderPdf(status, id, req, res) { + const { url } = _generateWebformUrls(id, req); + + return pdf + .get(url, req.page) + .then((pdfBuffer) => { + const filename = `${req.body.form_id || req.query.form_id}${ + req.body.instance_id ? `-${req.body.instance_id}` : '' + }.pdf`; + // TODO: We've already set to json content-type in authCheck. This may be bad. + res.set('Content-Type', 'application/pdf') + .set('Content-disposition', `attachment;filename=${filename}`) + .status(status) + .end(pdfBuffer, 'binary'); + }) + .catch((e) => { + _render( + e.status || 500, + `PDF generation failed: ${e.message}`, + res + ); + }); +} + +function _renderHeadless(status, id, req, res) { + const { url } = _generateWebformUrls(id, req); + + return headless + .run(url) + .then((fieldsubmissions) => { + const message = 'OK'; + const code = fieldsubmissions > 0 ? 201 : 200; + res.status(code).json({ message, fieldsubmissions }); + }) + .catch((e) => { + _render(e.status || 500, e.message, res); + }); +} diff --git a/packages/enketo-express/app/controllers/submission-controller.js b/packages/enketo-express/app/controllers/submission-controller.js index ec21c1879..a34b49d0d 100644 --- a/packages/enketo-express/app/controllers/submission-controller.js +++ b/packages/enketo-express/app/controllers/submission-controller.js @@ -24,6 +24,40 @@ module.exports = (app) => { router.param('enketo_id', routerUtils.enketoId); router.param('encrypted_enketo_id_single', routerUtils.encryptedEnketoIdSingle); router.param('encrypted_enketo_id_view', routerUtils.encryptedEnketoIdView); +router.param( + 'encrypted_enketo_id_view_dn', + routerUtils.encryptedEnketoIdViewDn +); +router.param( + 'encrypted_enketo_id_view_dn_c', + routerUtils.encryptedEnketoIdViewDnC +); +router.param('encrypted_enketo_id_fs_c', routerUtils.encryptedEnketoIdFsC); +router.param( + 'encrypted_enketo_id_fs_participant', + routerUtils.encryptedEnketoIdFsParticipant +); +router.param( + 'encrypted_enketo_id_full_participant', + routerUtils.encryptedEnketoIdFullParticipant +); +router.param('encrypted_enketo_id_rfc', routerUtils.encryptedEnketoIdEditRfc); +router.param( + 'encrypted_enketo_id_rfc_c', + routerUtils.encryptedEnketoIdEditRfcC +); +router.param( + 'encrypted_enketo_id_headless', + routerUtils.encryptedEnketoIdEditHeadless +); +router.param( + 'encrypted_enketo_id_inc_rfc', + routerUtils.encryptedEnketoIdIncRfc +); +router.param( + 'encrypted_enketo_id_inc_rfc_c', + routerUtils.encryptedEnketoIdIncRfcC +); router .all('*', (req, res, next) => { @@ -32,11 +66,36 @@ router }) .get('/max-size/:encrypted_enketo_id_single', maxSize) .get('/max-size/:encrypted_enketo_id_view', maxSize) + .get('/max-size/:encrypted_enketo_id_fs_c', maxSize) + .get('/max-size/:encrypted_enketo_id_view_dn', maxSize) + .get('/max-size/:encrypted_enketo_id_view_dn_c', maxSize) + .get('/max-size/:encrypted_enketo_id_rfc', maxSize) + .get('/max-size/:encrypted_enketo_id_rfc_c', maxSize) + .get('/max-size/:encrypted_enketo_id_inc_rfc', maxSize) + .get('/max-size/:encrypted_enketo_id_inc_rfc_c', maxSize) + .get('/max-size/:encrypted_enketo_id_headless', maxSize) + .get('/max-size/:encrypted_enketo_id_full_participant', maxSize) + .get('/max-size/:encrypted_enketo_id_fs_participant', maxSize) + .get('/:encrypted_enketo_id_fs_c', getInstance) .get('/max-size/:enketo_id?', maxSize) .get('/:encrypted_enketo_id_view', getInstance) + .get('/:encrypted_enketo_id_view_dn', getInstance) + .get('/:encrypted_enketo_id_view_dn_c', getInstance) + .get('/:encrypted_enketo_id_rfc', getInstance) + .get('/:encrypted_enketo_id_rfc_c', getInstance) + .get('/:encrypted_enketo_id_inc_rfc', getInstance) + .get('/:encrypted_enketo_id_inc_rfc_c', getInstance) + .get('/:encrypted_enketo_id_headless', getInstance) + .get('/:encrypted_enketo_id_fs_participant', getInstance) .get('/:enketo_id', getInstance) .post('/:encrypted_enketo_id_single', submit) .post('/:enketo_id', submit) + .post('/:encrypted_enketo_id_fs_participant', submit) + .post('/:encrypted_enketo_id_full_participant', (req, res, next) => { + req.fullRecord = true; + + return submit(req, res, next); + }) .all('/*', (req, res, next) => { const error = new Error('Not allowed'); error.status = 405; @@ -75,7 +134,10 @@ async function submit(req, res, next) { const id = req.enketoId; const survey = await surveyModel.get(id); const submissionUrl = - communicator.getSubmissionUrl(survey.openRosaServer) + query; + communicator.getSubmissionUrl( + survey.openRosaServer, + req.fullRecord + ) + query; const credentials = userModel.getCredentials(req); const authHeader = await communicator.getAuthHeader( submissionUrl, diff --git a/packages/enketo-express/app/controllers/survey-controller.js b/packages/enketo-express/app/controllers/survey-controller.js index eaab85107..4a0da6710 100644 --- a/packages/enketo-express/app/controllers/survey-controller.js +++ b/packages/enketo-express/app/controllers/survey-controller.js @@ -21,6 +21,44 @@ module.exports = (app) => { router.param('enketo_id', routerUtils.enketoId); router.param('encrypted_enketo_id_single', routerUtils.encryptedEnketoIdSingle); router.param('encrypted_enketo_id_view', routerUtils.encryptedEnketoIdView); +router.param( + 'encrypted_enketo_id_view_dn', + routerUtils.encryptedEnketoIdViewDn +); +router.param( + 'encrypted_enketo_id_view_dn_c', + routerUtils.encryptedEnketoIdViewDnC +); +router.param( + 'encrypted_enketo_id_preview', + routerUtils.encryptedEnketoIdPreview +); +router.param('encrypted_enketo_id_fs_c', routerUtils.encryptedEnketoIdFsC); +router.param( + 'encrypted_enketo_id_fs_participant', + routerUtils.encryptedEnketoIdFsParticipant +); +router.param( + 'encrypted_enketo_id_full_participant', + routerUtils.encryptedEnketoIdFullParticipant +); +router.param('encrypted_enketo_id_rfc', routerUtils.encryptedEnketoIdEditRfc); +router.param( + 'encrypted_enketo_id_rfc_c', + routerUtils.encryptedEnketoIdEditRfcC +); +router.param( + 'encrypted_enketo_id_inc_rfc', + routerUtils.encryptedEnketoIdIncRfc +); +router.param( + 'encrypted_enketo_id_inc_rfc_c', + routerUtils.encryptedEnketoIdIncRfcC +); +router.param( + 'encrypted_enketo_id_headless', + routerUtils.encryptedEnketoIdEditHeadless +); router.param('mod', (req, rex, next, mod) => { if (mod === 'i') { @@ -34,18 +72,53 @@ router.param('mod', (req, rex, next, mod) => { router // .get( '*', loggedInCheck ) + .get('*/participant/*', (req, res, next) => { + req.participant = true; + next(); + }) + .get('*/headless*', _setHeadless) + .get('/preview*', _setJini) + .get('/preview*', _setNextPrompt) + .get(/\/(single|edit)\/fs(\/rfc)?(\/c)?\/i/, _setJini) + .get(/\/(single)\/fs(\/rfc)?(\/c)?\/i/, _setNextPrompt) + .get('*', _setCloseButton) + .get('*', _setCompleteButton) + .get( + `${config['offline path']}/full/participant/:encrypted_enketo_id_full_participant`, + fullParticipantOffline + ) .get(`${config['offline path']}/:enketo_id`, offlineWebform) .get(`${config['offline path']}/`, redirect) .get('/connection', (req, res) => { res.status = 200; res.send(`connected ${Math.random()}`); }) + .get('/preview/:encrypted_enketo_id_preview', preview) + .get('/preview/:mod/:encrypted_enketo_id_preview', preview) .get('/preview', preview) .get('/preview/:mod', preview) - .get('/preview/:enketo_id', preview) - .get('/preview/:mod/:enketo_id', preview) + .get('/preview/participant/:mod', preview) + .get( + '/preview/participant/:mod/:encrypted_enketo_id_fs_participant', + preview + ) .get('/:enketo_id', webform) .get('/:mod/:enketo_id', webform) + .get('/single/fs/:mod/:enketo_id', fieldSubmission) + .get('/single/fs/c/:mod/:encrypted_enketo_id_fs_c', fieldSubmission) + .get('/single/fs/rfc/:mod/:encrypted_enketo_id_inc_rfc', fieldSubmission) + .get( + '/single/fs/rfc/c/:mod/:encrypted_enketo_id_inc_rfc_c', + fieldSubmission + ) + .get( + '/single/fs/participant/:mod/:encrypted_enketo_id_fs_participant', + fieldSubmission + ) + .get( + '/single/full/participant/:encrypted_enketo_id_full_participant', + fullParticipant + ) .get('/single/:enketo_id', single) .get('/single/:encrypted_enketo_id_single', single) .get('/single/:mod/:enketo_id', single) @@ -54,17 +127,35 @@ router .get('/view/:mod/:encrypted_enketo_id_view', view) .get('/edit/:enketo_id', edit) .get('/edit/:mod/:enketo_id', edit) + .get('/edit/fs/rfc/:mod/:encrypted_enketo_id_rfc', fieldSubmission) + .get('/edit/fs/rfc/c/:mod/:encrypted_enketo_id_rfc_c', fieldSubmission) + .get('/edit/fs/inc/rfc/:mod/:encrypted_enketo_id_inc_rfc', fieldSubmission) + .get( + '/edit/fs/inc/rfc/c/:mod/:encrypted_enketo_id_inc_rfc_c', + fieldSubmission + ) + .get('/edit/fs/:mod/:enketo_id', fieldSubmission) + .get('/edit/fs/c/:mod/:encrypted_enketo_id_fs_c', fieldSubmission) + .get('/edit/fs/dn/:mod/:encrypted_enketo_id_view_dn', fieldSubmission) + .get('/edit/fs/dn/c/:mod/:encrypted_enketo_id_view_dn_c', fieldSubmission) + .get( + '/edit/fs/participant/:mod/:encrypted_enketo_id_fs_participant', + fieldSubmission + ) + .get('/edit/fs/headless/:encrypted_enketo_id_headless', fieldSubmission) + .get('/edit/fs/rfc/headless/:encrypted_enketo_id_headless', fieldSubmission) + .get('/view/fs/:encrypted_enketo_id_view', fieldSubmission) + .get('/view/fs/:mod/:encrypted_enketo_id_view', fieldSubmission) .get('/xform/:enketo_id', xform) .get('/xform/:encrypted_enketo_id_single', xform) .get('/xform/:encrypted_enketo_id_view', xform) + .get('/xform/:encrypted_enketo_id_view_dn', xform) + .get('/xform/:encrypted_enketo_id_view_dn_c', xform) + .get('/xform/:encrypted_enketo_id_fs_c', xform) + .get('/xform/:encrypted_enketo_id_fs_participant', xform) + .get('/xform/:encrypted_enketo_id_full_participant', xform) .get(/.*\/::[A-z0-9]{4,8}/, redirect); -// TODO: I suspect this check is no longer used and can be removed -// function loggedInCheck( req, res, next ) { -// req.logout = !!userModel.getCredentials( req ); -// next(); -// } - /** * @param {module:api-controller~ExpressRequest} req - HTTP request * @param {module:api-controller~ExpressResponse} res - HTTP response @@ -118,6 +209,85 @@ function single(req, res, next) { } } +function _setJini(req, res, next) { + req.jini = + req.query.jini === 'true' && + config.jini['style url'] && + config.jini['script url'] + ? config.jini + : null; + next(); +} + +function _setNextPrompt(req, res, next) { + req.nextPrompt = req.query.next_prompt ? req.query.next_prompt : null; + next(); +} + +function _setCloseButton(req, res, next) { + // only RFC edit views that require a COMPLETED record do not + // have a Close button + req.closeButton = !/\/edit\/fs\/rfc/.test(req.originalUrl); + next(); +} + +function _setCompleteButton(req, res, next) { + req.completeButton = + /\/(edit|single)\/fs(\/inc)?\/(?!(participant|dn|view))/.test( + req.originalUrl + ); + next(); +} + +function _setHeadless(req, res, next) { + req.headless = true; + next(); +} + +function fieldSubmission(req, res, next) { + const options = { + type: 'fieldsubmission', + iframe: req.iframe, + print: req.query.print === 'true', + jini: req.jini, + nojump: req.participant, + completeButton: req.completeButton, + closeButton: req.closeButton, + nextPrompt: req.nextPrompt, + headless: !!req.headless, + participant: req.participant, + }; + + _renderWebform(req, res, next, options); +} + +function fullParticipant(req, res, next) { + const options = { + type: 'full', + nojump: req.participant, + }; + + _renderWebform(req, res, next, options); +} + +function fullParticipantOffline(req, res, next) { + const options = { + type: 'full', + nojump: req.participant, + offlinePath: config['offline path'], + }; + + if (!req.app.get('offline enabled')) { + const error = new Error( + 'Offline functionality has not been enabled for this application.' + ); + error.status = 405; + next(error); + } else { + _renderWebform(req, res, next, options); + } +} + /** * @param {module:api-controller~ExpressRequest} req - HTTP request * @param {module:api-controller~ExpressResponse} res - HTTP response @@ -141,8 +311,10 @@ function view(req, res, next) { function preview(req, res, next) { const options = { type: 'preview', + jini: req.jini, iframe: req.iframe || !!req.query.iframe, notification: utils.pickRandomItemFromArray(config.notifications), + nextPrompt: req.nextPrompt, }; _renderWebform(req, res, next, options); diff --git a/packages/enketo-express/app/controllers/transformation-controller.js b/packages/enketo-express/app/controllers/transformation-controller.js index 4e87f386e..6d1290f5f 100644 --- a/packages/enketo-express/app/controllers/transformation-controller.js +++ b/packages/enketo-express/app/controllers/transformation-controller.js @@ -26,6 +26,44 @@ module.exports = (app) => { router.param('enketo_id', routerUtils.enketoId); router.param('encrypted_enketo_id_single', routerUtils.encryptedEnketoIdSingle); router.param('encrypted_enketo_id_view', routerUtils.encryptedEnketoIdView); +router.param( + 'encrypted_enketo_id_view_dn', + routerUtils.encryptedEnketoIdViewDn +); +router.param( + 'encrypted_enketo_id_view_dn_c', + routerUtils.encryptedEnketoIdViewDnC +); +router.param( + 'encrypted_enketo_id_preview', + routerUtils.encryptedEnketoIdPreview +); +router.param('encrypted_enketo_id_fs_c', routerUtils.encryptedEnketoIdFsC); +router.param( + 'encrypted_enketo_id_fs_participant', + routerUtils.encryptedEnketoIdFsParticipant +); +router.param( + 'encrypted_enketo_id_full_participant', + routerUtils.encryptedEnketoIdFullParticipant +); +router.param('encrypted_enketo_id_rfc', routerUtils.encryptedEnketoIdEditRfc); +router.param( + 'encrypted_enketo_id_rfc_c', + routerUtils.encryptedEnketoIdEditRfcC +); +router.param( + 'encrypted_enketo_id_headless', + routerUtils.encryptedEnketoIdEditHeadless +); +router.param( + 'encrypted_enketo_id_inc_rfc', + routerUtils.encryptedEnketoIdIncRfc +); +router.param( + 'encrypted_enketo_id_inc_rfc_c', + routerUtils.encryptedEnketoIdIncRfcC +); router .post('*', (req, res, next) => { @@ -35,9 +73,21 @@ router }) .post('/xform/:encrypted_enketo_id_single', getSurveyParts) .post('/xform/:encrypted_enketo_id_view', getSurveyParts) + .post('/xform/:encrypted_enketo_id_preview', getSurveyParts) + .post('/xform/:encrypted_enketo_id_view_dn', getSurveyParts) + .post('/xform/:encrypted_enketo_id_view_dn_c', getSurveyParts) + .post('/xform/:encrypted_enketo_id_fs_c', getSurveyParts) + .post('/xform/:encrypted_enketo_id_rfc', getSurveyParts) + .post('/xform/:encrypted_enketo_id_rfc_c', getSurveyParts) + .post('/xform/:encrypted_enketo_id_inc_rfc', getSurveyParts) + .post('/xform/:encrypted_enketo_id_inc_rfc_c', getSurveyParts) + .post('/xform/:encrypted_enketo_id_fs_participant', getSurveyParts) + .post('/xform/:encrypted_enketo_id_full_participant', getSurveyParts) + .post('/xform/:encrypted_enketo_id_headless', getSurveyParts) .post('/xform/:enketo_id', getSurveyParts) .post('/xform', getSurveyParts) - .post('/xform/hash/:enketo_id', getSurveyHash); + .post('/xform/hash/:enketo_id', getSurveyHash) + .post('/xform/hash/:encrypted_enketo_id_full_participant', getSurveyHash); /** * Obtains HTML Form, XML model, and existing XML instance @@ -155,7 +205,7 @@ function _updateCache(survey) { delete survey.mediaHash; delete survey.mediaUrlHash; delete survey.formHash; - + survey.openclinica = true; return communicator .getXForm(survey) .then(transformer.transform) diff --git a/packages/enketo-express/app/lib/communicator.js b/packages/enketo-express/app/lib/communicator.js index d5ca94981..49b91aa7e 100644 --- a/packages/enketo-express/app/lib/communicator.js +++ b/packages/enketo-express/app/lib/communicator.js @@ -242,13 +242,14 @@ function getFormListUrl(server, id, customParam) { /** * @static + * @param full * @param { string } server - server URL * @return { string } url */ -function getSubmissionUrl(server) { +function getSubmissionUrl(server, full = false) { return server.lastIndexOf('/') === server.length - 1 ? `${server}submission` - : `${server}/submission`; + : `${server}/submission${full ? '-full' : ''}`; } /** diff --git a/packages/enketo-express/app/lib/headless-browser.js b/packages/enketo-express/app/lib/headless-browser.js new file mode 100644 index 000000000..b2c738b8a --- /dev/null +++ b/packages/enketo-express/app/lib/headless-browser.js @@ -0,0 +1,39 @@ +const puppeteer = require('puppeteer'); + +const args = ['--no-startup-window']; +const userDataDir = './chromium-cache'; + +/** + * This class approach makes it easy to open multiple browser instances with + * different arguments in case that is ever required. + */ +class BrowserHandler { + constructor() { + const launchBrowser = async () => { + this.browser = false; + this.browser = await puppeteer.launch({ + headless: 'new', + devtools: false, + args, + userDataDir, + }); + this.browser.on('disconnected', launchBrowser); + }; + + (async () => { + await launchBrowser(); + })(); + } +} + +const getBrowser = (handler) => + new Promise((resolve) => { + const browserCheck = setInterval(() => { + if (handler.browser !== false) { + clearInterval(browserCheck); + resolve(handler.browser); + } + }, 100); + }); + +module.exports = { BrowserHandler, getBrowser }; diff --git a/packages/enketo-express/app/lib/headless.js b/packages/enketo-express/app/lib/headless.js new file mode 100644 index 000000000..137fe9025 --- /dev/null +++ b/packages/enketo-express/app/lib/headless.js @@ -0,0 +1,70 @@ +const { BrowserHandler, getBrowser } = require('./headless-browser'); +const config = require('../models/config-model').server; + +const { timeout } = config.headless; +const browserHandler = new BrowserHandler(); + +async function run(url) { + if (!url) { + throw new Error('No url provided'); + } + const browser = await getBrowser(browserHandler); + const page = await browser.newPage(); + + // Turns request interceptor on + await page.setRequestInterception(true); + + // Ignore certain resources + const ignoreTypes = ['image', 'stylesheet', 'media', 'font']; + page.on('request', (request) => { + if (ignoreTypes.includes(request.resourceType())) { + request.abort(); + } else { + request.continue(); + } + }); + + let fieldsubmissions; + + try { + await page + .goto(url, { waitUntil: 'networkidle0', timeout }) + .catch((e) => { + // Martijn has not been able to actually reach this code. + e.status = 400; + throw e; + }); + + const element = await page + .waitForSelector('#headless-result', { timeout }) + .catch((e) => { + e.status = /timeout/i.test(e.message) ? 408 : 400; + throw e; + }); + + const errorEl = await element.$('#error'); + // Load or submission errors caught by Enketo + if (errorEl) { + const msg = await errorEl.getProperty('textContent'); + const error = new Error(await msg.jsonValue()); + error.status = 400; + throw error; + } + + const fsEl = await element.$('#fieldsubmissions'); + if (fsEl) { + const fs = await fsEl.getProperty('textContent'); + fieldsubmissions = Number(await fs.jsonValue()); + } + } catch (e) { + e.status = e.status || 400; + await page.close(); + throw e; + } + + await page.close(); + + return fieldsubmissions; +} + +module.exports = { run }; diff --git a/packages/enketo-express/app/lib/pdf.js b/packages/enketo-express/app/lib/pdf.js index 4ab203af7..e5ab76031 100644 --- a/packages/enketo-express/app/lib/pdf.js +++ b/packages/enketo-express/app/lib/pdf.js @@ -1,11 +1,12 @@ /** * @module pdf */ +const { URL } = require('url'); const config = require('../models/config-model').server; +const { BrowserHandler, getBrowser } = require('./headless-browser'); +const browserHandler = new BrowserHandler(); const { timeout } = config.headless; -const puppeteer = require('puppeteer'); -const { URL } = require('url'); /** * @typedef PdfGetOptions @@ -35,35 +36,52 @@ const DEFAULTS = { * @param {PdfGetOptions} [options] - PDF options * @return { Promise } a promise that returns the PDF */ -async function get(url, options = {}) { +async function get( + url, + { + format = DEFAULTS.FORMAT, + margin = DEFAULTS.MARGIN, + landscape = DEFAULTS.LANDSCAPE, + scale = DEFAULTS.SCALE, + } = {} +) { if (!url) { throw new Error('No url provided'); } - options.format = options.format || DEFAULTS.FORMAT; - options.margin = options.margin || DEFAULTS.MARGIN; - options.landscape = options.landscape || DEFAULTS.LANDSCAPE; - options.scale = options.scale || DEFAULTS.SCALE; - const urlObj = new URL(url); - urlObj.searchParams.append('format', options.format); - urlObj.searchParams.append('margin', options.margin); - urlObj.searchParams.append('landscape', options.landscape); - urlObj.searchParams.append('scale', options.scale); + urlObj.searchParams.append('format', format); + urlObj.searchParams.append('margin', margin); + urlObj.searchParams.append('landscape', landscape); + urlObj.searchParams.append('scale', scale); - const browser = await puppeteer.launch({ headless: true }); + const browser = await getBrowser(browserHandler); const page = await browser.newPage(); let pdf; try { - await page + // To use an eventhandler here and catch a specific error, + // we have to return a Promise (in this case one that never resolves). + const detect401 = new Promise((resolve, reject) => { + page.on('requestfinished', (request) => { + if (request.response().status() === 401) { + const e = new Error('Authentication required'); + e.status = 401; + reject(e); + } + }); + }); + const goToPage = page .goto(urlObj.href, { waitUntil: 'networkidle0', timeout }) .catch((e) => { e.status = /timeout/i.test(e.message) ? 408 : 400; throw e; }); + // Either a 401 error is thrown or goto succeeds (or encounters a real loading error) + await Promise.race([detect401, goToPage]); + /* * This works around an issue with puppeteer not printing canvas * images that were loaded from a file. @@ -84,23 +102,24 @@ async function get(url, options = {}) { image.style['max-width'] = '100%'; image.className = element.className; - element.parentNode?.insertBefore(image, element); - element.parentNode?.removeChild(element); + element.parentNode && + element.parentNode.insertBefore(image, element); + element.parentNode && element.parentNode.removeChild(element); } document.querySelectorAll('canvas').forEach(canvasToImage); }); pdf = await page.pdf({ - landscape: options.landscape, - format: options.format, + landscape, + format, margin: { - top: options.margin, - left: options.margin, - right: options.margin, - bottom: options.margin, + top: margin, + left: margin, + right: margin, + bottom: margin, }, - scale: options.scale, + scale, printBackground: true, timeout, }); @@ -111,7 +130,6 @@ async function get(url, options = {}) { } await page.close(); - await browser.close(); return pdf; } diff --git a/packages/enketo-express/app/lib/router-utils.js b/packages/enketo-express/app/lib/router-utils.js index 6d6f18961..7548d6fc8 100644 --- a/packages/enketo-express/app/lib/router-utils.js +++ b/packages/enketo-express/app/lib/router-utils.js @@ -14,6 +14,17 @@ const config = require('../models/config-model').server; const keys = { singleOnce: config['less secure encryption key'], view: `${config['less secure encryption key']}view`, + viewDn: `${config['less secure encryption key']}view-dn`, + viewDnC: `${config['less secure encryption key']}view-dnc`, + preview: `${config['less secure encryption key']}preview-oc`, + fsC: `${config['less secure encryption key']}fs-c`, + fsParticipant: `${config['less secure encryption key']}fs-participant`, + fullParticipant: `${config['less secure encryption key']}full-participant`, + editRfc: `${config['less secure encryption key']}edit-rfc`, + editRfcC: `${config['less secure encryption key']}edit-rfc-c`, + editHeadless: `${config['less secure encryption key']}edit-headless`, + incRfc: `${config['less secure encryption key']}inc-rfc`, + incRfcC: `${config['less secure encryption key']}inc-rfc-c`, }; /** @@ -64,6 +75,50 @@ function encryptedEnketoIdParamView(req, res, next, id) { _encryptedEnketoIdParam(req, res, next, id, keys.view); } +function encryptedEnketoIdPreview(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.preview); +} + +function encryptedEnketoIdViewDn(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.viewDn); +} + +function encryptedEnketoIdViewDnC(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.viewDnC); +} + +function encryptedEnketoIdFsC(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.fsC); +} + +function encryptedEnketoIdFsParticipant(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.fsParticipant); +} + +function encryptedEnketoIdFullParticipant(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.fullParticipant); +} + +function encryptedEnketoIdEditRfc(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.editRfc); +} + +function encryptedEnketoIdEditRfcC(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.editRfcC); +} + +function encryptedEnketoIdIncRfc(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.incRfc); +} + +function encryptedEnketoIdIncRfcC(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.incRfcC); +} + +function encryptedEnketoIdEditHeadless(req, res, next, id) { + _encryptedEnketoIdParam(req, res, next, id, keys.editHeadless); +} + /** * Returns decrypted Enketo ID * @@ -107,4 +162,15 @@ module.exports = { idEncryptionKeys: keys, encryptedEnketoIdSingle: encryptedEnketoIdParamSingle, encryptedEnketoIdView: encryptedEnketoIdParamView, + encryptedEnketoIdPreview, + encryptedEnketoIdViewDn, + encryptedEnketoIdViewDnC, + encryptedEnketoIdFsC, + encryptedEnketoIdEditRfc, + encryptedEnketoIdEditRfcC, + encryptedEnketoIdIncRfc, + encryptedEnketoIdIncRfcC, + encryptedEnketoIdEditHeadless, + encryptedEnketoIdFullParticipant, + encryptedEnketoIdFsParticipant, }; diff --git a/packages/enketo-express/app/lib/url-oc.js b/packages/enketo-express/app/lib/url-oc.js new file mode 100644 index 000000000..e2d7ba5aa --- /dev/null +++ b/packages/enketo-express/app/lib/url-oc.js @@ -0,0 +1,28 @@ +const path = require('path'); + +const getSubmissionUrlAPI1 = (server, type) => { + const lastPathPart = type === 'field' || !type ? '' : `/${type}`; + + return server.lastIndexOf('/') === server.length - 1 + ? `${server}fieldsubmission${lastPathPart}` + : `${server}/fieldsubmission${lastPathPart}`; +}; + +const getSubmissionUrlAPI2 = (server, pth) => { + const baseUrl = new URL(server); + const basePath = baseUrl.pathname; + pth = path.join( + basePath, + pth.replace( + /^(\/fieldsubmission)(\/[A-z0-9]+)(\/ecid\/.+)$/, + (match, p1, p2, p3) => `${p1}${p3}` + ) + ); + + return new URL(pth, baseUrl.origin).href; +}; + +module.exports = { + getSubmissionUrlAPI1, + getSubmissionUrlAPI2, +}; diff --git a/packages/enketo-express/app/models/account-model.js b/packages/enketo-express/app/models/account-model.js index 60839c994..3e5dfb7c2 100644 --- a/packages/enketo-express/app/models/account-model.js +++ b/packages/enketo-express/app/models/account-model.js @@ -8,7 +8,9 @@ const config = require('./config-model').server; const customGetAccount = config['account lib'] ? require(config['account lib']).getAccount : undefined; -// var debug = require( 'debug' )( 'account-model' ); +const pending = {}; +const { mainClient } = require('../lib/db'); +// const debug = require( 'debug' )( 'account-model' ); /** * @typedef AccountObj @@ -76,6 +78,245 @@ function get(survey) { return _getAccount(server); } +/** + * Create an account + * + * @param {{linkedServer: string, key: string}} account - [description] + * @return {[type]} [description] + */ +function set(account) { + let error; + let dbKey; + const hardcodedAccount = _getHardcodedAccount(); + + return new Promise((resolve, reject) => { + if (!account.linkedServer || !account.key) { + error = new Error('Bad Request. Server URL and/or API key missing'); + error.status = 400; + reject(error); + } else if (!utils.isValidUrl(account.linkedServer)) { + error = new Error('Bad Request. Server URL is not a valid URL.'); + error.status = 400; + reject(error); + } else if ( + !account.key || + typeof account.key !== 'string' || + account.key.length === 0 + ) { + error = new Error( + 'Bad Request. Account API key malformed or missing.' + ); + error.status = 400; + reject(error); + } else { + dbKey = `ac:${utils.cleanUrl(account.linkedServer)}`; + if (pending[dbKey]) { + error = new Error( + 'Conflict. Busy handling pending request for same account' + ); + error.status = 409; + reject(error); + } + if ( + hardcodedAccount && + _isAllowed(hardcodedAccount, account.linkedServer) + ) { + resolve(account); + } else { + // to avoid issues with fast subsequent requests + pending[dbKey] = true; + + mainClient.hgetall(dbKey, (error, obj) => { + if (error) { + delete pending[dbKey]; + reject(error); + } else if (!obj || obj.openRosaServer) { + // also update if deprecated openRosaServer property is present + mainClient.hmset(dbKey, account, (error) => { + delete pending[dbKey]; + if (error) { + reject(error); + } + // remove deprecated field, don't wait for result + if (obj && obj.openRosaServer) { + mainClient.hdel(dbKey, 'openRosaServer'); + } + account.status = 201; + resolve(account); + }); + } else if (!obj.linkedServer || !obj.key) { + delete pending[dbKey]; + error = new Error('Account information is incomplete.'); + error.status = 406; + reject(error); + } else { + delete pending[dbKey]; + obj.status = 200; + resolve(obj); + } + }); + } + } + }); +} + +/** + * Update an account + * + * @param {{linkedServer: string, key: string}} account - [description] + * @return {[type]} [description] + */ +function update(account) { + let error; + let dbKey; + const hardcodedAccount = _getHardcodedAccount(); + + return new Promise((resolve, reject) => { + if (!account.linkedServer) { + error = new Error('Bad Request. Server URL missing'); + error.status = 400; + reject(error); + } else if (!utils.isValidUrl(account.linkedServer)) { + error = new Error('Bad Request. Server URL is not a valid URL.'); + error.status = 400; + reject(error); + } else if ( + !account.key || + typeof account.key !== 'string' || + account.key.length === 0 + ) { + error = new Error( + 'Bad Request. Account API key malformed or missing.' + ); + error.status = 400; + reject(error); + } else if ( + hardcodedAccount && + _isAllowed(hardcodedAccount, account.linkedServer) + ) { + resolve(account); + } else { + dbKey = `ac:${utils.cleanUrl(account.linkedServer)}`; + mainClient.hgetall(dbKey, (error, obj) => { + if (error) { + reject(error); + } else if (!obj) { + error = new Error('Account Not found. Nothing to update'); + error.status = 404; + reject(error); + } else if (utils.areOwnPropertiesEqual(obj, account)) { + account.status = 200; + resolve(account); + } else { + mainClient.hmset(dbKey, account, (error) => { + if (error) { + reject(error); + } + // remove deprecated field, don't wait for result + if (obj.openRosaServer) { + mainClient.hdel(dbKey, 'openRosaServer'); + } + account.status = 201; + resolve(account); + }); + } + }); + } + }); +} + +/** + * Remove an account + * + * @param {{linkedServer: string, key: string}} account - [description] + * @return {[type]} [description] + */ +function remove(account) { + let error; + let dbKey; + const hardcodedAccount = _getHardcodedAccount(); + + return new Promise((resolve, reject) => { + if (!account.linkedServer) { + error = new Error('Bad Request. Server URL missing'); + error.status = 400; + reject(error); + } else if (!utils.isValidUrl(account.linkedServer)) { + error = new Error('Bad Request. Server URL is not a valid URL.'); + error.status = 400; + reject(error); + } else if ( + hardcodedAccount && + _isAllowed(hardcodedAccount, account.linkedServer) + ) { + error = new Error( + 'Not Allowed. Hardcoded account cannot be removed via API.' + ); + error.status = 405; + reject(error); + } else { + dbKey = `ac:${utils.cleanUrl(account.linkedServer)}`; + mainClient.hgetall(dbKey, (error, obj) => { + if (error) { + reject(error); + } else if (!obj) { + error = new Error('Not Found. Account not present.'); + error.status = 404; + reject(error); + } else { + mainClient.del(dbKey, (error) => { + if (error) { + reject(error); + } else { + resolve(account); + } + }); + } + }); + } + }); +} + +/** + * Obtains a list of acccounts + * + * @return {[type]} [description] + */ +function getList() { + let hardcodedAccount; + let multi; + const list = []; + + hardcodedAccount = _getHardcodedAccount(); + + if (hardcodedAccount) { + list.push(hardcodedAccount); + } + + return new Promise((resolve, reject) => { + mainClient.keys('ac:*', (error, accounts) => { + if (error) { + reject(error); + } else if (accounts.length === 0) { + resolve(list); + } else if (accounts.length > 0) { + multi = mainClient.multi(); + + accounts.forEach((account) => { + multi.hgetall(account); + }); + + multi.exec((errors, replies) => { + if (errors) { + reject(errors[0]); + } + resolve(list.concat(replies)); + }); + } + }); + }); +} + /** * Check if account for passed survey is active, and not exceeding quota. * This passes back the original survey object and therefore differs from the get function! @@ -101,10 +342,11 @@ function check(survey) { */ function _isAllowed(account, serverUrl) { return ( - account.linkedServer === '' || - new RegExp(`https?://${_stripProtocol(account.linkedServer)}`).test( - serverUrl - ) + account && + (account.linkedServer === '' || + new RegExp(`https?://${_stripProtocol(account.linkedServer)}`).test( + serverUrl + )) ); } @@ -136,7 +378,7 @@ function _stripProtocol(url) { function _getAccount(serverUrl) { const hardcodedAccount = _getHardcodedAccount(); - if (_isAllowed(hardcodedAccount, serverUrl)) { + if (hardcodedAccount && _isAllowed(hardcodedAccount, serverUrl)) { return Promise.resolve(hardcodedAccount); } @@ -144,12 +386,29 @@ function _getAccount(serverUrl) { return customGetAccount(serverUrl, config['account api url']); } - const error = new Error( - 'Forbidden. This server is not linked with Enketo.' - ); - error.status = 403; - - return Promise.reject(error); + return new Promise((resolve, reject) => { + mainClient.hgetall(`ac:${utils.cleanUrl(serverUrl)}`, (error, obj) => { + if (error) { + reject(error); + } + if (!obj) { + error = new Error( + 'Forbidden. This server is not linked with Enketo' + ); + error.status = 403; + reject(error); + } else { + // correct deprecated property name if necessary + resolve({ + linkedServer: obj.linkedServer + ? obj.linkedServer + : obj.openRosaServer, + key: obj.key, + quota: obj.quota || Infinity, + }); + } + }); + }); } /** @@ -163,6 +422,7 @@ function _getHardcodedAccount() { // check if configuration is acceptable if ( + config['account manager api key'] || !linkedServer || typeof linkedServer['server url'] === 'undefined' || typeof linkedServer['api key'] === 'undefined' @@ -185,14 +445,26 @@ function _getHardcodedAccount() { * @return { string|null } server */ function _getServer(survey) { - if (!survey || (typeof survey === 'object' && !survey.openRosaServer)) { + if ( + !survey || + (typeof survey === 'object' && + !survey.openRosaServer && + !survey.linkedServer) + ) { return null; } + if (typeof survey === 'string') { + return survey; + } - return typeof survey === 'string' ? survey : survey.openRosaServer; + return survey.linkedServer || survey.openRosaServer; } module.exports = { get, check, + set, + update, + remove, + getList, }; diff --git a/packages/enketo-express/app/models/user-model.js b/packages/enketo-express/app/models/user-model.js index a308f6049..84367bdd3 100644 --- a/packages/enketo-express/app/models/user-model.js +++ b/packages/enketo-express/app/models/user-model.js @@ -44,6 +44,14 @@ function getCredentials(req) { bearer: tokenValue, }; } + } else if (authType === 'token message') { + const tokenValue = req.cookies.__authToken; + // Obtain token value from GET/POST request cookie that was set by Enketo in the client in settings.js + if (tokenValue) { + creds = { + bearer: tokenValue, + }; + } } return creds; diff --git a/packages/enketo-express/app/views/layout.pug b/packages/enketo-express/app/views/layout.pug index cff7ec5d7..5550c34ee 100644 --- a/packages/enketo-express/app/views/layout.pug +++ b/packages/enketo-express/app/views/layout.pug @@ -14,9 +14,10 @@ html(lang=language, dir=dir(language)) block head-script - block style + if !headless + block style - - var cl = (iframe) ? 'iframe' : '' + - var cl = (iframe) ? 'iframe' : ( headless ? 'headless' : '') body(class=cl) if iframe .ios-iframe-bug-wrap diff --git a/packages/enketo-express/app/views/styles/common.scss b/packages/enketo-express/app/views/styles/common.scss index 6203b3e9b..1f86746f0 100644 --- a/packages/enketo-express/app/views/styles/common.scss +++ b/packages/enketo-express/app/views/styles/common.scss @@ -7,11 +7,9 @@ html { // Both enketo-core and foundation set this to 100%, which is the common approach. //height: auto; // to avoid revealing the side-slider in direction rtl when scrolling horizontally height: 100%; - overflow-x: hidden; } body { - overflow-x: hidden; margin: 0; min-height: 100%; display: flex; diff --git a/packages/enketo-express/app/views/styles/component/_common-oc.scss b/packages/enketo-express/app/views/styles/component/_common-oc.scss new file mode 100644 index 000000000..01d4f6d9a --- /dev/null +++ b/packages/enketo-express/app/views/styles/component/_common-oc.scss @@ -0,0 +1,482 @@ +// background of default button +$secondary-color: #eeeeee; + +// error block inside a modal dialog (e.g. upon Complete button click). +.vex.vex-theme-plain .vex-dialog-form .vex-dialog-message { + color: inherit; +} + +.vex-dialog-title { + .icon { + margin-right: 10px; + } +} + +.or .or-repeat .repeat-number { + display: none; +} + +// centering of image labels +.or .question-label~img { + max-width: 90%; + max-height: inherit; +} + +// headless views +.headless { + //background-image: repeating-linear-gradient(45deg, #ccc, #ccc 30px, #dbdbdb 30px, #dbdbdb 60px); + background-image: url("data:image/svg+xml;utf8,headless"); +} + +// readonly and note-only views +.oc-view, +.touch .oc-view { + .form-progress { + display: none; + } + + // hover style of radiobuttons and checkboxes + .option-wrapper:not(.or-comment-widget__content__user__dn-notify)>label:hover { + background: inherit !important; + } + + // suppress focus style, for most form controls except for discrepancy note controls + select:not([name=dn-assignee]), + textarea:not([name=dn-comment]), + input:not([name=dn-notify], [name=dn-comment]), + .draw-widget__body__canvas { + &:focus { + box-shadow: inherit !important; + + &:not(:checked) { + border-color: $input-border !important; + } + } + } + + // suppress focus style for pulldown widgets + a:focus { + outline: 0 !important; + } + + // suppress image grid focus style + input:focus~.active { + box-shadow: inherit !important; + border: none !important; + } +} + +// Error messages on groups (which is a feature that doesn't exist in Enketo Express and Enketo Core) +.or-group, +.or-group-data { + &.invalid-relevant { + border: 2px solid $state-danger-text; + + .or-repeat { + background: transparent; + } + + .or-relevant-msg { + padding: 4px 8px; + } + + // only show relevant error message that belong to groups (not questions) + >.or-relevant-msg { + display: block; + width: 100%; + } + + .or-repeat, + .or-group, + .or-group-data { + >.or-relevant-msg { + display: block; + } + } + + // hide constraint and required messages that are descendants of an + // irrelevant group + .or-constraint-msg.active, + .or-required-msg.active { + display: none; + } + } +} + +.or-branch.or-branch.pre-init.invalid-relevant { + display: inherit; +} + + +.fieldsubmission-status { + order: 15; + color: #555; + font-size: 12px; + line-height: 1.5em; + min-height: 1.5em; + padding-right: 10px; + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.error, + &.fail { + color: $state-danger-text; + } + + &.readonly { + font-weight: bold; + } +} + +.record-signed-status { + text-align: center; + font-weight: bold; +} + +.form-footer__feedback.fieldsubmission-status { + margin: 10px auto; + text-align: center; + max-width: 100%; +} + +// regular non-pages-mode webform +.form-footer .form-footer__content__main-controls { + + #complete-form, + #close-form { + display: block; + margin-left: auto; + margin-right: auto; + } +} + +.reason-for-change { + $b: 1px solid $gray-light; + margin-bottom: 10px; + + &__item { + margin: 0; + padding: 10px 10px 0 10px; + border-left: $b; + border-right: $b; + border-bottom: 1px solid transparent; + background-color: #f1f1f1; + + &:first-of-type { + border-top: $b; + } + + &:last-of-type { + border-bottom: $b; + padding-bottom: 10px; + } + + &__repeat-number { + padding: 0 5px; + } + + &.added:not(.edited) { + input[type=text] { + opacity: 0.5; + } + } + + &.invalid { + input[type=text] { + @extend .invalid-required; + border: 1px solid $state-danger-border; + margin: inherit !important; + padding: inherit !important; + border-radius: 0; + } + } + + &:not(.added) { + + .reason-for-change__item__label, + .reason-for-change__item__repeat-number { + &:last-of-type::after { + content: '*'; + color: $brand-primary-color; + padding: 0 3px; + } + } + } + + input[type=text] { + @include form-control; + background-color: $question-bg; + padding-left: 5px; + padding-right: 5px; + width: 100%; + } + } + + &__header { + h5 { + margin-bottom: 5px; + } + + &__apply-to-all.question { + display: flex; + flex-direction: row; + border: $b; + border-bottom: none; + margin-bottom: 0; + padding: 10px; + + &.question:not(.note):not(.focus):hover { + background: none; + } + + .option-wrapper { + flex-direction: row; + align-items: center; + margin-left: 10px; + } + + input[type=text] { + @include form-control; + flex: 1; + width: auto; + background-color: $question-bg; + padding-left: 5px; + padding-right: 5px; + border: 1px solid #ccc; + margin: 0; + } + + } + + // do not show heading if there is no following sibling + &:last-child { + display: none + } + } +} + +.bootstrap-select .btn-default.dropdown-toggle { + background: white; +} + +.oc-reason-msg { + @extend .or-required-msg; + color: #4f89bd !important; + display: block !important; +} + +// multiple constraints, see equivalent rules in enketo-core +@for $number from 1 through $max-constraints { + .invalid-constraint#{$number} { + @include question-invalid; + .or-constraint#{$number}-msg.active { + @include question-error-message; + display: block; + } + } + .or-constraint#{$number}-msg{ + display: none; + } +} + +.pages ~ .form-footer { + .form-footer { + &__content { + &__main-controls { + $marg: 15px; + + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-around; + + .btn { + padding-left: 10px; + padding-right: 10px; + + .icon { + margin-left: 6px; + } + + // show save-draft and exit-form button on every page + &#save-draft{ + display: inline-block; + } + &#exit-form{ + display: block; + } + } + + .previous-page, + #close-form, + #complete-form, + .next-page { + margin-left: $marg; + margin-right: $marg; + max-width: calc(33% - (2 * #{$marg})); + } + + #complete-form, + #close-form, + #exit-form { + min-width: 140px; + } + + .previous-page, + .next-page { + position: static; + min-width: 90px; + } + + .previous-page { + order: 1; + + &.disabled { + display: block; + visibility: hidden; + } + } + + #close-form, + #exit-form, + #save-draft, + #submit-form, + #validate-form, + #complete-form { + order: 2; + } + + #close-form { + display: block; + } + + #close-form.participant, + #close-form ~ #complete-form { + visibility: hidden; + } + + .next-page { + order: 3; + // reverse icon and text + display: flex; + + .icon { + order: 2; + } + + &.disabled { + display: block; + visibility: hidden; + } + } + + #close-form ~ #complete-form, + #exit-form ~ #close-form { + display: block; + margin: 0 30% 20px 30%; + order: 4; + } + + .logout { + order: 5; + } + + .enketo-power { + order: 6; + } + } + } + } + + &.end { + + #close-form ~ #complete-form, + #submit-form, + #close-form.participant, + #validate-form { + visibility: visible; + } + .next-prompt { + display: block; + width: 100%; + } + .logout { + margin-bottom: 50px; + } + } +} + +.pages~.form-footer, form:not(.pages)~.form-footer { + .next-prompt { + border: none; + display: none; + &:not(.focus):hover { + background: transparent; + } + + .option-wrapper { + display: inline-block; + } + + input[type=checkbox] { + float: none; + } + + span { + vertical-align: top; + margin-left: 0; + display: inline-block; + } + } +} + +form:not(.pages)~.form-footer { + .next-prompt { + display: block; + } +} + +.pages.empty-untouched~.form-footer { + .form-footer__content { + &__main-controls { + #close-form.participant { + visibility: visible; + } + } + } +} + +@media screen and (max-width: 430px) { + .fieldsubmission-status { + order: 65; + max-width: 100%; + width: 100%; + margin-top: 16px; + } + + .pages~.form-footer { + .form-footer { + &__content { + &__main-controls { + + .previous-page, + .next-page { + margin-left: 5px; + margin-right: 5px; + min-width: 40px; + + span { + display: none; + } + } + } + } + } + } +} + +.icon-exclamation-circle { + @extend .fa-exclamation-circle; +} diff --git a/packages/enketo-express/app/views/styles/component/_common-ui.scss b/packages/enketo-express/app/views/styles/component/_common-ui.scss index 210dd2a01..360893ea6 100644 --- a/packages/enketo-express/app/views/styles/component/_common-ui.scss +++ b/packages/enketo-express/app/views/styles/component/_common-ui.scss @@ -3,6 +3,7 @@ @import 'alert'; @import 'feedback-bar'; @import 'notification'; +@import 'common-oc'; .main-loader__image { border-color: $brand-primary-color; diff --git a/packages/enketo-express/app/views/styles/component/_grid-oc.scss b/packages/enketo-express/app/views/styles/component/_grid-oc.scss new file mode 100644 index 000000000..eeaff4bd2 --- /dev/null +++ b/packages/enketo-express/app/views/styles/component/_grid-oc.scss @@ -0,0 +1,40 @@ +.question .btn-dn { + margin: 0 8px; + top: -2px; +} + +.question legend .btn-dn { + top: -4px; +} + +// Error messages on groups (which is a feature that doesn't exist in Enketo Express and Enketo Core) +.or-group, +.or-group-data { + &.invalid-relevant { + margin: 0; + padding: 0; + border-radius: 0; + } +} + +.or-comment-widget__content__user__dn-assignee select, +.or-comment-widget__content__type select { + border: 0; + outline: 1px solid black; +} + +.repeat-buttons .remove { + line-height: 20px; +} + +// This extra push is to integrate with the dn-widget. Hence it's not included in Enketo Core as part of the analog scale widget +.or-analog-scale-initialized.or-appearance-vertical:not(.disabled) .analog-scale-widget { + margin-top: 15px; +} + +// multiple constraints, see equivalent rules in enketo-core +@for $number from 1 through $max-constraints { + .or-constraint#{$number}-msg{ + order: 5; //used in Grid Theme + } +} diff --git a/packages/enketo-express/app/views/styles/component/_modal.scss b/packages/enketo-express/app/views/styles/component/_modal.scss index 66dce6812..ff8cd43f8 100644 --- a/packages/enketo-express/app/views/styles/component/_modal.scss +++ b/packages/enketo-express/app/views/styles/component/_modal.scss @@ -43,7 +43,7 @@ .vex-dialog-link { margin-top: 20px; font-size: 0.8em; - font-color: $gray; + color: $gray; text-align: center; display: block; } diff --git a/packages/enketo-express/app/views/styles/component/_print-oc.scss b/packages/enketo-express/app/views/styles/component/_print-oc.scss new file mode 100644 index 000000000..0fef80b24 --- /dev/null +++ b/packages/enketo-express/app/views/styles/component/_print-oc.scss @@ -0,0 +1,166 @@ +.or { + // multiple constraints, see equivalent rules in enketo-core + @for $number from 1 through $max-constraints { + .invalid-constraint#{$number} { + .or-constraint#{$number}-msg.active { + display:none; + } + } + } +} + +.or-appearance-dn { + width: 100% !important; + flex: 100% !important; + + &.printified { + display: inherit !important; + + input { + display: none; + } + + table.temp-print { + order: 5; + } + + &::after { + content: none; + } + } +} + +.or.print-relevant-only { + + //.question.or-branch.disabled, + .or-branch.disabled { + display: inherit; + + &.question:not(.or-appearance-dn):not(.printified), + .question:not(.or-appearance-dn):not(.printified) { + display: none; + } + } + + .question.or-appearance-dn.printified { + + &[role="comment"] { + padding-top: 5px; + padding-bottom: 10px; + height: 100% !important; + + } + } + + &.pages.or { + + [role="page"].current:not(.question) { + + .or-group:not(.disabled), + .or-group-data:not(.disabled), + .or-repeat:not(.disabled) { + display: block; + } + } + } +} + + +.dn-temp-print { + margin-top: 5px; + margin-bottom: 0px; + + .or-comment-widget { + &__content__history__row, + &.audit { + margin-bottom: 5px; + display: table; + border-collapse: separate; + border-spacing: 15px 0; + page-break-inside: avoid; + + &:not(:last-child) { + margin-bottom: 2px; + } + + &__start { + display: table-cell; + + &__username { + border: none; + height: 18px; + line-height: 18px; + display: inline-block; + } + + } + + &__main { + border: none; + padding: 0; + display: table-cell; + + &--audit { + padding: 0 20px 0 10px; + + .or-comment-widget__content__history__row__main__comment__meta { + display: none; + } + + } + + &:before, + &:after { + opacity: 0; + } + + &__comment__meta { + display: inline-block; // 'display: block' result in some margin on print preview + } + } + } + } +} + +.btn-dn { + display: none; +} + +.or-appearance-analog-scale { + // if analog-scale widget is a non-current page the display is set to block + // which moves the vertical widget below the label instead of next to it. + &:not(.or-appearance-horizontal) { + display: flex !important; + flex-wrap: nowrap; + } + + .scale__ticks { + display: none; + } + + .slider-vertical .slider-track { + border-right: 1px solid black; + margin-left: -8.5px; + } + + .slider-horizontal .slider-track { + border-bottom: 1px solid black; + margin-top: -10px; + } + + .slider-handle { + border: 1px solid black; + } + + .slider-vertical .slider-handle { + margin-left: -1px; + } + + .slider-horizontal .slider-handle { + margin-top: -1px; + } + + .slider-vertical .min-label { + margin-top: 10px; + } +} diff --git a/packages/enketo-express/app/views/styles/component/_variables.scss b/packages/enketo-express/app/views/styles/component/_variables.scss index 6e4486a47..cd7d23113 100644 --- a/packages/enketo-express/app/views/styles/component/_variables.scss +++ b/packages/enketo-express/app/views/styles/component/_variables.scss @@ -1 +1,6 @@ $fa-font-path: '../fonts'; + +$dn-nav-min-width: 220px !default; +$dn-nav-max-width: 220px !default; + +$max-constraints: 20; diff --git a/packages/enketo-express/app/views/styles/theme-formhub/theme-formhub.print.scss b/packages/enketo-express/app/views/styles/theme-formhub/theme-formhub.print.scss index 40a276647..f62f10029 100644 --- a/packages/enketo-express/app/views/styles/theme-formhub/theme-formhub.print.scss +++ b/packages/enketo-express/app/views/styles/theme-formhub/theme-formhub.print.scss @@ -1,2 +1,4 @@ @import '/packages/enketo-core/src/sass/formhub/formhub-print'; +@import '../component/variables'; @import '../component/print'; +@import '../component/print-oc'; diff --git a/packages/enketo-express/app/views/styles/theme-grid/_form-grid.scss b/packages/enketo-express/app/views/styles/theme-grid/_form-grid.scss index 709c22510..909ffbda1 100644 --- a/packages/enketo-express/app/views/styles/theme-grid/_form-grid.scss +++ b/packages/enketo-express/app/views/styles/theme-grid/_form-grid.scss @@ -7,6 +7,7 @@ .btn.remove { margin-right: -26px; margin-left: -26px; + border: 1px solid #ccc; .icon { margin: 0 4px; } diff --git a/packages/enketo-express/app/views/styles/theme-grid/_variables.scss b/packages/enketo-express/app/views/styles/theme-grid/_variables.scss new file mode 100644 index 000000000..a67dab4c8 --- /dev/null +++ b/packages/enketo-express/app/views/styles/theme-grid/_variables.scss @@ -0,0 +1,2 @@ +$dn-nav-min-width: 220px; +$dn-nav-max-width: 320px; \ No newline at end of file diff --git a/packages/enketo-express/app/views/styles/theme-grid/theme-grid.print.scss b/packages/enketo-express/app/views/styles/theme-grid/theme-grid.print.scss index 1acd19a8e..34cb50cd0 100644 --- a/packages/enketo-express/app/views/styles/theme-grid/theme-grid.print.scss +++ b/packages/enketo-express/app/views/styles/theme-grid/theme-grid.print.scss @@ -1,2 +1,4 @@ @import '/packages/enketo-core/src/sass/grid/grid-print'; +@import '../component/variables'; @import '../component/print'; +@import '../component/print-oc'; diff --git a/packages/enketo-express/app/views/styles/theme-grid/theme-grid.scss b/packages/enketo-express/app/views/styles/theme-grid/theme-grid.scss index 01b8c91f6..a65875c49 100644 --- a/packages/enketo-express/app/views/styles/theme-grid/theme-grid.scss +++ b/packages/enketo-express/app/views/styles/theme-grid/theme-grid.scss @@ -1,4 +1,5 @@ // variables +@import 'variables'; @import '../component/variables'; @import '/packages/enketo-core/src/sass/grid/variables'; @import '/packages/enketo-core/src/sass/formhub/variables'; @@ -37,3 +38,6 @@ @import 'form-grid'; @import '../component/form_header'; @import '../component/form_footer'; + +// custom OC +@import '../component/grid-oc'; diff --git a/packages/enketo-express/app/views/styles/theme-kobo/theme-kobo.print.scss b/packages/enketo-express/app/views/styles/theme-kobo/theme-kobo.print.scss index 40a276647..f62f10029 100644 --- a/packages/enketo-express/app/views/styles/theme-kobo/theme-kobo.print.scss +++ b/packages/enketo-express/app/views/styles/theme-kobo/theme-kobo.print.scss @@ -1,2 +1,4 @@ @import '/packages/enketo-core/src/sass/formhub/formhub-print'; +@import '../component/variables'; @import '../component/print'; +@import '../component/print-oc'; diff --git a/packages/enketo-express/app/views/styles/theme-oc/_variables.scss b/packages/enketo-express/app/views/styles/theme-oc/_variables.scss new file mode 100644 index 000000000..f24a926fe --- /dev/null +++ b/packages/enketo-express/app/views/styles/theme-oc/_variables.scss @@ -0,0 +1,12 @@ +$body-bg: #f0f0f0; + +// OC Orange +$brand-primary-color: #f58220; +$brand-secondary-color: lighten($brand-primary-color, 35%); +$repeat-bg: lighten($brand-primary-color, 50%); +$button-border-width: 1px; + + +// wider forms +$max-content-width: 900px; +$main-breakpoint: $max-content-width; diff --git a/packages/enketo-express/app/views/styles/theme-oc/theme-oc.print.scss b/packages/enketo-express/app/views/styles/theme-oc/theme-oc.print.scss new file mode 100644 index 000000000..2ce0b06a5 --- /dev/null +++ b/packages/enketo-express/app/views/styles/theme-oc/theme-oc.print.scss @@ -0,0 +1,4 @@ +@import '../component/variables'; +@import '../../../../node_modules/enketo-core/src/sass/formhub/formhub-print'; +@import '../component/print'; +@import '../component/print-oc'; diff --git a/packages/enketo-express/app/views/styles/theme-oc/theme-oc.scss b/packages/enketo-express/app/views/styles/theme-oc/theme-oc.scss new file mode 100644 index 000000000..6d677ad02 --- /dev/null +++ b/packages/enketo-express/app/views/styles/theme-oc/theme-oc.scss @@ -0,0 +1,50 @@ +// variables, in the right order +@import 'variables'; +@import '../component/variables'; +@import '../../../../node_modules/enketo-core/src/sass/formhub/variables'; +@import '../../../../node_modules/enketo-core/src/sass/core/variables'; + +// fonts +@import '../component/fonts'; + +// mixins, in the right order +@import '../../../../node_modules/enketo-core/src/sass/core/mixins'; +@import '../../../../node_modules/enketo-core/src/sass/formhub/mixins'; +@import '../component/mixins'; + +// reset +@import '../../../../node_modules/enketo-core/src/sass/core/reset'; + +// common styles +@import '../component/common-ui'; + +// utilities +@import '../../../../node_modules/enketo-core/src/sass/core/utilities'; + +// icons +@import '../component/icons'; + +.or-repeat-info .icon-plus:before { + content: 'Add Another'; + font-family: 'OpenSansRegular', Arial, sans-serif; + font-size: 15px; +} + +.or-repeat .icon-minus:before { + content: 'Remove'; + font-family: 'OpenSansRegular', Arial, sans-serif; + font-size: 15px; +} + +// buttons +@import '../component/buttons'; + +// layout +@import '../../../../node_modules/enketo-core/src/sass/core/layout'; +@import '../../../../node_modules/enketo-core/src/sass/core/pages'; + +// components +@import '../component/side-slider'; +@import '../theme-formhub/form_formhub'; +@import '../component/form_header'; +@import '../component/form_footer'; diff --git a/packages/enketo-express/app/views/styles/theme-plain/theme-plain.print.scss b/packages/enketo-express/app/views/styles/theme-plain/theme-plain.print.scss index 44604fffa..f35794920 100644 --- a/packages/enketo-express/app/views/styles/theme-plain/theme-plain.print.scss +++ b/packages/enketo-express/app/views/styles/theme-plain/theme-plain.print.scss @@ -1,4 +1,6 @@ @import '/packages/enketo-core/src/sass/core/variables'; @import '/packages/enketo-core/src/sass/core/mixins'; @import '/packages/enketo-core/src/sass/core/print'; +@import '../component/variables'; @import '../component/print'; +@import '../component/print-oc'; diff --git a/packages/enketo-express/app/views/surveys/component/_enketo-power.pug b/packages/enketo-express/app/views/surveys/component/_enketo-power.pug index 3f21af01a..4b1c29679 100644 --- a/packages/enketo-express/app/views/surveys/component/_enketo-power.pug +++ b/packages/enketo-express/app/views/surveys/component/_enketo-power.pug @@ -1,6 +1,8 @@ // If you're considering changing this, be careful not to violate licence terms. .enketo-power - span(data-i18n='enketo.power')= t('enketo.power') - a(href="https://enketo.org", title="enketo.org website") - img(src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMjIwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCwgMCwgMjIwLCAxMDAiPgogIDxnIGlkPSJMYXllciAxIj4KICAgIDxnPgogICAgICA8cGF0aCBkPSJNMTcuOTUyLDcyLjQ4MiBMMTcuOTUyLDM4LjU0MSBDMTcuOTUyLDM1Ljg2OCAyMC4wNjYsMzMuNzU0IDIyLjczOCwzMy43NTQgTDM2LjQ5NiwzMy43NTQgQzM4Ljg1OSwzMy43NTQgNDAuNzg2LDM1LjY4MSA0MC43ODYsMzguMDQ0IEM0MC43ODYsNDAuNDA3IDM4Ljg1OSw0Mi4yNzEgMzYuNDk2LDQyLjI3MSBMMjcuNDY0LDQyLjI3MSBMMjcuNDY0LDUxLjA5OCBMMzMuNjk5LDUxLjA5OCBDMzYuMDYxLDUxLjA5OCAzNy45ODksNTMuMDI1IDM3Ljk4OSw1NS4zODggQzM3Ljk4OSw1Ny43NSAzNi4wNjEsNTkuNjE1IDMzLjY5OSw1OS42MTUgTDI3LjQ2NCw1OS42MTUgTDI3LjQ2NCw2OC43NTQgTDM2LjgwOCw2OC43NTQgQzM5LjE3LDY4Ljc1NCA0MS4wOTgsNzAuNjggNDEuMDk4LDczLjA0MyBDNDEuMDk4LDc1LjQwNCAzOS4xNyw3Ny4yNyAzNi44MDgsNzcuMjcgTDIyLjczOSw3Ny4yNyBDMjAuMDY2LDc3LjI2OSAxNy45NTIsNzUuMTU2IDE3Ljk1Miw3Mi40ODIgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNMTE3LjkxOCw3Mi40ODIgTDExNy45MTgsMzguNTQxIEMxMTcuOTE4LDM1Ljg2OCAxMjAuMDMyLDMzLjc1NCAxMjIuNzA1LDMzLjc1NCBMMTM2LjQ2MywzMy43NTQgQzEzOC44MjUsMzMuNzU0IDE0MC43NTIsMzUuNjgxIDE0MC43NTIsMzguMDQ0IEMxNDAuNzUyLDQwLjQwNyAxMzguODI1LDQyLjI3MSAxMzYuNDYzLDQyLjI3MSBMMTI3LjQzLDQyLjI3MSBMMTI3LjQzLDUxLjA5OCBMMTMzLjY2Nyw1MS4wOTggQzEzNi4wMjgsNTEuMDk4IDEzNy45NTUsNTMuMDI1IDEzNy45NTUsNTUuMzg4IEMxMzcuOTU1LDU3Ljc1IDEzNi4wMjgsNTkuNjE1IDEzMy42NjcsNTkuNjE1IEwxMjcuNDMsNTkuNjE1IEwxMjcuNDMsNjguNzU0IEwxMzYuNzc1LDY4Ljc1NCBDMTM5LjEzNiw2OC43NTQgMTQxLjA2NCw3MC42OCAxNDEuMDY0LDczLjA0MyBDMTQxLjA2NCw3NS40MDQgMTM5LjEzNiw3Ny4yNyAxMzYuNzc1LDc3LjI3IEwxMjIuNzA2LDc3LjI3IEMxMjAuMDMyLDc3LjI2OSAxMTcuOTE4LDc1LjE1NiAxMTcuOTE4LDcyLjQ4MiB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik0xNTMuOTAyLDQyLjU4MiBMMTQ5Ljg4MSw0Mi41ODIgQzE0Ny40NTcsNDIuNTgyIDE0NS40NjcsNDAuNTkzIDE0NS40NjcsMzguMTY4IEMxNDUuNDY3LDM1Ljc0NCAxNDcuNDU3LDMzLjc1NCAxNDkuODgxLDMzLjc1NCBMMTY3LjQ5MywzMy43NTQgQzE2OS45MTcsMzMuNzU0IDE3MS45MDcsMzUuNzQzIDE3MS45MDcsMzguMTY4IEMxNzEuOTA3LDQwLjU5MyAxNjkuOTE3LDQyLjU4MiAxNjcuNDkzLDQyLjU4MiBMMTYzLjQ3Myw0Mi41ODIgTDE2My40NzMsNzIuODU1IEMxNjMuNDczLDc1LjUyOSAxNjEuMzYsNzcuNjQyIDE1OC42ODgsNzcuNjQyIEMxNTYuMDEzLDc3LjY0MiAxNTMuOTAxLDc1LjUyOSAxNTMuOTAxLDcyLjg1NSBMMTUzLjkwMSw0Mi41ODIgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNNzQuMzgzLDc3Ljk3NyBDNzIuODg2LDc3Ljk3NyA3MS40MjIsNzcuMjI2IDcwLjU3Miw3NS44NjEgTDQ4LjY3Miw0MC42MzQgQzQ3LjM2NCwzOC41MzIgNDguMDEsMzUuNzY4IDUwLjExMiwzNC40NjIgQzUyLjIxMSwzMy4xNTUgNTQuOTc2LDMzLjc5OSA1Ni4yODMsMzUuOTAxIEw3OC4xODMsNzEuMTI4IEM3OS40OSw3My4yMzEgNzguODQ2LDc1Ljk5MyA3Ni43NDQsNzcuMyBDNzYuMDA4LDc3Ljc1OSA3NS4xOSw3Ny45NzcgNzQuMzgzLDc3Ljk3NyB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik03NS4wMDEsNzcuOTc2IEM3Mi41MjcsNzcuOTc2IDcwLjUyLDc1Ljk3IDcwLjUyLDczLjQ5NiBMNzAuNTIsMzguMjY4IEM3MC41MiwzNS43OTMgNzIuNTI4LDMzLjc4NyA3NS4wMDEsMzMuNzg3IEM3Ny40NzcsMzMuNzg3IDc5LjQ4MiwzNS43OTMgNzkuNDgyLDM4LjI2OCBMNzkuNDgyLDczLjQ5NiBDNzkuNDgyLDc1Ljk3IDc3LjQ3Niw3Ny45NzYgNzUuMDAxLDc3Ljk3NiB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik01MS4wNTMsNzcuOTc2IEM0OC41NzcsNzcuOTc2IDQ2LjU3Miw3NS45NyA0Ni41NzIsNzMuNDk2IEw0Ni41NzIsMzguMjY4IEM0Ni41NzIsMzUuNzkzIDQ4LjU3OCwzMy43ODcgNTEuMDUzLDMzLjc4NyBDNTMuNTI4LDMzLjc4NyA1NS41MzQsMzUuNzkzIDU1LjUzNCwzOC4yNjggTDU1LjUzNCw3My40OTYgQzU1LjUzNSw3NS45NyA1My41MjksNzcuOTc2IDUxLjA1Myw3Ny45NzYgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNODkuNjQ4LDc3Ljk3NiBDODcuMTczLDc3Ljk3NiA4NS4xNjcsNzUuOTcgODUuMTY3LDczLjQ5NiBMODUuMTY3LDM4LjI2OCBDODUuMTY3LDM1Ljc5MyA4Ny4xNzQsMzMuNzg3IDg5LjY0OCwzMy43ODcgQzkyLjEyMiwzMy43ODcgOTQuMTI5LDM1Ljc5MyA5NC4xMjksMzguMjY4IEw5NC4xMjksNzMuNDk2IEM5NC4xMyw3NS45NyA5Mi4xMjMsNzcuOTc2IDg5LjY0OCw3Ny45NzYgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNOTAuNjc5LDY4LjAwNCBDODkuODE2LDY4LjAwNCA4OC45NDEsNjcuNzU1IDg4LjE3Miw2Ny4yMzQgQzg2LjEyMyw2NS44NDUgODUuNTg1LDYzLjA2IDg2Ljk3Myw2MS4wMSBMMTA0LjA2OCwzNS43NTUgQzEwNS40NTcsMzMuNzA1IDEwOC4yNDEsMzMuMTY5IDExMC4yOTMsMzQuNTU2IEMxMTIuMzQyLDM1Ljk0MyAxMTIuODc5LDM4LjcyOSAxMTEuNDkyLDQwLjc3OSBMOTQuMzk2LDY2LjAzNCBDOTMuNTI5LDY3LjMxNCA5Mi4xMTcsNjguMDA0IDkwLjY3OSw2OC4wMDQgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNMTA5LjA4LDc3Ljk3NyBDMTA3LjYyNyw3Ny45NzcgMTA2LjIwMyw3Ny4yNzEgMTA1LjMzOSw3NS45NyBMOTIuNDMzLDU2LjQ5NSBDOTEuMDY3LDU0LjQzMiA5MS42MzEsNTEuNjUxIDkzLjY5Myw1MC4yODQgQzk1Ljc1NSw0OC45MTYgOTguNTM2LDQ5LjQ4MSA5OS45MDUsNTEuNTQ0IEwxMTIuODExLDcxLjAxOSBDMTE0LjE3OCw3My4wODIgMTEzLjYxNCw3NS44NjMgMTExLjU1MSw3Ny4yMyBDMTEwLjc4OSw3Ny43MzUgMTA5LjkyOSw3Ny45NzcgMTA5LjA4LDc3Ljk3NyB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik0xODguMTkxLDc4LjAyOCBDMTgwLjU0OSw3OC4wMjggMTc0LjMzMyw3MS44MTEgMTc0LjMzMyw2NC4xNyBMMTc0LjMzMyw0Ny41OTMgQzE3NC4zMzMsMzkuOTUyIDE4MC41NSwzMy43MzYgMTg4LjE5MSwzMy43MzYgQzE5NS44MzIsMzMuNzM2IDIwMi4wNDgsMzkuOTUyIDIwMi4wNDgsNDcuNTkzIEwyMDIuMDQ4LDY0LjE3IEMyMDIuMDQ4LDcxLjgxMSAxOTUuODMyLDc4LjAyOCAxODguMTkxLDc4LjAyOCB6IE0xODguMTkxLDQyLjgwMSBDMTg1LjU0OCw0Mi44MDEgMTgzLjM5OCw0NC45NSAxODMuMzk4LDQ3LjU5MyBMMTgzLjM5OCw2NC4xNyBDMTgzLjM5OCw2Ni44MTMgMTg1LjU0OCw2OC45NjMgMTg4LjE5MSw2OC45NjMgQzE5MC44MzMsNjguOTYzIDE5Mi45ODMsNjYuODEzIDE5Mi45ODMsNjQuMTcgTDE5Mi45ODMsNDcuNTkzIEMxOTIuOTgzLDQ0Ljk1IDE5MC44MzMsNDIuODAxIDE4OC4xOTEsNDIuODAxIHoiIGZpbGw9IiMwMDAwMDAiLz4KICAgICAgPHBhdGggZD0iTTEyNy40ODMsMjkuMzQzIEMxMjcuNDUsMjkuMzQzIDEyNy40MTYsMjkuMzQzIDEyNy4zODIsMjkuMzQyIEMxMjYuMjczLDI5LjMxNSAxMjUuMjE5LDI4Ljg0NCAxMjQuNDU5LDI4LjAzMyBMMTE4LjYzNCwyMS44MTggQzExNy4wNjksMjAuMTQ5IDExNy4xNTIsMTcuNTI2IDExOC44MjMsMTUuOTYgQzEyMC40OTIsMTQuMzk1IDEyMy4xMTYsMTQuNDggMTI0LjY4LDE2LjE1IEwxMjcuNjI0LDE5LjI5IEwxMzcuMDY2LDEwLjE0MiBDMTM4LjcwOSw4LjU1MSAxNDEuMzMyLDguNTkxIDE0Mi45MjYsMTAuMjM1IEMxNDQuNTE5LDExLjg3OSAxNDQuNDc3LDE0LjUwMyAxNDIuODMzLDE2LjA5NSBMMTMwLjM2NCwyOC4xNzQgQzEyOS41OTEsMjguOTI2IDEyOC41NTgsMjkuMzQzIDEyNy40ODMsMjkuMzQzIHoiIGZpbGw9IiNGMTVBMjkiLz4KICAgIDwvZz4KICA8L2c+CiAgPGRlZnMvPgo8L3N2Zz4K", alt="Enketo logo") - + span(data-i18n='enketo.power')= t('enketo.power') + if (power) + | !{' ' + power} + else + a(href="https://enketo.org", title="enketo.org website") + img(src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMjIwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCwgMCwgMjIwLCAxMDAiPgogIDxnIGlkPSJMYXllciAxIj4KICAgIDxnPgogICAgICA8cGF0aCBkPSJNMTcuOTUyLDcyLjQ4MiBMMTcuOTUyLDM4LjU0MSBDMTcuOTUyLDM1Ljg2OCAyMC4wNjYsMzMuNzU0IDIyLjczOCwzMy43NTQgTDM2LjQ5NiwzMy43NTQgQzM4Ljg1OSwzMy43NTQgNDAuNzg2LDM1LjY4MSA0MC43ODYsMzguMDQ0IEM0MC43ODYsNDAuNDA3IDM4Ljg1OSw0Mi4yNzEgMzYuNDk2LDQyLjI3MSBMMjcuNDY0LDQyLjI3MSBMMjcuNDY0LDUxLjA5OCBMMzMuNjk5LDUxLjA5OCBDMzYuMDYxLDUxLjA5OCAzNy45ODksNTMuMDI1IDM3Ljk4OSw1NS4zODggQzM3Ljk4OSw1Ny43NSAzNi4wNjEsNTkuNjE1IDMzLjY5OSw1OS42MTUgTDI3LjQ2NCw1OS42MTUgTDI3LjQ2NCw2OC43NTQgTDM2LjgwOCw2OC43NTQgQzM5LjE3LDY4Ljc1NCA0MS4wOTgsNzAuNjggNDEuMDk4LDczLjA0MyBDNDEuMDk4LDc1LjQwNCAzOS4xNyw3Ny4yNyAzNi44MDgsNzcuMjcgTDIyLjczOSw3Ny4yNyBDMjAuMDY2LDc3LjI2OSAxNy45NTIsNzUuMTU2IDE3Ljk1Miw3Mi40ODIgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNMTE3LjkxOCw3Mi40ODIgTDExNy45MTgsMzguNTQxIEMxMTcuOTE4LDM1Ljg2OCAxMjAuMDMyLDMzLjc1NCAxMjIuNzA1LDMzLjc1NCBMMTM2LjQ2MywzMy43NTQgQzEzOC44MjUsMzMuNzU0IDE0MC43NTIsMzUuNjgxIDE0MC43NTIsMzguMDQ0IEMxNDAuNzUyLDQwLjQwNyAxMzguODI1LDQyLjI3MSAxMzYuNDYzLDQyLjI3MSBMMTI3LjQzLDQyLjI3MSBMMTI3LjQzLDUxLjA5OCBMMTMzLjY2Nyw1MS4wOTggQzEzNi4wMjgsNTEuMDk4IDEzNy45NTUsNTMuMDI1IDEzNy45NTUsNTUuMzg4IEMxMzcuOTU1LDU3Ljc1IDEzNi4wMjgsNTkuNjE1IDEzMy42NjcsNTkuNjE1IEwxMjcuNDMsNTkuNjE1IEwxMjcuNDMsNjguNzU0IEwxMzYuNzc1LDY4Ljc1NCBDMTM5LjEzNiw2OC43NTQgMTQxLjA2NCw3MC42OCAxNDEuMDY0LDczLjA0MyBDMTQxLjA2NCw3NS40MDQgMTM5LjEzNiw3Ny4yNyAxMzYuNzc1LDc3LjI3IEwxMjIuNzA2LDc3LjI3IEMxMjAuMDMyLDc3LjI2OSAxMTcuOTE4LDc1LjE1NiAxMTcuOTE4LDcyLjQ4MiB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik0xNTMuOTAyLDQyLjU4MiBMMTQ5Ljg4MSw0Mi41ODIgQzE0Ny40NTcsNDIuNTgyIDE0NS40NjcsNDAuNTkzIDE0NS40NjcsMzguMTY4IEMxNDUuNDY3LDM1Ljc0NCAxNDcuNDU3LDMzLjc1NCAxNDkuODgxLDMzLjc1NCBMMTY3LjQ5MywzMy43NTQgQzE2OS45MTcsMzMuNzU0IDE3MS45MDcsMzUuNzQzIDE3MS45MDcsMzguMTY4IEMxNzEuOTA3LDQwLjU5MyAxNjkuOTE3LDQyLjU4MiAxNjcuNDkzLDQyLjU4MiBMMTYzLjQ3Myw0Mi41ODIgTDE2My40NzMsNzIuODU1IEMxNjMuNDczLDc1LjUyOSAxNjEuMzYsNzcuNjQyIDE1OC42ODgsNzcuNjQyIEMxNTYuMDEzLDc3LjY0MiAxNTMuOTAxLDc1LjUyOSAxNTMuOTAxLDcyLjg1NSBMMTUzLjkwMSw0Mi41ODIgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNNzQuMzgzLDc3Ljk3NyBDNzIuODg2LDc3Ljk3NyA3MS40MjIsNzcuMjI2IDcwLjU3Miw3NS44NjEgTDQ4LjY3Miw0MC42MzQgQzQ3LjM2NCwzOC41MzIgNDguMDEsMzUuNzY4IDUwLjExMiwzNC40NjIgQzUyLjIxMSwzMy4xNTUgNTQuOTc2LDMzLjc5OSA1Ni4yODMsMzUuOTAxIEw3OC4xODMsNzEuMTI4IEM3OS40OSw3My4yMzEgNzguODQ2LDc1Ljk5MyA3Ni43NDQsNzcuMyBDNzYuMDA4LDc3Ljc1OSA3NS4xOSw3Ny45NzcgNzQuMzgzLDc3Ljk3NyB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik03NS4wMDEsNzcuOTc2IEM3Mi41MjcsNzcuOTc2IDcwLjUyLDc1Ljk3IDcwLjUyLDczLjQ5NiBMNzAuNTIsMzguMjY4IEM3MC41MiwzNS43OTMgNzIuNTI4LDMzLjc4NyA3NS4wMDEsMzMuNzg3IEM3Ny40NzcsMzMuNzg3IDc5LjQ4MiwzNS43OTMgNzkuNDgyLDM4LjI2OCBMNzkuNDgyLDczLjQ5NiBDNzkuNDgyLDc1Ljk3IDc3LjQ3Niw3Ny45NzYgNzUuMDAxLDc3Ljk3NiB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik01MS4wNTMsNzcuOTc2IEM0OC41NzcsNzcuOTc2IDQ2LjU3Miw3NS45NyA0Ni41NzIsNzMuNDk2IEw0Ni41NzIsMzguMjY4IEM0Ni41NzIsMzUuNzkzIDQ4LjU3OCwzMy43ODcgNTEuMDUzLDMzLjc4NyBDNTMuNTI4LDMzLjc4NyA1NS41MzQsMzUuNzkzIDU1LjUzNCwzOC4yNjggTDU1LjUzNCw3My40OTYgQzU1LjUzNSw3NS45NyA1My41MjksNzcuOTc2IDUxLjA1Myw3Ny45NzYgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNODkuNjQ4LDc3Ljk3NiBDODcuMTczLDc3Ljk3NiA4NS4xNjcsNzUuOTcgODUuMTY3LDczLjQ5NiBMODUuMTY3LDM4LjI2OCBDODUuMTY3LDM1Ljc5MyA4Ny4xNzQsMzMuNzg3IDg5LjY0OCwzMy43ODcgQzkyLjEyMiwzMy43ODcgOTQuMTI5LDM1Ljc5MyA5NC4xMjksMzguMjY4IEw5NC4xMjksNzMuNDk2IEM5NC4xMyw3NS45NyA5Mi4xMjMsNzcuOTc2IDg5LjY0OCw3Ny45NzYgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNOTAuNjc5LDY4LjAwNCBDODkuODE2LDY4LjAwNCA4OC45NDEsNjcuNzU1IDg4LjE3Miw2Ny4yMzQgQzg2LjEyMyw2NS44NDUgODUuNTg1LDYzLjA2IDg2Ljk3Myw2MS4wMSBMMTA0LjA2OCwzNS43NTUgQzEwNS40NTcsMzMuNzA1IDEwOC4yNDEsMzMuMTY5IDExMC4yOTMsMzQuNTU2IEMxMTIuMzQyLDM1Ljk0MyAxMTIuODc5LDM4LjcyOSAxMTEuNDkyLDQwLjc3OSBMOTQuMzk2LDY2LjAzNCBDOTMuNTI5LDY3LjMxNCA5Mi4xMTcsNjguMDA0IDkwLjY3OSw2OC4wMDQgeiIgZmlsbD0iIzAwMDAwMCIvPgogICAgICA8cGF0aCBkPSJNMTA5LjA4LDc3Ljk3NyBDMTA3LjYyNyw3Ny45NzcgMTA2LjIwMyw3Ny4yNzEgMTA1LjMzOSw3NS45NyBMOTIuNDMzLDU2LjQ5NSBDOTEuMDY3LDU0LjQzMiA5MS42MzEsNTEuNjUxIDkzLjY5Myw1MC4yODQgQzk1Ljc1NSw0OC45MTYgOTguNTM2LDQ5LjQ4MSA5OS45MDUsNTEuNTQ0IEwxMTIuODExLDcxLjAxOSBDMTE0LjE3OCw3My4wODIgMTEzLjYxNCw3NS44NjMgMTExLjU1MSw3Ny4yMyBDMTEwLjc4OSw3Ny43MzUgMTA5LjkyOSw3Ny45NzcgMTA5LjA4LDc3Ljk3NyB6IiBmaWxsPSIjMDAwMDAwIi8+CiAgICAgIDxwYXRoIGQ9Ik0xODguMTkxLDc4LjAyOCBDMTgwLjU0OSw3OC4wMjggMTc0LjMzMyw3MS44MTEgMTc0LjMzMyw2NC4xNyBMMTc0LjMzMyw0Ny41OTMgQzE3NC4zMzMsMzkuOTUyIDE4MC41NSwzMy43MzYgMTg4LjE5MSwzMy43MzYgQzE5NS44MzIsMzMuNzM2IDIwMi4wNDgsMzkuOTUyIDIwMi4wNDgsNDcuNTkzIEwyMDIuMDQ4LDY0LjE3IEMyMDIuMDQ4LDcxLjgxMSAxOTUuODMyLDc4LjAyOCAxODguMTkxLDc4LjAyOCB6IE0xODguMTkxLDQyLjgwMSBDMTg1LjU0OCw0Mi44MDEgMTgzLjM5OCw0NC45NSAxODMuMzk4LDQ3LjU5MyBMMTgzLjM5OCw2NC4xNyBDMTgzLjM5OCw2Ni44MTMgMTg1LjU0OCw2OC45NjMgMTg4LjE5MSw2OC45NjMgQzE5MC44MzMsNjguOTYzIDE5Mi45ODMsNjYuODEzIDE5Mi45ODMsNjQuMTcgTDE5Mi45ODMsNDcuNTkzIEMxOTIuOTgzLDQ0Ljk1IDE5MC44MzMsNDIuODAxIDE4OC4xOTEsNDIuODAxIHoiIGZpbGw9IiMwMDAwMDAiLz4KICAgICAgPHBhdGggZD0iTTEyNy40ODMsMjkuMzQzIEMxMjcuNDUsMjkuMzQzIDEyNy40MTYsMjkuMzQzIDEyNy4zODIsMjkuMzQyIEMxMjYuMjczLDI5LjMxNSAxMjUuMjE5LDI4Ljg0NCAxMjQuNDU5LDI4LjAzMyBMMTE4LjYzNCwyMS44MTggQzExNy4wNjksMjAuMTQ5IDExNy4xNTIsMTcuNTI2IDExOC44MjMsMTUuOTYgQzEyMC40OTIsMTQuMzk1IDEyMy4xMTYsMTQuNDggMTI0LjY4LDE2LjE1IEwxMjcuNjI0LDE5LjI5IEwxMzcuMDY2LDEwLjE0MiBDMTM4LjcwOSw4LjU1MSAxNDEuMzMyLDguNTkxIDE0Mi45MjYsMTAuMjM1IEMxNDQuNTE5LDExLjg3OSAxNDQuNDc3LDE0LjUwMyAxNDIuODMzLDE2LjA5NSBMMTMwLjM2NCwyOC4xNzQgQzEyOS41OTEsMjguOTI2IDEyOC41NTgsMjkuMzQzIDEyNy40ODMsMjkuMzQzIHoiIGZpbGw9IiNGMTVBMjkiLz4KICAgIDwvZz4KICA8L2c+CiAgPGRlZnMvPgo8L3N2Zz4K", alt="Enketo logo") diff --git a/packages/enketo-express/app/views/surveys/component/_form-footer.pug b/packages/enketo-express/app/views/surveys/component/_form-footer.pug index ca12fe63f..b18753549 100644 --- a/packages/enketo-express/app/views/surveys/component/_form-footer.pug +++ b/packages/enketo-express/app/views/surveys/component/_form-footer.pug @@ -3,30 +3,53 @@ section.form-footer if !headless .form-footer__content__main-controls if type === 'preview' + if nextPrompt + fieldset.question.next-prompt + .option-wrapper: label + input.ignore(type='checkbox', name='next-prompt') + span.option-label #{nextPrompt} button#validate-form.btn.btn-primary i.icon.icon-check= ' ' span(data-i18n='formfooter.validate.btn')= t('formfooter.validate.btn') - else if type === 'view' - button#close-form.btn.btn-default.hide - span(data-i18n='alert.default.button')= t('alert.default.button') - else + else if type === 'full' if draftEnabled && offlinePath button#save-draft.btn.btn-default - i.icon.icon-info-circle.save-draft-info= ' ' - i.icon.icon-pencil= ' ' - span(data-i18n='formfooter.savedraft.btn')= t( 'formfooter.savedraft.btn' ) + i.icon.icon-info-circle.save-draft-info= ' ' + i.icon.icon-pencil= ' ' + span(data-i18n='formfooter.savedraft.btn')= t( 'formfooter.savedraft.btn' ) button#submit-form.btn.btn-primary i.icon.icon-check= ' ' span(data-i18n='formfooter.submit.btn')= t('formfooter.submit.btn') + else if type === 'fieldsubmission' || type === 'view' + if nextPrompt && completeButton + fieldset.question.next-prompt + .option-wrapper: label + input.ignore(type='checkbox', name='next-prompt') + span.option-label #{nextPrompt} + if participant + button(id="exit-form", class="btn btn-default participant", data-i18n='formfooter.exit.btn')= t('formfooter.exit.btn') + button(id="close-form", class="btn btn-primary participant", data-i18n='formfooter.done.btn')= t('formfooter.done.btn') + else if closeButton + button(id="close-form", class=`btn ${completeButton ? 'btn-default' : 'btn-primary'}`, data-i18n='alert.default.button')= t('alert.default.button') + if completeButton + button(id="complete-form", class=`btn btn-primary`) + if !closeButton + span(data-i18n='alert.default.button')= t('alert.default.button') + else + i.icon.icon-check= ' ' + span(data-i18n='formfooter.complete.btn')= t('formfooter.complete.btn') a.btn.btn-primary.next-page(href="#") i.icon.icon-arrow-right span(data-i18n='form.pages.next')= t('form.pages.next') include _logout include _enketo-power - a.previous-page.disabled(href="#", data-i18n='form.pages.back')= t('form.pages.back') + a.btn.btn-default.previous-page.disabled(href="#") + i.icon.icon-arrow-left + span(data-i18n='form.pages.back')= t('form.pages.back') .form-footer__content__jump-nav - a.btn.btn-default.disabled.first-page(href="#", data-i18n='form.pages.return')= t('form.pages.return') - a.btn.btn-default.disabled.last-page(href="#", data-i18n='form.pages.end')= t('form.pages.end') + if !nojump + a.btn.btn-default.disabled.first-page(href="#", data-i18n='form.pages.return')= t('form.pages.return') + a.btn.btn-default.disabled.last-page(href="#", data-i18n='form.pages.end')= t('form.pages.end') diff --git a/packages/enketo-express/app/views/surveys/component/_form-header.pug b/packages/enketo-express/app/views/surveys/component/_form-header.pug index c80dba7ec..9b298df62 100644 --- a/packages/enketo-express/app/views/surveys/component/_form-header.pug +++ b/packages/enketo-express/app/views/surveys/component/_form-header.pug @@ -18,7 +18,7 @@ header.form-header span.form-language-selector.hide: span(data-i18n='form.chooseLanguage')= t('form.chooseLanguage') button.form-header__button--homescreen.btn-icon-only.hide(type="button", aria-label="add to homescreen") i.icon.icon-bookmark-o - nav.pages-toc.hide(role="navigation") + //-nav.pages-toc.hide(role="navigation") label(for="toc-toggle") input#toc-toggle.ignore(type="checkbox" value="show") ul.pages-toc__list diff --git a/packages/enketo-express/app/views/surveys/component/_side-slider.pug b/packages/enketo-express/app/views/surveys/component/_side-slider.pug index 8ef52b517..b4d54f5dd 100644 --- a/packages/enketo-express/app/views/surveys/component/_side-slider.pug +++ b/packages/enketo-express/app/views/surveys/component/_side-slider.pug @@ -9,10 +9,10 @@ aside.record-list.side-slider - var uploadGuidanceKey = draftEnabled ? 'record-list.msg2' : 'record-list.msg2-nodraft'; p(data-i18n=uploadGuidanceKey, data-i18n-icon='icon-pencil')!= draftEnabled ? t('record-list.msg2', {icon: ' ', interpolation: {escapeValue: false}}) : t('record-list.msg2-nodraft') p(data-i18n='record-list.msg3')= t('record-list.msg3') - p.side-slider__app-version + p.side-slider__app-version.hide span(data-i18n='version')= t('version') span.side-slider__app-version__value - p.side-slider__advanced.hide + p.side-slider__advanced button.side-slider__advanced__button.flush-db.btn.btn-default(type="button", data-i18n='advanced.flushdb')= t('advanced.flushdb') button.side-slider__toggle.open(type="button", aria-label="open") button.side-slider__toggle.close(type="button", aria-label="close") diff --git a/packages/enketo-express/app/views/surveys/dev.pug b/packages/enketo-express/app/views/surveys/dev.pug new file mode 100644 index 000000000..2c04db886 --- /dev/null +++ b/packages/enketo-express/app/views/surveys/dev.pug @@ -0,0 +1,53 @@ +extends ../layout + +block style + // trying to mimic situation in OC-hosted iframe parent window + style. + html { + box-sizing: border-box; + } + body { + font-size: 100%; + font-family: 'Open Sans', Helvetica, Arial, sans-serif; + margin: 0; + width: 100% !important; + overflow: auto; + } + .iframe-parent{ + overflow: auto; + -webkit-overflow-scrolling: touch; + } + iframe { + z-index:1011; + width:100vw; + height:100vh; + } + +block content + div.iframe-parent + iframe(src=decodeURIComponent(src)) + script. + window.addEventListener('message', receiveMessage, false); + function receiveMessage(event){ + const data = JSON.parse(event.data); + // TODO in real life, for security reasons, check origin! -> if (event.origin !== "http://enk.to:8080") return; + console.log('data received from iframe', data); + console.log('origin of message', event.origin); + console.log('source of message', event.source); + if (data.enketoEvent === 'signature-request'){ + document.querySelector( 'iframe' ).contentWindow.postMessage( + JSON.stringify({ + event: 'signature-request-received' + }), + location.href + ); + } + } + script. + const iframe = document.querySelector( 'iframe' ); + const enketoUrl = iframe.src + // Send message once the iframe has completely finished loading. + // Pass an object with a `token` property + iframe.contentWindow.addEventListener( 'load' , () => { + iframe.contentWindow.postMessage( { 'authToken': 'abc123456' }, enketoUrl ); + }); diff --git a/packages/enketo-express/app/views/surveys/webform.pug b/packages/enketo-express/app/views/surveys/webform.pug index 2fca6de41..40e997894 100644 --- a/packages/enketo-express/app/views/surveys/webform.pug +++ b/packages/enketo-express/app/views/surveys/webform.pug @@ -19,9 +19,19 @@ block style link(rel='stylesheet', media=`${media}`, type='text/css' href=`${basePath}${offlinePath || ''}/css/theme-${defaultTheme}.print.css`) block script - - var suffix = (type && type !== 'preview' && type !== 'single') ? '-' + type : '' - script#main-script(defer, type="module", src=`${basePath}${offlinePath || ''}/js/build/enketo-webform${suffix}.js`) + - var suffix = (type && type !== 'single') ? '-' + (type === 'full' || type === 'preview' || type === 'fieldsubmission' ? 'oc' : type): '' + script#main-script(defer, type='module', src=`${basePath}${offlinePath || ''}/js/build/enketo-webform${suffix}.js`) + -// load jini stuff asynchronously (OC) + if jini && !headless + script(async, src=`${jini['script url']}`) + script. + var jiniSheet = document.createElement('link'); + jiniSheet.rel = 'stylesheet'; + jiniSheet.href = '#{jini["style url"]}'; + jiniSheet.type = 'text/css'; + jiniSheet.media = 'all'; + document.head.appendChild(jiniSheet); script. var env = !{JSON.stringify(clientConfig).replace(/<\//g, '<\\/')}; diff --git a/packages/enketo-express/config/build.js b/packages/enketo-express/config/build.js index 07e30bffe..6c51b39d4 100644 --- a/packages/enketo-express/config/build.js +++ b/packages/enketo-express/config/build.js @@ -23,6 +23,6 @@ module.exports = /** @satisfies {import('esbuild').BuildOptions} */ ({ minify: true, outdir: path.resolve(cwd, './public/js/build'), sourcemap: true, - splitting: true, + splitting: false, target: ['chrome89', 'edge89', 'firefox90', 'safari13'], }); diff --git a/packages/enketo-express/config/default-config.json b/packages/enketo-express/config/default-config.json index 2bdacd7fd..86d165067 100644 --- a/packages/enketo-express/config/default-config.json +++ b/packages/enketo-express/config/default-config.json @@ -1,13 +1,12 @@ { "app name": "Enketo Smart Paper for KoBoCAT", "port": "8005", + "account manager api key": "ocrocks", "max processes": 16, "offline enabled": true, "id length": 8, "linked form and data server": { "name": "KoBoCAT", - "server url": "kf.kobotoolbox.org", - "api key": "enketorules", "legacy formhub": false, "authentication": { "type": "basic", @@ -18,7 +17,7 @@ "expiry for record cache": 30000, "encryption key": "s0m3v3rys3cr3tk3y", "less secure encryption key": "this $3cr3t key is crackable", - "default theme": "kobo", + "default theme": "oc", "themes supported": [], "base path": "", "log": { @@ -44,6 +43,10 @@ "file", "draw", "rank", + "../../../widget/analog-scale/analog-scalepicker", + "../../../widget/discrepancy-note/dn-widget", + "../../../widget/strict-class/strict-class", + "../../../widget/signature-external/signature-external", "likert", "range", "columns", @@ -74,6 +77,10 @@ "site id": "" } }, + "jini": { + "style url": "", + "script url": "" + }, "headless": { "timeout": 60000 }, @@ -98,7 +105,7 @@ } }, "logo": { - "source": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjAiIHk9IjAiIHdpZHRoPSIyNDMuNjI0IiBoZWlnaHQ9IjYxLjgwNiIgdmlld0JveD0iMCwgMCwgMjQzLjYyNCwgNjEuODA2Ij4KICA8ZyBpZD0icG9zaXRpdmUiPgogICAgPHBhdGggZD0iTTMwLjQzMyw2LjgxMyBDMzIuNjIzLDYuODEzIDM0LjQwNiw4LjU5NiAzNC40MDYsMTAuODA0IEwzNC40MDYsNTMuMjM5IEMzNC40MDYsNTUuNDQ4IDMyLjYyMyw1Ny4yMzEgMzAuNDMzLDU3LjIzMSBMMzAuNDE0LDU3LjIxMSBMNi41NDIsNTcuMjExIEw2LjU2MSw1Ny4yMzEgQzQuMzUzLDU3LjIzMSAyLjU3LDU1LjQ0OCAyLjU3LDUzLjIzOSBMMi41NywxMC44MDQgQzIuNTcsOC41OTYgNC4zNTMsNi44MTMgNi41NjEsNi44MTMgTDMwLjQzMyw2LjgxMyB6IE0yLjU3LDEwLjgwNCBMMi41NywxMC43ODUgTTM0LjQwNiwxMC44MDQgTDM0LjQwNiwxMC43ODUgTTYuNTYxLDYuODEzIEw2LjU0Miw2LjgxMyIgZmlsbD0iI0FBQUFBQSIvPgogICAgPHBhdGggZD0iTTMxLjc1MSw1LjQ3NiBMMzEuNzUxLDUuNDk1IEMzMy45Niw1LjQ5NSAzNS43NDIsNy4yNzggMzUuNzQyLDkuNDY4IEwzNS43MjMsOS40NjggTDM1LjcyMyw1MS45MDIgTDM1Ljc0Miw1MS45MjEgQzM1Ljc0Miw1NC4xMTEgMzMuOTYsNTUuODk0IDMxLjc1MSw1NS44OTQgTDcuODc5LDU1Ljg5NCBDNS42OSw1NS44OTQgMy45MDcsNTQuMTExIDMuOTA3LDUxLjkyMSBMMy44ODgsNTEuOTAyIEwzLjg4OCw5LjQ2OCBMMy45MDcsOS40NjggQzMuOTA3LDcuMjc4IDUuNjksNS40OTUgNy44NzksNS40OTUgTDcuODc5LDUuNDc2IEwzMS43NTEsNS40NzYgeiIgZmlsbD0iIzNFNzVBNiIvPgogICAgPHBhdGggZD0iTTE1LjM3OCw0NC4zNDUgTDI0LjIzMyw0NC4zNDUgTDI0LjI1Miw0NC4zNjUgQzI2LjQ0Miw0NC4zNjUgMjguMjI0LDQyLjU4MiAyOC4yMjQsNDAuMzczIEwyOC4yMjQsMjEuNjk0IEwyOC4yMjQsMjEuNzEzIEMyOC4yMjQsMTkuNTA0IDI2LjQ0MiwxNy43MjIgMjQuMjUyLDE3LjcyMiBMMTUuMzc4LDE3LjcyMiBMMTUuMzk3LDE3LjcyMiBDMTMuMTg4LDE3LjcyMiAxMS40MDYsMTkuNTA0IDExLjQwNiwyMS43MTMgTDExLjQwNiwyMS42OTQgTDExLjQwNiw0MC4zNzMgQzExLjQwNiw0Mi41ODIgMTMuMTg4LDQ0LjM2NSAxNS4zOTcsNDQuMzY1IiBmaWxsPSIjRkZGRkZGIi8+CiAgICA8cGF0aCBkPSJNMTkuODM0LDIzLjk0MiBDMjIuMDI0LDIzLjk0MiAyMy44MDcsMjUuNzI0IDIzLjgwNywyNy45MzMgTDIzLjgwNywzNi44MDggQzIzLjgwNywzOS4wMTcgMjIuMDI0LDQwLjc5OSAxOS44MzQsNDAuNzk5IEwxOS44MTUsNDAuNzggTDE3LjE0MSw0MC43OCBMMTcuMTYsNDAuNzk5IEMxNC45NTIsNDAuNzk5IDEzLjE2OSwzOS4wMTcgMTMuMTY5LDM2LjgwOCBMMTMuMTY5LDI3LjkzMyBDMTMuMTY5LDI1LjcyNCAxNC45NTIsMjMuOTQyIDE3LjE2LDIzLjk0MiBMMTkuODM0LDIzLjk0MiB6IE0xMy4xNjksMjcuOTMzIEwxMy4xNjksMjcuOTE0IE0yMy44MDcsMjcuOTMzIEwyMy44MDcsMjcuOTE0IE0xNy4xNiwyMy45NDIgTDE3LjE0MSwyMy45NDIiIGZpbGw9IiNBQUFBQUEiLz4KICAgIDxwYXRoIGQ9Ik0yMS4xNTIsMjIuNjI0IEMyMy4zNjEsMjIuNjI0IDI1LjE0MywyNC40MDcgMjUuMTQzLDI2LjU5NiBMMjUuMTI0LDI2LjU5NiBMMjUuMTI0LDM1LjQ3MSBMMjUuMTQzLDM1LjQ5IEMyNS4xNDMsMzcuNjggMjMuMzYxLDM5LjQ2MiAyMS4xNTIsMzkuNDYyIEwxOC40NzgsMzkuNDYyIEMxNi4yODksMzkuNDYyIDE0LjUwNiwzNy42OCAxNC41MDYsMzUuNDkgTDE0LjQ4NywzNS40NzEgTDE0LjQ4NywyNi41OTYgTDE0LjUwNiwyNi41OTYgQzE0LjUwNiwyNC40MDcgMTYuMjg5LDIyLjYyNCAxOC40NzgsMjIuNjI0IEwyMS4xNTIsMjIuNjI0IHoiIGZpbGw9IiMzRTc1QTYiLz4KICAgIDxwYXRoIGQ9Ik0xMS44NTEsNS40OTUgQzExLjAxOCw1LjQ5NSA5Ljg1Niw1LjMwMSA4Ljg2Nyw1LjMyMSBDNS4yMjUsNS40MTggNC4yOTQsOC41MTggMy45MDcsMTEuNzkyIEMzLjE3MSwxOC4wNTEgNC40MywyNC45ODggMy45MDcsMzIuNjggQzMuNDQyLDM5LjMyNiAxLjY3OSw0Ni41MTUgMy41NzgsNTAuNTg0IEM1LjIwNSw1NC4xMTEgOS41NDYsNTUuMzEyIDE0LjgzNSw1Ni4yMjMgQzIwLjQzNSw1Ny4yMTEgMjcuMDYyLDU3Ljg3IDMxLjA5Miw1NS44OTMgQzMzLjM5OCw1NC43NyAzNC44MzIsNTIuNzkzIDM1Ljc0Myw1MC40MjkgQzM4LjQzNiw0My4zNTcgMzYuMjI3LDMyLjkxMyAzNi43MzEsMjIuNDExIEMzNi45NjMsMTcuNTg2IDM3Ljc3NywxMi43NDIgMzYuNDAxLDkuNDY3IEMzMy4xNDYsMS43MTcgMTcuNjA2LDIuNzYzIDEwLjUzNCw1LjQ5NSIgZmlsbC1vcGFjaXR5PSIwIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iMC4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICAgIDxwYXRoIGQ9Ik0xNy4xNjEsMTguNzQ5IEMxMC40NTYsMTguNDk3IDguNDQxLDI2Ljc5IDkuODc1LDMzLjM1OSBDMTEuMTczLDM5LjM2NSAxNS4zNzgsNDMuOTM4IDE5LjQ4Niw0My42MjggQzIzLjMwMyw0My4zMzcgMjcuMDYyLDM4Ljg0MiAyOC4xMDgsMzMuMTg0IEMyOC43MjgsMjkuODUyIDI4LjQxOCwyNi4xMTIgMjcuMTIsMjMuMzk5IEMyNS4yMjEsMTkuNDQ2IDIxLjI0OSwxNy42NjQgMTcuMTYxLDE4Ljc0OSIgZmlsbC1vcGFjaXR5PSIwIiBzdHJva2U9IiMzRjNGM0YiIHN0cm9rZS13aWR0aD0iMC4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICAgIDxwYXRoIGQ9Ik0xNy4xNjEsMjYuNzEzIEMxMi41ODgsMjcuMzMyIDEzLjk4MywzMy41MzMgMTYuNTAyLDM1LjAwNiBDMjAuMzM4LDM3LjI3MyAyNi43NzEsMjguNTczIDE3LjE2MSwyNi43MTMiIGZpbGwtb3BhY2l0eT0iMCIgc3Ryb2tlPSIjM0YzRjNGIiBzdHJva2Utd2lkdGg9IjAuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+CiAgICA8cGF0aCBkPSJNNDcuNjEyLDQ0Ljc5NyBMNTEuMTU1LDQ0Ljc5NyBMNTEuMTU1LDM0LjMzMSBMNTMuNzYyLDMxLjMxOCBMNjIuODAyLDQ0Ljc5NyBMNjYuOTk3LDQ0Ljc5NyBMNTYuMjg2LDI4Ljk5NyBMNjYuMjIzLDE3LjM1IEw2MS44MjUsMTcuMzUgTDUzLjQzNiwyNy42NTMgQzUyLjc0MywyOC41NDkgNTIuMDEsMjkuNTI2IDUxLjI3NywzMC41ODUgTDUxLjE1NSwzMC41ODUgTDUxLjE1NSwxNy4zNSBMNDcuNjEyLDE3LjM1IHoiIGZpbGw9IiMyMzFGMjAiLz4KICAgIDxwYXRoIGQ9Ik03NC44NTYsMjQuNjM5IEM2OS4zOTksMjQuNjM5IDY1LjA4MywyOC41MDggNjUuMDgzLDM1LjEwNSBDNjUuMDgzLDQxLjMzNiA2OS4xOTYsNDUuMjQ1IDc0LjUzMSw0NS4yNDUgQzc5LjI5NSw0NS4yNDUgODQuMzQ1LDQyLjA2OSA4NC4zNDUsMzQuNzc5IEM4NC4zNDUsMjguNzUyIDgwLjUxNywyNC42MzkgNzQuODU2LDI0LjYzOSB6IE03NC43NzUsMjcuMzI3IEM3OS4wMSwyNy4zMjcgODAuNjgsMzEuNTYyIDgwLjY4LDM0LjkwMiBDODAuNjgsMzkuMzQgNzguMTE0LDQyLjU1OCA3NC42OTQsNDIuNTU4IEM3MS4xOTEsNDIuNTU4IDY4LjcwNywzOS4zIDY4LjcwNywzNC45ODMgQzY4LjcwNywzMS4yMzYgNzAuNTQsMjcuMzI3IDc0Ljc3NSwyNy4zMjcgeiIgZmlsbD0iIzIzMUYyMCIvPgogICAgPHBhdGggZD0iTTg1LjkzMyw0NC43MTYgQzg3LjExNCw0NC44NzkgODguOTg4LDQ1LjA0MiA5MS40MzEsNDUuMDQyIEM5NS45MTEsNDUuMDQyIDk5LjAwNiw0NC4yMjcgMTAwLjkyLDQyLjQ3NiBDMTAyLjMwNCw0MS4xMzIgMTAzLjI0MSwzOS4zNCAxMDMuMjQxLDM2Ljk3OCBDMTAzLjI0MSwzMi45MDYgMTAwLjE4NywzMC43NDggOTcuNTgsMzAuMDk2IEw5Ny41OCwzMC4wMTUgQzEwMC40NzIsMjguOTU2IDEwMi4yMjMsMjYuNjM1IDEwMi4yMjMsMjMuOTg4IEMxMDIuMjIzLDIxLjgyOSAxMDEuMzY4LDIwLjIgOTkuOTQyLDE5LjE0MSBDOTguMjMyLDE3Ljc1NyA5NS45NTEsMTcuMTQ2IDkyLjQwOCwxNy4xNDYgQzg5LjkyNCwxNy4xNDYgODcuNDgxLDE3LjM5IDg1LjkzMywxNy43MTYgeiBNODkuNDc2LDIwLjExOSBDOTAuMDQ2LDE5Ljk5NyA5MC45ODMsMTkuODc0IDkyLjYxMiwxOS44NzQgQzk2LjE5NiwxOS44NzQgOTguNjM5LDIxLjEzNyA5OC42MzksMjQuMzU0IEM5OC42MzksMjcuMDAxIDk2LjQ0LDI4Ljk1NiA5Mi42OTMsMjguOTU2IEw4OS40NzYsMjguOTU2IHogTTg5LjQ3NiwzMS42NDQgTDkyLjQwOCwzMS42NDQgQzk2LjI3NywzMS42NDQgOTkuNDk0LDMzLjE5MSA5OS40OTQsMzYuOTM4IEM5OS40OTQsNDAuOTI5IDk2LjExNCw0Mi4yNzMgOTIuNDQ5LDQyLjI3MyBDOTEuMTg3LDQyLjI3MyA5MC4xNjksNDIuMjMyIDg5LjQ3Niw0Mi4xMSB6IiBmaWxsPSIjMjMxRjIwIi8+CiAgICA8cGF0aCBkPSJNMTEzLjM0LDI0LjYzOSBDMTA3Ljg4MywyNC42MzkgMTAzLjU2NywyOC41MDggMTAzLjU2NywzNS4xMDUgQzEwMy41NjcsNDEuMzM2IDEwNy42OCw0NS4yNDUgMTEzLjAxNSw0NS4yNDUgQzExNy43NzksNDUuMjQ1IDEyMi44MjksNDIuMDY5IDEyMi44MjksMzQuNzc5IEMxMjIuODI5LDI4Ljc1MiAxMTkuMDAxLDI0LjYzOSAxMTMuMzQsMjQuNjM5IHogTTExMy4yNTksMjcuMzI3IEMxMTcuNDk0LDI3LjMyNyAxMTkuMTY0LDMxLjU2MiAxMTkuMTY0LDM0LjkwMiBDMTE5LjE2NCwzOS4zNCAxMTYuNTk4LDQyLjU1OCAxMTMuMTc4LDQyLjU1OCBDMTA5LjY3NSw0Mi41NTggMTA3LjE5MSwzOS4zIDEwNy4xOTEsMzQuOTgzIEMxMDcuMTkxLDMxLjIzNiAxMDkuMDI0LDI3LjMyNyAxMTMuMjU5LDI3LjMyNyB6IiBmaWxsPSIjMjMxRjIwIi8+CiAgICA8cGF0aCBkPSJNMTMzLjk4Nyw0NC43OTcgTDEzNy41NzEsNDQuNzk3IEwxMzcuNTcxLDIwLjM2MyBMMTQ1Ljk2LDIwLjM2MyBMMTQ1Ljk2LDE3LjM1IEwxMjUuNjM5LDE3LjM1IEwxMjUuNjM5LDIwLjM2MyBMMTMzLjk4NywyMC4zNjMgeiIgZmlsbD0iIzNFNzVBNiIvPgogICAgPHBhdGggZD0iTTE1MS4yOTUsMjQuNjM5IEMxNDUuODM4LDI0LjYzOSAxNDEuNTIxLDI4LjUwOCAxNDEuNTIxLDM1LjEwNSBDMTQxLjUyMSw0MS4zMzYgMTQ1LjYzNCw0NS4yNDUgMTUwLjk2OSw0NS4yNDUgQzE1NS43MzQsNDUuMjQ1IDE2MC43ODQsNDIuMDY5IDE2MC43ODQsMzQuNzc5IEMxNjAuNzg0LDI4Ljc1MiAxNTYuOTU2LDI0LjYzOSAxNTEuMjk1LDI0LjYzOSB6IE0xNTEuMjEzLDI3LjMyNyBDMTU1LjQ0OSwyNy4zMjcgMTU3LjExOCwzMS41NjIgMTU3LjExOCwzNC45MDIgQzE1Ny4xMTgsMzkuMzQgMTU0LjU1Myw0Mi41NTggMTUxLjEzMiw0Mi41NTggQzE0Ny42Myw0Mi41NTggMTQ1LjE0NiwzOS4zIDE0NS4xNDYsMzQuOTgzIEMxNDUuMTQ2LDMxLjIzNiAxNDYuOTc4LDI3LjMyNyAxNTEuMjEzLDI3LjMyNyB6IiBmaWxsPSIjM0U3NUE2Ii8+CiAgICA8cGF0aCBkPSJNMTcwLjU5OCwyNC42MzkgQzE2NS4xNDEsMjQuNjM5IDE2MC44MjQsMjguNTA4IDE2MC44MjQsMzUuMTA1IEMxNjAuODI0LDQxLjMzNiAxNjQuOTM3LDQ1LjI0NSAxNzAuMjcyLDQ1LjI0NSBDMTc1LjAzNyw0NS4yNDUgMTgwLjA4Nyw0Mi4wNjkgMTgwLjA4NywzNC43NzkgQzE4MC4wODcsMjguNzUyIDE3Ni4yNTksMjQuNjM5IDE3MC41OTgsMjQuNjM5IHogTTE3MC41MTcsMjcuMzI3IEMxNzQuNzUyLDI3LjMyNyAxNzYuNDIxLDMxLjU2MiAxNzYuNDIxLDM0LjkwMiBDMTc2LjQyMSwzOS4zNCAxNzMuODU2LDQyLjU1OCAxNzAuNDM1LDQyLjU1OCBDMTY2LjkzMyw0Mi41NTggMTY0LjQ0OSwzOS4zIDE2NC40NDksMzQuOTgzIEMxNjQuNDQ5LDMxLjIzNiAxNjYuMjgxLDI3LjMyNyAxNzAuNTE3LDI3LjMyNyB6IiBmaWxsPSIjM0U3NUE2Ii8+CiAgICA8cGF0aCBkPSJNMTgxLjU1Myw0NC43OTcgTDE4NS4xMzYsNDQuNzk3IEwxODUuMTM2LDE1Ljg4NCBMMTgxLjU1MywxNS44ODQgeiIgZmlsbD0iIzNFNzVBNiIvPgogICAgPHBhdGggZD0iTTE5MC45OCw0NC43OTcgTDE5MS4xNDMsNDEuNTQgTDE5MS4yNjUsNDEuNTQgQzE5Mi43MzIsNDQuMTQ2IDE5NS4wMTIsNDUuMjQ1IDE5Ny44NjMsNDUuMjQ1IEMyMDIuMjYxLDQ1LjI0NSAyMDYuNyw0MS43NDMgMjA2LjcsMzQuNjk4IEMyMDYuNzQsMjguNzEyIDIwMy4yNzksMjQuNjM5IDE5OC4zOTIsMjQuNjM5IEMxOTUuMjE2LDI0LjYzOSAxOTIuOTM1LDI2LjA2NCAxOTEuNjczLDI4LjI2NCBMMTkxLjU5MSwyOC4yNjQgTDE5MS41OTEsMTUuODg0IEwxODguMDQ4LDE1Ljg4NCBMMTg4LjA0OCwzOS43MDcgQzE4OC4wNDgsNDEuNDU4IDE4Ny45NjcsNDMuNDU0IDE4Ny44ODUsNDQuNzk3IHogTTE5MS41OTEsMzMuMzU0IEMxOTEuNTkxLDMyLjc4NCAxOTEuNzEzLDMyLjI5NSAxOTEuNzk1LDMxLjg4OCBDMTkyLjUyOCwyOS4xNTkgMTk0LjgwOCwyNy40OSAxOTcuMjUyLDI3LjQ5IEMyMDEuMDgsMjcuNDkgMjAzLjExNiwzMC44NyAyMDMuMTE2LDM0LjgyIEMyMDMuMTE2LDM5LjM0IDIwMC44NzYsNDIuMzk1IDE5Ny4xMyw0Mi4zOTUgQzE5NC41MjMsNDIuMzk1IDE5Mi40NDYsNDAuNjg0IDE5MS43NTQsMzguMiBDMTkxLjY3MywzNy43OTMgMTkxLjU5MSwzNy4zNDUgMTkxLjU5MSwzNi44OTcgeiIgZmlsbD0iIzNFNzVBNiIvPgogICAgPHBhdGggZD0iTTIxNi41NzUsMjQuNjM5IEMyMTEuMTE4LDI0LjYzOSAyMDYuODAxLDI4LjUwOCAyMDYuODAxLDM1LjEwNSBDMjA2LjgwMSw0MS4zMzYgMjEwLjkxNCw0NS4yNDUgMjE2LjI0OSw0NS4yNDUgQzIyMS4wMTQsNDUuMjQ1IDIyNi4wNjQsNDIuMDY5IDIyNi4wNjQsMzQuNzc5IEMyMjYuMDY0LDI4Ljc1MiAyMjIuMjM2LDI0LjYzOSAyMTYuNTc1LDI0LjYzOSB6IE0yMTYuNDk0LDI3LjMyNyBDMjIwLjcyOSwyNy4zMjcgMjIyLjM5OSwzMS41NjIgMjIyLjM5OSwzNC45MDIgQzIyMi4zOTksMzkuMzQgMjE5LjgzMyw0Mi41NTggMjE2LjQxMiw0Mi41NTggQzIxMi45MSw0Mi41NTggMjEwLjQyNiwzOS4zIDIxMC40MjYsMzQuOTgzIEMyMTAuNDI2LDMxLjIzNiAyMTIuMjU4LDI3LjMyNyAyMTYuNDk0LDI3LjMyNyB6IiBmaWxsPSIjM0U3NUE2Ii8+CiAgICA8cGF0aCBkPSJNMjI0LjY0MiwyNS4wODcgTDIzMS4zMjEsMzQuNzM5IEwyMjQuMzE3LDQ0Ljc5NyBMMjI4LjI2Nyw0NC43OTcgTDIzMS4xMTgsNDAuMzU5IEMyMzEuODUxLDM5LjE3OCAyMzIuNTQzLDM4LjExOSAyMzMuMTk0LDM2LjkzOCBMMjMzLjI3NiwzNi45MzggQzIzMy45NjgsMzguMTE5IDIzNC42MiwzOS4yMTggMjM1LjM5NCw0MC4zNTkgTDIzOC4yODUsNDQuNzk3IEwyNDIuMzU3LDQ0Ljc5NyBMMjM1LjQzNCwzNC42MTYgTDI0Mi4xNTQsMjUuMDg3IEwyMzguMjg1LDI1LjA4NyBMMjM1LjUxNiwyOS4yODIgQzIzNC44NjQsMzAuMzQgMjM0LjIxMywzMS4zNTkgMjMzLjU2MSwzMi41NCBMMjMzLjQzOSwzMi41NCBDMjMyLjc4NywzMS40NCAyMzIuMTc2LDMwLjQyMiAyMzEuNDQzLDI5LjMyMiBMMjI4LjYzMywyNS4wODcgeiIgZmlsbD0iIzNFNzVBNiIvPgogIDwvZz4KPC9zdmc+Cg==", + "source": "data:image/png;base64,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Z2VSZWFkeXHJZTwAAAPraVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0idXVpZDo1RDIwODkyNDkzQkZEQjExOTE0QTg1OTBEMzE1MDhDOCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpCRDFGRjY3MDc4QzAxMUU0ODM4QUJDRjI3RUQ2NkRBRSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpCRDFGRjY2Rjc4QzAxMUU0ODM4QUJDRjI3RUQ2NkRBRSIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBJbGx1c3RyYXRvciBDUzYgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDowMTgwMTE3NDA3MjA2ODExODIyQUFEQTlDOTFFNDFEOSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDowMTgwMTE3NDA3MjA2ODExODIyQUFEQTlDOTFFNDFEOSIvPiA8ZGM6dGl0bGU+IDxyZGY6QWx0PiA8cmRmOmxp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", "href": "" }, "disable save as draft": false, @@ -106,7 +113,7 @@ "validate continuously": false, "validate page": true, "payload limit": "100kb", - "text field character limit": 2000, + "text field character limit": 3999, "csrf cookie name": "__csrf", "frameguard deny": false, "no sniff": false, diff --git a/packages/enketo-express/config/express.js b/packages/enketo-express/config/express.js index 8acf6f026..b4b78a89e 100644 --- a/packages/enketo-express/config/express.js +++ b/packages/enketo-express/config/express.js @@ -120,6 +120,7 @@ app.use((req, res, next) => { res.locals.defaultTheme = req.app.get('default theme').replace('theme-', '') || 'kobo'; res.locals.title = req.app.get('app name'); + res.locals.power = req.app.get('powered by'); res.locals.dir = (lng) => i18next.dir(lng); res.locals.basePath = req.app.get('base path'); res.locals.draftEnabled = !req.app.get('disable save as draft'); diff --git a/packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-1.0.0-resolved.json b/packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-1.0.0-resolved.json new file mode 100644 index 000000000..1dce3abcd --- /dev/null +++ b/packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-1.0.0-resolved.json @@ -0,0 +1,618 @@ +{ + "swagger": "2.0", + "info": { + "description": "An API that receives individual submissions for each changed form field to build up a complete record.", + "version": "1.0.0", + "title": "OpenClinica Fieldsubmission API", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "host": "example.com", + "basePath": "/acc", + "schemes": [ + "https", + "http" + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "paths": { + "/fieldsubmission": { + "head": { + "tags": [ + "fieldsubmission" + ], + "description": "Return headers. Useful to check if the requested API version is supported.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + } + ], + "security": [ + { + "auth": [] + } + ], + "responses": { + "200": { + "description": "Headers only.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } + }, + "post": { + "tags": [ + "fieldsubmission" + ], + "description": "Submits a single field for a new and incomplete record.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + }, + { + "$ref": "#/parameters/xml_fragment" + }, + { + "$ref": "#/parameters/media_file" + }, + { + "$ref": "#/parameters/instance_id" + } + ], + "security": [ + { + "auth": [] + } + ], + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "413": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } + }, + "put": { + "tags": [ + "fieldsubmission" + ], + "description": "Updates a single field for a existing and completed record.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + }, + { + "$ref": "#/parameters/xml_fragment" + }, + { + "$ref": "#/parameters/media_file" + }, + { + "$ref": "#/parameters/instance_id" + }, + { + "$ref": "#/parameters/deprecated_id" + } + ], + "security": [ + { + "auth": [] + } + ], + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "413": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } + }, + "delete": { + "tags": [ + "fieldsubmission" + ], + "description": "Delete a repeat.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + }, + { + "$ref": "#/parameters/xml_fragment" + }, + { + "$ref": "#/parameters/instance_id" + }, + { + "$ref": "#/parameters/deprecated_id_optional" + } + ], + "security": [ + { + "auth": [] + } + ], + "responses": { + "204": { + "description": "Removed from database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "413": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } + } + }, + "/fieldsubmission/complete": { + "post": { + "tags": [ + "fieldsubmission" + ], + "description": "Mark a never-completed record as complete.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + }, + { + "$ref": "#/parameters/instance_id" + } + ], + "security": [ + { + "auth": [] + } + ], + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } + }, + "put": { + "tags": [ + "fieldsubmission" + ], + "description": "Mark an edited record as complete.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + }, + { + "$ref": "#/parameters/instance_id" + }, + { + "$ref": "#/parameters/deprecated_id" + } + ], + "security": [ + { + "auth": [] + } + ], + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } + } + } + }, + "securityDefinitions": { + "auth": { + "description": "API key as username. Password empty.", + "type": "basic" + } + }, + "definitions": {}, + "parameters": { + "oc_header": { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "type": "string", + "enum": [ + "1.0" + ] + } + }, + "responses": { + "headers": { + "description": "Headers only.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "changed": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "unchanged": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "removed": { + "description": "Removed from database.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "unauthorized": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "bad": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "notfound": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "locked": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + }, + "toolarge": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "type": "string", + "description": "1.0" + } + } + } + } +} \ No newline at end of file diff --git a/packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-2.0.0-resolved.json b/packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-2.0.0-resolved.json new file mode 100644 index 000000000..6b2816252 --- /dev/null +++ b/packages/enketo-express/fieldsubmission-api-backup/martijnr-openclinica-fieldsubmission-2.0.0-resolved.json @@ -0,0 +1,978 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "OpenClinica Fieldsubmission API", + "description": "An API that receives individual submissions for each changed form field to build up a complete record.", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "2.0.0" + }, + "servers": [ + { + "url": "https://example.com/fieldsubmission" + } + ], + "paths": { + "/ecid/{ecid}/instance/{instanceId}/repeat-group/{repeatNodeName}/ordinal/{repeatOrdinal}/node/{nodeName}": { + "put": { + "tags": [ + "fieldsubmission" + ], + "description": "Updates a single field for a existing and completed record.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "$ref": "#/components/headers/ocHeader" + } + }, + { + "name": "ecid", + "in": "path", + "description": "The ECID value.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instanceID of the record.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + { + "name": "repeatNodeName", + "in": "path", + "description": "The XML nodeName of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "repeatOrdinal", + "in": "path", + "description": "The ordinal of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "number" + } + } + } + }, + { + "name": "nodeName", + "in": "path", + "description": "The XML nodeName of the node that has changed.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/newValue" + }, + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "413": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + } + }, + "security": [ + { + "auth": [] + } + ] + }, + "post": { + "tags": [ + "fieldsubmission" + ], + "description": "Submits a single field for a new and incomplete record.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "$ref": "#/components/headers/ocHeader" + } + }, + { + "name": "ecid", + "in": "path", + "description": "The ECID value.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instanceID of the record.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + { + "name": "repeatNodeName", + "in": "path", + "description": "The XML nodeName of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "repeatOrdinal", + "in": "path", + "description": "The ordinal of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "number" + } + } + } + }, + { + "name": "nodeName", + "in": "path", + "description": "The XML nodeName of the node that has changed.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/newValue" + }, + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "413": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + } + }, + "security": [ + { + "auth": [] + } + ] + } + }, + "/ecid/{ecid}/instance/{instanceId}/repeat-group/{repeatNodeName}/ordinal/{repeatOrdinal}": { + "delete": { + "tags": [ + "fieldsubmission" + ], + "description": "Delete a repeat.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "$ref": "#/components/headers/ocHeader" + } + }, + { + "name": "ecid", + "in": "path", + "description": "The ECID value.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instanceID of the record.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + { + "name": "repeatNodeName", + "in": "path", + "description": "The XML nodeName of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "repeatOrdinal", + "in": "path", + "description": "The ordinal of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "number" + } + } + } + } + ], + "responses": { + "204": { + "description": "Removed from database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "413": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + } + }, + "security": [ + { + "auth": [] + } + ] + } + }, + "/complete/ecid/{ecid}/instance/{instanceId}/repeat-group/{repeatNodeName}/ordinal/{repeatOrdinal}/node/{nodeName}": { + "put": { + "tags": [ + "fieldsubmission" + ], + "description": "Mark an edited record as complete.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "$ref": "#/components/headers/ocHeader" + } + }, + { + "name": "ecid", + "in": "path", + "description": "The ECID value.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instanceID of the record.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + { + "name": "repeatNodeName", + "in": "path", + "description": "The XML nodeName of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "repeatOrdinal", + "in": "path", + "description": "The ordinal of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "number" + } + } + } + }, + { + "name": "nodeName", + "in": "path", + "description": "The XML nodeName of the node that has changed.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/newValue" + }, + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + } + }, + "security": [ + { + "auth": [] + } + ] + }, + "post": { + "tags": [ + "fieldsubmission" + ], + "description": "Mark a never-completed record as complete.\n", + "parameters": [ + { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "$ref": "#/components/headers/ocHeader" + } + }, + { + "name": "ecid", + "in": "path", + "description": "The ECID value.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "instanceId", + "in": "path", + "description": "The instanceID of the record.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + { + "name": "repeatNodeName", + "in": "path", + "description": "The XML nodeName of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + { + "name": "repeatOrdinal", + "in": "path", + "description": "The ordinal of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "number" + } + } + } + }, + { + "name": "nodeName", + "in": "path", + "description": "The XML nodeName of the node that has changed.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/newValue" + }, + "responses": { + "201": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "202": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "400": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "401": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "404": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "409": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + } + }, + "security": [ + { + "auth": [] + } + ] + } + } + }, + "components": { + "schemas": {}, + "responses": { + "changed": { + "description": "Submission received. Field changed in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "unchanged": { + "description": "Submission received but no change required. Field unchanged in database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "removed": { + "description": "Removed from database.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "unauthorized": { + "description": "Not Allowed. Invalid API Key.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "bad": { + "description": "Bad Request.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "notfound": { + "description": "Record/repeat not found.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "locked": { + "description": "Forbidden. Form is locked.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + }, + "toolarge": { + "description": "Payload too large.", + "headers": { + "X-OpenClinica-Version": { + "$ref": "#/components/headers/ocHeader" + } + } + } + }, + "parameters": { + "ocHeader": { + "name": "X-OpenClinica-Version", + "in": "header", + "description": "Fixed X-OpenClinica-Version header.", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "$ref": "#/components/headers/ocHeader" + } + }, + "ecid": { + "name": "ecid", + "in": "path", + "description": "The ECID value.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "instanceId": { + "name": "instanceId", + "in": "path", + "description": "The instanceID of the record.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + "repeatNodeName": { + "name": "repeatNodeName", + "in": "path", + "description": "The XML nodeName of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "repeatOrdinal": { + "name": "repeatOrdinal", + "in": "path", + "description": "The ordinal of the repeat of the resource that has changed, or 0 if not\n applicable.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "number" + } + } + } + }, + "nodeName": { + "name": "nodeName", + "in": "path", + "description": "The XML nodeName of the node that has changed.", + "required": true, + "style": "simple", + "explode": false, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + }, + "requestBodies": { + "newValue": { + "description": "The new value of the changed node.", + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "newValue": { + "type": "string" + }, + "file": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "required": true + } + }, + "headers": { + "ocHeader": { + "style": "simple", + "explode": false, + "schema": { + "type": "string", + "enum": [ + "2.0" + ] + } + } + }, + "securitySchemes": { + "auth": { + "type": "http", + "description": "API key as username. Password empty.", + "scheme": "basic" + } + } + } +} \ No newline at end of file diff --git a/packages/enketo-express/locales/src/de/translation-additions.json b/packages/enketo-express/locales/src/de/translation-additions.json new file mode 100644 index 000000000..49595ed28 --- /dev/null +++ b/packages/enketo-express/locales/src/de/translation-additions.json @@ -0,0 +1,536 @@ +{ + "advanced": { + "flushdb": "Speicher leeren" + }, + "alert": { + "addtohomescreen": { + "androidchrome": { + "msg": "Um Ihrem Startbildschirm dieses Formular hinzuzufügen, klicken Sie auf das Symbol für die Browsereinstellungen __image1__ und wählen Sie „Zum Startbildschirm hinzufügen“ aus." + }, + "androidfirefox": { + "msg": "Um Ihrem Startbildschirm dieses Formular hinzuzufügen, drücken Sie lange auf die Adressleiste und wählen Sie „Zum Startbildschirm hinzufügen“ aus." + }, + "heading": "Zum Startbildschirm hinzufügen", + "iossafari": { + "msg": "Um Ihrem Startbildschirm dieses Formular hinzuzufügen, klicken Sie rechts oben in Ihrem Browser auf das Teilen-Symbol __image1__ und wählen „+ Zum Startbildschirm hinzufügen“ aus." + } + }, + "appupdated": { + "heading": "Bitte aktualisieren", + "msg": "Aktualisieren Sie diese Webseite, damit Formulare offline verwenden werden können (Ref.: Anwendung)" + }, + "closing": { + "heading": "Wird geschlossen..." + }, + "default": { + "button": "Schließen", + "heading": "Warnung" + }, + "export": { + "error": { + "filecreatedmsg": "Trotzdem wurde eine unvollständige Exportdatei erstellt.", + "heading": "Exportfehler", + "msg": "Fehler beim Erstellen der Datensatzexportdatei für nicht übermittelte Datensätze. Fehler: __errors__" + }, + "success": { + "heading": "Export erstellt", + "msg": "Für alle nicht übermittelten Datensätze, die zu diesem Formular gehören, wurde eine Exportdatei erstellt." + } + }, + "formupdated": { + "heading": "Bitte aktualisieren", + "msg": "Aktualisieren Sie diese Webseite, damit Formulare offline verwenden werden können (Ref.: Definition)" + }, + "gotonotfound": { + "msg": "Frage „__path__“ wurde im Formular nicht gefunden. Ist der Verweis auf diese Frage korrekt?" + }, + "loaderror": { + "editadvice": "", + "entryadvice": "", + "heading": "Einen Moment bitte...", + "msg1": "Wenden Sie sich bitte an Ihr Supportteam mit einem Link auf diese Seite und der nachstehenden Meldung, wenn Sie weitere Hilfe benötigen.", + "msg2": "", + "heading_plural": "Einen Moment bitte...", + "msg1_plural": "Wenden Sie sich bitte an Ihr Supportteam mit einem Link auf diese Seite und den nachstehenden Meldungen, wenn Sie weitere Hilfe benötigen.", + "msg2_plural": "" + }, + "logout": { + "heading": "Abgemeldet", + "msg": "Sie wurden abgemeldet." + }, + "offlinesupported": { + "heading": "Formular funktioniert offline!", + "msg": [ + "Dieses Formular kann jetzt geladen und ohne Internetverbindung auf diesem Gerät verwendet werden. Setzen Sie ein Lesezeichen, um offline einfach drauf zuzugreifen.", + "Datensätze werden automatisch gespeichert und in Ihrem Browser in die Warteschlange gestellt, bis eine Internetverbindung verfügbar ist. Wenn die App online ist, werden die Datensätze nacheinander automatisch übermittelt.", + "Ein Datensatz wird erst aus der Warteschlange entfernt, wenn er erfolgreich übermittelt wurde. Sie können Ihren Browser und Ihr Gerät mit Einträgen in der Warteschlange gefahrlos schließen. Wenn Sie das Formular das nächste Mal laden, sind sie nach wie vor vorhanden." + ] + }, + "offlineunsupported": { + "heading": "Anwendung kann offline nicht gestartet werden", + "msg": "Das Starten von Offline-Anwendungen wird von Ihrem Browser nicht unterstützt. Sie können die Umfrage auch ohne diese Funktion verwenden oder die Optionen zur Behebung dieses Problems anzeigen.", + "negButton": "Verwenden", + "posButton": "Optionen anzeigen", + "refresh": "Dieses Formular kann nicht mehr offline gestartet werden. Aktualisieren Sie die Seite, um dieses Problem zu beheben." + }, + "queuesubmissionsuccess": { + "msg": "__recordNames__ wurde erfolgreich übermittelt", + "msg_plural": "__recordNames__ wurden erfolgreich übermittelt" + }, + "recordloadsuccess": { + "msg": "__recordName__ wurde geladen" + }, + "recordnotfound": { + "msg": "Datensatz konnte nicht abgerufen werden oder enthielt keine Daten." + }, + "recordsavesuccess": { + "draftmsg": "Datensatz als Entwurf gespeichert.", + "finalmsg": "Datensatz für die Übermittlung in die Warteschlange gestellt." + }, + "savedraftinfo": { + "heading": "Datensatz später fertigstellen", + "msg": [ + "Der Datensatzentwurf wird nur im aktuellen Browser gespeichert. Sie können diesen Browser schließen, ohne dass gespeicherte Datensätze verloren gehen.", + "Auf Datensatzentwürfe kann durch erneutes Öffnen dieser Seite und Klicken auf die Schaltfläche __icon__ links auf dem Bildschirm zugegriffen werden.", + "Warnung: Wenn Sie den Browser-Cache leeren, werden alle Entwürfe und nicht übermittelten fertiggestellten Datensätze dauerhaft gelöscht." + ] + }, + "submission": { + "msg": "Wird übermittelt...", + "redirectmsg": "Nach der Übermittlung werden Sie automatisch umgeleitet.", + "http500": "Es ist ein Problem mit Ihrer Übermittlung aufgetreten. Versuchen Sie es erneut oder wenden Sie sich an __supportEmail__, wenn das Problem weiterhin besteht." + }, + "submissionerror": { + "authrequiredmsg": "Authentifizierung erforderlich. Authentifizieren Sie sich __here__ auf einer anderen Browser-Registerkarte und versuchen Sie es erneut.", + "fnfmsg": [ + "Die folgenden Mediendateien konnten nicht abgerufen werden: __failedFiles__. Die Übermittlung wurde ohne diese Dateien erfolgreich ausgeführt.", + "Wenden Sie sich an __supportEmail__, um einen Fehler zu melden, und erläutern Sie möglichst, wie der Fehler reproduziert werden kann." + ], + "heading": "Übermittlung fehlgeschlagen" + }, + "submissionsuccess": { + "heading": "Übermittlung erfolgreich", + "msg": "Ihre Daten wurden übermittelt.", + "redirectmsg": "Sie werden nun umgeleitet." + }, + "thanks": { + "heading": "Vielen Dank für Ihre Teilnahme.", + "msg": "Sie können dieses Fenster jetzt schließen.", + "msgTaken": "Sie haben diese Umfrage bereits abgeschlossen." + }, + "validationerror": { + "msg": "Das Formular enthält Fehler. Bitte beachten Sie die rot markierten Felder." + }, + "validationsuccess": { + "heading": "OK", + "msg": "Das Formular ist gültig." + }, + "valuehasspaces": { + "multiple": "Die Mehrfachauswahlfrage hat einen unzulässigen Wert „__value__“, der ein Leerzeichen enthält." + }, + "xpatherror": { + "heading": "Formelfehler", + "msg": "Bei der Auswertung der Formel ist ein Fehler aufgetreten. Kontaktieren Sie __emailLink__ mit diesem Fehler:" + }, + "goto": { + "irrelevant": "Das Element, auf das Sie zugreifen möchten, ist aktuell ausgeblendet.", + "invisible": "Das Element, auf das Sie zugreifen möchten, ist auf dem Formular nicht sichtbar.", + "notfound": "Das Element, auf das Sie zugreifen möchten, wurde aus diesem Formular entfernt.", + "msg1": "Verwenden Sie das Symbol „Nur Abfrage anzeigen“ anstatt des Symbols „Abfrage im Datensatz anzeigen“, um es anzuzeigen.", + "msg2": "Besuchen Sie __miniform__, um es anzuzeigen.", + "form": "dieses Formular" + } + }, + "changelog": "Änderungsprotokoll", + "confirm": { + "deleteall": { + "heading": "WARNUNG", + "msg": [ + "Damit werden alle Daten in der Warteschlange und alle Ihre Formulare aus dem Browserspeicher gelöscht. Die Daten sind für immer verloren und die Formulare können erst erneut verwendet werden, wenn Sie wieder online sind. Verwenden Sie dies nur, wenn Ihr Browserspeicher beschädigt ist.", + "Schließen Sie vor dem Fortfahren alle anderen Registerkarten der Offline-Formulare. Diese Seite wird neu geladen. Wenn ein Fehler angezeigt wird, müssen Sie diese Aktion möglicherweise wiederholen.", + "Möchten Sie fortfahren?" + ], + "posButton": "Alle löschen" + }, + "autosaveload": { + "heading": "Nicht gespeicherten Datensatz gefunden", + "msg": "Enketo hat einen nicht gespeicherten Datensatz gefunden. Möchten Sie diesen Datensatz laden oder verwerfen?", + "negButton": "Verwerfen", + "posButton": "Datensatz laden" + }, + "default": { + "heading": "Sind Sie sicher?", + "msg": "Bitte bestätigen Sie diese Aktion", + "negButton": "Abbrechen", + "posButton": "Bestätigen" + }, + "discardcurrent": { + "heading": "Nicht gespeicherte Bearbeitungen", + "msg": "Das aktuelle Formular enthält nicht gespeicherte Änderungen. Möchten Sie einen Datensatz laden, ohne die Änderungen an dem aktuellen Formular zu speichern?", + "posButton": "Fortfahren ohne speichern" + }, + "login": { + "heading": "Anmeldung erforderlich", + "msg": [ + "Um die Daten in der Warteschlange zu übermitteln, müssen Sie angemeldet sein. Wenn Sie dies jetzt vornehmen möchten, werden Sie umgeleitet und nicht gespeicherte Informationen gehen verloren.", + "Möchten Sie sich jetzt oder später anmelden?" + ], + "negButton": "Später", + "posButton": "Jetzt anmelden" + }, + "print": { + "a4": "A4", + "landscape": "Querformat", + "letter": "Letter", + "orientation": "Papierausrichtung", + "portrait": "Hochformat", + "posButton": "Vorbereiten", + "psize": "Papierformat", + "reminder": "Denken Sie daran, die gleichen Druckeinstellungen anschließend im Druckmenü des Browsers vorzunehmen!", + "queries": "Verlauf anzeigen", + "queryShow": "anzeigen", + "heading": "Optionen", + "msg": "Zum Vorbereiten eines optimierten Ausdrucks wählen Sie die Optionen unten aus." + + }, + "repeatremove": { + "heading": "Diese Antwortgruppe löschen?", + "msg": "Diese Aktion kann nicht rückgängig gemacht werden (sie wird im Prüfpfad erfasst). Möchten Sie wirklich fortfahren?" + }, + "save": { + "existingerror": "Datensatzname oder ID ist bereits vorhanden. Bitte ändern.", + "hint": "Anhand dieses Namens können Sie Ihren Datensatzentwurf einfach finden, um ihn später fertigzustellen.", + "msg": "Es sind nicht gespeicherte Änderungen vorhanden. Möchten Sie fortfahren, ohne diese Änderungen zu speichern?", + "name": "Datensatzname", + "posButton": "Speichern und schließen", + "renamemsg": "Möchten Sie __currentName__ wirklich in __newName__ umbenennen?", + "unkownerror": "Beim Speichern der Daten ist ein unbekannter Fehler aufgetreten." + } + }, + "constraint": { + "invalid": "Wert nicht zulässig", + "required": "Dieses Feld ist erforderlich.", + "relevant": "Durch eine Antwort wurde zu einer anderen Frage gewechselt, für die diese Frage ausgeblendet werden muss, sie kann aber nicht ausgeblendet werden, solange sie Daten enthält. Löschen Sie die Daten oder ändern Sie die abhängigen Antworten." + }, + "contact": { + "admin": "Bitte wenden Sie sich an den Umfrage-Administrator.", + "support": "Bitte wenden Sie sich an __supportEmail__." + }, + "drawwidget": { + "annotation": "Datei und Zeichnen", + "drawing": "Zeichnen", + "signature": "Signatur" + }, + "enketo": { + "power": "Powered by" + }, + "error": { + "code": "Fehlercode: __code__", + "dataloadfailed": "Laden von __filename__ fehlgeschlagen.", + "econnrefused": "Verbindung mit dem Formularserver nicht möglich", + "encryptionnotsupported": "Dieses Formular erfordert die lokale Verschlüsselung von Datensätzen. Dies wird leider von Ihrem Browser nicht unterstützt. Wir empfehlen Ihnen, zu einem modernen Browser zu wechseln.", + "formloadfailed": "Laden des Formulars fehlgeschlagen", + "instancenotfound": "Datensatz nicht vorhanden. Er ist möglicherweise abgelaufen.", + "invalidediturl": "Keine gültige Bearbeitungs-URL", + "loadfailed": "Laden von __resource__ fehlgeschlagen.", + "notfoundinformlist": [ + "Dieses Formular ist nicht (mehr) vorhanden. Höchstwahrscheinlich hat es der Eigentümer des Formulars gelöscht, archiviert oder deaktiviert. Bitte kontaktieren Sie den Eigentümer des Formulars, um dies zu bestätigen.", + "Detaillierter Fehler: Formular mit ID __formId__ nicht in /formList gefunden." + ], + "pagenotfound": "Seite nicht gefunden", + "surveyidnotactive": "Umfrage mit dieser ID ist nicht mehr aktiv", + "surveyidnotfound": "Umfrage mit dieser ID nicht gefunden", + "unknown": "Unbekannter Fehler aufgetreten" + }, + "feedback": { + "header": "Informationen" + }, + "filepicker": { + "file": "Datei", + "notFound": "Datei __existing__ wurde nicht gefunden (unverändert belassen, wenn sie bereits übermittelt wurde und Sie sie beibehalten möchten).", + "placeholder": "Zum Hochladen der Datei hier klicken. (< __maxSize__)", + "resetWarning": "Dadurch wird __item__ entfernt. Möchten Sie diese Aktion wirklich durchführen?", + "toolargeerror": "Datei zu groß (> __maxSize__)", + "waitingForPermissions": "Warten auf Benutzerberechtigungen." + }, + "form": { + "chooseLanguage": "Sprache auswählen", + "logout": "abmelden", + "pages": { + "back": "Zurück", + "end": "Zum Ende", + "next": "Weiter", + "return": "Zurück zum Anfang" + }, + "required": "erforderlich" + }, + "formfooter": { + "complete": { + "btn": "Abgeschlossen" + }, + "savedraft": { + "btn": "Entwurf speichern", + "label": "Als Entwurf speichern" + }, + "submit": { + "btn": "Übermitteln" + }, + "validate": { + "btn": "Validieren" + } + }, + "geopicker": { + "accuracy": "Genauigkeit (m)", + "altitude": "Höhe (m)", + "closepolygon": "Polygon schließen", + "kmlcoords": "KML-Koordinaten", + "kmlpaste": "KML-Koordinaten hier einfügen", + "latitude": "Breitengrad (x.y °)", + "longitude": "Längengrad (x.y °)", + "points": "Punkte", + "removePoint": "Dadurch wird der aktuelle Geopunkt vollständig aus der Liste der Geopunkte entfernt. Dies kann nicht rückgängig gemacht werden. Möchten Sie diese Aktion wirklich durchführen?", + "searchPlaceholder": "nach Ort oder Adresse suchen", + "bordersintersectwarning": "Grenzen können sich nicht überschneiden" + }, + "here": "hier", + "hint": { + "guidance": { + "details": "weitere Details" + } + }, + "home": { + "msg": "__appTitle__ wird ausgeführt. Rufen Sie OpenClinica-Formulare von Ihrer __serverName__-Installation aus auf." + }, + "imagemap": { + "svgNotFound": "SVG-Bild nicht gefunden" + }, + "langs": { + "ar": "Arabisch", + "cs": "Tschechisch", + "de": "Deutsch", + "el": "Griechisch", + "en": "Englisch", + "es": "Spanisch", + "fa": "Persisch", + "fi": "Finnisch", + "fr": "Französisch", + "he": "Hebräisch", + "hi": "Hindi", + "it": "Italienisch", + "ka": "Georgisch", + "km": "Khmer", + "lo": "Laotisch", + "nl": "Niederländisch", + "no": "Norwegisch", + "pa": "Panjabi", + "pl": "Polnisch", + "pt": "Portugiesisch", + "ro": "Rumänisch", + "ru": "Russisch", + "sk": "Slowakisch", + "sq": "Albanisch", + "supported": "Unterstützte Sprachen: ", + "sv": "Schwedisch", + "sw": "Suaheli", + "th": "Thai", + "tr": "Türkisch", + "vi": "Vietnamesisch", + "zh": "Chinesisch" + }, + "literacywidget": { + "finish": "Beenden", + "start": "Starten" + }, + "page": { + "modernbrowsers": { + "heading": "Moderne Browser", + "ie": { + "heading": "Warum werden diese veralteten Versionen von Internet Explorer nicht unterstützt?", + "msg": "Wir haben Verständnis dafür, dass manche Menschen, insbesondere diejenigen, die in großen Behörden arbeiten, keinen Zugang zu den neuesten und besten Browsern haben. Da wir wissen, wie großartig das Web mit den richtigen Tools sein könnte, haben wir großes Mitgefühl für Benutzer in dieser unglücklichen Lage. Normalerweise würden wir versuchen, Workarounds bereitzustellen, um einige populäre, veraltete Browser zu unterstützen. Der Grund für die Existenz von Enketo ist jedoch die Verfügbarkeit einiger sehr ausgeklügelter Spitzentechnologien. Auf Benutzer älterer Browser kann daher keine Rücksicht genommen werden." + }, + "msg1": "Es tut uns leid! Sie wurden wahrscheinlich hierher umgeleitet, weil Ihr Browser veraltet ist oder die wichtigsten Funktionen durch die Verwendung des Modus „Privates Surfen“ deaktiviert sind.", + "msg2": "Wenn Sie einen veralteten Browser verwenden, empfehlen wir Ihnen ein Upgrade auf eine aktuelle Version eines der folgenden hervorragenden modernen Browser:" + }, + "offline": { + "heading": "Auf die angeforderte Seite kann nicht zugegriffen werden", + "msg1": "Auf diese Seite kann nur zugegriffen werden, wenn der Browser mit dem Internet verbunden und der Server zugänglich ist (außer die Seite ist nicht vorhanden)." + } + }, + "prompt": { + "default": { + "heading": "Werte eingeben" + }, + "login": { + "heading": "Anmeldeinformationen für __server__ eingeben", + "password": "Kennwort", + "remember": "Auf diesem Computer speichern", + "submit": "Übermitteln", + "username": "Benutzername" + } + }, + "rankwidget": { + "clickstart": "Zum Starten klicken", + "tapstart": "Zum Starten tippen" + }, + "record-list": { + "export": "Exportieren", + "msg1": "Datensätze werden so lange in Ihrem Browser gespeichert, bis sie hochgeladen wurden (sogar wenn Sie Ihren Computer ausschalten oder offline gehen).", + "msg2": "Datensätze in der Warteschlange, außer denjenigen, die als Entwurf __icon__ markiert sind, werden alle 5 Minuten automatisch im Hintergrund hochgeladen, wenn die Webseite geöffnet und eine Internetverbindung vorhanden ist.", + "msg2-nodraft": "Datensätze in der Warteschlange werden alle 5 Minuten automatisch im Hintergrund hochgeladen, wenn die Webseite geöffnet und eine Internetverbindung vorhanden ist.", + "msg3": "Um das Hochladen zwischen den automatischen Versuchen zu erzwingen, klicken Sie auf „Hochladen“.", + "norecords": "keine Datensätze in der Warteschlange", + "title": "Warteschlange", + "upload": "Hochladen" + }, + "selectpicker": { + "noneselected": "keine ausgewählt", + "numberselected": "__number__ ausgewählt" + }, + "store": { + "error": { + "iosusesafari": "Ihr iOS-Browser kann dieses Formular nicht ausführen. Wir empfehlen, für iOS Safari zu verwenden.", + "notavailable": "Browserspeicher ist erforderlich, ist aber nicht verfügbar, ist beschädigt oder nicht beschreibbar. Wenn Sie im Modus „Privates Surfen“ arbeiten, wechseln Sie bitte in den normalen Modus, wechseln Sie ansonsten zu einem anderen Browser. (Fehler: __error__)", + "notsupported": "Ihr Browser kann dieses Formular nicht ausführen. Wir empfehlen, einen modernen Browser zu verwenden." + } + }, + "submission": { + "http0": "Der Server ist nicht verfügbar oder Sie sind nicht mit dem Internet verbunden.", + "http2xx": "Unerwartete Antwort bei der Datenübermittlung.", + "http400": "Der Datenserver hat die Daten nicht akzeptiert.", + "http401": "Authentifizierungsproblem beim Datenserver.", + "http403": "Es ist nicht zulässig, Daten an diesen Datenserver zu senden.", + "http404": "Übermittlungsdienst auf Datenserver nicht gefunden.", + "http408": "Zeitüberschreitung beim Datenserver.", + "http413": "Daten sind zu groß.", + "http4xx": "Unbekanntes Übermittlungsproblem auf Datenserver.", + "http500": "Der Datenserver für Ihr Formular oder der Enketo-Server ist ausgefallen. Versuchen Sie es später erneut oder wenden Sie sich an __supportEmail__.", + "http504": "Verbindung mit dem Datenserver konnte nicht hergestellt werden." + }, + "themes": { + "supported": "Unterstützte Designs" + }, + "version": "Version:", + "widget": { + "comment": { + "update": "Aktualisieren" + }, + "dn": { + "assignto": "Zuweisen zu:", + "assignedto": "__id__ zugewiesen zu __assignee__.", + "closequerytext": "Diese Abfrage schließen", + "notifytext": "E-Mail?", + "addquerybutton": "Abfrage hinzufügen", + "addannotationbutton": "Anmerkung hinzufügen", + "addnewtext": "Neu", + "emptyhistorytext": "Kein Verlauf", + "printhistoryheading": "Verlauf für – __labelText__ (__questionName__)", + "me": "Mich", + "status": "Status: __status__", + "reopen": "Erneut öffnen", + "reopenlabel": "Zum erneuten Öffnen hier eingeben", + "autoclosed": "Abfrage vom System geschlossen, da dieses Element ausgeblendet war", + "valuechange": "Wert geändert von __previous__ in __new__", + "autoconstraint": "Automatische Abfrage für: __errorMsg__", + "autonoreason": "Automatische Abfrage für: Wert geändert und kein Grund für die Änderung angegeben", + "closedmodified": "Daten nach Schließen der Abfrage geändert", + "newfile": "Neue Datei hochgeladen", + "fileremoved": "Datei entfernt", + "addnewquery": "Neue Abfrage hinzufügen", + "addnewannotation": "Neue Anmerkung hinzufügen", + "typeresponse": "Auf Abfrage antworten", + "allhistory": "Gesamten Verlauf anzeigen", + "queries": "Abfragen", + "annotations": "Anmerkungen", + "showvaluechanges": "Wertänderungen anzeigen", + "now": "Gerade eben", + "second": "__count__ Sekunde", + "second_plural": "__count__ Sekunden", + "minute": "__count__ Minute", + "minute_plural": "__count__ Minuten", + "hour": "__count__ Stunde", + "hour_plural": "__count__ Stunden", + "day": "__count__ Tag", + "day_plural": "__count__ Tage", + "month": "__count__ Monat", + "month_plural": "__count__ Monate", + "year": "__count__ Jahr", + "year_plural": "__count__ Jahre" + } + }, + "fieldsubmission": { + "alert": { + "close": { + "heading1": "Wird geschlossen", + "heading2": "Warnung", + "msg1": "Nicht gespeicherte Daten werden übermittelt...", + "msg2": "Nicht alle Daten wurden übermittelt. Wenn Sie diese Seite verlassen, gehen sie verloren." + }, + "locked": { + "heading": "Gesperrt", + "msg": "Dieses Formular oder dieser Datensatz ist gesperrt und Übermittlungen können nicht gespeichert werden." + }, + "complete": { + "msg": "Daten konnten nicht übermittelt werden." + }, + "validationerror": { + "msg": "Sie müssen alle Fehler in diesem Formular beheben, bevor es als abgeschlossen markiert werden kann. Aktualisieren Sie den Wert oder fügen Sie eine Abfrage für jedes rot markierte Feld hinzu." + }, + "relevantvalidationerror": { + "msg": "Durch die Antwort(en) in einem (einigen) Feld(ern) werden einige Daten in diesem Formular ungültig. Das muss korrigiert werden, damit das Formular als abgeschlossen markiert werden kann." + }, + "reasonforchangevalidationerror": { + "msg": "Für jedes geänderte Feld muss ein Grund für die Änderung angegeben werden. Fügen Sie die fehlenden Gründe hinzu." + }, + "stricterror": { + "heading": "Ungültiger Wert", + "msg": "Geben Sie einen passenden Wert zum Aktualisieren des Felds ein." + }, + "participanterror": { + "msg": [ "Einige Felder scheinen Ihre Aufmerksamkeit zu benötigen.", "Überprüfen Sie sie, bevor Sie fortfahren." ] + } + }, + "confirm": { + "autoquery": { + "msg1": "Einige Feldwerte enthalten Fehler. Diese müssen behoben werden, bevor das Formular geschlossen wird.", + "msg2": [ + "Klicken Sie auf „Abbrechen“, um zum Formular zurückzukehren und den Wert manuell zu aktualisieren oder eine Abfrage für jedes rot markierte Feld hinzuzufügen.", + "Klicken Sie auf „Fortfahren“, um mit dem Schließen des Formulars fortzufahren. Für jedes rot markierte Feld wird automatisch eine Abfrage hinzugefügt." + ], + "automatic": "Fortfahren", + "manual": "Abbrechen" + }, + "complete": { + "msg": "Möchten Sie diesen Datensatz abschließen und beenden?", + "heading": "Abschluss bestätigen" + }, + "leaveanyway": { + "msg": "Möchten Sie die Änderungen verwerfen und trotzdem beenden?", + "button": "Trotzdem beenden" + } + }, + "prompt": { + "reason": { + "msg": "Wenn ja, geben Sie bitte einen Grund für das Löschen der Gruppe an." + } + }, + "feedback": { + "ongoing": "Wird gespeichert...", + "success": "Alle Änderungen gespeichert.", + "fail": "Speichern der Änderungen fehlgeschlagen.", + "disabled": "Speichern ist deaktiviert." + }, + "readonly": { + "msg": "Sie arbeiten im schreibgeschützten Modus." + }, + "noteonly": { + "msg": "Sie arbeiten im Modus „Nur überprüfen“." + }, + "reason": { + "heading": "Sie haben anscheinend Aktualisierungen vorgenommen. Teilen Sie uns bitte mit, warum:", + "placeholder1": "Grund für die Änderungen eingeben", + "placeholder2": "Grund für die Änderung dieses Werts eingeben", + "applytoall": "Für alle anwenden", + "questionmsg": "Geben Sie einen Grund für die Änderung unten auf der Seite ein." + } + } +} diff --git a/packages/enketo-express/locales/src/en/translation-additions.json b/packages/enketo-express/locales/src/en/translation-additions.json new file mode 100644 index 000000000..8245c2432 --- /dev/null +++ b/packages/enketo-express/locales/src/en/translation-additions.json @@ -0,0 +1,185 @@ +{ + "widget": { + "dn": { + "assignto": "Assign to:", + "assignedto": "__id__ assigned to __assignee__.", + "closequerytext": "Close This Query", + "notifytext": "Email?", + "addquerybutton": "Add Query", + "addannotationbutton": "Add Annotation", + "addnewtext": "New", + "emptyhistorytext": "No History", + "printhistoryheading": "History for - __labelText__ (__questionName__)", + "me": "Me", + "status": "Status: __status__", + "reopen": "Reopen", + "reopenlabel": "Type here to reopen", + "autoclosed": "Query closed by system because this item was hidden", + "valuechange": "Value changed from __previous__ to __new__", + "autoconstraint": "Automatic query for: __errorMsg__", + "autonoreason": "Automatic query for: Value changed and no reason for change provided", + "closedmodified": "Data changed after query was closed", + "newfile": "New file uploaded", + "fileremoved": "File removed", + "addnewquery": "Add a new query", + "addnewannotation": "Add a new annotation", + "typeresponse": "Respond to query", + "allhistory": "View All History", + "queries": "Queries", + "annotations": "Annotations", + "showvaluechanges": "Show value changes", + "now": "Just now", + "second": "__count__ second", + "second_plural": "__count__ seconds", + "minute": "__count__ minute", + "minute_plural": "__count__ minutes", + "hour": "__count__ hour", + "hour_plural": "__count__ hours", + "day": "__count__ day", + "day_plural": "__count__ days", + "month": "__count__ month", + "month_plural": "__count__ months", + "year": "__count__ year", + "year_plural": "__count__ years" + } + }, + "fieldsubmission": { + "alert": { + "close": { + "heading1": "Closing", + "heading2": "Warning", + "msg1": "Submitting unsaved data...", + "msg2": "Not all data has been submitted. If you exit this page, it will be lost." + }, + "locked": { + "heading": "Locked", + "msg": "This form or record is locked and submissions cannot be saved." + }, + "complete": { + "msg": "Data could not be submitted." + }, + "validationerror": { + "msg": "You must address all errors on this form before it can be marked complete. Please update the value or add a query for each field marked in red." + }, + "relevantvalidationerror": { + "msg": "Response(s) in some field(s) make some data on this form invalid. This must be corrected to mark the form complete." + }, + "reasonforchangevalidationerror": { + "msg": "For each changed field a reason for change has to be provided. Please add any missing reasons." + }, + "stricterror": { + "heading": "Invalid Value", + "msg": "Please enter a suitable value to update the field." + }, + "participanterror": { + "msg": [ "It looks like some fields need attention", "Please review them before proceeding." ] + }, + "signatureservicenotavailable": { + "msg": "Signature service is currently not available. The question has been unchecked." + }, + "signatureservicefailed":{ + "msg": "Signature not received. The question has been unchecked." + } + }, + "confirm": { + "autoquery": { + "msg1": "Some field values have errors. These must be addressed before the form is closed.", + "msg2": [ + "Click Cancel to return to the form to manually update the value or add a query for each field marked in red.", + "Click Proceed to continue closing the form now. A query will be automatically added for each field marked in red." + ], + "automatic": "Proceed", + "manual": "Cancel" + }, + "complete": { + "msg": "Would you like to complete this record and exit?", + "heading": "Confirm Completion" + }, + "leaveanyway": { + "msg": "Would you like to discard the changes and leave anyway?", + "button": "Leave Anyway" + } + }, + "prompt": { + "reason": { + "msg": "If so, please enter a reason for deleting the group." + } + }, + "feedback": { + "ongoing": "Saving...", + "success": "All changes saved.", + "fail": "Failed to save changes.", + "disabled": "Saving is disabled." + }, + "readonly": { + "msg": "You're in read-only mode." + }, + "noteonly": { + "msg": "You're in review-only mode." + }, + "reason": { + "heading": "Looks like you've made some updates. Please tell us why:", + "placeholder1": "Enter a reason for your changes", + "placeholder2": "Enter a reason for changing this value", + "applytoall": "Apply to all", + "questionmsg": "Please enter a reason for change at bottom of page." + } + }, + "confirm": { + "deleteall": { + "heading": "WARNING", + "msg": [ + "This will delete all of your queued data and all of your forms from the browser storage. The data will be lost forever and the forms will not be usable until you are back online. Use this only if your browser storage seems corrupt.", + "Before proceeding, please close all of your other Offline Form tabs. This page will reload. If you see an error, you may have to repeat this action.", + "Would you like to proceed?" + ], + "posButton": "Delete All" + }, + "print": { + "queries": "Show History", + "queryShow": "show", + "heading": "Options", + "msg": "To prepare an optimized print, please select the options below" + }, + "repeatremove": { + "msg": "This action is irreversible (it will be recorded in the audit trail). Are you sure you want to proceed?" + } + }, + "constraint": { + "relevant": "An answer has changed to another question that requires this question to be hidden, but we cannot hide it while it has data. Please clear out the data or modify the dependent answers." + }, + "home": { + "msg": "__appTitle__ is running! Please access OpenClinica forms from your __serverName__ installation." + }, + "alert": { + "appupdated": { + "heading": "Please refresh", + "msg": "Please refresh this webpage to use the form offline (ref: application)" + }, + "formupdated": { + "heading": "Please refresh", + "msg": "Please refresh this webpage to use the form offline (ref: definition)" + }, + "goto": { + "irrelevant": "The item you are trying to access is currently hidden.", + "invisible": "The item you are trying to access is not visible on the form.", + "notfound": "The item you are trying to access has been removed from this form.", + "msg1": "Please use the View Query Only icon instead of the View Query Within Record icon to see it.", + "msg2": "Please visit __miniform__ to see it.", + "form": "this form" + }, + "loaderror": { + "editadvice": "", + "entryadvice": "", + "heading": "Hold on a second...", + "msg1": "Please contact your support team with the link to this page and the message below if you need further help.", + "msg2": "", + "heading_plural": "Hold on a second...", + "msg1_plural": "Please contact your support team with the link to this page and the messages below if you need further help.", + "msg2_plural": "" + } + }, + "submission": { + "http500": "There was a problem with your submission. Please try again or contact __supportEmail__ if this persists." + } +} diff --git a/packages/enketo-express/locales/src/en/translation.json b/packages/enketo-express/locales/src/en/translation.json index e9cb28dee..cf9c8493c 100644 --- a/packages/enketo-express/locales/src/en/translation.json +++ b/packages/enketo-express/locales/src/en/translation.json @@ -267,6 +267,12 @@ }, "validate": { "btn": "Validate" + }, + "exit": { + "btn": "Finish Later" + }, + "done": { + "btn": "I'm done" } }, "geopicker": { diff --git a/packages/enketo-express/package.json b/packages/enketo-express/package.json index 195c34f8d..932d55c89 100644 --- a/packages/enketo-express/package.json +++ b/packages/enketo-express/package.json @@ -35,11 +35,13 @@ "bristol": "^0.4.0", "compression": "^1.7.4", "cookie-parser": "^1.4.6", + "crypto-js": "^4.1.1", "csurf": "^1.11.0", "db.js": "^0.15.0", "debug": "^4.3.4", "enketo-core": "8.0.0", "enketo-transformer": "4.0.0", + "enketo-xpath-extensions-oc": "github:OpenClinica/enketo-xpath-extensions-oc#ab81eeb7d0f1fb34bcf2615d2c6a27c3b0915f56", "evp_bytestokey": "^1.0.3", "express": "^4.18.2", "express-cls-hooked": "^0.3.8", @@ -89,13 +91,15 @@ "enketo/widgets": "./public/js/build/widgets", "enketo/translator": "./public/js/src/module/translator", "enketo/dialog": "./public/js/src/module/gui", - "enketo/file-manager": "./public/js/src/module/file-manager" + "enketo/file-manager": "./public/js/src/module/file-manager", + "enketo/xpath-evaluator-binding": "./public/js/src/module/xpath-evaluator-binding" }, "entries": [ "public/js/src/enketo-webform.js", "public/js/src/enketo-webform-edit.js", "public/js/src/enketo-webform-view.js", - "public/js/src/enketo-offline-fallback.js" + "public/js/src/enketo-offline-fallback.js", + "public/js/src/enketo-webform-oc.js" ], "engines": { "node": ">=18 <21", diff --git a/packages/enketo-express/public/images/favicon.ico b/packages/enketo-express/public/images/favicon.ico index 467d82222446b3f3deafd862cbc2928cb7d49a69..555bf2288e595478ba7b86c9488e1caed4891aa2 100644 GIT binary patch literal 77907 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 C;XU#I diff --git a/packages/enketo-express/public/images/icon_180x180.png b/packages/enketo-express/public/images/icon_180x180.png index a0746faf988e6915453f1a1bb13d9e1e601cfc0d..566028d09cb085e521458ad168d83d626769ec73 100644 GIT binary patch literal 7597 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�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 literal 7913 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 .paper > .form-header'); +const footer = document.querySelector('.form-footer'); +const survey = { + enketoId: settings.enketoId, + serverUrl: settings.serverUrl, + xformId: settings.xformId, + xformUrl: settings.xformUrl, + instanceId: settings.instanceId, +}; +const range = document.createRange(); + +_setEmergencyHandlers(); + +if (settings.offline) { + console.log('App in offline-capable mode.', survey); + delete survey.xformUrl; + _setAppCacheEventHandlers(); + applicationCache + .init(survey) + .then(initTranslator) + .then(formCache.init) + .then(_swapTheme) + .then(formCache.updateMaxSubmissionSize) + .then(_updateMaxSizeSetting) + .then(_init) + .then((formParts) => { + formParts.languages.forEach(loadTranslation); + + return formParts; + }) + .then(formCache.updateMedia) + .then(_setFormCacheEventHandlers) + .catch(_showErrorOrAuthenticate); +} else { + console.log('App in online-only mode.'); + const isPreview = settings.type === 'preview'; + + initTranslator(survey) + .then((props) => + connection.getFormParts({ + ...props, + isPreview, + }) + ) + .then((formParts) => { + if ( + location.pathname.indexOf('/edit/') > -1 || + location.pathname.indexOf('/view/') > -1 + ) { + if (survey.instanceId) { + return connection + .getExistingInstance(survey) + .then((response) => { + formParts.instance = response.instance; + formParts.instanceAttachments = + response.instanceAttachments; + + // TODO: this will fail massively if instanceID is not populated (will use POST instead of PUT). Maybe do a check? + return formParts; + }); + } + if (location.pathname.indexOf('/edit/') > -1) { + throw new Error('This URL is invalid'); + } + } + + return formParts; + }) + .then((formParts) => { + // don't use settings.headless here because this also includes pdf views + if (window.location.pathname.includes('/headless')) { + return formParts; + } + if (formParts.form && formParts.model) { + return gui.swapTheme(formParts); + } + throw new Error(t('error.unknown')); + }) + .then((formParts) => { + if (/\/fs\/dnc?\//.test(window.location.pathname)) { + return _convertToReadonly(formParts, true); + } + if (settings.type === 'view') { + return _convertToReadonly(formParts, false); + } + + return formParts; + }) + .then((survey) => { + if (isPreview && settings.xformUrl) { + return survey; + } + + return connection.getMaximumSubmissionSize(survey); + }) + .then(_updateMaxSizeSetting) + .then(_init) + .catch(_showErrorOrAuthenticate); +} + +/** + * Swaps the theme if necessary. + * + * @param {*} survey - [description] + * @return {*} [description] + */ +function _swapTheme(survey) { + if (survey.form && survey.model) { + return gui.swapTheme(survey); + } + return Promise.reject(new Error('Received form incomplete')); +} + +function _updateMaxSizeSetting(survey) { + if (survey.maxSize) { + // overwrite default max size + settings.maxSize = survey.maxSize; + } + + return survey; +} + +function _showErrorOrAuthenticate(error) { + loader.classList.add('fail'); + if (error.status === 401) { + window.location.href = `/login?return_url=${encodeURIComponent( + window.location.href + )}`; + } else { + gui.alert(error.message, t('alert.loaderror.heading')); + if (settings.headless) { + gui.showHeadlessResult({ error: error.message }); + } + } +} + +function _setAppCacheEventHandlers() { + document.addEventListener(events.OfflineLaunchCapable().type, (event) => { + const { capable } = event.detail; + gui.updateStatus.offlineCapable(capable); + + const scriptUrl = applicationCache.serviceWorkerScriptUrl; + if (scriptUrl) { + connection + .getServiceWorkerVersion(scriptUrl) + .then(gui.updateStatus.applicationVersion); + } + }); + + document.addEventListener(events.ApplicationUpdated().type, () => { + gui.feedback( + t('alert.appupdated.msg'), + null, + t('alert.appupdated.heading') + ); + }); +} + +function _setFormCacheEventHandlers(survey) { + document.addEventListener(events.FormUpdated().type, () => { + gui.feedback( + t('alert.formupdated.msg'), + null, + t('alert.formupdated.heading') + ); + }); + + return survey; +} + +/** + * Advanced/emergency handlers that should always be activated even if form loading fails. + */ +function _setEmergencyHandlers() { + const flushBtn = document.querySelector( + '.side-slider__advanced__button.flush-db' + ); + + if (flushBtn) { + flushBtn.addEventListener('click', () => { + gui.confirm( + { + msg: t('confirm.deleteall.msg'), + heading: t('confirm.deleteall.heading'), + }, + { + posButton: t('confirm.deleteall.posButton'), + } + ) + .then((confirmed) => { + if (!confirmed) { + throw new Error('Cancelled by user'); + } + + return store.flush(); + }) + .then(() => { + location.reload(); + }) + .catch(() => {}); + }); + } +} + +/** + * Converts questions to readonly + * Disables calculations, deprecatedID mechanism and preload items. + * + * @param {object} formParts - formParts object + * @param {boolean} notesEnabled - whether notes are enabled + * @return {object} formParts object + */ +function _convertToReadonly(formParts, notesEnabled) { + // Styling changes + document.querySelector('body').classList.add('oc-view'); + + // Partially disable calculations in Enketo Core + console.info('Calculations restricted to clinicaldata only.'); + calculationModule.originalUpdate = calculationModule.update; + calculationModule.update = function (updated) { + return calculationModule.originalUpdate.call( + this, + updated, + '[data-oc-external="clinicaldata"]' + ); + }; + console.info('Setvalue disabled.'); + calculationModule.setvalue = () => {}; + + // Completely disable preload items + console.info('Preloaders disabled.'); + preloadModule.init = () => {}; + + // Disable clearing (and submissions) of non-relevant readonly values + console.info('Clearing of non-relevant values disabled.'); + relevantModule.clear = () => {}; + + // Disable removing repeats (in case model contains more repeats than repeat count number) + console.info('Disabling repeat removal'); + repeatModule.remove = () => {}; + + // change status message + const i18nKey = notesEnabled + ? 'fieldsubmission.noteonly.msg' + : 'fieldsubmission.readonly.msg'; + formheader.prepend( + range.createContextualFragment( + `
${t( + i18nKey + )}
` + ) + ); + footer.prepend( + range.createContextualFragment( + `` + ) + ); + + formParts.formFragment = range.createContextualFragment(formParts.form); + + // Note: Enketo made a syntax error by adding the readonly attribute on a `; + + return gui.prompt(texts, choices, inputs).then((values) => { + if (values) { + return values['record-name']; + } + throw new Error('Cancelled by user'); + }); +} + +/** + * Used to submit a full record. + * This function does not save the record in the browser storage + * and is not used in offline-capable views. + */ +function _submitRecord() { + let authLink; + let level; + let msg = ''; + const include = { irrelevant: false }; + + form.view.html.dispatchEvent(events.BeforeSave()); + + authLink = `${t( + 'here' + )}`; + + gui.alert( + `${t( + 'alert.submission.redirectmsg' + )}
`, + t('alert.submission.msg'), + 'bare' + ); + + return fileManager + .getCurrentFiles() + .then((files) => { + const record = { + enketoId: settings.enketoId, + xml: form.getDataStr(include), + files, + instanceId: form.instanceID, + deprecatedId: form.deprecatedID, + }; + + return record; + }) + .then((record) => connection.uploadQueuedRecord(record)) + .then((result) => { + result = result || {}; + level = 'success'; + + if (result.failedFiles && result.failedFiles.length > 0) { + msg = `${t('alert.submissionerror.fnfmsg', { + failedFiles: result.failedFiles.join(', '), + supportEmail: settings.supportEmail, + })}
`; + level = 'warning'; + } + }) + .then(() => { + // this event is used in communicating back to iframe parent window + document.dispatchEvent(events.SubmissionSuccess()); + msg += t('alert.submissionsuccess.redirectmsg'); + gui.alert(msg, t('alert.submissionsuccess.heading'), level); + setTimeout(() => { + location.href = decodeURIComponent( + settings.returnUrl || settings.defaultReturnUrl + ); + }, 1200); + }) + .catch((result) => { + let message; + result = result || {}; + console.error('submission failed', result); + if (result.status === 401) { + message = t('alert.submissionerror.authrequiredmsg', { + here: authLink, + // switch off escaping just for this known safe value + interpolation: { + escapeValue: false, + }, + }); + } else { + message = + result.message || gui.getErrorResponseMsg(result.status); + } + gui.alert(message, t('alert.submissionerror.heading')); + }); +} + +/** + * @param {Survey} survey + * @param {boolean} draft - whether the record is a draft + * @param {string} [recordName] - proposed name of the record + * @param {boolean} [confirmed] - whether the name of the record has been confirmed by the user + */ +function _saveRecord(survey, draft, recordName, confirmed) { + const include = { irrelevant: draft }; + + // triggering "before-save" event to update possible "timeEnd" meta data in form + form.view.html.dispatchEvent(events.BeforeSave()); + + // check recordName + if (!recordName) { + return _getRecordName().then((name) => + _saveRecord(survey, draft, name, false) + ); + } + + // check whether record name is confirmed if necessary + if (draft && !confirmed) { + return _confirmRecordName(recordName, draft) + .then((name) => _saveRecord(survey, draft, name, true)) + .catch(() => {}); + } + + return fileManager + .getCurrentFiles() + .then((files) => + // build the record object + ({ + draft, + xml: form.getDataStr(include), + name: recordName, + instanceId: form.instanceID, + deprecateId: form.deprecatedID, + enketoId: settings.enketoId, + files, + }) + ) + .then((record) => { + // Change file object for database, not sure why this was chosen. + record.files = record.files.map((file) => + typeof file === 'string' + ? { + name: file, + } + : { + name: file.name, + item: file, + } + ); + + // Save the record, determine the save method + const saveMethod = form.recordName ? 'update' : 'set'; + + return records.save(saveMethod, record); + }) + .then(() => { + records.removeAutoSavedRecord(); + _resetForm(survey, { isOffline: true }); + + if (draft) { + gui.alert( + t('alert.recordsavesuccess.draftmsg'), + t('alert.savedraftinfo.heading'), + 'info', + 5 + ); + return true; + } + + return records.uploadQueue({ isUserTriggered: !draft }); + }) + .catch((error) => { + console.error('save error', error); + let errorMsg = error.message; + if ( + !errorMsg && + error.target && + error.target.error && + error.target.error.name && + error.target.error.name.toLowerCase() === 'constrainterror' + ) { + return _confirmRecordName( + recordName, + draft, + t('confirm.save.existingerror') + ).then((name) => _saveRecord(survey, draft, name, true)); + } + if (!errorMsg) { + errorMsg = t('confirm.save.unkownerror'); + } + gui.alert(errorMsg, 'Save Error'); + }); +} + +/** + * Loads a record from storage + * + * @param {Survey} survey + * @param {string} instanceId - [description] + * @param {=boolean?} confirmed - [description] + */ +function _loadRecord(survey, instanceId, confirmed) { + let texts; + let choices; + let loadErrors; + + if (!confirmed && form.editStatus) { + texts = { + msg: t('confirm.discardcurrent.msg'), + heading: t('confirm.discardcurrent.heading'), + }; + choices = { + posButton: t('confirm.discardcurrent.posButton'), + }; + gui.confirm(texts, choices).then((confirmed) => { + if (confirmed) { + _loadRecord(survey, instanceId, true); + } + }); + } else { + records + .get(instanceId) + .then((record) => { + if (!record || !record.xml) { + return gui.alert(t('alert.recordnotfound.msg')); + } + + const formEl = form.resetView(); + form = new Form( + formEl, + { + modelStr: formData.modelStr, + instanceStr: record.xml, + external: formData.external, + submitted: false, + }, + formOptions + ); + loadErrors = form.init(); + + form.view.html.dispatchEvent(events.FormReset()); + + formCache.updateMedia(survey); + + form.recordName = record.name; + records.setActive(record.instanceId); + + if (loadErrors.length > 0) { + throw loadErrors; + } else { + gui.feedback( + t('alert.recordloadsuccess.msg', { + recordName: record.name, + }), + 2 + ); + } + $('.side-slider__toggle.close').click(); + }) + .catch((errors) => { + console.error('load errors: ', errors); + if (!Array.isArray(errors)) { + errors = [errors.message]; + } + gui.alertLoadErrors(errors, t('alert.loaderror.editadvice')); + }); + } +} + +/** + * Triggers auto queries. + * + * @param {*} $questions + * @param questions + */ +function _autoAddQueries(questions) { + questions.forEach((q) => { + if (q.matches('.question')) { + q.dispatchEvent(events.AddQuery()); + } else if ( + q.matches( + '.or-group.invalid-relevant, .or-group-data.invalid-relevant' + ) + ) { + q.querySelectorAll('.question:not(.or-appearance-dn)').forEach( + (el) => el.dispatchEvent(events.AddQuery()) + ); + } + }); +} + +function _autoAddReasonQueries(rfcInputs) { + rfcInputs.forEach((input) => { + input.dispatchEvent( + events.ReasonChange({ + type: 'autoquery', + reason: t('widget.dn.autonoreason'), + }) + ); + }); +} + +function _doNotSubmit(fullPath) { + // no need to check on cloned radiobuttons, selects or textareas + const pathWithoutPositions = fullPath.replace(/\[[0-9]+\]/g, ''); + + return !!form.view.html.querySelector( + `input[data-oc-external="clinicaldata"][name="${pathWithoutPositions}"], + input[data-oc-external="signature"][name="${pathWithoutPositions}"]` + ); +} + +function _setFormEventHandlers() { + form.view.html.addEventListener(events.ProgressUpdate().type, (event) => { + if ( + event.target.classList.contains('or') && + formprogress && + event.detail + ) { + formprogress.style.width = `${event.detail}%`; + } + }); + + // field submission triggers, only for online-only views + if (!settings.fullRecord) { + // Trigger fieldsubmissions for static defaults in added repeat instance + // It is important that this listener comes before the NewRepeat and AddRepeat listeners in enketo-core + // that will also run setvalue/odk-new-repeat actions, calculations, and other stuff + form.view.html.addEventListener(events.NewRepeat().type, (event) => { + // Note: in XPath, a predicate position is 1-based! The event.detail includes a 0-based index. + const selector = `${event.detail.repeatPath}[${ + event.detail.repeatIndex + 1 + }]//*`; + const staticDefaultNodes = [ + ...form.model + .node(selector, null, { noEmpty: true }) + .getElements(), + ]; + _addFieldsubmissionsForModelNodes(form.model, staticDefaultNodes); + }); + + // After repeat removal from view (before removal from model) + form.view.html.addEventListener(events.Removed().type, (event) => { + const updated = event.detail || {}; + const instanceId = form.instanceID; + + if (!updated.nodeName) { + console.error( + 'Could not submit repeat removal fieldsubmission. Node name is missing.' + ); + + return; + } + if (!updated.ordinal) { + console.error( + 'Could not submit repeat removal fieldsubmission. Ordinal is missing.' + ); + + return; + } + + if (!instanceId) { + console.error( + 'Could not submit repeat removal fieldsubmission. InstanceID missing' + ); + + return; + } + + postHeartbeat(); + + fieldSubmissionQueue.addRepeatRemoval( + updated.nodeName, + updated.ordinal, + instanceId + ); + fieldSubmissionQueue.submitAll(); + }); + // Field is changed + form.view.html.addEventListener(events.DataUpdate().type, (event) => { + const updated = event.detail || {}; + const instanceId = form.instanceID; + let filePromise; + + if (updated.cloned) { + // This event is fired when a repeat is cloned. It does not trigger + // a fieldsubmission. + return; + } + + // This is a bit of a hacky test for /meta/instanceID and /meta/deprecatedID. Both meta and instanceID nodes could theoretically have any namespace prefix. + // and if the namespace is not in the default or the "http://openrosa.org/xforms" namespace it should actually be submitted. + if ( + /meta\/(.*:)?instanceID$/.test(updated.fullPath) || + /meta\/(.*:)?deprecatedID$/.test(updated.fullPath) + ) { + return; + } + + if (!updated.xmlFragment) { + console.error( + 'Could not submit field. XML fragment missing. (If repeat was deleted, this is okay.)' + ); + + return; + } + if (!instanceId) { + console.error('Could not submit field. InstanceID missing'); + + return; + } + if (!updated.fullPath) { + console.error('Could not submit field. Path missing.'); + } + if (_doNotSubmit(updated.fullPath)) { + return; + } + if (updated.file) { + filePromise = fileManager.getCurrentFile(updated.file); + } else { + filePromise = Promise.resolve(); + } + + // remove the Participate class that shows a Close button on every page + form.view.html.classList.remove('empty-untouched'); + + // Only now will we check for the deprecatedID value, which at this point should be (?) + // populated at the time the instanceID dataupdate event is processed and added to the fieldSubmission queue. + postHeartbeat(); + filePromise.then((file) => { + fieldSubmissionQueue.addFieldSubmission( + updated.fullPath, + updated.xmlFragment, + instanceId, + form.deprecatedID, + file + ); + fieldSubmissionQueue.submitAll(); + }); + }); + } else { + console.info( + 'offline-capable so not setting fieldsubmission handlers' + ); + } + + if (settings.type !== 'preview') { + form.view.html.addEventListener( + events.SignatureRequested().type, + (event) => { + const resetQuestion = () => { + event.target.checked = false; + event.target.dispatchEvent(events.Change()); + }; + + form.validate().then((valid) => { + if (valid) { + let timeoutId; + const receiveMessage = (evt) => { + // TODO: remove this temporary logging + console.log( + `evt.origin: ${evt.origin}, settings.parentWindowOrigin: ${settings.parentWindowOrigin}` + ); + console.log('msg received: ', JSON.parse(evt.data)); + if (evt.origin === settings.parentWindowOrigin) { + const msg = JSON.parse(evt.data); + if ( + msg.event === 'signature-request-received' + ) { + clearTimeout(timeoutId); + } else if ( + msg.event === 'signature-request-failed' + ) { + clearTimeout(timeoutId); + resetQuestion(); + window.removeEventListener( + 'message', + receiveMessage + ); + } + } else { + console.error( + 'message received from untrusted source' + ); + } + }; + const failHandler = () => { + resetQuestion(); + window.removeEventListener( + 'message', + receiveMessage + ); + gui.alert( + t( + 'fieldsubmission.alert.signatureservicenotavailable.msg' + ) + ); + }; + timeoutId = setTimeout(failHandler, 3 * 1000); + window.addEventListener( + 'message', + receiveMessage, + false + ); + rc.postEventAsMessageToParentWindow(event); + } else { + // If this logic becomes complex, with autoqueries, rfc e.g., consider using + // code in the _complete or _close functions to avoid duplication + resetQuestion(); + gui.alert( + t('fieldsubmission.alert.participanterror.msg') + ); + } + }); + } + ); + } + + // Before repeat removal from view and model + if (settings.reasonForChange) { + $('.form-footer') + .find('.next-page, .last-page, .previous-page, .first-page') + .on('click', (evt) => { + const valid = reasons.validate(); + if (!valid) { + evt.stopImmediatePropagation(); + + return false; + } + reasons.clearAll(); + + return true; + }); + } +} + +function _setLanguageUiEventHandlers() { + // This actually belongs in gui.js but that module doesn't have access to the form object. + // Enketo core takes care of language switching of the form itself, i.e. all language strings in the form definition. + // This handler does the UI around the form, as well as the UI inside the form that are part of the application. + const formLanguages = document.querySelector('#form-languages'); + if (formLanguages) { + formLanguages.addEventListener(events.Change().type, (event) => { + event.preventDefault(); + console.log('ready to set UI lang', form.currentLanguage); + localize(document.querySelector('body'), form.currentLanguage).then( + (dir) => document.querySelector('html').setAttribute('dir', dir) + ); + }); + } + + // This actually belongs in gui.js but that module doesn't have access to the form object. + // This handler is also used in forms that have no translation (and thus no defined language). + // See scenario X in https://docs.google.com/spreadsheets/d/1CigMLAQewcXi-OJJHi_JQQ-fJXOam99livM0oYrtbkk/edit#gid=1504432290 + document.addEventListener(events.AddRepeat().type, (event) => { + localize(event.target, form.currentLanguage); + }); +} + +/** + * @param {Survey} survey + */ +function _setButtonEventHandlers(survey) { + const completeButton = document.querySelector('button#complete-form'); + if (completeButton) { + const options = { + autoQueries: settings.autoQueries, + reasons: settings.reasonForChange, + }; + completeButton.addEventListener('click', () => { + const $button = $(completeButton).btnBusyState(true); + _complete(form.model.isMarkedComplete(), options) + .catch((e) => { + gui.alert(e.message); + }) + .then(() => { + $button.btnBusyState(false); + }); + + return false; + }); + } + + const closeButton = document.querySelector('button#close-form'); + if (closeButton) { + const options = { + autoQueries: settings.autoQueries, + reasons: settings.reasonForChange, + }; + closeButton.addEventListener('click', () => { + const $button = $(closeButton).btnBusyState(true); + _close(options) + .catch((e) => { + gui.alert(e.message); + }) + .then(() => { + $button.btnBusyState(false); + }); + + return false; + }); + } + + const exitButton = document.querySelector('button#exit-form'); + if (exitButton) { + exitButton.addEventListener('click', () => { + document.dispatchEvent(events.Exit()); + _redirect(100); + }); + } + + $('button#validate-form:not(.disabled)').click(function () { + if (typeof form !== 'undefined') { + const $button = $(this); + $button.btnBusyState(true); + setTimeout(() => { + form.validate() + .then((valid) => { + $button.btnBusyState(false); + if (!valid) { + if (settings.strictViolationSelector) { + const strictViolations = + form.view.html.querySelector( + settings.strictViolationSelector + ); + if (strictViolations) { + gui.alert( + t( + 'fieldsubmission.confirm.autoquery.msg1' + ), + null, + 'oc-strict-error' + ); + } else { + gui.alert(t('alert.validationerror.msg')); + } + } else { + gui.alert(t('alert.validationerror.msg')); + } + } else { + gui.alert( + t('alert.validationsuccess.msg'), + t('alert.validationsuccess.heading'), + 'success' + ); + } + }) + .catch((e) => { + gui.alert(e.message); + }) + .then(() => { + $button.btnBusyState(false); + }); + }, 100); + } + + return false; + }); + + // Participant views that submit the whole record (i.e. not fieldsubmissions). + if (settings.fullRecord) { + $('button#submit-form').click(function () { + const $button = $(this).btnBusyState(true); + + form.validate() + .then((valid) => { + if (valid) { + if (settings.offline) { + return _saveRecord(survey, false); + } + return _submitRecord(); + } + gui.alert(t('alert.validationerror.msg')); + }) + .catch((e) => { + gui.alert(e.message); + }) + .then(() => { + $button.btnBusyState(false); + }); + + return false; + }); + + const draftButton = document.querySelector('button#save-draft'); + if (draftButton) { + draftButton.addEventListener('click', (event) => { + if (!event.target.matches('.save-draft-info')) { + const $button = $(draftButton).btnBusyState(true); + setTimeout(() => { + _saveRecord(survey, true) + .then(() => { + $button.btnBusyState(false); + }) + .catch((e) => { + $button.btnBusyState(false); + throw e; + }); + }, 100); + } + }); + } + + $('.record-list__button-bar__button.upload').on('click', () => { + records.uploadQueue({ isUserTriggered: true }); + }); + + $('.record-list__button-bar__button.export').on('click', () => { + const downloadLink = + 'download'; + + records + .exportToZip(form.surveyName) + .then((zipFile) => { + gui.alert( + t('alert.export.success.msg') + downloadLink, + t('alert.export.success.heading'), + 'normal' + ); + updateDownloadLinkAndClick( + document.querySelector('#download-export'), + zipFile + ); + }) + .catch((error) => { + let message = t('alert.export.error.msg', { + errors: error.message, + interpolation: { + escapeValue: false, + }, + }); + if (error.exportFile) { + message += `

${t( + 'alert.export.error.filecreatedmsg' + )}

${downloadLink}`; + } + gui.alert(message, t('alert.export.error.heading')); + if (error.exportFile) { + updateDownloadLinkAndClick( + document.querySelector('#download-export'), + error.exportFile + ); + } + }); + }); + + $(document).on( + 'click', + '.record-list__records__record[data-draft="true"]', + function () { + _loadRecord(survey, $(this).attr('data-id'), false); + } + ); + + $(document).on('click', '.record-list__records__record', function () { + $(this).next('.record-list__records__msg').toggle(100); + }); + } + + if (rc.inIframe() && settings.parentWindowOrigin) { + document.addEventListener( + events.SubmissionSuccess().type, + rc.postEventAsMessageToParentWindow + ); + document.addEventListener( + events.Edited().type, + rc.postEventAsMessageToParentWindow + ); + document.addEventListener( + events.Close().type, + rc.postEventAsMessageToParentWindow + ); + + document.addEventListener( + events.Exit().type, + rc.postEventAsMessageToParentWindow + ); + + form.view.html.addEventListener(events.PageFlip().type, postHeartbeat); + form.view.html.addEventListener(events.AddRepeat().type, postHeartbeat); + form.view.html.addEventListener(events.Heartbeat().type, postHeartbeat); + } + + if (settings.type !== 'view') { + window.onbeforeunload = () => { + if (!ignoreBeforeUnload) { + // Do not add auto queries for note-only views + if (!/\/fs\/dn\//.test(window.location.pathname)) { + _autoAddQueries( + form.view.html.querySelectorAll('.invalid-constraint') + ); + _autoAddReasonQueries(reasons.getInvalidFields()); + } + if ( + fieldSubmissionQueue.enabled && + Object.keys(fieldSubmissionQueue.get()).length > 0 + ) { + return 'Any unsaved data will be lost'; + } + } + }; + } +} + +function updateDownloadLinkAndClick(anchor, file) { + const objectUrl = URL.createObjectURL(file); + + anchor.textContent = file.name; + downloadUtils.updateDownloadLink(anchor, objectUrl, file.name); + anchor.click(); +} + +function postHeartbeat() { + if (rc.inIframe() && settings.parentWindowOrigin) { + rc.postEventAsMessageToParentWindow(events.Heartbeat()); + } +} + +export default { + init, +}; diff --git a/packages/enketo-express/public/js/src/module/controller-webform.js b/packages/enketo-express/public/js/src/module/controller-webform.js index c5309cdeb..3bba49945 100644 --- a/packages/enketo-express/public/js/src/module/controller-webform.js +++ b/packages/enketo-express/public/js/src/module/controller-webform.js @@ -908,11 +908,13 @@ function inIframe() { * @param {Event} event - event */ function postEventAsMessageToParentWindow(event) { + const nextPrompt = document.querySelector('input[name=next-prompt]'); if (event && event.type) { try { window.parent.postMessage( JSON.stringify({ enketoEvent: event.type, + nextForm: nextPrompt && nextPrompt.checked, }), settings.parentWindowOrigin ); diff --git a/packages/enketo-express/public/js/src/module/custom.js b/packages/enketo-express/public/js/src/module/custom.js new file mode 100644 index 000000000..2c51e770b --- /dev/null +++ b/packages/enketo-express/public/js/src/module/custom.js @@ -0,0 +1,41 @@ +// Custom OC things used across views +import events from './event'; + +const range = document.createRange(); + +function addSignedStatus(form) { + const metaPlus = `/*/meta/${form.model.getNamespacePrefix( + 'http://openclinica.org/xforms' + )}:`; /* + model.getNamespacePrefix( 'http://openrosa.org/xforms' ) + ':' */ + const signature = form.model.evaluate(`${metaPlus}signature`, 'string'); + if (signature) { + const statusEl = range.createContextualFragment( + `
${signature + .replace(/\\n/g, '
') + .replace(/\n/g, '
')}
` + ); + document.querySelector('#form-title').before(statusEl); + + const _changeHandler = (ev) => { + if (!ev.target.closest('.or-appearance-dn')) { + const status = document.querySelector('.record-signed-status'); + if (status) { + status.remove(); + } + event.currentTarget.removeEventListener( + events.XFormsValueChanged().type, + _changeHandler + ); + } + }; + + form.view.html.addEventListener( + events.XFormsValueChanged().type, + _changeHandler + ); + } +} + +export default { + addSignedStatus, +}; diff --git a/packages/enketo-express/public/js/src/module/download-utils.js b/packages/enketo-express/public/js/src/module/download-utils.js new file mode 100644 index 000000000..be1fb3233 --- /dev/null +++ b/packages/enketo-express/public/js/src/module/download-utils.js @@ -0,0 +1,11 @@ +import downloadUtils from 'enketo-core/src/js/download-utils'; + +const originalUpdateDownloadLink = downloadUtils.updateDownloadLink; + +downloadUtils.updateDownloadLink = (anchor, ...args) => { + originalUpdateDownloadLink(anchor, ...args); + anchor.setAttribute( + 'title', + 'Right click and select "Save link as..." to download' + ); +}; diff --git a/packages/enketo-express/public/js/src/module/event.js b/packages/enketo-express/public/js/src/module/event.js index 8bf0b0410..4755f5149 100644 --- a/packages/enketo-express/public/js/src/module/event.js +++ b/packages/enketo-express/public/js/src/module/event.js @@ -1,5 +1,17 @@ import events from 'enketo-core/src/js/event'; +events.ReasonChange = function (detail) { + return new CustomEvent('reasonchange', { detail, bubbles: true }); +}; + +events.Heartbeat = function () { + return new CustomEvent('heartbeat'); +}; + +events.Hiding = function () { + return new CustomEvent('hiding'); +}; + events.QueueSubmissionSuccess = function (detail) { return new CustomEvent('queuesubmissionsuccess', { detail, bubbles: true }); }; @@ -12,6 +24,26 @@ events.Close = function () { return new CustomEvent('close', { bubbles: true }); }; +events.Exit = function () { + return new CustomEvent('exit', { bubbles: true }); +}; + +events.SignatureRequested = function () { + return new CustomEvent('signature-request', { bubbles: true }); +}; + +events.AddQuery = function () { + return new CustomEvent('addquery', { bubbles: true }); +}; + +events.FakeInputUpdate = function () { + return new CustomEvent('fakeinputupdate', { bubbles: true }); +}; + +events.DelayChange = function () { + return new CustomEvent('delaychange', { bubbles: true }); +}; + events.OfflineLaunchCapable = function (detail) { return new CustomEvent('offlinelaunchcapable', { detail, bubbles: true }); }; diff --git a/packages/enketo-express/public/js/src/module/field-submission-queue.js b/packages/enketo-express/public/js/src/module/field-submission-queue.js new file mode 100644 index 000000000..f98f776ee --- /dev/null +++ b/packages/enketo-express/public/js/src/module/field-submission-queue.js @@ -0,0 +1,336 @@ +import $ from 'jquery'; +import MD5 from 'crypto-js/md5'; +import settings from './settings'; +import { t } from './translator'; +import utils from './utils'; +import gui from './gui'; + +const FIELDSUBMISSION_URL = settings.enketoId + ? `${settings.basePath}/fieldsubmission/${ + settings.enketoId + }${utils.getQueryString({ name: 'ecid', value: settings.ecid })}` + : null; +const FIELDSUBMISSION_2_URL = settings.enketoId + ? `${settings.basePath}/fieldsubmission/${ + settings.enketoId + }/ecid/${encodeURIComponent(settings.ecid)}` + : null; +const FIELDSUBMISSION_COMPLETE_URL = settings.enketoId + ? `${settings.basePath}/fieldsubmission/complete/${ + settings.enketoId + }${utils.getQueryString({ name: 'ecid', value: settings.ecid })}` + : null; + +class FieldSubmissionQueue { + constructor(options = { showStatus: true }) { + this.submissionQueue = {}; + this.submissionOngoing = null; + this.lastAdded = {}; + this.repeatRemovalCounter = 0; + this.submittedCounter = 0; + this._enabled = false; + + // TODO: move outside of constructor + /** + * Shows upload progress + * + * @type {object} + */ + this._uploadStatus = { + init() { + const statusElementSelector = + '.fieldsubmission-status:not(.readonly)'; + this.statusElements = document.querySelectorAll( + statusElementSelector + ); + + if (!this.statusElements.length) { + const range = document.createRange(); + const first = range.createContextualFragment( + '
' + ); + const second = range.createContextualFragment( + '