Skip to content

Commit e43636f

Browse files
committed
feat: remember google folder selection
When a user uses google, recall the recent folder selection information this is much more convenient for users loading to a specific folder every time
1 parent c67fb77 commit e43636f

File tree

14 files changed

+228
-57
lines changed

14 files changed

+228
-57
lines changed

libs/features/load-records-multi-object/src/LoadRecordsMultiObject.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ANALYTICS_KEYS, DATE_FORMATS, INPUT_ACCEPT_FILETYPES, TITLES } from '@j
22
import { APP_ROUTES } from '@jetstream/shared/ui-router';
33
import { formatNumber, initXlsx, useNonInitialEffect, useTitle } from '@jetstream/shared/ui-utils';
44
import { getErrorMessage } from '@jetstream/shared/utils';
5-
import { InputReadFileContent, InputReadGoogleSheet, LocalOrGoogle } from '@jetstream/types';
5+
import { InputReadFileContent, InputReadGoogleSheet, LocalOrGoogle, Maybe } from '@jetstream/types';
66
import {
77
AutoFullHeightContainer,
88
Checkbox,
@@ -45,7 +45,7 @@ export const LoadRecordsMultiObject = () => {
4545
const selectedOrg = useAtomValue(selectedOrgState);
4646
const orgType = useAtomValue(selectedOrgType);
4747

48-
const [inputFilename, setInputFilename] = useState<string | null>(null);
48+
const [inputFilename, setInputFilename] = useState<Maybe<string>>(null);
4949
const [inputFileType, setInputFileType] = useState<LocalOrGoogle>();
5050
const [inputFileData, setInputFileData] = useState<XLSX.WorkBook>();
5151
const { serverUrl, defaultApiVersion, google_apiKey, google_appId, google_clientId } = useAtomValue(applicationCookieState);

libs/features/load-records/src/steps/SelectObjectAndFile.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ export const LoadRecordsSelectObjectAndFile = ({
138138
async function handleGoogleFile({ workbook, selectedFile }: InputReadGoogleSheet) {
139139
try {
140140
const { data, headers } = await parseWorkbook(workbook, { onParsedMultipleWorkbooks });
141-
onFileChange(data, headers, selectedFile.name, 'google', selectedFile);
141+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
142+
onFileChange(data, headers, selectedFile.name!, 'google', selectedFile);
142143
} catch (ex) {
143144
fireToast({
144145
message: `There was an error reading your file. ${getErrorMessage(ex)}`,

libs/shared/data/src/lib/client-data-data-helper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export async function handleExternalRequest<T = any>(config: AxiosRequestConfig)
105105
});
106106
let message = 'An unknown error has occurred';
107107
if (error.isAxiosError && error.response) {
108+
const response = error.response;
108109
message = error.message || 'An unknown error has occurred';
109110
logger.error(`[HTTP][RES][${response.config.method?.toUpperCase()}][${response.status}]`, response.config.url, {
110111
response: response.data,

libs/shared/data/src/lib/client-data.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
FileNameFormat,
3535
GenericRequestPayload,
3636
GoogleFileApiResponse,
37+
GoogleUserInfo,
3738
HttpMethod,
3839
InputReadFileContent,
3940
JetstreamPricesByLookupKey,
@@ -1115,6 +1116,19 @@ export async function salesforceApiReq(): Promise<SalesforceApiRequest[]> {
11151116
return handleRequest({ method: 'GET', url: `/api/salesforce-api/requests` }, { useCache: true }).then(unwrapResponseIgnoreCache);
11161117
}
11171118

1119+
export async function googleGetUserinfo(accessToken: string): Promise<GoogleUserInfo> {
1120+
return await handleExternalRequest<GoogleUserInfo>({
1121+
method: 'GET',
1122+
url: `https://www.googleapis.com/oauth2/v1/userinfo`,
1123+
headers: {
1124+
Authorization: `Bearer ${accessToken}`,
1125+
},
1126+
params: {
1127+
alt: 'json',
1128+
},
1129+
}).then((response) => response.data);
1130+
}
1131+
11181132
export async function googleUploadFile(
11191133
accessToken: string,
11201134
{ fileMimeType, filename, folderId, fileData }: { fileMimeType: string; filename: string; folderId?: string | null; fileData: any },

libs/shared/ui-utils/src/lib/hooks/useDrivePicker.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { logger } from '@jetstream/shared/client-logger';
2-
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { useCallback, useRef, useState } from 'react';
33
import { GoogleApiClientConfig, useGoogleApi } from './useGoogleApi';
44

55
export interface PickerConfigurationNew {
66
title?: string;
77
viewGroups?: google.picker.ViewGroup[];
88
views: { view: google.picker.DocsView | google.picker.DocsUploadView | google.picker.ViewId; label?: string }[];
99
features?: google.picker.Feature[];
10-
locale?: string;
10+
locale?: google.picker.Locales;
1111
/** If true, then the picker will not build itself, allowing the user to perform any desired actions */
1212
skipBuild?: boolean;
1313
}
@@ -38,14 +38,10 @@ function setViewLabel(view: google.picker.DocsView | google.picker.DocsUploadVie
3838
export function useDrivePicker(apiConfig: GoogleApiClientConfig) {
3939
const picker = useRef<google.picker.PickerBuilder>(null);
4040
const pickerInstance = useRef<google.picker.Picker>(null);
41-
const { error, getToken, loading } = useGoogleApi(apiConfig);
41+
const { error, getToken, userInfo, loading } = useGoogleApi(apiConfig);
4242
const [callBackInfo, setCallBackInfo] = useState<google.picker.ResponseObject>();
4343
const [isVisible, setIsVisible] = useState(false);
4444

45-
useEffect(() => {
46-
logger.log('[GOOGLE][PICKER][RENDERED]');
47-
});
48-
4945
const pickerCallback = useCallback((data: google.picker.ResponseObject) => {
5046
logger.log('[GOOGLE][PICKER]', data);
5147
setIsVisible(pickerInstance.current?.isVisible() || false);
@@ -61,7 +57,7 @@ export function useDrivePicker(apiConfig: GoogleApiClientConfig) {
6157

6258
const openPicker = useCallback(
6359
async ({ views, viewGroups, features, locale = 'en', title, skipBuild }: PickerConfigurationNew) => {
64-
logger.log('[GOOGLE][PICKER][RENDERED] openPicker()');
60+
logger.log('[GOOGLE][PICKER] openPicker()');
6561

6662
pickerInstance.current?.dispose();
6763

@@ -118,6 +114,7 @@ export function useDrivePicker(apiConfig: GoogleApiClientConfig) {
118114
loading,
119115
error,
120116
data: callBackInfo,
117+
userInfo,
121118
openPicker,
122119
isVisible,
123120
};

libs/shared/ui-utils/src/lib/hooks/useGoogleApi.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { logger } from '@jetstream/shared/client-logger';
2-
import { Maybe } from '@jetstream/types';
2+
import { googleGetUserinfo } from '@jetstream/shared/data';
3+
import { GoogleUserInfo, Maybe } from '@jetstream/types';
34
import { addSeconds } from 'date-fns/addSeconds';
45
import { isAfter } from 'date-fns/isAfter';
56
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -19,11 +20,13 @@ if (!globalThis.__IS_BROWSER_EXTENSION__) {
1920
let _apiLoaded = false;
2021
let _tokenClient: Maybe<google.accounts.oauth2.TokenClient>;
2122
let _tokenResponse: Maybe<google.accounts.oauth2.TokenResponse>;
23+
let _userInfo: Maybe<GoogleUserInfo>;
2224
let _tokenExpiration: Maybe<Date>;
2325

2426
export const SCOPES = {
2527
'drive.file': 'https://www.googleapis.com/auth/drive.file',
26-
};
28+
email: 'https://www.googleapis.com/auth/userinfo.email',
29+
} as const;
2730

2831
export interface GoogleApiClientConfig {
2932
appId: string;
@@ -36,10 +39,11 @@ export interface GoogleApiClientConfig {
3639
* Handles loading Google API via script tag and loading individual libraries
3740
* @returns
3841
*/
39-
export function useGoogleApi({ clientId, scopes = [SCOPES['drive.file']] }: GoogleApiClientConfig) {
42+
export function useGoogleApi({ clientId, scopes = [SCOPES['drive.file'], SCOPES.email] }: GoogleApiClientConfig) {
4043
const rollbar = useRollbar();
4144
const tokenClient = useRef<Maybe<google.accounts.oauth2.TokenClient>>(_tokenClient);
4245
const tokenResponse = useRef<Maybe<google.accounts.oauth2.TokenResponse>>(_tokenResponse);
46+
const userInfo = useRef<Maybe<GoogleUserInfo>>(_userInfo);
4347
const tokenCallback = useRef<
4448
| {
4549
resolve: (value: google.accounts.oauth2.TokenResponse) => void;
@@ -74,7 +78,7 @@ export function useGoogleApi({ clientId, scopes = [SCOPES['drive.file']] }: Goog
7478
}
7579
}, [gapiScriptLoadError, gisScriptLoadError, rollbar]);
7680

77-
const callback = useCallback((response: google.accounts.oauth2.TokenResponse) => {
81+
const callback = useCallback(async (response: google.accounts.oauth2.TokenResponse) => {
7882
logger.log('[GOOGLE] access token obtained');
7983
tokenResponse.current = response;
8084
_tokenResponse = tokenResponse.current;
@@ -89,6 +93,11 @@ export function useGoogleApi({ clientId, scopes = [SCOPES['drive.file']] }: Goog
8993
tokenCallback.current = null;
9094
}
9195
} else {
96+
await googleGetUserinfo(response.access_token).then((userInfoResponse) => {
97+
_userInfo = userInfoResponse;
98+
userInfo.current = _userInfo;
99+
});
100+
92101
_tokenExpiration = addSeconds(new Date(), Number(response.expires_in));
93102
tokenExpiration.current = _tokenExpiration;
94103
setCurrentTokenExpiration(tokenExpiration.current);
@@ -163,21 +172,20 @@ export function useGoogleApi({ clientId, scopes = [SCOPES['drive.file']] }: Goog
163172
}, []);
164173

165174
const revokeToken = useCallback(async () => {
166-
if (tokenResponse.current && tokenResponse.current.access_token) {
167-
google.accounts.oauth2.revoke(tokenResponse.current.access_token, () => {
168-
logger.log('Revoked: ' + tokenResponse.current?.access_token);
169-
});
170-
}
171175
tokenResponse.current = null;
172176
_tokenResponse = tokenResponse.current;
173177
_tokenExpiration = null;
174-
tokenExpiration.current = _tokenExpiration;
178+
_userInfo = null;
179+
tokenClient.current = null;
180+
tokenExpiration.current = null;
181+
userInfo.current = null;
175182
setCurrentTokenExpiration(tokenExpiration.current);
176183
}, []);
177184

178185
return {
179186
loading,
180187
error,
188+
userInfo: userInfo.current,
181189
isTokenValid,
182190
getToken,
183191
revokeToken,

libs/types/src/lib/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,13 @@ export type AddOrgHandlerFn = (
393393
options: { serverUrl: string; loginUrl: string; addLoginTrue?: boolean; orgGroupId?: Maybe<string>; loginHint?: string },
394394
callback: (org: SalesforceOrgUi) => void,
395395
) => void;
396+
397+
export type GoogleUserInfo = {
398+
id: string;
399+
email: string;
400+
verified_email: boolean;
401+
name: string;
402+
given_name: string;
403+
family_name: string;
404+
picture?: string;
405+
};

libs/ui/src/lib/file-download-modal/options/FileDownloadGoogle.tsx

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,74 @@
11
import { useNonInitialEffect } from '@jetstream/shared/ui-utils';
2-
import { FunctionComponent, useState } from 'react';
2+
import { GoogleUserInfo, Maybe } from '@jetstream/types';
3+
import { FunctionComponent, useCallback, useState } from 'react';
4+
import z from 'zod';
35
import GoogleFolderSelector from '../../form/file-selector/GoogleFolderSelector';
46
import RadioButton from '../../form/radio/RadioButton';
57
import RadioGroup from '../../form/radio/RadioGroup';
68
import GoogleSignIn from '../../google/GoogleSignIn';
79
import GridCol from '../../grid/GridCol';
810

11+
const WhichFolderSchema = z.enum(['root', 'specified']);
12+
type WhichFolder = z.infer<typeof WhichFolderSchema>;
13+
14+
const FolderSelectionSchema = z.object({ name: z.string(), folderId: z.string() });
15+
type FolderSelection = z.infer<typeof FolderSelectionSchema>;
16+
17+
const LS_FOLDER_SELECTION_KEY = 'RECENT_GOOGLE_FOLDER_SELECTION';
18+
const LS_FOLDER_INFO_KEY = 'RECENT_GOOGLE_FOLDER';
19+
20+
function getWhichFolderFromStorage(userInfo: Maybe<GoogleUserInfo>): WhichFolder {
21+
if (!userInfo) {
22+
return WhichFolderSchema.enum.root;
23+
}
24+
const key = `${LS_FOLDER_SELECTION_KEY}:${userInfo.id}`;
25+
const value = localStorage.getItem(key);
26+
if (value) {
27+
try {
28+
return WhichFolderSchema.parse(value);
29+
} catch {
30+
return WhichFolderSchema.enum.root;
31+
}
32+
}
33+
return WhichFolderSchema.enum.root;
34+
}
35+
36+
function getFolderSelectionFromStorage(userInfo: Maybe<GoogleUserInfo>): Maybe<FolderSelection> {
37+
if (!userInfo) {
38+
return null;
39+
}
40+
const key = `${LS_FOLDER_INFO_KEY}:${userInfo.id}`;
41+
const value = localStorage.getItem(key);
42+
if (value) {
43+
try {
44+
return FolderSelectionSchema.parse(JSON.parse(value));
45+
} catch {
46+
return null;
47+
}
48+
}
49+
return null;
50+
}
51+
52+
function saveWhichFolderToStorage(userInfo: GoogleUserInfo, whichFolder: WhichFolder) {
53+
if (!userInfo) {
54+
return;
55+
}
56+
const key = `${LS_FOLDER_SELECTION_KEY}:${userInfo.id}`;
57+
localStorage.setItem(key, whichFolder);
58+
}
59+
60+
function saveFolderSelectionToStorage(userInfo: GoogleUserInfo, folder: Maybe<FolderSelection>) {
61+
if (!userInfo) {
62+
return;
63+
}
64+
const key = `${LS_FOLDER_INFO_KEY}:${userInfo.id}`;
65+
if (!folder) {
66+
localStorage.removeItem(key);
67+
} else {
68+
localStorage.setItem(key, JSON.stringify(folder));
69+
}
70+
}
71+
972
export interface FileDownloadGoogleProps {
1073
google_apiKey: string;
1174
google_appId: string;
@@ -25,37 +88,65 @@ export const FileDownloadGoogle: FunctionComponent<FileDownloadGoogleProps> = ({
2588
onFolderSelected,
2689
onSelectorVisible,
2790
}) => {
28-
const [googleFolder, setGoogleFolder] = useState<{ name: string; folderId: string }>();
91+
const [userInfo, setUserInfo] = useState<Maybe<GoogleUserInfo>>(null);
92+
const [googleFolder, setGoogleFolder] = useState<Maybe<FolderSelection>>(null);
2993
const [whichFolder, setWhichFolder] = useState<'root' | 'specified'>('root');
3094
const [apiConfig] = useState({ apiKey: google_apiKey, appId: google_appId, clientId: google_clientId });
3195

3296
useNonInitialEffect(() => {
3397
onFolderSelected(whichFolder === 'root' ? undefined : googleFolder?.folderId);
98+
// eslint-disable-next-line react-hooks/exhaustive-deps
3499
}, [whichFolder, googleFolder]);
35100

101+
const handleUserInfoChange = useCallback((user: Maybe<GoogleUserInfo>) => {
102+
setUserInfo(user);
103+
if (user) {
104+
setWhichFolder(getWhichFolderFromStorage(user));
105+
setGoogleFolder(getFolderSelectionFromStorage(user));
106+
}
107+
}, []);
108+
36109
function handleGoogleFolderSelected(data: google.picker.DocumentObject) {
37-
setGoogleFolder({ name: data.name, folderId: data.id });
110+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
111+
const folderData = { name: data.name!, folderId: data.id! };
112+
setGoogleFolder(folderData);
113+
if (userInfo) {
114+
saveFolderSelectionToStorage(userInfo, folderData);
115+
}
38116
}
39117

40118
return (
41119
<div className="slds-p-horizontal_medium slds-p-bottom_medium">
42-
<GoogleSignIn apiConfig={apiConfig} onSignInChanged={onSignInChanged}>
120+
<GoogleSignIn apiConfig={apiConfig} onSignInChanged={onSignInChanged} onUserInfoChange={handleUserInfoChange}>
43121
<GridCol size={12}>
44122
<RadioGroup label="Which Google Drive folder would you like to save to?" isButtonGroup>
45123
<RadioButton
46124
name="which-google-folder"
47125
label="Do not store in a folder"
48126
value="root"
49127
checked={whichFolder === 'root'}
50-
onChange={(value) => setWhichFolder('root')}
128+
onChange={(value) => {
129+
setWhichFolder('root');
130+
if (userInfo) {
131+
saveWhichFolderToStorage(userInfo, 'root');
132+
}
133+
}}
51134
disabled={disabled}
52135
/>
53136
<RadioButton
54137
name="which-google-folder"
55138
label="Choose a folder"
56139
value="specified"
57140
checked={whichFolder === 'specified'}
58-
onChange={(value) => setWhichFolder('specified')}
141+
onChange={(value) => {
142+
setWhichFolder('specified');
143+
if (userInfo) {
144+
saveWhichFolderToStorage(userInfo, 'specified');
145+
if (googleFolder) {
146+
saveFolderSelectionToStorage(userInfo, googleFolder);
147+
}
148+
}
149+
}}
59150
disabled={disabled}
60151
/>
61152
</RadioGroup>

libs/ui/src/lib/form/file-selector/GoogleFileSelector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export const GoogleFileSelector: FunctionComponent<GoogleFileSelectorProps> = ({
152152
const handleOpenPicker = useCallback(() => {
153153
openPicker({
154154
views: [
155-
{ view: new google.picker.DocsView(window.google.picker.ViewId.SPREADSHEETS).setParent('root').setIncludeFolders(true) },
155+
{ view: new google.picker.DocsView(window.google.picker.ViewId.SPREADSHEETS).setIncludeFolders(true) },
156156
{ view: new google.picker.DocsView(window.google.picker.ViewId.SPREADSHEETS), label: 'All spreadsheets' },
157157
{ view: new google.picker.DocsView(window.google.picker.ViewId.SPREADSHEETS).setStarred(true), label: 'Starred spreadsheets' },
158158
{ view: new google.picker.DocsView(window.google.picker.ViewId.SPREADSHEETS).setEnableDrives(true).setIncludeFolders(true) },

0 commit comments

Comments
 (0)