diff --git a/jest.config.js b/jest.config.js index a67623088..e00409ff1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,9 +12,10 @@ module.exports = { "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" }, transformIgnorePatterns: [ - "node_modules/(?!((jest-)?react-native(-.*)?|@react-native(-community)?)/)" + "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?)/)", ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleDirectories: ["node_modules", "src"], + globals: {"__DEV__": false}, collectCoverage: true, }; diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 048f8f81d..1753e5646 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -102,6 +102,7 @@ }, "dependencies": { "@havesource/cordova-plugin-push": "git+https://github.com/havesource/cordova-plugin-push.git#4.0.0-dev.0", + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", @@ -141,6 +142,7 @@ "cordova-plugin-x-socialsharing": "6.0.4", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", "i18next": "^22.5.0", @@ -151,7 +153,6 @@ "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", "moment": "^2.29.4", "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", diff --git a/package.serve.json b/package.serve.json index 6315b6a46..c66c32b1e 100644 --- a/package.serve.json +++ b/package.serve.json @@ -53,6 +53,7 @@ "prettier": "3.0.3" }, "dependencies": { + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", @@ -72,6 +73,7 @@ "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", "i18next": "^22.5.0", @@ -82,7 +84,6 @@ "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", "moment": "^2.29.4", "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", diff --git a/webpack.config.js b/webpack.config.js index 1e504ac5f..3e7e6d368 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -79,7 +79,12 @@ module.exports = { /* Enketo expects its per-app configuration to be available as 'enketo-config', so we have to alias it here. https://github.com/enketo/enketo-core#global-configuration */ - 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config') + 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config'), + /* enketo-transformer has 'libxslt' as an optional peer dependency. + We don't need it since we are only doing client-side transformations via + enketo-transformer/web (https://github.com/enketo/enketo-transformer#web). + So, we can tell webpack it's ok to ignore libxslt by aliasing it to false. */ + 'libxslt': false, }, extensions: ['.web.js', '.jsx', '.tsx', '.ts', '.js'], }, diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 92ac23a6b..1d3934ea4 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,4 +1,5 @@ import packageJsonBuild from '../../package.cordovabuild.json'; +import fakeConfig from './fakeConfig.json'; export const mockCordova = () => { window['cordova'] ||= {}; @@ -34,7 +35,7 @@ export const mockFile = () => { //for consent document const _storage = {}; -export const mockBEMUserCache = () => { +export const mockBEMUserCache = (config?) => { const _cache = {}; const messages = []; const mockBEMUserCache = { @@ -100,11 +101,20 @@ export const mockBEMUserCache = () => { ); }, getDocument: (key: string, withMetadata?: boolean) => { - return new Promise((rs, rj) => - setTimeout(() => { - rs(_storage[key]); - }, 100), - ); + //returns the config provided as a paramenter to this mock! + if (key == 'config/app_ui_config') { + return new Promise((rs, rj) => + setTimeout(() => { + rs(config || fakeConfig); + }, 100), + ); + } else { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_storage[key]); + }, 100), + ); + } }, isEmptyDoc: (doc) => { if (doc == undefined) { @@ -117,6 +127,20 @@ export const mockBEMUserCache = () => { return false; } }, + getAllTimeQuery: () => { + return { key: 'write_ts', startTs: 0, endTs: Date.now() / 1000 }; + }, + getSensorDataForInterval: (key, tq, withMetadata) => { + if (key == `manual/demographic_survey`) { + return new Promise((rs, rj) => + setTimeout(() => { + rs({ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }); + }, 100), + ); + } else { + return undefined; + } + }, }; window['cordova'] ||= {}; window['cordova'].plugins ||= {}; diff --git a/www/__mocks__/fakeConfig.json b/www/__mocks__/fakeConfig.json new file mode 100644 index 000000000..dabec6cd9 --- /dev/null +++ b/www/__mocks__/fakeConfig.json @@ -0,0 +1,88 @@ +{ + "version": 1, + "ts": 1655143472, + "server": { + "connectUrl": "https://openpath-test.nrel.gov/api/", + "aggregate_call_auth": "user_only" + }, + "intro": { + "program_or_study": "study", + "start_month": "10", + "start_year": "2023", + "program_admin_contact": "K. Shankari", + "deployment_partner_name": "NREL", + "translated_text": { + "en": { + "deployment_partner_name": "NREL", + "deployment_name": "Testing environment for Jest testing", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + }, + "es": { + "deployment_partner_name": "NREL", + "deployment_name": "Ambiente prueba para las pruebas de Jest", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + } + } + }, + "survey_info": { + "surveys": { + "TimeUseSurvey": { + "compatibleWith": 1, + "formPath": "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + "labelTemplate": { + "en": "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + "es": "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}" + }, + "labelVars": { + "da": { + "key": "Domestic_activities", + "type": "length" + }, + "erea": { + "key": "Employment_related_a_Education_activities", + "type": "length" + } + }, + "version": 9 + } + }, + "trip-labels": "ENKETO" + }, + "display_config": { + "use_imperial": false + }, + "profile_controls": { + "support_upload": true, + "trip_end_notification": false + }, + "admin_dashboard": { + "overview_users": true, + "overview_active_users": true, + "overview_trips": true, + "overview_signup_trends": true, + "overview_trips_trend": true, + "data_uuids": true, + "data_trips": true, + "data_trips_columns_exclude": [], + "additional_trip_columns": [], + "data_uuids_columns_exclude": [], + "token_generate": true, + "token_prefix": "nrelop", + "map_heatmap": true, + "map_bubble": true, + "map_trip_lines": true, + "push_send": true, + "options_uuids": true, + "options_emails": true + } +} diff --git a/www/__mocks__/messageFormatMocks.ts b/www/__mocks__/messageFormatMocks.ts new file mode 100644 index 000000000..eadfc0c7c --- /dev/null +++ b/www/__mocks__/messageFormatMocks.ts @@ -0,0 +1,29 @@ +//call signature MessageFormat.compile(templage)(vars); +//in - template an vars -- {... pca: 0, ...} +//out - 1 Personal Care, + +export default class MessageFormat { + constructor(locale: string) {} + + compile(message: string) { + return (vars: {}) => { + let label = ''; + const brokenList = message.split('}{'); + console.log(brokenList); + + for (let key in vars) { + brokenList.forEach((item) => { + let brokenItem = item.split(','); + if (brokenItem[0] == key) { + let getLabel = brokenItem[2].split('#'); + console.log(getLabel); + label = vars[key] + ' ' + getLabel[1]; + return label; + } + }); + } + }; + } +} + +exports.MessageFormat = MessageFormat; diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts new file mode 100644 index 000000000..c4fda7dc4 --- /dev/null +++ b/www/__tests__/enketoHelper.test.ts @@ -0,0 +1,340 @@ +import { + getInstanceStr, + filterByNameAndVersion, + resolveTimestamps, + resolveLabel, + loadPreviousResponseForSurvey, + saveResponse, +} from '../js/survey/enketo/enketoHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { getConfig, resetStoredConfig } from '../../www/js/config/dynamicConfig'; +import fakeConfig from '../__mocks__/fakeConfig.json'; + +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; + +mockBEMUserCache(fakeConfig); +mockLogger(); + +global.URL = require('url').URL; +global.Blob = require('node:buffer').Blob; + +beforeEach(() => { + resetStoredConfig(); +}); + +it('gets the survey config', async () => { + //this is aimed at testing my mock of the config + //mocked getDocument for the case of getting the config + let config = await getConfig(); + let mockSurveys = { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + labelVars: { + da: { key: 'Domestic_activities', type: 'length' }, + erea: { key: 'Employment_related_a_Education_activities', type: 'length' }, + }, + version: 9, + }, + }; + expect(config.survey_info.surveys).toMatchObject(mockSurveys); +}); + +it('gets the model response, if avaliable, or returns null', () => { + const xmlModel = + ''; + const filled = + '2016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00'; + const opts = { prefilledSurveyResponse: filled }; + const opts2 = { + prefillFields: { + Start_date: '2016-07-25', + Start_time: '17:24:32.928-06:00', + End_date: '2016-07-25', + End_time: '17:30:31.000-06:00', + }, + }; + + //if no xmlModel, returns null + expect(getInstanceStr(null, opts)).toBe(null); + //if there is a prefilled survey, return it + expect(getInstanceStr(xmlModel, opts)).toBe(filled); + //if there is a model and fields, return prefilled + expect(getInstanceStr(xmlModel, opts2)).toBe(filled); + //if none of those things, also return null + expect(getInstanceStr(xmlModel, {})).toBe(null); +}); + +//resolve timestamps +it('resolves the timestamps', () => { + const xmlParser = new window.DOMParser(); + const timelineEntry = { + end_local_dt: { timezone: 'America/Los_Angeles' }, + start_ts: 1469492672.928242, + end_ts: 1469493031, + }; + + //missing data returns null + const missingData = + ' 2016-08-28 2016-07-25 17:30:31.000-06:00 '; + const missDataDoc = xmlParser.parseFromString(missingData, 'text/html'); + expect(resolveTimestamps(missDataDoc, timelineEntry)).toBeNull(); + //bad time returns undefined + const badTimes = + ' 2016-08-28 2016-07-25 17:32:32.928-06:00 17:30:31.000-06:00 '; + const badTimeDoc = xmlParser.parseFromString(badTimes, 'text/xml'); + expect(resolveTimestamps(badTimeDoc, timelineEntry)).toBeUndefined(); + //if within a minute, timelineEntry timestamps + const timeEntry = + ' 2016-07-25 2016-07-25 17:24:32.928-06:00 17:30:31.000-06:00 '; + const xmlDoc1 = xmlParser.parseFromString(timeEntry, 'text/xml'); + expect(resolveTimestamps(xmlDoc1, timelineEntry)).toMatchObject({ + start_ts: 1469492672.928242, + end_ts: 1469493031, + }); + // else survey timestamps + const timeSurvey = + ' 2016-07-25 2016-07-25 17:22:33.928-06:00 17:33:33.000-06:00 '; + const xmlDoc2 = xmlParser.parseFromString(timeSurvey, 'text/xml'); + expect(resolveTimestamps(xmlDoc2, timelineEntry)).toMatchObject({ + start_ts: 1469492553.928, + end_ts: 1469493213, + }); +}); + +//resolve label +it('resolves the label, normal case', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do + //no custom function, fallback to UseLabelTemplate (standard case) + mockBEMUserCache(fakeConfig); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('3 Domestic'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('3 Employment/Education, 3 Domestic'); +}); + +it('resolves the label, if no template, returns "Answered"', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noTemplate = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelVars: { + da: { + key: 'Domestic_activities', + type: 'length', + }, + erea: { + key: 'Employment_related_a_Education_activities', + type: 'length', + }, + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noTemplate); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('Answered'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('Answered'); +}); + +it('resolves the label, if no labelVars, returns template', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noLabels = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noLabels); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); +}); + +/** + * @param surveyName the name of the survey (e.g. "TimeUseSurvey") + * @param enketoForm the Form object from enketo-core that contains this survey + * @param appConfig the dynamic config file for the app + * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' + * @returns Promise of the saved result, or an Error if there was a problem + */ +// export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { +it('gets the saved result or throws an error', () => { + const surveyName = 'TimeUseSurvey'; + const form = { + getDataStr: () => { + return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; + }, + }; + //the start time listed is after the end time listed + const badForm = { + getDataStr: () => { + return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-08-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; + }, + }; + const config = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + labelVars: { + da: { key: 'Domestic_activities', type: 'length' }, + erea: { key: 'Employment_related_a_Education_activities', type: 'length' }, + }, + version: 9, + }, + }, + }, + }; + const opts = { + timelineEntry: { + end_local_dt: { timezone: 'America/Los_Angeles' }, + start_ts: 1469492672.928242, + end_ts: 1469493031, + }, + }; + + console.log(config); + expect(saveResponse(surveyName, form, config, opts)).resolves.toMatchObject({ + label: '1 Personal Care', + name: 'TimeUseSurvey', + }); + expect(saveResponse(surveyName, badForm, config, opts)).resolves.toMatchObject({ + message: + 'The times you entered are invalid. Please ensure that the start time is before the end time.', + }); +}); + +/* + * We retrieve all the records every time instead of caching because of the + * usage pattern. We assume that the demographic survey is edited fairly + * rarely, so loading it every time will likely do a bunch of unnecessary work. + * Loading it on demand seems like the way to go. If we choose to experiment + * with incremental updates, we may want to revisit this. + */ +it('loads the previous response to a given survey', () => { + expect(loadPreviousResponseForSurvey('manual/demographic_survey')).resolves.toMatchObject({ + data: 'completed', + time: '01/01/2001', + }); +}); + +/** + * filterByNameAndVersion filter the survey responses by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey response version must be greater than or equal to `compatibleWith` to be included. + */ +it('filters the survey responses by their name and version', () => { + //no response -> no filtered responses + expect(filterByNameAndVersion('TimeUseSurvey', [])).resolves.toStrictEqual([]); + + const response = [ + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '1', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + ]; + + //one response -> that response + expect(filterByNameAndVersion('TimeUseSurvey', response)).resolves.toStrictEqual(response); + + const responses = [ + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '1', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'OtherSurvey', //survey name + version: '1', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:39', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '0.5', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + ]; + + //several responses -> only the one that has a name match + expect(filterByNameAndVersion('TimeUseSurvey', responses)).resolves.toStrictEqual(response); +}); diff --git a/www/index.js b/www/index.js index 0ae3896a2..74ebc4c5d 100644 --- a/www/index.js +++ b/www/index.js @@ -13,7 +13,6 @@ import './js/i18n-utils.js'; import './js/main.js'; import './js/diary.js'; import './js/diary/services.js'; -import './js/survey/enketo/answer.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index eb709c16c..801e24b07 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -10,6 +10,11 @@ export let storedConfig = null; export let configChanged = false; export const setConfigChanged = (b) => (configChanged = b); +//used test multiple configs, not used outside of test +export const resetStoredConfig = function () { + storedConfig = null; +}; + const _getStudyName = function (connectUrl) { const orig_host = new URL(connectUrl).hostname; const first_domain = orig_host.split('.')[0]; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index b26d5e85a..5225cf6c6 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -15,7 +15,7 @@ import { updateUser } from '../services/commHelper'; * BEGIN: Simple read/write wrappers */ export function forcePluginSync() { - return window.cordova.plugins.BEMServerSync.forceSync(); + return window['cordova'].plugins.BEMServerSync.forceSync(); } const formatConfigForDisplay = (configToFormat) => { @@ -27,11 +27,11 @@ const formatConfigForDisplay = (configToFormat) => { }; const setConfig = function (config) { - return window.cordova.plugins.BEMServerSync.setConfig(config); + return window['cordova'].plugins.BEMServerSync.setConfig(config); }; const getConfig = function () { - return window.cordova.plugins.BEMServerSync.getConfig(); + return window['cordova'].plugins.BEMServerSync.getConfig(); }; export async function getHelperSyncSettings() { @@ -39,14 +39,6 @@ export async function getHelperSyncSettings() { return formatConfigForDisplay(tempConfig); } -const getEndTransitionKey = function () { - if (window.cordova.platformId == 'android') { - return 'local.transition.stopped_moving'; - } else if (window.cordova.platformId == 'ios') { - return 'T_TRIP_ENDED'; - } -}; - type syncConfig = { sync_interval: number; ios_use_remote_push: boolean }; //forceSync and endForceSync SettingRows & their actions @@ -60,7 +52,7 @@ export const ForceSyncRow = ({ getState }) => { async function forceSync() { try { - let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); + let addedEvent = await addStatEvent(statKeys.BUTTON_FORCE_SYNC); console.log('Added ' + statKeys.BUTTON_FORCE_SYNC + ' event'); let sync = await forcePluginSync(); @@ -70,7 +62,7 @@ export const ForceSyncRow = ({ getState }) => { * See https://github.com/e-mission/e-mission-phone/issues/279 for details */ var sensorKey = 'statemachine/transition'; - let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages( + let sensorDataList = await window['cordova'].plugins.BEMUserCache.getAllMessages( sensorKey, true, ); @@ -104,25 +96,25 @@ export const ForceSyncRow = ({ getState }) => { } const getStartTransitionKey = function () { - if (window.cordova.platformId == 'android') { + if (window['cordova'].platformId == 'android') { return 'local.transition.exited_geofence'; - } else if (window.cordova.platformId == 'ios') { + } else if (window['cordova'].platformId == 'ios') { return 'T_EXITED_GEOFENCE'; } }; const getEndTransitionKey = function () { - if (window.cordova.platformId == 'android') { + if (window['cordova'].platformId == 'android') { return 'local.transition.stopped_moving'; - } else if (window.cordova.platformId == 'ios') { + } else if (window['cordova'].platformId == 'ios') { return 'T_TRIP_ENDED'; } }; const getOngoingTransitionState = function () { - if (window.cordova.platformId == 'android') { + if (window['cordova'].platformId == 'android') { return 'local.state.ongoing_trip'; - } else if (window.cordova.platformId == 'ios') { + } else if (window['cordova'].platformId == 'ios') { return 'STATE_ONGOING_TRIP'; } }; @@ -130,12 +122,12 @@ export const ForceSyncRow = ({ getState }) => { async function getTransition(transKey) { var entry_data = {}; const curr_state = await getState(); - entry_data.curr_state = curr_state; + entry_data['curr_state'] = curr_state; if (transKey == getEndTransitionKey()) { - entry_data.curr_state = getOngoingTransitionState(); + entry_data['curr_state'] = getOngoingTransitionState(); } - entry_data.transition = transKey; - entry_data.ts = moment().unix(); + entry_data['transition'] = transKey; + entry_data['ts'] = moment().unix(); return entry_data; } @@ -144,9 +136,9 @@ export const ForceSyncRow = ({ getState }) => { * result for start so that we ensure ordering */ var sensorKey = 'statemachine/transition'; let entry_data = await getTransition(getStartTransitionKey()); - let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + let messagePut = await window['cordova'].plugins.BEMUserCache.putMessage(sensorKey, entry_data); entry_data = await getTransition(getEndTransitionKey()); - messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + messagePut = await window['cordova'].plugins.BEMUserCache.putMessage(sensorKey, entry_data); forceSync(); } @@ -242,7 +234,6 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { Logger.displayError('Error while setting sync config', err); } } - const onChooseInterval = function (interval) { let tempConfig = { ...localConfig }; tempConfig.sync_interval = interval.value; @@ -259,7 +250,7 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { * configure the UI */ let toggle; - if (window.cordova.platformId == 'ios') { + if (window['cordova'].platformId == 'ios') { toggle = ( diff --git a/www/js/diary.js b/www/js/diary.js index c580ad8f2..08909886d 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,11 +2,7 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular - .module('emission.main.diary', [ - 'emission.main.diary.services', - 'emission.plugin.logger', - 'emission.survey.enketo.answer', - ]) + .module('emission.main.diary', ['emission.main.diary.services', 'emission.plugin.logger']) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 174974a9d..1a2c87462 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,11 +1,10 @@ import moment from 'moment'; -import { displayError, logDebug } from '../plugin/logger'; +import { logDebug } from '../plugin/logger'; import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; -import i18next from 'i18next'; import { UserInputEntry } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; -import { getNotDeletedCandidates, getUniqueEntries } from '../survey/inputMatcher'; +import { filterByNameAndVersion } from '../survey/enketo/enketoHelper'; const cachedGeojsons = new Map(); /** @@ -92,7 +91,9 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { // fill in the unprocessedLabels object with the labels we just read labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - unprocessedLabels['SURVEY'] = r; + filterByNameAndVersion('TripConfirmSurvey', r).then((filtered) => { + unprocessedLabels['SURVEY'] = filtered; + }); } else { unprocessedLabels[getLabelInputs()[i]] = r; } diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index a7aa9b26c..1d169ee9b 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -4,10 +4,8 @@ import { StyleSheet, Modal, ScrollView, SafeAreaView, Pressable } from 'react-na import { ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; -import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; -import { fetchUrlCached } from '../../services/commHelper'; +import { SurveyOptions, fetchSurvey, getInstanceStr, saveResponse } from './enketoHelper'; import { displayError, displayErrorMsg } from '../../plugin/logger'; -// import { transform } from 'enketo-transformer/web'; type Props = Omit & { surveyName: string; @@ -22,22 +20,6 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const enketoForm = useRef
(null); const appConfig = useAppConfig(); - async function fetchSurveyJson(url) { - const responseText = await fetchUrlCached(url); - try { - return JSON.parse(responseText); - } catch ({ name, message }) { - // not JSON, so it must be XML - return Promise.reject( - 'downloaded survey was not JSON; enketo-transformer is not available yet', - ); - /* uncomment once enketo-transformer is available */ - // if `response` is not JSON, it is an XML string and needs transformation to JSON - // const xmlText = await res.text(); - // return await transform({xform: xmlText}); - } - } - async function validateAndSave() { const valid = await enketoForm.current.validate(); if (!valid) return false; @@ -62,7 +44,7 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; if (!formPath) return console.error('No form path found for survey', surveyName); - fetchSurveyJson(formPath).then(({ form, model }) => { + fetchSurvey(formPath).then(({ form, model }) => { surveyJson.current = { form, model }; headerEl?.current.insertAdjacentHTML('afterend', form); // inject form into DOM const formEl = document.querySelector('form.or'); diff --git a/www/js/survey/enketo/answer.js b/www/js/survey/enketo/answer.js deleted file mode 100644 index cb5745037..000000000 --- a/www/js/survey/enketo/answer.js +++ /dev/null @@ -1,192 +0,0 @@ -import angular from 'angular'; -import MessageFormat from 'messageformat'; -import { getConfig } from '../../config/dynamicConfig'; - -angular - .module('emission.survey.enketo.answer', ['ionic']) - .factory('EnketoSurveyAnswer', function ($http) { - /** - * @typedef EnketoAnswerData - * @type {object} - * @property {string} label - display label (this value is use for displaying on the button) - * @property {string} ts - the timestamp at which the survey was filled out (in seconds) - * @property {string} fmt_time - the formatted timestamp at which the survey was filled out - * @property {string} name - survey name - * @property {string} version - survey version - * @property {string} xmlResponse - survey answer XML string - * @property {string} jsonDocResponse - survey answer JSON object - */ - - /** - * @typedef EnketoAnswer - * @type {object} - * @property {EnketoAnswerData} data - answer data - * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) - */ - - /** - * @typedef EnketoSurveyConfig - * @type {{ - * [surveyName:string]: { - * formPath: string; - * labelFields: string[]; - * version: number; - * compatibleWith: number; - * } - * }} - */ - - const LABEL_FUNCTIONS = { - UseLabelTemplate: (xmlDoc, name) => { - return _lazyLoadConfig().then((configSurveys) => { - const config = configSurveys[name]; // config for this survey - const lang = i18next.resolvedLanguage; - const labelTemplate = config.labelTemplate?.[lang]; - - if (!labelTemplate) return 'Answered'; // no template given in config - if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, - // so we return the unaltered template - - // gather vars that will be interpolated into the template according to the survey config - const labelVars = {}; - for (let lblVar in config.labelVars) { - const fieldName = config.labelVars[lblVar].key; - let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); - if (fieldStr == '') fieldStr = null; - if (config.labelVars[lblVar].type == 'length') { - const fieldMatches = fieldStr?.split(' '); - labelVars[lblVar] = fieldMatches?.length || 0; - } else { - throw new Error(`labelVar type ${config.labelVars[lblVar].type} is not supported!`); - } - } - - // use MessageFormat interpolate the label template with the label vars - const mf = new MessageFormat(lang); - const label = mf.compile(labelTemplate)(labelVars); - return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas - }); - }, - }; - - /** @type {EnketoSurveyConfig} _config */ - let _config; - - /** - * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. - * @param {XMLDocument} xmlDoc survey answer object - * @param {string} tagName tag name - * @returns {string} answer string. If not found, return "\" - */ - function _getAnswerByTagName(xmlDoc, tagName) { - const vals = xmlDoc.getElementsByTagName(tagName); - const val = vals.length ? vals[0].innerHTML : null; - if (!val) return ''; - return val; - } - - /** - * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config - * @returns {Promise} enketo survey config - */ - function _lazyLoadConfig() { - if (_config !== undefined) { - return Promise.resolve(_config); - } - return getConfig().then((newConfig) => { - Logger.log('Resolved UI_CONFIG_READY promise in answer.js, filling in templates'); - _config = newConfig.survey_info.surveys; - return _config; - }); - } - - /** - * filterByNameAndVersion filter the survey answers by survey name and their version. - * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. - * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers - */ - function filterByNameAndVersion(name, answers) { - return _lazyLoadConfig().then((config) => - answers.filter( - (answer) => - answer.data.name === name && answer.data.version >= config[name].compatibleWith, - ), - ); - } - - /** - * resolve answer label for the survey - * @param {string} name survey name - * @param {XMLDocument} xmlDoc survey answer object - * @returns {Promise} label string Promise - */ - function resolveLabel(name, xmlDoc) { - // Some studies may want a custom label function for their survey. - // Those can be added in LABEL_FUNCTIONS with the survey name as the key. - // Otherwise, UseLabelTemplate will create a label using the template in the config - if (LABEL_FUNCTIONS[name]) return LABEL_FUNCTIONS[name](xmlDoc); - return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); - } - - /** - * resolve timestamps label from the survey response - * @param {XMLDocument} xmlDoc survey answer object - * @param {object} trip trip object - * @returns {object} object with `start_ts` and `end_ts` - * - null if no timestamps are resolved - * - undefined if the timestamps are invalid - */ - function resolveTimestamps(xmlDoc, timelineEntry) { - // check for Date and Time fields - const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; - let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; - const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; - let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; - - // if any of the fields are missing, return null - if (!startDate || !startTime || !endDate || !endTime) return null; - - const timezone = - timelineEntry.start_local_dt?.timezone || - timelineEntry.enter_local_dt?.timezone || - timelineEntry.end_local_dt?.timezone || - timelineEntry.exit_local_dt?.timezone; - // split by + or - to get time without offset - startTime = startTime.split(/\-|\+/)[0]; - endTime = endTime.split(/\-|\+/)[0]; - - let additionStartTs = moment.tz(startDate + 'T' + startTime, timezone).unix(); - let additionEndTs = moment.tz(endDate + 'T' + endTime, timezone).unix(); - - if (additionStartTs > additionEndTs) { - return undefined; // if the start time is after the end time, this is an invalid response - } - - /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to - the millisecond. To avoid precision issues, we will check if the start/end timestamps from - the survey response are within the same minute as the start/end or enter/exit timestamps. - If so, we will use the exact trip/place timestamps */ - const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; - const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; - if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) - additionStartTs = entryStartTs; - if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) - additionEndTs = entryEndTs; - - // return unix timestamps in seconds - return { - start_ts: additionStartTs, - end_ts: additionEndTs, - }; - } - - return { - filterByNameAndVersion, - resolveLabel, - resolveTimestamps, - }; - }); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 424e364d2..d7ecf0bb9 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,9 +1,15 @@ -import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; +import { transform } from 'enketo-transformer/web'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from '../../plugin/logger'; +import MessageFormat from '@messageformat/core'; +import { logDebug, logInfo } from '../../plugin/logger'; +import { getConfig } from '../../config/dynamicConfig'; +import { DateTime } from 'luxon'; +import { fetchUrlCached } from '../../services/commHelper'; import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; +import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes'; +import { CompositeTrip, ConfirmedPlace, TimelineEntry } from '../../types/diaryTypes'; export type PrefillFields = { [key: string]: string }; @@ -15,6 +21,104 @@ export type SurveyOptions = { dataKey?: string; }; +type EnketoResponseData = { + label: string; //display label (this value is use for displaying on the button) + ts: string; //the timestamp at which the survey was filled out (in seconds) + fmt_time: string; //the formatted timestamp at which the survey was filled out + name: string; //survey name + version: string; //survey version + xmlResponse: string; //survey response as XML string + jsonDocResponse: string; //survey response as JSON object +}; + +type EnketoResponse = { + data: EnketoResponseData; //survey response data + metadata: any; +}; + +const LABEL_FUNCTIONS = { + UseLabelTemplate: async (xmlDoc: XMLDocument, name: string) => { + let appConfig = await getConfig(); + const configSurveys = appConfig.survey_info.surveys; + + const config = configSurveys[name]; // config for this survey + const lang = i18next.resolvedLanguage; + const labelTemplate = config.labelTemplate?.[lang]; + + if (!labelTemplate) return 'Answered'; // no template given in config + if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, + // so we return the unaltered template + + // gather vars that will be interpolated into the template according to the survey config + const labelVars = {}; + for (let lblVar in config.labelVars) { + const fieldName = config.labelVars[lblVar].key; + let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); + if (fieldStr == '') fieldStr = null; + if (config.labelVars[lblVar].type == 'length') { + const fieldMatches = fieldStr?.split(' '); + labelVars[lblVar] = fieldMatches?.length || 0; + } else { + throw new Error(`labelVar type ${config.labelVars[lblVar].type} is not supported!`); + } + } + + // use MessageFormat interpolate the label template with the label vars + const mf = new MessageFormat(lang); + const label = mf.compile(labelTemplate)(labelVars); + return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas + }, +}; + +/** + * _getAnswerByTagName look up how a question was answered, given the survey response + * and the tag name of the question + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {string} tagName tag name of the question + * @returns {string} answer string. If not found, return "\" + */ +function _getAnswerByTagName(xmlDoc: XMLDocument, tagName: string) { + const vals = xmlDoc.getElementsByTagName(tagName); + const val = vals.length ? vals[0].innerHTML : null; + if (!val) return ''; + return val; +} + +/** @type {EnketoSurveyConfig} _config */ +let _config: EnketoSurveyConfig; + +/** + * filterByNameAndVersion filter the survey responses by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The survey version of the response must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoResponse[]} responses An array of previously recorded responses to Enketo surveys + * (presumably having been retrieved from unifiedDataLoader) + * @return {Promise} filtered survey responses + */ +export function filterByNameAndVersion(name: string, responses: EnketoResponse[]) { + return getConfig().then((config) => + responses.filter( + (r) => + r.data.name === name && r.data.version >= config.survey_info.surveys[name].compatibleWith, + ), + ); +} + +/** + * resolve a label for the survey response + * @param {string} name survey name + * @param {XMLDocument} xmlDoc survey response as XML object + * @returns {Promise} label string Promise + */ +export async function resolveLabel(name: string, xmlDoc: XMLDocument) { + // Some studies may want a custom label function for their survey. + // Those can be added in LABEL_FUNCTIONS with the survey name as the key. + // Otherwise, UseLabelTemplate will create a label using the template in the config + if (LABEL_FUNCTIONS[name]) return await LABEL_FUNCTIONS[name](xmlDoc); + return await LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); +} + /** * @param xmlModel the blank XML model to be prefilled * @param prefillFields an object with keys that are the XML tag names and values that are the values to be prefilled @@ -22,7 +126,7 @@ export type SurveyOptions = { */ function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { if (!prefillFields) return null; - const xmlParser = new window.DOMParser(); + const xmlParser = new DOMParser(); const xmlDoc = xmlParser.parseFromString(xmlModel, 'text/xml'); for (const [tagName, value] of Object.entries(prefillFields)) { @@ -45,6 +149,62 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | return null; } +/** + * resolve timestamps label from the survey response + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {object} timelineEntry trip or place object + * @returns {object} object with `start_ts` and `end_ts` + * - null if no timestamps are resolved + * - undefined if the timestamps are invalid + */ +export function resolveTimestamps(xmlDoc: XMLDocument, timelineEntry: TimelineEntry) { + // check for Date and Time fields + const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; + let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; + const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; + let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; + + // if any of the fields are missing, return null + if (!startDate || !startTime || !endDate || !endTime) return null; + + const timezone = + (timelineEntry as CompositeTrip).start_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).enter_local_dt?.timezone || + (timelineEntry as CompositeTrip).end_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).exit_local_dt?.timezone; + // split by + or - to get time without offset + startTime = startTime.split(/\-|\+/)[0]; + endTime = endTime.split(/\-|\+/)[0]; + + let additionStartTs = DateTime.fromISO(startDate + 'T' + startTime, { + zone: timezone, + }).toSeconds(); + let additionEndTs = DateTime.fromISO(endDate + 'T' + endTime, { zone: timezone }).toSeconds(); + + if (additionStartTs > additionEndTs) { + return undefined; // if the start time is after the end time, this is an invalid response + } + + /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to + the millisecond. To avoid precision issues, we will check if the start/end timestamps from + the survey response are within the same minute as the start/end or enter/exit timestamps. + If so, we will use the exact trip/place timestamps */ + const entryStartTs = + (timelineEntry as CompositeTrip).start_ts || (timelineEntry as ConfirmedPlace).enter_ts; + const entryEndTs = + (timelineEntry as CompositeTrip).end_ts || (timelineEntry as ConfirmedPlace).exit_ts; + if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) + additionStartTs = entryStartTs; + if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) + additionEndTs = entryEndTs; + + // return unix timestamps in seconds + return { + start_ts: additionStartTs, + end_ts: additionEndTs, + }; +} + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey @@ -52,14 +212,18 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' * @returns Promise of the saved result, or an Error if there was a problem */ -export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { - const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); +export function saveResponse( + surveyName: string, + enketoForm: Form, + appConfig: AppConfig, + opts: SurveyOptions, +) { const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); const xml2js = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: 'attr' }); const jsonDocResponse = xml2js.parse(xmlResponse); - return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc) + return resolveLabel(surveyName, xmlDoc) .then((rsLabel) => { const data: any = { label: rsLabel, @@ -69,15 +233,14 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op jsonDocResponse, }; if (opts.timelineEntry) { - let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); + let timestamps = resolveTimestamps(xmlDoc, opts.timelineEntry); if (timestamps === undefined) { // timestamps were resolved, but they are invalid return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); } // if timestamps were not resolved from the survey, we will use the trip or place timestamps - timestamps ||= opts.timelineEntry; - data.start_ts = timestamps.start_ts || timestamps.enter_ts; - data.end_ts = timestamps.end_ts || timestamps.exit_ts; + data.start_ts = timestamps?.start_ts || opts.timelineEntry.enter_ts; + data.end_ts = timestamps?.end_ts || opts.timelineEntry.exit_ts; // UUID generated using this method https://stackoverflow.com/a/66332305 data.match_id = URL.createObjectURL(new Blob([])).slice(-36); } else { @@ -92,10 +255,11 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op .then((data) => data); } -const _getMostRecent = (answers) => { - answers.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); - console.log('first answer is ', answers[0], ' last answer is ', answers[answers.length - 1]); - return answers[0]; +const _getMostRecent = (responses) => { + responses.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); + logDebug(`_getMostRecent: first response is ${responses[0]}; + last response is ${responses.slice(-1)[0]}`); + return responses[0]; }; /* @@ -109,7 +273,17 @@ export function loadPreviousResponseForSurvey(dataKey: string) { const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - return getUnifiedDataForInterval(dataKey, tq, getMethod).then((answers) => - _getMostRecent(answers), + return getUnifiedDataForInterval(dataKey, tq, getMethod).then((responses) => + _getMostRecent(responses), ); } + +export async function fetchSurvey(url: string) { + const responseText = await fetchUrlCached(url); + try { + return JSON.parse(responseText); + } catch (e) { + logDebug(`${e.name}: Survey was not in JSON format. Attempting to transform XML -> JSON...`); + return await transform({ xform: responseText }); + } +} diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts new file mode 100644 index 000000000..1a2e50722 --- /dev/null +++ b/www/js/types/appConfigTypes.ts @@ -0,0 +1,27 @@ +// WIP: type definitions for the 'dynamic config' spec +// examples of configs: https://github.com/e-mission/nrel-openpath-deploy-configs/tree/main/configs + +export type AppConfig = { + server: ServerConnConfig; + survey_info: { + 'trip-labels': 'MULTILABEL' | 'ENKETO'; + surveys: EnketoSurveyConfig; + }; + [k: string]: any; // TODO fill in all the other fields +}; + +export type ServerConnConfig = { + connectUrl: `https://${string}`; + aggregate_call_auth: 'no_auth' | 'user_only' | 'never'; +}; + +export type EnketoSurveyConfig = { + [surveyName: string]: { + formPath: string; + labelTemplate: { [lang: string]: string }; + labelVars: { [activity: string]: { [key: string]: string; type: string } }; + version: number; + compatibleWith: number; + dataKey?: string; + }; +}; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 743d75b15..7cce67923 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -5,14 +5,17 @@ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; type ObjectId = { $oid: string }; -type ConfirmedPlace = { +export type ConfirmedPlace = { _id: ObjectId; additions: UserInputEntry[]; cleaned_place: ObjectId; ending_trip: ObjectId; - enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 + enter_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 enter_local_dt: LocalDt; enter_ts: number; // Unix timestamp + exit_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 + exit_local_dt: LocalDt; + exit_ts: number; // Unix timestamp key: string; location: { type: string; coordinates: number[] }; origin_key: string; @@ -76,6 +79,11 @@ export type CompositeTrip = { so a 'timeline entry' is either a trip or a place. */ export type TimelineEntry = ConfirmedPlace | CompositeTrip; +/* Type guard to disambiguate timeline entries as either trips or places + If it has a 'start_ts' and 'end_ts', it's a trip. Else, it's a place. */ +export const isTrip = (entry: TimelineEntry): entry is CompositeTrip => + entry.hasOwnProperty('start_ts') && entry.hasOwnProperty('end_ts'); + /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = {