From 02655ba28913a06caa80795aa3a07a4b15d06ef3 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 May 2024 15:25:54 -0400 Subject: [PATCH 1/4] refactor ENKETO userinputs to use survey name as key instead of 'SURVEY' Part of the initiative described in https://github.com/e-mission/e-mission-docs/issues/1045 This implements the "proposed" structure on the phone for Enketo user inputs. When unprocessed user inputs are read and stored into unprocessedLabels in timelineHelper, responses will now be sorted by survey name instead of all being kept in one slot 'SURVEY'. Also implement a new function, enketoHelper > resolveSurveyButtonConfig, which reads the config and determines what survey(s) are available for a button (like 'trip label', 'trip addition', 'place label', 'place addition'). Includes backwards compat so old configs (which don't have those fields) will just use the default 'TripConfirmSurvey'. Including the translations for TripConfirmSurvey's 'not-filled-in-label' directly in the backwards compat to be consistent with new-style surveys where the translations are directly in the config. This new function is used by , simplifying the code that was there previously. It now stores the returned SurveyButtonConfig object as 'survey' rather than keeping 'surveyName' and 'notFilledInLabel' separately. This allows conditionalSurveys > getSurveyForTimelineEntry to be simplified too. The new function is also used in timelineHelper > updateUnprocessedInputs. Instead of calling filterByNameAndVersion only for 'TripConfirmSurvey' and then storing those under the key of 'SURVEY', it first calls `resolveSurveyButtonConfig` to get *all* possible 'trip-label' surveys, iterates over those surveys calling filterByNameAndVersion for each, and stores unprocessed responses using the name of the survey as the key. See types updated in LabelTabContext.ts. appConfigTypes.ts updated to reflect that showsIf is optional (if not present, that survey shows unconditionally) --- www/js/diary/LabelTabContext.ts | 12 +++--- www/js/diary/timelineHelper.ts | 16 ++++++-- www/js/survey/enketo/UserInputButton.tsx | 41 +++++++++---------- www/js/survey/enketo/conditionalSurveys.ts | 16 +++----- www/js/survey/enketo/enketoHelper.ts | 28 ++++++++++++- .../survey/enketo/infinite_scroll_filters.ts | 3 +- www/js/survey/inputMatcher.ts | 18 ++++---- www/js/types/appConfigTypes.ts | 2 +- 8 files changed, 85 insertions(+), 51 deletions(-) diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 2feb0cce7..46b58b95e 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -5,13 +5,13 @@ import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper'; import { VehicleIdentity } from '../types/appConfigTypes'; export type UserInputMap = { - /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input - value will have the raw 'xmlResponse' string */ - SURVEY?: EnketoUserInputEntry; -} & { - /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration - and will have the 'label' string but no 'xmlResponse' string */ + /* If keys are 'MODE', 'PURPOSE', 'REPLACED_MODE', this is the MULTILABEL configuration. + Values are entries that have a 'label' value in their 'data' */ [k in MultilabelKey]?: UserInputEntry; +} & { + /* Otherwise we are in the ENKETO configuration, and keys are names of surveys. + Values are entries that have an 'xmlResponse' value in their 'data' */ + [k: string]: EnketoUserInputEntry | undefined; }; export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f850f0074..85b507780 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -21,7 +21,11 @@ import { } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; -import { EnketoUserInputEntry, filterByNameAndVersion } from '../survey/enketo/enketoHelper'; +import { + EnketoUserInputEntry, + filterByNameAndVersion, + resolveSurveyButtonConfig, +} from '../survey/enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; import { Point, Feature } from 'geojson'; import { ble_matching } from 'e-mission-common'; @@ -91,7 +95,8 @@ export function compositeTrips2TimelineMap(ctList: Array, unpackPlaces?: bo } /* 'LABELS' are 1:1 - each trip or place has a single label for each label type - (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */ + (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or the name of the survey + for ENKETO configuration) */ export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; /* 'NOTES' are 1:n - each trip or place can have any number of notes */ export let unprocessedNotes: EnketoUserInputEntry[] = []; @@ -117,8 +122,11 @@ function updateUnprocessedInputs( // fill in the unprocessedLabels object with the labels we just read labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - const filtered = filterByNameAndVersion('TripConfirmSurvey', r, appConfig); - unprocessedLabels['SURVEY'] = filtered as UserInputEntry[]; + const tripSurveys = resolveSurveyButtonConfig(appConfig, 'trip-label'); + tripSurveys.forEach((survey) => { + const filtered = filterByNameAndVersion(survey.surveyName, r, appConfig); + unprocessedLabels[survey.surveyName] = filtered as UserInputEntry[]; + }); } else { unprocessedLabels[getLabelInputs()[i]] = r; } diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index e3e629bda..de66d5d7f 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -18,6 +18,8 @@ import LabelTabContext from '../../diary/LabelTabContext'; import useAppConfig from '../../useAppConfig'; import { getSurveyForTimelineEntry } from './conditionalSurveys'; import useDerivedProperties from '../../diary/useDerivedProperties'; +import { resolveSurveyButtonConfig } from './enketoHelper'; +import { SurveyButtonConfig } from '../../types/appConfigTypes'; type Props = { timelineEntry: any; @@ -33,28 +35,25 @@ const UserInputButton = ({ timelineEntry }: Props) => { const derivedTripProps = useDerivedProperties(timelineEntry); // which survey will this button launch? - const [surveyName, notFilledInLabel] = useMemo(() => { - if (!appConfig) return []; // no config loaded yet; show blank for now - const tripLabelConfig = appConfig?.survey_info?.buttons?.['trip-label']; - if (!tripLabelConfig) { - // config doesn't specify; use default - return ['TripConfirmSurvey', t('diary.choose-survey')]; - } - // config lists one or more surveys; find which one to use - const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry, derivedTripProps); - const lang = i18n.resolvedLanguage || 'en'; - return [s?.surveyName, s?.['not-filled-in-label'][lang]]; + const survey = useMemo(() => { + if (!appConfig) return null; // no config loaded yet; show blank for now + const possibleSurveysForButton = resolveSurveyButtonConfig(appConfig, 'trip-label'); + // if there is only one survey, no need to check further + if (possibleSurveysForButton.length == 1) return possibleSurveysForButton[0]; + // config lists one or more surveys; find which one to use for this timeline entry + return getSurveyForTimelineEntry(possibleSurveysForButton, timelineEntry, derivedTripProps); }, [appConfig, timelineEntry, i18n.resolvedLanguage]); - // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo( - () => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined, - [userInputFor(timelineEntry)?.['SURVEY']?.data.label], - ); + // the label resolved from the survey response, or undefined if there is no response yet + const responseLabel = useMemo(() => { + if (!survey) return undefined; + return userInputFor(timelineEntry)?.[survey.surveyName]?.data.label || undefined; + }, [survey, userInputFor(timelineEntry)?.[survey?.surveyName || '']?.data.label]); function launchUserInputSurvey() { + if (!survey) return displayErrorMsg('UserInputButton: no survey to launch'); logDebug('UserInputButton: About to launch survey'); - const prevResponse = userInputFor(timelineEntry)?.['SURVEY']; + const prevResponse = userInputFor(timelineEntry)?.[survey.surveyName]; if (prevResponse?.data?.xmlResponse) { setPrevSurveyResponse(prevResponse.data.xmlResponse); } @@ -65,27 +64,27 @@ const UserInputButton = ({ timelineEntry }: Props) => { if (result) { logDebug(`UserInputButton: response was saved, about to addUserInputToEntry; result = ${JSON.stringify(result)}`); - addUserInputToEntry(timelineEntry._id.$oid, { SURVEY: result }, 'label'); + addUserInputToEntry(timelineEntry._id.$oid, { [result.name]: result }, 'label'); } else { displayErrorMsg('UserInputButton: response was not saved, result=', result); } } - if (!surveyName) return <>; // no survey to launch + if (!survey) return <>; // no survey to launch return ( <> launchUserInputSurvey()}> - {responseLabel || notFilledInLabel} + {responseLabel || survey['not-filled-in-label'][i18n.resolvedLanguage || 'en']} setModalVisible(false)} onResponseSaved={onResponseSaved} - surveyName={surveyName} + surveyName={survey.surveyName} opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }} /> diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts index 607b49431..a96ee2de8 100644 --- a/www/js/survey/enketo/conditionalSurveys.ts +++ b/www/js/survey/enketo/conditionalSurveys.ts @@ -29,26 +29,22 @@ const scopedEval = (script: string, scope: { [k: string]: any }) => // the first survey in the list that passes its condition will be returned export function getSurveyForTimelineEntry( - tripLabelConfig: SurveyButtonConfig | SurveyButtonConfig[], + possibleSurveys: SurveyButtonConfig[], tlEntry: TimelineEntry, derivedProperties: DerivedProperties, ) { - // if only one survey is given, just return it - if (!(tripLabelConfig instanceof Array)) return tripLabelConfig; - if (tripLabelConfig.length == 1) return tripLabelConfig[0]; - // else we have an array of possible surveys, we need to find which one to use for this entry - for (let surveyConfig of tripLabelConfig) { - if (!surveyConfig.showsIf) return surveyConfig; // survey shows unconditionally + for (let survey of possibleSurveys) { + if (!survey.showsIf) return survey; // survey shows unconditionally const scope = { ...tlEntry, ...derivedProperties, ...conditionalSurveyFunctions, }; try { - const evalResult = scopedEval(surveyConfig.showsIf, scope); - if (evalResult) return surveyConfig; + const evalResult = scopedEval(survey.showsIf, scope); + if (evalResult) return survey; } catch (e) { - displayError(e, `Error evaluating survey condition "${surveyConfig.showsIf}"`); + displayError(e, `Error evaluating survey condition "${survey.showsIf}"`); } } // TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default? diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 2df2d3b2d..e90354856 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -8,7 +8,7 @@ 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 { AppConfig, EnketoSurveyConfig, SurveyButtonConfig } from '../../types/appConfigTypes'; import { CompositeTrip, ConfirmedPlace, @@ -315,6 +315,32 @@ export function loadPreviousResponseForSurvey(dataKey: string) { ); } +/** + * @description Returns an array of surveys that could be prompted for one button in the UI (trip label, trip notes, place label, or place notes) + * (If multiple are returned, they will show conditionally in the UI based on their `showsIf` field) + * Includes backwards compats for app config fields that didn't use to exist + */ +export function resolveSurveyButtonConfig( + config: AppConfig, + button: 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes', +): SurveyButtonConfig[] { + const buttonConfig = config.survey_info.buttons?.[button]; + // backwards compat: default to the trip confirm survey if this button isn't configured + if (!buttonConfig) { + return [ + { + surveyName: 'TripConfirmSurvey', + 'not-filled-in-label': { + en: 'Add Trip Details', + es: 'Agregar detalles del viaje', + lo: 'ເພີ່ມລາຍລະອຽດການເດີນທາງ', + }, + }, + ]; + } + return buttonConfig instanceof Array ? buttonConfig : [buttonConfig]; +} + export async function fetchSurvey(url: string) { const responseText = await fetchUrlCached(url); if (!responseText) return; diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index d4b281713..512b272c4 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -8,7 +8,8 @@ import i18next from 'i18next'; -const unlabeledCheck = (trip, userInputForTrip) => !userInputForTrip?.['SURVEY']; +const unlabeledCheck = (trip, userInputForTrip) => + !userInputForTrip || !Object.values(userInputForTrip).some((input) => input); const TO_LABEL = { key: 'to_label', diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index b1460194e..a8d518d5f 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -18,7 +18,7 @@ import { inputType2retKey, removeManualPrefix, } from './multilabel/confirmHelper'; -import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; +import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../diary/LabelTabContext'; import { MultilabelKey } from '../types/labelTypes'; import { EnketoUserInputEntry } from './enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; @@ -280,16 +280,16 @@ export function mapInputsToTimelineEntries( allEntries.forEach((tlEntry, i) => { const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') { - // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs + // ENKETO configuration: consider reponses from all surveys in unprocessedLabels const userInputForTrip = getUserInputForTimelineEntry( tlEntry, nextEntry, - unprocessedLabels['SURVEY'], + Object.values(unprocessedLabels).flat(1), ) as EnketoUserInputEntry; if (userInputForTrip) { - timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + timelineLabelMap[tlEntry._id.$oid] = { [userInputForTrip.data.name]: userInputForTrip }; } else { - let processedSurveyResponse; + let processedSurveyResponse: EnketoUserInputEntry | undefined; for (const dataKey of keysForLabelInputs(appConfig)) { const key = removeManualPrefix(dataKey); if (tlEntry.user_input?.[key]) { @@ -297,12 +297,16 @@ export function mapInputsToTimelineEntries( break; } } - timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse }; + if (processedSurveyResponse) { + timelineLabelMap[tlEntry._id.$oid] = { + [processedSurveyResponse.data.name]: processedSurveyResponse, + }; + } } } else { // MULTILABEL configuration: use the label inputs from the labelOptions to determine which // keys to look for in the unprocessedInputs - const labelsForTrip: { [k: string]: UserInputEntry | undefined } = {}; + const labelsForTrip: UserInputMap = {}; Object.keys(getLabelInputDetails(appConfig)).forEach((label: MultilabelKey) => { // Check unprocessed labels first since they are more recent const userInputForTrip = getUserInputForTimelineEntry( diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index d5a15fe4a..e58b679f5 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -54,7 +54,7 @@ export type SurveyButtonConfig = { 'not-filled-in-label': { [lang: string]: string; }; - showsIf: string; // a JS expression that evaluates to a boolean + showsIf?: string; // a JS expression that evaluates to a boolean }; export type SurveyButtonsConfig = { [k in 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes']: From cb1e45b2ccdafc04ae8b9ec567a7d09fba6e9f93 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 May 2024 15:33:56 -0400 Subject: [PATCH 2/4] don't filter deleted additions twice I found an edge case where if an old addition (ie one that was already processed) was edited, there would be duplicates; both the old and new version would show up (until the new one got processed). This is because we were first applying the 'not deleted' filter to unprocessed entries. Then after they are merged with processed entreis we applied it to the merged copy. This didn't work for the edge case because the DELETED entry was already filtered out the first time, so the processed original entry just sticks around. This fix ensures that the 'not deleted' filter ONLY happens once processed+unprocessed are merged together, this way the filter considers all entries at once --- www/js/survey/inputMatcher.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index a8d518d5f..bd7e861e6 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -216,9 +216,8 @@ export function getAdditionsForTimelineEntry( return []; } - // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => + // filter out additions that do not start within the bounds of the timeline entry + const matchingAdditions = additionsList.filter((ui) => validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), ); From 63357d1b25092cf73f9722c0236de431ab7d0758 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 May 2024 16:40:16 -0400 Subject: [PATCH 3/4] fix inputMatcher and timelineHelper tests 02655ba28913a06caa80795aa3a07a4b15d06ef3 changed from using 'SURVEY' as the key for unprocessed user inputs to using the name of the survey These tests still expected 'SURVEY'; replaced with the survey name. The existing test only had 'TripConfirmSurvey'; I also changed one to 'MyCustomSurvey' to highlight that they will be stored under separate fields now. inputMatcher.test.ts tests on both MULTILABEL and ENKETO configurations. It is necessary to call updateUnprocessedInputs after testing MULTILABEL and before testing ENKETO so that unprocessed MULTILABEL inputs are not kept it memory. It is also necessary for updateUnprocessedInputs to clear unprocessedLabels before re-filling it; otherwise survey responses could linger from a previous time the function was called. Previously all responses were kept in SURVEY, so SURVEY would always get reassigned. but now each survey's responses are kept under the survey name as a key --- www/__tests__/inputMatcher.test.ts | 22 ++++++++++++++-------- www/__tests__/timelineHelper.test.ts | 4 ++-- www/js/diary/timelineHelper.ts | 1 + 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 062951b35..9ea4d9b02 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,6 +1,6 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { unprocessedLabels, updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; +import { updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; import * as logger from '../js/plugin/logger'; import { EnketoUserInputEntry } from '../js/survey/enketo/enketoHelper'; import { @@ -376,9 +376,9 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { user_input: { trip_user_input: { data: { - name: 'TripConfirmSurvey', + name: 'MyCustomSurvey', version: 1, - xmlResponse: '', + xmlResponse: '', start_ts: 1000, end_ts: 3000, }, @@ -417,6 +417,12 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { ], }, ] as any as TimelineEntry[]; + + // reset local unprocessed inputs to ensure MUTLILABEL inputs don't leak into ENKETO tests + beforeAll(async () => { + await updateLocalUnprocessedInputs({ start_ts: 1000, end_ts: 5000 }, fakeConfigEnketo); + }); + it('creates a map that has the processed responses and notes', () => { const [labelMap, notesMap] = mapInputsToTimelineEntries( timelineEntriesEnketo, @@ -424,8 +430,8 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { ); expect(labelMap).toMatchObject({ trip1: { - SURVEY: { - data: { xmlResponse: '' }, + MyCustomSurvey: { + data: { xmlResponse: '' }, }, }, }); @@ -460,12 +466,12 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { expect(labelMap).toMatchObject({ trip1: { - SURVEY: { - data: { xmlResponse: '' }, + MyCustomSurvey: { + data: { xmlResponse: '' }, }, }, trip2: { - SURVEY: { + TripConfirmSurvey: { data: { xmlResponse: '' }, }, }, diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index c40262aae..c1c130272 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -165,10 +165,10 @@ describe('unprocessedLabels, unprocessedNotes', () => { // update unprocessed inputs and check that the trip survey response shows up in unprocessedLabels await updateAllUnprocessedInputs({ start_ts: 4, end_ts: 6 }, mockTLH.mockConfigEnketo); - expect(unprocessedLabels['SURVEY'][0].data).toEqual(tripSurveyResponse); + expect(unprocessedLabels['TripConfirmSurvey'][0].data).toEqual(tripSurveyResponse); // the second response is ignored for now because we haven't enabled place_user_input yet // so the length is only 1 - expect(unprocessedLabels['SURVEY'].length).toEqual(1); + expect(unprocessedLabels['TripConfirmSurvey'].length).toEqual(1); }); it('has some trip- and place- level additions after they were just recorded', async () => { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 85b507780..3dae4f370 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -120,6 +120,7 @@ function updateUnprocessedInputs( const labelResults = comboResults.slice(0, labelsPromises.length); const notesResults = comboResults.slice(labelsPromises.length).flat(2); // fill in the unprocessedLabels object with the labels we just read + unprocessedLabels = {}; labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { const tripSurveys = resolveSurveyButtonConfig(appConfig, 'trip-label'); From 2761e19bb42f2f8a9fa818b3b0406feae462f2a5 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 May 2024 16:45:28 -0400 Subject: [PATCH 4/4] remove 'choose-survey' from en.json Trip and place surveys give their own button text in the config now, so this text + translations relocated to the backwards-compat in enketoHelper > resolveSurveyButtonConfig --- www/i18n/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9a8b6bb61..2834219af 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -136,7 +136,6 @@ "choose-mode": "Mode", "choose-replaced-mode": "Replaces", "choose-purpose": "Purpose", - "choose-survey": "Add Trip Details", "select-mode-scroll": "Mode (scroll for more)", "select-replaced-mode-scroll": "Replaces (scroll for more)", "select-purpose-scroll": "Purpose (scroll for more)",