diff --git a/jest.config.js b/jest.config.js index 73521d81e..c47992ee5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,4 +23,6 @@ module.exports = { "!www/js/**/index.{ts,tsx,js,jsx}", "!www/js/types/**/*.{ts,tsx,js,jsx}", ], + // several functions in commHelper do not have unit tests; see note in commHelper.test.ts + coveragePathIgnorePatterns: ['www/js/services/commHelper.ts'], }; diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index d7018abb5..c66fb5f36 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -47,4 +47,8 @@ it('fetches text from a URL and caches it so the next call is faster', async () * - updateUser * - getUser * - putOne + * - getUserCustomLabels + * - insertUserCustomLabel + * - updateUserCustomLabel + * - deleteUserCustomLabel */ diff --git a/www/i18n/en.json b/www/i18n/en.json index 7347f8e46..738df38f5 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -8,7 +8,10 @@ "trip-confirm": { "services-please-fill-in": "Please fill in the {{text}} not listed.", "services-cancel": "Cancel", - "services-save": "Save" + "services-save": "Save", + "custom-mode": "Custom Mode", + "custom-purpose": "Custom Purpose", + "custom-labels": "Custom Labels" }, "control": { @@ -52,7 +55,8 @@ "refresh-app-config": "Refresh App Configuration", "current-version": "Current version: {{version}}", "refreshing-app-config": "Refreshing app configuration, please wait...", - "already-up-to-date": "Already up to date!" + "already-up-to-date": "Already up to date!", + "manage-custom-labels": "Manage Custom Labels" }, "general-settings": { diff --git a/www/js/App.tsx b/www/js/App.tsx index d59d7f270..0d45ebeca 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -13,16 +13,22 @@ import usePermissionStatus from './usePermissionStatus'; import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; +import { getUserCustomLabels } from './services/commHelper'; import { initCustomDatasetHelper } from './metrics/customMetricsHelper'; import AlertBar from './components/AlertBar'; import Main from './Main'; export const AppContext = createContext({}); +const CUSTOM_LABEL_KEYS_IN_DATABASE = ['mode', 'purpose']; +type CustomLabelMap = { + [k: string]: string[]; +}; const App = () => { // will remain null while the onboarding state is still being determined const [onboardingState, setOnboardingState] = useState(null); const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); + const [customLabelMap, setCustomLabelMap] = useState({}); const appConfig = useAppConfig(); const permissionStatus = usePermissionStatus(); @@ -39,6 +45,7 @@ const App = () => { initPushNotify(); initStoreDeviceSettings(); initRemoteNotifyHandler(); + getUserCustomLabels(CUSTOM_LABEL_KEYS_IN_DATABASE).then((res) => setCustomLabelMap(res)); initCustomDatasetHelper(appConfig); }, [appConfig]); @@ -50,6 +57,8 @@ const App = () => { permissionStatus, permissionsPopupVis, setPermissionsPopupVis, + customLabelMap, + setCustomLabelMap, }; let appContent; diff --git a/www/js/control/CustomLabelSettingRow.tsx b/www/js/control/CustomLabelSettingRow.tsx new file mode 100644 index 000000000..c106d0985 --- /dev/null +++ b/www/js/control/CustomLabelSettingRow.tsx @@ -0,0 +1,166 @@ +import React, { useState, useContext } from 'react'; +import SettingRow from './SettingRow'; +import { + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, + useWindowDimensions, + ScrollView, +} from 'react-native'; +import { Icon, TextInput, Dialog, Button, useTheme, SegmentedButtons } from 'react-native-paper'; +import { AppContext } from '../App'; +import { useTranslation } from 'react-i18next'; +import { deleteUserCustomLabel, insertUserCustomLabel } from '../services/commHelper'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { labelKeyToReadable, readableLabelToKey } from '../survey/multilabel/confirmHelper'; + +const CustomLabelSettingRow = () => { + const [isCustomLabelModalOpen, setIsCustomLabelModalOpen] = useState(false); + const { customLabelMap, setCustomLabelMap } = useContext(AppContext); + const [isAddLabelOn, setIsAddLabelOn] = useState(false); + const [text, setText] = useState(''); + const [key, setKey] = useState('mode'); + + const { t } = useTranslation(); //this accesses the translations + const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors + const { height } = useWindowDimensions(); + + const labelKeysButton = [ + { + value: 'mode', + label: t('diary.mode'), + }, + { + value: 'purpose', + label: t('diary.purpose'), + }, + ]; + + const onDeleteLabel = async (label) => { + const processedLabel = readableLabelToKey(label); + try { + const res = await deleteUserCustomLabel(key, processedLabel); + if (res) { + setCustomLabelMap({ + ...customLabelMap, + [key]: res['label'], + }); + logDebug(`Successfuly deleted custom ${key}, ${JSON.stringify(res)}`); + } + } catch (e) { + displayErrorMsg(e, 'Delete Mode Error'); + } + }; + + const onSaveLabel = async () => { + const processedLabel = readableLabelToKey(text); + if (customLabelMap[key]?.length > 0 && customLabelMap[key].indexOf(processedLabel) > -1) { + return; + } + try { + const res = await insertUserCustomLabel(key, processedLabel); + if (res) { + setText(''); + setCustomLabelMap({ + ...customLabelMap, + [key]: res['label'], + }); + setIsAddLabelOn(false); + logDebug(`Successfuly inserted custom ${key}, ${JSON.stringify(res)}`); + } + } catch (e) { + displayErrorMsg(e, 'Create Mode Error'); + } + }; + + return ( + <> + setIsCustomLabelModalOpen(true)}> + setIsCustomLabelModalOpen(false)} + transparent={true}> + setIsCustomLabelModalOpen(false)}> + + {t('trip-confirm.custom-labels')} + setIsAddLabelOn(true)}> + + + + + + {isAddLabelOn && ( + <> + + + + + + + )} + + {customLabelMap[key]?.length > 0 && + customLabelMap[key].map((label, idx) => { + return ( + + {labelKeyToReadable(label)} + onDeleteLabel(label)}> + + + + ); + })} + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + itemWrapper: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 16, + borderBottomWidth: 1, + }, + saveButtonWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + plusIconWrapper: { + position: 'absolute', + right: 0, + }, +}); + +export default CustomLabelSettingRow; diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index 264ba8ba0..ef61e0a24 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -37,6 +37,7 @@ import { storageClear } from '../plugin/storage'; import { getAppVersion } from '../plugin/clientStats'; import { getConsentDocument } from '../splash/startprefs'; import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import CustomLabelSettingRow from './CustomLabelSettingRow'; import { fetchOPCode, getSettings } from '../services/controlHelper'; import { updateScheduledNotifs, @@ -425,6 +426,7 @@ const ProfileSettings = () => { desc={authSettings.opcode} descStyle={settingStyles.monoDesc}> + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && } ((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/get', + 'keys', + keys, + rs, + rj, + ); + }).catch((error) => { + error = 'While getting labels, ' + error; + throw error; + }); +} + +export function insertUserCustomLabel(key, newLabel) { + const insertedLabel = { + key: key, + label: newLabel, + }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/insert', + 'inserted_label', + insertedLabel, + rs, + rj, + ); + }).catch((error) => { + error = `While inserting one ${key}, ${error}`; + throw error; + }); +} + +export function updateUserCustomLabel(key, oldLabel, newLabel, isNewLabelMustAdded) { + const updatedLabel = { + key: key, + old_label: oldLabel, + new_label: newLabel, + is_new_label_must_added: isNewLabelMustAdded, + }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/update', + 'updated_label', + updatedLabel, + rs, + rj, + ); + }).catch((error) => { + error = `While updating one ${key}, ${error}`; + throw error; + }); +} + +export function deleteUserCustomLabel(key, newLabel) { + const deletedLabel = { + key: key, + label: newLabel, + }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/delete', + 'deleted_label', + deletedLabel, + rs, + rj, + ); + }).catch((error) => { + error = `While deleting one ${key}, ${error}`; + throw error; + }); +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 6c4c876ce..466bb9868 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -12,6 +12,7 @@ import { RadioButton, Button, TextInput, + Divider, } from 'react-native-paper'; import DiaryButton from '../../components/DiaryButton'; import { useTranslation } from 'react-i18next'; @@ -29,22 +30,23 @@ import { } from './confirmHelper'; import useAppConfig from '../../useAppConfig'; import { MultilabelKey } from '../../types/labelTypes'; +import { updateUserCustomLabel } from '../../services/commHelper'; +import { AppContext } from '../../App'; const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); const { t } = useTranslation(); const appConfig = useAppConfig(); const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(TimelineContext); + const { customLabelMap, setCustomLabelMap } = useContext(AppContext); const { height: windowHeight } = useWindowDimensions(); - // modal visible for which input type? (MODE or PURPOSE or REPLACED_MODE, null if not visible) const [modalVisibleFor, setModalVisibleFor] = useState(null); const [otherLabel, setOtherLabel] = useState(null); - const chosenLabel = useMemo(() => { + const initialLabel = useMemo(() => { if (modalVisibleFor == null) return null; - if (otherLabel != null) return 'other'; return labelFor(trip, modalVisibleFor)?.value || null; - }, [modalVisibleFor, otherLabel]); + }, [modalVisibleFor]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { @@ -81,16 +83,36 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { if (!Object.keys(inputs).length) return displayErrorMsg('No inputs to store'); const inputsToStore: UserInputMap = {}; const storePromises: any[] = []; - for (let [inputType, chosenLabel] of Object.entries(inputs)) { + + for (let [inputType, newLabel] of Object.entries(inputs)) { if (isOther) { /* Let's make the value for user entered inputs look consistent with our other values (i.e. lowercase, and with underscores instead of spaces) */ - chosenLabel = readableLabelToKey(chosenLabel); + newLabel = readableLabelToKey(newLabel); + } + // If a user saves a new customized label or makes changes to/from customized labels, the labels need to be updated. + const key = inputType.toLowerCase(); + if ( + isOther || + (initialLabel && customLabelMap[key].indexOf(initialLabel) > -1) || + (newLabel && customLabelMap[key].indexOf(newLabel) > -1) + ) { + updateUserCustomLabel(key, initialLabel ?? '', newLabel, isOther ?? false) + .then((res) => { + setCustomLabelMap({ + ...customLabelMap, + [key]: res['label'], + }); + logDebug('Successfuly stored custom label ' + JSON.stringify(res)); + }) + .catch((e) => { + displayErrorMsg(e, 'Create Label Error'); + }); } const inputDataToStore = { start_ts: trip.start_ts, end_ts: trip.end_ts, - label: chosenLabel, + label: newLabel, }; inputsToStore[inputType] = inputDataToStore; @@ -107,6 +129,8 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { } const tripInputDetails = labelInputDetailsForTrip(userInputFor(trip), appConfig); + const customLabelKeyInDatabase = modalVisibleFor === 'PURPOSE' ? 'purpose' : 'mode'; + return ( <> @@ -164,16 +188,47 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { onChooseLabel(val)} - value={chosenLabel || ''}> + // if 'other' button is selected and input component shows up, make 'other' radio button filled + value={otherLabel !== null ? 'other' : initialLabel || ''}> {modalVisibleFor && - labelOptions?.[modalVisibleFor]?.map((o, i) => ( - - ))} + labelOptions?.[modalVisibleFor]?.map((o, i) => { + const radioItemForOption = ( + + ); + /* if this is the 'other' option and there are some custom labels, + show the custom labels section before 'other' */ + if (o.value == 'other' && customLabelMap[customLabelKeyInDatabase]?.length) { + return ( + <> + + + {(modalVisibleFor === 'MODE' || + modalVisibleFor === 'REPLACED_MODE') && + t('trip-confirm.custom-mode')} + {modalVisibleFor === 'PURPOSE' && t('trip-confirm.custom-purpose')} + + {customLabelMap[customLabelKeyInDatabase].map((key, i) => ( + + ))} + + {radioItemForOption} + + ); + } + // otherwise, just show the radio item as normal + return radioItemForOption; + })} @@ -185,6 +240,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { })} value={otherLabel || ''} onChangeText={(t) => setOtherLabel(t)} + maxLength={25} />