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<any>({}); +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<OnboardingState | null>(null); const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); + const [customLabelMap, setCustomLabelMap] = useState<CustomLabelMap>({}); 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 ( + <> + <SettingRow + textKey="control.manage-custom-labels" + iconName="label-multiple" + action={() => setIsCustomLabelModalOpen(true)}></SettingRow> + <Modal + visible={isCustomLabelModalOpen} + onDismiss={() => setIsCustomLabelModalOpen(false)} + transparent={true}> + <Dialog visible={isCustomLabelModalOpen} onDismiss={() => setIsCustomLabelModalOpen(false)}> + <Dialog.Title> + <Text>{t('trip-confirm.custom-labels')}</Text> + <TouchableOpacity style={styles.plusIconWrapper} onPress={() => setIsAddLabelOn(true)}> + <Icon source="plus-circle" size={24} /> + </TouchableOpacity> + </Dialog.Title> + <Dialog.Content> + <SegmentedButtons + style={{ marginBottom: 10 }} + value={key} + onValueChange={setKey} + buttons={labelKeysButton} + /> + {isAddLabelOn && ( + <> + <TextInput + label={t('trip-confirm.services-please-fill-in', { + text: key, + })} + value={text} + onChangeText={setText} + maxLength={25} + style={{ marginTop: 10 }} + /> + <View style={styles.saveButtonWrapper}> + <Button onPress={() => setIsAddLabelOn(false)}> + {t('trip-confirm.services-cancel')} + </Button> + <Button onPress={onSaveLabel}>{t('trip-confirm.services-save')}</Button> + </View> + </> + )} + <ScrollView contentContainerStyle={{ height: height / 2 }}> + {customLabelMap[key]?.length > 0 && + customLabelMap[key].map((label, idx) => { + return ( + <View + key={label + idx} + style={[styles.itemWrapper, { borderBottomColor: colors.outline }]}> + <Text>{labelKeyToReadable(label)}</Text> + <TouchableOpacity onPress={() => onDeleteLabel(label)}> + <Icon source="trash-can" size={20} /> + </TouchableOpacity> + </View> + ); + })} + </ScrollView> + </Dialog.Content> + <Dialog.Actions> + <Button onPress={() => setIsCustomLabelModalOpen(false)}> + {t('trip-confirm.services-cancel')} + </Button> + </Dialog.Actions> + </Dialog> + </Modal> + </> + ); +}; + +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}></SettingRow> <DemographicsSettingRow></DemographicsSettingRow> + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && <CustomLabelSettingRow />} <SettingRow textKey="control.view-privacy" iconName="eye" diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index 75fdbf8de..012089313 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -233,3 +233,77 @@ export function putOne(key, data) { throw error; }); } + +export function getUserCustomLabels(keys) { + return new Promise<any>((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<any>((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<MultilabelKey | null>(null); const [otherLabel, setOtherLabel] = useState<string | null>(null); - const chosenLabel = useMemo<string | null>(() => { + const initialLabel = useMemo<string | null>(() => { 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 ( <> <View style={{ flexDirection: 'row', alignItems: 'center' }}> @@ -164,16 +188,47 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { <ScrollView style={{ paddingBottom: 24 }}> <RadioButton.Group onValueChange={(val) => 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) => ( - <RadioButton.Item - key={i} - label={o.text || labelKeyToReadable(o.value)} - value={o.value} - style={{ paddingVertical: 2 }} - /> - ))} + labelOptions?.[modalVisibleFor]?.map((o, i) => { + const radioItemForOption = ( + <RadioButton.Item + key={i} + label={o.text || labelKeyToReadable(o.value)} + value={o.value} + style={{ paddingVertical: 2 }} + /> + ); + /* 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 ( + <> + <Divider style={{ marginVertical: 10 }} /> + <Text + style={{ fontSize: 12, color: colors.onSurface, paddingVertical: 4 }}> + {(modalVisibleFor === 'MODE' || + modalVisibleFor === 'REPLACED_MODE') && + t('trip-confirm.custom-mode')} + {modalVisibleFor === 'PURPOSE' && t('trip-confirm.custom-purpose')} + </Text> + {customLabelMap[customLabelKeyInDatabase].map((key, i) => ( + <RadioButton.Item + key={i} + label={labelKeyToReadable(key)} + value={key} + style={{ paddingVertical: 2 }} + /> + ))} + <Divider style={{ marginVertical: 10 }} /> + {radioItemForOption} + </> + ); + } + // otherwise, just show the radio item as normal + return radioItemForOption; + })} </RadioButton.Group> </ScrollView> </Dialog.Content> @@ -185,6 +240,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { })} value={otherLabel || ''} onChangeText={(t) => setOtherLabel(t)} + maxLength={25} /> <Dialog.Actions> <Button onPress={() => store({ [modalVisibleFor]: otherLabel }, true)}>