Skip to content

Commit

Permalink
Merge pull request #1187 from JGreenlee/onboarding-redesign-oct2024
Browse files Browse the repository at this point in the history
Support `join` URLs to generate OPcodes on phone, Fix URL scheme, "Paste" improvements
  • Loading branch information
shankari authored Oct 23, 2024
2 parents 66f61e0 + 478815e commit ccc7aef
Show file tree
Hide file tree
Showing 15 changed files with 391 additions and 200 deletions.
2 changes: 2 additions & 0 deletions package.cordovabuild.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
},
"com.unarin.cordova.beacon": {},
"cordova-plugin-ionic-keyboard": {},
"cordova-clipboard": {},
"cordova-plugin-app-version": {},
"cordova-plugin-file": {},
"cordova-plugin-device": {},
Expand Down Expand Up @@ -116,6 +117,7 @@
"chartjs-plugin-annotation": "^3.0.1",
"com.unarin.cordova.beacon": "github:e-mission/cordova-plugin-ibeacon",
"cordova-android": "13.0.0",
"cordova-clipboard": "^1.3.0",
"cordova-ios": "7.1.1",
"cordova-plugin-advanced-http": "3.3.1",
"cordova-plugin-androidx-adapter": "1.1.3",
Expand Down
2 changes: 1 addition & 1 deletion setup/setup_native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|"

npx cordova prepare$PLATFORMS

EXPECTED_COUNT=25
EXPECTED_COUNT=26
INSTALLED_COUNT=`npx cordova plugin list | wc -l`
echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT"
if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ];
Expand Down
80 changes: 56 additions & 24 deletions www/__tests__/dynamicConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { getConfig, initByUser } from '../js/config/dynamicConfig';

import { getConfig, joinWithTokenOrUrl } from '../js/config/dynamicConfig';
import initializedI18next from '../js/i18nextInit';
import { storageClear } from '../js/plugin/storage';
import i18next from '../js/i18nextInit';

window['i18next'] = initializedI18next;

beforeEach(() => {
Expand Down Expand Up @@ -56,6 +57,8 @@ global.fetch = (url: string) => {
}) as any;
};

const windowAlert = jest.spyOn(window, 'alert').mockImplementation(() => {});

describe('dynamicConfig', () => {
const fakeStudyName = 'gotham-city-transit';
const validStudyNrelCommute = 'nrel-commute';
Expand All @@ -65,9 +68,9 @@ describe('dynamicConfig', () => {
it('should resolve with null since no config is set yet', async () => {
await expect(getConfig()).resolves.toBeNull();
});
it('should resolve with a valid config once initByUser is called for an nrel-commute token', async () => {
it('should resolve with a valid config once joinWithTokenOrUrl is called for an nrel-commute token', async () => {
const validToken = `nrelop_${validStudyNrelCommute}_user1`;
await initByUser({ token: validToken });
await joinWithTokenOrUrl(validToken);
const config = await getConfig();
expect(config!.server.connectUrl).toBe('https://nrel-commute-openpath.nrel.gov/api/');
expect(config!.joined).toEqual({
Expand All @@ -77,9 +80,9 @@ describe('dynamicConfig', () => {
});
});

it('should resolve with a valid config once initByUser is called for a denver-casr token', async () => {
it('should resolve with a valid config once joinWithTokenOrUrl is called for a denver-casr token', async () => {
const validToken = `nrelop_${validStudyDenverCasr}_test_user1`;
await initByUser({ token: validToken });
await joinWithTokenOrUrl(validToken);
const config = await getConfig();
expect(config!.server.connectUrl).toBe('https://denver-casr-openpath.nrel.gov/api/');
expect(config!.joined).toEqual({
Expand All @@ -90,39 +93,68 @@ describe('dynamicConfig', () => {
});
});

describe('initByUser', () => {
describe('joinWithTokenOrUrl', () => {
// fake study (gotham-city-transit)
it('should error if the study is nonexistent', async () => {
it('returns false if the study is nonexistent', async () => {
const fakeBatmanToken = `nrelop_${fakeStudyName}_batman`;
await expect(initByUser({ token: fakeBatmanToken })).rejects.toThrow();
await expect(joinWithTokenOrUrl(fakeBatmanToken)).resolves.toBe(false);
expect(windowAlert).toHaveBeenLastCalledWith(
expect.stringContaining(i18next.t('config.unable-download-config')),
);
});

// real study without subgroups (nrel-commute)
it('should error if the study exists but the token is invalid format', async () => {
const badToken1 = validStudyNrelCommute; // doesn't start with nrelop_
await expect(initByUser({ token: badToken1 })).rejects.toThrow();
const badToken2 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _
await expect(initByUser({ token: badToken2 })).rejects.toThrow();
const badToken3 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _
await expect(initByUser({ token: badToken3 })).rejects.toThrow();
it('returns false if the study exists but the token is invalid format', async () => {
const badToken1 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _
await expect(joinWithTokenOrUrl(badToken1)).resolves.toBe(false);
expect(windowAlert).toHaveBeenLastCalledWith(
expect.stringContaining(
i18next.t('config.not-enough-parts-old-style', { token: badToken1 }),
),
);

const badToken2 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _
await expect(joinWithTokenOrUrl(badToken2)).resolves.toBe(false);
expect(windowAlert).toHaveBeenLastCalledWith(
expect.stringContaining(
i18next.t('config.not-enough-parts-old-style', { token: badToken2 }),
),
);

const badToken3 = `invalid_${validStudyNrelCommute}_user3`; // doesn't start with nrelop_
await expect(joinWithTokenOrUrl(badToken3)).resolves.toBe(false);
expect(windowAlert).toHaveBeenLastCalledWith(
expect.stringContaining(i18next.t('config.no-nrelop-start', { token: badToken3 })),
);
});
it('should return true after successfully storing the config for a valid token', async () => {

it('returns true after successfully storing the config for a valid token', async () => {
const validToken = `nrelop_${validStudyNrelCommute}_user2`;
await expect(initByUser({ token: validToken })).resolves.toBe(true);
await expect(joinWithTokenOrUrl(validToken)).resolves.toBe(true);
});

// real study with subgroups (denver-casr)
it('should error if the study uses subgroups but the token has no subgroup', async () => {
it('returns false if the study uses subgroups but the token has no subgroup', async () => {
const tokenWithoutSubgroup = `nrelop_${validStudyDenverCasr}_user2`;
await expect(initByUser({ token: tokenWithoutSubgroup })).rejects.toThrow();
await expect(joinWithTokenOrUrl(tokenWithoutSubgroup)).resolves.toBe(false);
expect(windowAlert).toHaveBeenLastCalledWith(
expect.stringContaining(
i18next.t('config.not-enough-parts', { token: tokenWithoutSubgroup }),
),
);
});
it('should error if the study uses subgroups and the token is invalid format', async () => {
it('returns false if the study uses subgroups and the token is invalid format', async () => {
const badToken1 = `nrelop_${validStudyDenverCasr}_test_`; // doesn't have user code after last _
await expect(initByUser({ token: badToken1 })).rejects.toThrow();
await expect(joinWithTokenOrUrl(badToken1)).resolves.toBe(false);
expect(windowAlert).toHaveBeenLastCalledWith(
expect.stringContaining(
i18next.t('config.not-enough-parts-old-style', { token: badToken1 }),
),
);
});
it('should return true after successfully storing the config for a valid token with subgroup', async () => {
it('returns true after successfully storing the config for a valid token with subgroup', async () => {
const validToken = `nrelop_${validStudyDenverCasr}_test_user2`;
await expect(initByUser({ token: validToken })).resolves.toBe(true);
await expect(joinWithTokenOrUrl(validToken)).resolves.toBe(true);
});
});
});
81 changes: 81 additions & 0 deletions www/__tests__/opcode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// * @example getTokenFromUrl('https://open-access-openpath.nrel.gov/join/') => nrelop_open-access_default_randomLongStringWith32Characters
// * @example getTokenFromUrl('emission://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random
// * @example getTokenFromUrl('nrelopenpath://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random

import { getStudyNameFromToken, getSubgroupFromToken, getTokenFromUrl } from '../js/config/opcode';
import AppConfig from '../js/types/appConfigTypes';
describe('opcode', () => {
describe('getStudyNameFromToken', () => {
const token = 'nrelop_great-study_default_randomLongStringWith32Characters';
it('returns the study name from a token', () => {
expect(getStudyNameFromToken(token)).toBe('great-study');
});
});

describe('getSubgroupFromToken', () => {
const amazingSubgroupToken = 'nrelop_great-study_amazing-subgroup_000';
it('returns the subgroup from a token with valid subgroup', () => {
const fakeconfig = {
opcode: {
subgroups: ['amazing-subgroup', 'other-subgroup'],
},
} as any as AppConfig;
expect(getSubgroupFromToken(amazingSubgroupToken, fakeconfig)).toBe('amazing-subgroup');
});

it("throws error if token's subgroup is not in config", () => {
const fakeconfig = {
opcode: {
subgroups: ['sad-subgroup', 'other-subgroup'],
},
} as any as AppConfig;
expect(() => getSubgroupFromToken(amazingSubgroupToken, fakeconfig)).toThrow();
});

it("returns 'default' if token has 'default' and config is not configured with subgroups", () => {
const defaultSubgroupToken = 'nrelop_great-study_default_000';
const fakeconfig = {
opcode: {},
} as any as AppConfig;
expect(getSubgroupFromToken(defaultSubgroupToken, fakeconfig)).toBe('default');
});

it("throws error if token's subgroup is not 'default' and config is not configured with subgroups", () => {
const invalidSubgroupToken = 'nrelop_great-study_imaginary-subgroup_000';
const fakeconfig = {
opcode: {},
} as any as AppConfig;
expect(() => getSubgroupFromToken(invalidSubgroupToken, fakeconfig)).toThrow();
});
});

describe('getTokenFromUrl', () => {
it('generates a token for an nrel.gov join page URL', () => {
const url = 'https://open-access-openpath.nrel.gov/join/';
expect(getTokenFromUrl(url)).toMatch(/^nrelop_open-access_default_[a-zA-Z0-9]{32}$/);
});

it('generates a token for an nrel.gov join page URL with a sub_group parameter', () => {
const url = 'https://open-access-openpath.nrel.gov/join/?sub_group=foo';
expect(getTokenFromUrl(url)).toMatch(/^nrelop_open-access_foo_[a-zA-Z0-9]{32}$/);
});

it('generates a token for an emission://join URL', () => {
const url = 'emission://join?study_config=great-study';
expect(getTokenFromUrl(url)).toMatch(/^nrelop_great-study_default_[a-zA-Z0-9]{32}$/);
});

it('extracts the token from a nrelopenpath://login_token URL', () => {
const url = 'nrelopenpath://login_token?token=nrelop_study_subgroup_random';
expect(getTokenFromUrl(url)).toBe('nrelop_study_subgroup_random');
});

it('throws error for any URL with a path other than "join" or "login_token"', () => {
expect(() => getTokenFromUrl('https://open-access-openpath.nrel.gov/invalid/')).toThrow();
expect(() => getTokenFromUrl('nrelopenpath://jion?study_config=open-access')).toThrow();
expect(() =>
getTokenFromUrl('emission://togin_loken?token=nrelop_open-access_000'),
).toThrow();
});
});
});
3 changes: 2 additions & 1 deletion www/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,8 @@
"all-green-status": "Make sure that all status checks are green",
"dont-force-kill": "Do not force kill the app",
"background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off",
"close": "Close"
"close": "Close",
"proceeding-with-token": "Proceeding with OPcode: {{token}}"
},
"config": {
"unable-read-saved-config": "Unable to read saved config",
Expand Down
15 changes: 15 additions & 0 deletions www/js/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler';
// import { getUserCustomLabels } from './services/commHelper';
import AlertBar from './components/AlertBar';
import Main from './Main';
import { joinWithTokenOrUrl } from './config/dynamicConfig';
import { addStatReading } from './plugin/clientStats';

export const AppContext = createContext<any>({});
const CUSTOM_LABEL_KEYS_IN_DATABASE = ['mode', 'purpose'];
type CustomLabelMap = {
[k: string]: string[];
};
type OnboardingJoinMethod = 'scan' | 'paste' | 'textbox' | 'external';

const App = () => {
// will remain null while the onboarding state is still being determined
Expand All @@ -36,6 +39,17 @@ const App = () => {
refreshOnboardingState();
}, []);

// handleOpenURL function must be provided globally for cordova-plugin-customurlscheme
// https://www.npmjs.com/package/cordova-plugin-customurlscheme
window['handleOpenURL'] = async (url: string, joinMethod: OnboardingJoinMethod = 'external') => {
const configUpdated = await joinWithTokenOrUrl(url);
addStatReading('onboard', { configUpdated, joinMethod });
if (configUpdated) {
refreshOnboardingState();
}
return configUpdated;
};

useEffect(() => {
if (!appConfig) return;
setServerConnSettings(appConfig).then(() => {
Expand All @@ -49,6 +63,7 @@ const App = () => {

const appContextValue = {
appConfig,
handleOpenURL: window['handleOpenURL'],
onboardingState,
setOnboardingState,
refreshOnboardingState,
Expand Down
2 changes: 2 additions & 0 deletions www/js/components/AlertBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type AlertMessage = {
msgKey?: ParseKeys<'translation'>;
text?: string;
duration?: number;
style?: object;
};

// public static AlertManager that can add messages from a global context
Expand Down Expand Up @@ -45,6 +46,7 @@ const AlertBar = () => {
visible={true}
onDismiss={onDismissSnackBar}
duration={messages[0].duration}
style={messages[0].style}
action={{
label: t('join.close'),
onPress: onDismissSnackBar,
Expand Down
5 changes: 4 additions & 1 deletion www/js/components/QrCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ we can remove this wrapper and just use the QRCode component directly */
import React from 'react';
import QRCode from 'react-qr-code';
import { logDebug, logWarn } from '../plugin/logger';
import packageJsonBuild from '../../../package.cordovabuild.json';

const URL_SCHEME = packageJsonBuild.cordova.plugins['cordova-plugin-customurlscheme'].URL_SCHEME;

export function shareQR(message) {
/*code adapted from demo of react-qr-code*/
Expand Down Expand Up @@ -45,7 +48,7 @@ export function shareQR(message) {
const QrCode = ({ value, ...rest }) => {
let hasLink = value.toString().includes('//');
if (!hasLink) {
value = 'emission://login_token?token=' + value;
value = `${URL_SCHEME}://login_token?token=${value}`;
}

return (
Expand Down
Loading

0 comments on commit ccc7aef

Please sign in to comment.