Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle API-key errors in map-component #165

Merged
merged 1 commit into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/components/__tests__/api-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import '@testing-library/jest-dom';
import {importLibraryMock} from '../../libraries/__mocks__/lib/import-library-mock';

import {
APILoadingStatus,
APIProvider,
APIProviderContext,
APIProviderContextValue
} from '../api-provider';
import {ApiParams} from '../../libraries/google-maps-api-loader';
import {useApiIsLoaded} from '../../hooks/use-api-is-loaded';
import {APILoadingStatus} from '../../libraries/api-loading-status';

const apiLoadSpy = jest.fn();
const apiUnloadSpy = jest.fn();
Expand All @@ -30,10 +30,21 @@ let triggerMapsApiLoaded: () => void;

jest.mock('../../libraries/google-maps-api-loader', () => {
class GoogleMapsApiLoader {
static async load(params: ApiParams): Promise<void> {
static async load(
params: ApiParams,
onLoadingStatusChange: (s: APILoadingStatus) => void
): Promise<void> {
apiLoadSpy(params);
onLoadingStatusChange(APILoadingStatus.LOADING);

google.maps.importLibrary = importLibraryMock;
return new Promise(resolve => (triggerMapsApiLoaded = resolve));
return new Promise(
resolve =>
(triggerMapsApiLoaded = () => {
resolve();
onLoadingStatusChange(APILoadingStatus.LOADED);
})
);
}
static unload() {
apiUnloadSpy();
Expand Down
7 changes: 2 additions & 5 deletions src/components/__tests__/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks';
import '@testing-library/jest-dom';

import {Map as GoogleMap} from '../map';
import {
APILoadingStatus,
APIProviderContext,
APIProviderContextValue
} from '../api-provider';
import {APIProviderContext, APIProviderContextValue} from '../api-provider';
import {APILoadingStatus} from '../../libraries/api-loading-status';

jest.mock('../../libraries/google-maps-api-loader');

Expand Down
34 changes: 13 additions & 21 deletions src/components/api-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,7 @@ import React, {
} from 'react';

import {GoogleMapsApiLoader} from '../libraries/google-maps-api-loader';

export enum APILoadingStatus {
NOT_LOADED = 'NOT_LOADED',
LOADING = 'LOADING',
LOADED = 'LOADED',
FAILED = 'FAILED'
}

const {NOT_LOADED, LOADING, LOADED, FAILED} = APILoadingStatus;
import {APILoadingStatus} from '../libraries/api-loading-status';

type ImportLibraryFunction = typeof google.maps.importLibrary;
type GoogleMapsLibrary = Awaited<ReturnType<ImportLibraryFunction>>;
Expand Down Expand Up @@ -77,7 +69,7 @@ export type APIProviderProps = {
};

/**
* local hook to manage access to map-instances.
* local hook to set up the map-instance management context.
*/
function useMapInstances() {
const [mapInstances, setMapInstances] = useState<
Expand Down Expand Up @@ -107,7 +99,9 @@ function useMapInstances() {
function useGoogleMapsApiLoader(props: APIProviderProps) {
const {onLoad, apiKey, libraries = [], ...otherApiParams} = props;

const [status, setStatus] = useState<APILoadingStatus>(NOT_LOADED);
const [status, setStatus] = useState<APILoadingStatus>(
GoogleMapsApiLoader.loadingStatus
);
const [loadedLibraries, addLoadedLibrary] = useReducer(
(
loadedLibraries: LoadedLibraries,
Expand Down Expand Up @@ -147,17 +141,16 @@ function useGoogleMapsApiLoader(props: APIProviderProps) {

useEffect(
() => {
setStatus(LOADING);

(async () => {
try {
await GoogleMapsApiLoader.load({
key: apiKey,
libraries: librariesString,
...otherApiParams
});

setStatus(LOADED);
await GoogleMapsApiLoader.load(
{
key: apiKey,
libraries: librariesString,
...otherApiParams
},
status => setStatus(status)
);

for (const name of ['core', 'maps', ...libraries]) {
await importLibrary(name);
Expand All @@ -168,7 +161,6 @@ function useGoogleMapsApiLoader(props: APIProviderProps) {
}
} catch (error) {
console.error('<ApiProvider> failed to load Google Maps API', error);
setStatus(FAILED);
}
})();
},
Expand Down
43 changes: 43 additions & 0 deletions src/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {MapEventProps, useMapEvents} from './use-map-events';
import {useMapOptions} from './use-map-options';
import {useDeckGLCameraUpdate} from './use-deckgl-camera-update';
import {useInternalCameraState} from './use-internal-camera-state';
import {useApiLoadingStatus} from '../../hooks/use-api-loading-status';
import {APILoadingStatus} from '../../libraries/api-loading-status';

export interface GoogleMapsContextValue {
map: google.maps.Map | null;
Expand Down Expand Up @@ -66,6 +68,7 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
const {children, id, className, style, viewState, viewport} = props;

const context = useContext(APIProviderContext);
const loadingStatus = useApiLoadingStatus();

if (!context) {
throw new Error(
Expand All @@ -92,6 +95,16 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
[style, isViewportSet]
);

if (loadingStatus === APILoadingStatus.AUTH_FAILURE) {
return (
<div
style={{position: 'relative', ...(className ? {} : combinedStyle)}}
className={className}>
<AuthFailureMessage />
</div>
);
}

return (
<div
ref={mapRef}
Expand All @@ -109,6 +122,36 @@ export const Map = (props: PropsWithChildren<MapProps>) => {
};
Map.deckGLViewProps = true;

const AuthFailureMessage = () => {
const style: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
zIndex: 999,
display: 'flex',
flexFlow: 'column nowrap',
textAlign: 'center',
justifyContent: 'center',
fontSize: '.8rem',
color: 'rgba(0,0,0,0.6)',
background: '#dddddd',
padding: '1rem 1.5rem'
};

return (
<div style={style}>
<h2>Error: AuthFailure</h2>
<p>
A problem with your API key prevents the map from rendering correctly.
Please make sure the value of the <code>APIProvider.apiKey</code> prop
is correct. Check the error-message in the console for further details.
</p>
</div>
);
};

/**
* The main hook takes care of creating map-instances and registering them in
* the api-provider context.
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/__tests__/api-loading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import {initialize} from '@googlemaps/jest-mocks';
import {renderHook} from '@testing-library/react';

import {
APILoadingStatus,
APIProviderContext,
APIProviderContextValue
} from '../../components/api-provider';

import {useApiLoadingStatus} from '../use-api-loading-status';
import {useApiIsLoaded} from '../use-api-is-loaded';
import {APILoadingStatus} from '../../libraries/api-loading-status';

let wrapper: ({children}: {children: React.ReactNode}) => JSX.Element | null;
let mockContextValue: jest.MockedObject<APIProviderContextValue>;
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/__tests__/use-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks';

import {useMap} from '../use-map';
import {
APILoadingStatus,
APIProviderContext,
APIProviderContextValue
} from '../../components/api-provider';
import {Map as GoogleMap} from '../../components/map';
import {APILoadingStatus} from '../../libraries/api-loading-status';

let MockApiContextProvider: ({
children
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-api-is-loaded.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {APILoadingStatus} from '../components/api-provider';
import {useApiLoadingStatus} from './use-api-loading-status';
import {APILoadingStatus} from '../libraries/api-loading-status';
/**
* Hook to check if the Google Maps API is loaded
*/
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/use-api-loading-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useContext} from 'react';
import {APILoadingStatus, APIProviderContext} from '../components/api-provider';
import {APIProviderContext} from '../components/api-provider';
import {APILoadingStatus} from '../libraries/api-loading-status';

export function useApiLoadingStatus(): APILoadingStatus {
return useContext(APIProviderContext)?.status || APILoadingStatus.NOT_LOADED;
Expand Down
13 changes: 9 additions & 4 deletions src/libraries/__mocks__/google-maps-api-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import type {GoogleMapsApiLoader as ActualLoader} from '../google-maps-api-loade

// FIXME: this should no longer be needed with the next version of @googlemaps/jest-mocks
import {importLibraryMock} from './lib/import-library-mock';
import {APILoadingStatus} from '../api-loading-status';

export class GoogleMapsApiLoader {
static load: typeof ActualLoader.load = jest.fn(() => {
google.maps.importLibrary = importLibraryMock;
return Promise.resolve();
});
static loadingStatus: APILoadingStatus = APILoadingStatus.LOADED;
static load: typeof ActualLoader.load = jest.fn(
(_, onLoadingStatusChange) => {
google.maps.importLibrary = importLibraryMock;
onLoadingStatusChange(APILoadingStatus.LOADED);
return Promise.resolve();
}
);
}
9 changes: 9 additions & 0 deletions src/libraries/api-loading-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const APILoadingStatus = {
NOT_LOADED: 'NOT_LOADED',
LOADING: 'LOADING',
LOADED: 'LOADED',
FAILED: 'FAILED',
AUTH_FAILURE: 'AUTH_FAILURE'
};
export type APILoadingStatus =
(typeof APILoadingStatus)[keyof typeof APILoadingStatus];
54 changes: 44 additions & 10 deletions src/libraries/google-maps-api-loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {APILoadingStatus} from './api-loading-status';

export type ApiParams = {
key: string;
v?: string;
Expand All @@ -12,7 +14,7 @@ export type ApiParams = {
declare global {
interface Window {
__googleMapsCallback__?: () => void;
__googleMapsApiParams__?: string;
gm_authFailure?: () => void;
}
}

Expand All @@ -25,6 +27,9 @@ const MAPS_API_BASE_URL = 'https://maps.googleapis.com/maps/api/js';
* allow using the API in an useEffect hook, without worrying about multiple API loads.
*/
export class GoogleMapsApiLoader {
public static loadingStatus: APILoadingStatus = APILoadingStatus.NOT_LOADED;
public static serializedApiParams?: string;

/**
* Loads the Google Maps API with the specified parameters.
* Since the Maps library can only be loaded once per page, this will
Expand All @@ -33,24 +38,35 @@ export class GoogleMapsApiLoader {
*
* The returned promise resolves when loading completes
* and rejects in case of an error or when the loading was aborted.
* @param params
*/
static async load(params: ApiParams): Promise<void> {
static async load(
params: ApiParams,
onLoadingStatusChange: (status: APILoadingStatus) => void
): Promise<void> {
const libraries = params.libraries ? params.libraries.split(',') : [];
const serializedParams = this.serializeParams(params);

// note: if google.maps.importLibrary was defined externally, the params
// will be ignored. If it was defined by a previous call to this
// method, we will check that the key and other parameters have not been
// changed in between calls.

if (!window.google?.maps?.importLibrary) {
window.__googleMapsApiParams__ = serializedParams;
this.initImportLibrary(params);
this.serializedApiParams = serializedParams;
this.initImportLibrary(params, onLoadingStatusChange);
} else {
// if serializedApiParams isn't defined the library was loaded externally
// and we can only assume that went alright.
if (!this.serializedApiParams) {
this.loadingStatus = APILoadingStatus.LOADED;
}

onLoadingStatusChange(this.loadingStatus);
}

if (
window.__googleMapsApiParams__ &&
window.__googleMapsApiParams__ !== serializedParams
this.serializedApiParams &&
this.serializedApiParams !== serializedParams
) {
console.warn(
`The maps API has already been loaded with different ` +
Expand All @@ -75,7 +91,10 @@ export class GoogleMapsApiLoader {
].join('/');
}

private static initImportLibrary(params: ApiParams) {
private static initImportLibrary(
params: ApiParams,
onLoadingStatusChange: (status: APILoadingStatus) => void
) {
if (!window.google) window.google = {} as never;
if (!window.google.maps) window.google.maps = {} as never;

Expand Down Expand Up @@ -105,14 +124,29 @@ export class GoogleMapsApiLoader {
urlParams.set('callback', '__googleMapsCallback__');
scriptElement.src = MAPS_API_BASE_URL + `?` + urlParams.toString();

window.__googleMapsCallback__ = resolve;
window.__googleMapsCallback__ = () => {
this.loadingStatus = APILoadingStatus.LOADED;
onLoadingStatusChange(this.loadingStatus);
resolve();
};

scriptElement.onerror = () =>
window.gm_authFailure = () => {
this.loadingStatus = APILoadingStatus.AUTH_FAILURE;
onLoadingStatusChange(this.loadingStatus);
};

scriptElement.onerror = () => {
this.loadingStatus = APILoadingStatus.FAILED;
onLoadingStatusChange(this.loadingStatus);
reject(new Error('The Google Maps JavaScript API could not load.'));
};

scriptElement.nonce =
(document.querySelector('script[nonce]') as HTMLScriptElement)
?.nonce || '';

this.loadingStatus = APILoadingStatus.LOADING;
onLoadingStatusChange(this.loadingStatus);
document.head.append(scriptElement);
});

Expand Down
Loading