Skip to content

Commit ccc7aef

Browse files
authored
Merge pull request #1187 from JGreenlee/onboarding-redesign-oct2024
Support `join` URLs to generate OPcodes on phone, Fix URL scheme, "Paste" improvements
2 parents 66f61e0 + 478815e commit ccc7aef

15 files changed

+391
-200
lines changed

package.cordovabuild.json

+2
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
},
6161
"com.unarin.cordova.beacon": {},
6262
"cordova-plugin-ionic-keyboard": {},
63+
"cordova-clipboard": {},
6364
"cordova-plugin-app-version": {},
6465
"cordova-plugin-file": {},
6566
"cordova-plugin-device": {},
@@ -116,6 +117,7 @@
116117
"chartjs-plugin-annotation": "^3.0.1",
117118
"com.unarin.cordova.beacon": "github:e-mission/cordova-plugin-ibeacon",
118119
"cordova-android": "13.0.0",
120+
"cordova-clipboard": "^1.3.0",
119121
"cordova-ios": "7.1.1",
120122
"cordova-plugin-advanced-http": "3.3.1",
121123
"cordova-plugin-androidx-adapter": "1.1.3",

setup/setup_native.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|"
121121

122122
npx cordova prepare$PLATFORMS
123123

124-
EXPECTED_COUNT=25
124+
EXPECTED_COUNT=26
125125
INSTALLED_COUNT=`npx cordova plugin list | wc -l`
126126
echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT"
127127
if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ];

www/__tests__/dynamicConfig.test.ts

+56-24
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { getConfig, initByUser } from '../js/config/dynamicConfig';
2-
1+
import { getConfig, joinWithTokenOrUrl } from '../js/config/dynamicConfig';
32
import initializedI18next from '../js/i18nextInit';
43
import { storageClear } from '../js/plugin/storage';
4+
import i18next from '../js/i18nextInit';
5+
56
window['i18next'] = initializedI18next;
67

78
beforeEach(() => {
@@ -56,6 +57,8 @@ global.fetch = (url: string) => {
5657
}) as any;
5758
};
5859

60+
const windowAlert = jest.spyOn(window, 'alert').mockImplementation(() => {});
61+
5962
describe('dynamicConfig', () => {
6063
const fakeStudyName = 'gotham-city-transit';
6164
const validStudyNrelCommute = 'nrel-commute';
@@ -65,9 +68,9 @@ describe('dynamicConfig', () => {
6568
it('should resolve with null since no config is set yet', async () => {
6669
await expect(getConfig()).resolves.toBeNull();
6770
});
68-
it('should resolve with a valid config once initByUser is called for an nrel-commute token', async () => {
71+
it('should resolve with a valid config once joinWithTokenOrUrl is called for an nrel-commute token', async () => {
6972
const validToken = `nrelop_${validStudyNrelCommute}_user1`;
70-
await initByUser({ token: validToken });
73+
await joinWithTokenOrUrl(validToken);
7174
const config = await getConfig();
7275
expect(config!.server.connectUrl).toBe('https://nrel-commute-openpath.nrel.gov/api/');
7376
expect(config!.joined).toEqual({
@@ -77,9 +80,9 @@ describe('dynamicConfig', () => {
7780
});
7881
});
7982

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

93-
describe('initByUser', () => {
96+
describe('joinWithTokenOrUrl', () => {
9497
// fake study (gotham-city-transit)
95-
it('should error if the study is nonexistent', async () => {
98+
it('returns false if the study is nonexistent', async () => {
9699
const fakeBatmanToken = `nrelop_${fakeStudyName}_batman`;
97-
await expect(initByUser({ token: fakeBatmanToken })).rejects.toThrow();
100+
await expect(joinWithTokenOrUrl(fakeBatmanToken)).resolves.toBe(false);
101+
expect(windowAlert).toHaveBeenLastCalledWith(
102+
expect.stringContaining(i18next.t('config.unable-download-config')),
103+
);
98104
});
99105

100106
// real study without subgroups (nrel-commute)
101-
it('should error if the study exists but the token is invalid format', async () => {
102-
const badToken1 = validStudyNrelCommute; // doesn't start with nrelop_
103-
await expect(initByUser({ token: badToken1 })).rejects.toThrow();
104-
const badToken2 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _
105-
await expect(initByUser({ token: badToken2 })).rejects.toThrow();
106-
const badToken3 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _
107-
await expect(initByUser({ token: badToken3 })).rejects.toThrow();
107+
it('returns false if the study exists but the token is invalid format', async () => {
108+
const badToken1 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _
109+
await expect(joinWithTokenOrUrl(badToken1)).resolves.toBe(false);
110+
expect(windowAlert).toHaveBeenLastCalledWith(
111+
expect.stringContaining(
112+
i18next.t('config.not-enough-parts-old-style', { token: badToken1 }),
113+
),
114+
);
115+
116+
const badToken2 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _
117+
await expect(joinWithTokenOrUrl(badToken2)).resolves.toBe(false);
118+
expect(windowAlert).toHaveBeenLastCalledWith(
119+
expect.stringContaining(
120+
i18next.t('config.not-enough-parts-old-style', { token: badToken2 }),
121+
),
122+
);
123+
124+
const badToken3 = `invalid_${validStudyNrelCommute}_user3`; // doesn't start with nrelop_
125+
await expect(joinWithTokenOrUrl(badToken3)).resolves.toBe(false);
126+
expect(windowAlert).toHaveBeenLastCalledWith(
127+
expect.stringContaining(i18next.t('config.no-nrelop-start', { token: badToken3 })),
128+
);
108129
});
109-
it('should return true after successfully storing the config for a valid token', async () => {
130+
131+
it('returns true after successfully storing the config for a valid token', async () => {
110132
const validToken = `nrelop_${validStudyNrelCommute}_user2`;
111-
await expect(initByUser({ token: validToken })).resolves.toBe(true);
133+
await expect(joinWithTokenOrUrl(validToken)).resolves.toBe(true);
112134
});
113135

114136
// real study with subgroups (denver-casr)
115-
it('should error if the study uses subgroups but the token has no subgroup', async () => {
137+
it('returns false if the study uses subgroups but the token has no subgroup', async () => {
116138
const tokenWithoutSubgroup = `nrelop_${validStudyDenverCasr}_user2`;
117-
await expect(initByUser({ token: tokenWithoutSubgroup })).rejects.toThrow();
139+
await expect(joinWithTokenOrUrl(tokenWithoutSubgroup)).resolves.toBe(false);
140+
expect(windowAlert).toHaveBeenLastCalledWith(
141+
expect.stringContaining(
142+
i18next.t('config.not-enough-parts', { token: tokenWithoutSubgroup }),
143+
),
144+
);
118145
});
119-
it('should error if the study uses subgroups and the token is invalid format', async () => {
146+
it('returns false if the study uses subgroups and the token is invalid format', async () => {
120147
const badToken1 = `nrelop_${validStudyDenverCasr}_test_`; // doesn't have user code after last _
121-
await expect(initByUser({ token: badToken1 })).rejects.toThrow();
148+
await expect(joinWithTokenOrUrl(badToken1)).resolves.toBe(false);
149+
expect(windowAlert).toHaveBeenLastCalledWith(
150+
expect.stringContaining(
151+
i18next.t('config.not-enough-parts-old-style', { token: badToken1 }),
152+
),
153+
);
122154
});
123-
it('should return true after successfully storing the config for a valid token with subgroup', async () => {
155+
it('returns true after successfully storing the config for a valid token with subgroup', async () => {
124156
const validToken = `nrelop_${validStudyDenverCasr}_test_user2`;
125-
await expect(initByUser({ token: validToken })).resolves.toBe(true);
157+
await expect(joinWithTokenOrUrl(validToken)).resolves.toBe(true);
126158
});
127159
});
128160
});

www/__tests__/opcode.test.ts

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// * @example getTokenFromUrl('https://open-access-openpath.nrel.gov/join/') => nrelop_open-access_default_randomLongStringWith32Characters
2+
// * @example getTokenFromUrl('emission://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random
3+
// * @example getTokenFromUrl('nrelopenpath://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random
4+
5+
import { getStudyNameFromToken, getSubgroupFromToken, getTokenFromUrl } from '../js/config/opcode';
6+
import AppConfig from '../js/types/appConfigTypes';
7+
describe('opcode', () => {
8+
describe('getStudyNameFromToken', () => {
9+
const token = 'nrelop_great-study_default_randomLongStringWith32Characters';
10+
it('returns the study name from a token', () => {
11+
expect(getStudyNameFromToken(token)).toBe('great-study');
12+
});
13+
});
14+
15+
describe('getSubgroupFromToken', () => {
16+
const amazingSubgroupToken = 'nrelop_great-study_amazing-subgroup_000';
17+
it('returns the subgroup from a token with valid subgroup', () => {
18+
const fakeconfig = {
19+
opcode: {
20+
subgroups: ['amazing-subgroup', 'other-subgroup'],
21+
},
22+
} as any as AppConfig;
23+
expect(getSubgroupFromToken(amazingSubgroupToken, fakeconfig)).toBe('amazing-subgroup');
24+
});
25+
26+
it("throws error if token's subgroup is not in config", () => {
27+
const fakeconfig = {
28+
opcode: {
29+
subgroups: ['sad-subgroup', 'other-subgroup'],
30+
},
31+
} as any as AppConfig;
32+
expect(() => getSubgroupFromToken(amazingSubgroupToken, fakeconfig)).toThrow();
33+
});
34+
35+
it("returns 'default' if token has 'default' and config is not configured with subgroups", () => {
36+
const defaultSubgroupToken = 'nrelop_great-study_default_000';
37+
const fakeconfig = {
38+
opcode: {},
39+
} as any as AppConfig;
40+
expect(getSubgroupFromToken(defaultSubgroupToken, fakeconfig)).toBe('default');
41+
});
42+
43+
it("throws error if token's subgroup is not 'default' and config is not configured with subgroups", () => {
44+
const invalidSubgroupToken = 'nrelop_great-study_imaginary-subgroup_000';
45+
const fakeconfig = {
46+
opcode: {},
47+
} as any as AppConfig;
48+
expect(() => getSubgroupFromToken(invalidSubgroupToken, fakeconfig)).toThrow();
49+
});
50+
});
51+
52+
describe('getTokenFromUrl', () => {
53+
it('generates a token for an nrel.gov join page URL', () => {
54+
const url = 'https://open-access-openpath.nrel.gov/join/';
55+
expect(getTokenFromUrl(url)).toMatch(/^nrelop_open-access_default_[a-zA-Z0-9]{32}$/);
56+
});
57+
58+
it('generates a token for an nrel.gov join page URL with a sub_group parameter', () => {
59+
const url = 'https://open-access-openpath.nrel.gov/join/?sub_group=foo';
60+
expect(getTokenFromUrl(url)).toMatch(/^nrelop_open-access_foo_[a-zA-Z0-9]{32}$/);
61+
});
62+
63+
it('generates a token for an emission://join URL', () => {
64+
const url = 'emission://join?study_config=great-study';
65+
expect(getTokenFromUrl(url)).toMatch(/^nrelop_great-study_default_[a-zA-Z0-9]{32}$/);
66+
});
67+
68+
it('extracts the token from a nrelopenpath://login_token URL', () => {
69+
const url = 'nrelopenpath://login_token?token=nrelop_study_subgroup_random';
70+
expect(getTokenFromUrl(url)).toBe('nrelop_study_subgroup_random');
71+
});
72+
73+
it('throws error for any URL with a path other than "join" or "login_token"', () => {
74+
expect(() => getTokenFromUrl('https://open-access-openpath.nrel.gov/invalid/')).toThrow();
75+
expect(() => getTokenFromUrl('nrelopenpath://jion?study_config=open-access')).toThrow();
76+
expect(() =>
77+
getTokenFromUrl('emission://togin_loken?token=nrelop_open-access_000'),
78+
).toThrow();
79+
});
80+
});
81+
});

www/i18n/en.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,8 @@
404404
"all-green-status": "Make sure that all status checks are green",
405405
"dont-force-kill": "Do not force kill the app",
406406
"background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off",
407-
"close": "Close"
407+
"close": "Close",
408+
"proceeding-with-token": "Proceeding with OPcode: {{token}}"
408409
},
409410
"config": {
410411
"unable-read-saved-config": "Unable to read saved config",

www/js/App.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler';
1616
// import { getUserCustomLabels } from './services/commHelper';
1717
import AlertBar from './components/AlertBar';
1818
import Main from './Main';
19+
import { joinWithTokenOrUrl } from './config/dynamicConfig';
20+
import { addStatReading } from './plugin/clientStats';
1921

2022
export const AppContext = createContext<any>({});
2123
const CUSTOM_LABEL_KEYS_IN_DATABASE = ['mode', 'purpose'];
2224
type CustomLabelMap = {
2325
[k: string]: string[];
2426
};
27+
type OnboardingJoinMethod = 'scan' | 'paste' | 'textbox' | 'external';
2528

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

42+
// handleOpenURL function must be provided globally for cordova-plugin-customurlscheme
43+
// https://www.npmjs.com/package/cordova-plugin-customurlscheme
44+
window['handleOpenURL'] = async (url: string, joinMethod: OnboardingJoinMethod = 'external') => {
45+
const configUpdated = await joinWithTokenOrUrl(url);
46+
addStatReading('onboard', { configUpdated, joinMethod });
47+
if (configUpdated) {
48+
refreshOnboardingState();
49+
}
50+
return configUpdated;
51+
};
52+
3953
useEffect(() => {
4054
if (!appConfig) return;
4155
setServerConnSettings(appConfig).then(() => {
@@ -49,6 +63,7 @@ const App = () => {
4963

5064
const appContextValue = {
5165
appConfig,
66+
handleOpenURL: window['handleOpenURL'],
5267
onboardingState,
5368
setOnboardingState,
5469
refreshOnboardingState,

www/js/components/AlertBar.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type AlertMessage = {
1010
msgKey?: ParseKeys<'translation'>;
1111
text?: string;
1212
duration?: number;
13+
style?: object;
1314
};
1415

1516
// public static AlertManager that can add messages from a global context
@@ -45,6 +46,7 @@ const AlertBar = () => {
4546
visible={true}
4647
onDismiss={onDismissSnackBar}
4748
duration={messages[0].duration}
49+
style={messages[0].style}
4850
action={{
4951
label: t('join.close'),
5052
onPress: onDismissSnackBar,

www/js/components/QrCode.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ we can remove this wrapper and just use the QRCode component directly */
55
import React from 'react';
66
import QRCode from 'react-qr-code';
77
import { logDebug, logWarn } from '../plugin/logger';
8+
import packageJsonBuild from '../../../package.cordovabuild.json';
9+
10+
const URL_SCHEME = packageJsonBuild.cordova.plugins['cordova-plugin-customurlscheme'].URL_SCHEME;
811

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

5154
return (

0 commit comments

Comments
 (0)