Skip to content

Commit

Permalink
Merge pull request #1129 from jiji14/conditional-surveys-gpg
Browse files Browse the repository at this point in the history
📝 Conditional Surveys depending on trip characteristics
  • Loading branch information
shankari authored Mar 27, 2024
2 parents c61ed71 + e0e2d75 commit d0ed16b
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 7 deletions.
26 changes: 20 additions & 6 deletions www/js/survey/enketo/UserInputButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,34 @@ import { useTheme } from 'react-native-paper';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import EnketoModal from './EnketoModal';
import LabelTabContext from '../../diary/LabelTabContext';
import useAppConfig from '../../useAppConfig';
import { getSurveyForTimelineEntry } from './conditionalSurveys';

type Props = {
timelineEntry: any;
};
const UserInputButton = ({ timelineEntry }: Props) => {
const { colors } = useTheme();
const appConfig = useAppConfig();
const { t, i18n } = useTranslation();

const [prevSurveyResponse, setPrevSurveyResponse] = useState<string | undefined>(undefined);
const [modalVisible, setModalVisible] = useState(false);
const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext);

// which survey will this button launch?
const [surveyName, notFilledInLabel] = useMemo(() => {
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);
const lang = i18n.resolvedLanguage || 'en';
return [s?.surveyName, s?.['not-filled-in-label'][lang]];
}, [appConfig, timelineEntry, i18n.resolvedLanguage]);

// the label resolved from the survey response, or null if there is no response yet
const responseLabel = useMemo<string | undefined>(
() => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined,
Expand All @@ -52,23 +68,21 @@ const UserInputButton = ({ timelineEntry }: Props) => {
}
}

if (!surveyName) return <></>; // no survey to launch
return (
<>
<DiaryButton
// if a response has been been recorded, the button is 'filled in'
fillColor={responseLabel && colors.primary}
onPress={() => launchUserInputSurvey()}>
{/* if no response yet, show the default label */}
{responseLabel || t('diary.choose-survey')}
{responseLabel || notFilledInLabel}
</DiaryButton>

<EnketoModal
visible={modalVisible}
onDismiss={() => setModalVisible(false)}
onResponseSaved={onResponseSaved}
surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded.
In the future, if we ever implement something like
a "Place Details" survey, we may want to make this
configurable. */
surveyName={surveyName}
opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }}
/>
</>
Expand Down
54 changes: 54 additions & 0 deletions www/js/survey/enketo/conditionalSurveys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { displayError } from '../../plugin/logger';
import { SurveyButtonConfig } from '../../types/appConfigTypes';
import { TimelineEntry } from '../../types/diaryTypes';
import { Position } from 'geojson';

const conditionalSurveyFunctions = {
/**
@description Returns true if the given point is within the given bounds.
Coordinates are in [longitude, latitude] order, since that is the GeoJSON spec.
@param pt point to check as [lon, lat]
@param bounds NW and SE corners as [[lon, lat], [lon, lat]]
@returns true if pt is within bounds
*/
pointIsWithinBounds: (pt: Position, bounds: Position[]) => {
// pt's lon must be east of, or greater than, NW's lon; and west of, or less than, SE's lon
const lonInRange = pt[0] > bounds[0][0] && pt[0] < bounds[1][0];
// pt's lat must be south of, or less than, NW's lat; and north of, or greater than, SE's lat
const latInRange = pt[1] < bounds[0][1] && pt[1] > bounds[1][1];
return latInRange && lonInRange;
},
};

/**
* @description Executes a JS expression `script` in a restricted `scope`
* @example scopedEval('console.log(foo)', { foo: 'bar' })
*/
const scopedEval = (script: string, scope: { [k: string]: any }) =>
Function(...Object.keys(scope), `return ${script}`)(...Object.values(scope));

// the first survey in the list that passes its condition will be returned
export function getSurveyForTimelineEntry(
tripLabelConfig: SurveyButtonConfig | SurveyButtonConfig[],
tlEntry: TimelineEntry,
) {
// 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
const scope = {
...tlEntry,
...conditionalSurveyFunctions,
};
try {
const evalResult = scopedEval(surveyConfig.showsIf, scope);
if (evalResult) return surveyConfig;
} catch (e) {
displayError(e, `Error evaluating survey condition "${surveyConfig.showsIf}"`);
}
}
// TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default?
return null;
}
15 changes: 14 additions & 1 deletion www/js/types/appConfigTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type AppConfig = {
survey_info: {
'trip-labels': 'MULTILABEL' | 'ENKETO';
surveys: EnketoSurveyConfig;
buttons?: any;
buttons?: SurveyButtonsConfig;
};
reminderSchemes?: ReminderSchemesConfig;
[k: string]: any; // TODO fill in all the other fields
Expand Down Expand Up @@ -44,6 +44,19 @@ export type EnketoSurveyConfig = {
};
};

export type SurveyButtonConfig = {
surveyName: string;
'not-filled-in-label': {
[lang: string]: string;
};
showsIf: string; // a JS expression that evaluates to a boolean
};
export type SurveyButtonsConfig = {
[k in 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes']:
| SurveyButtonConfig
| SurveyButtonConfig[];
};

export type ReminderSchemesConfig = {
[schemeKey: string]: {
title: { [lang: string]: string };
Expand Down

0 comments on commit d0ed16b

Please sign in to comment.