Skip to content

Commit a6d1445

Browse files
mbehrlichjelbourn
authored andcommitted
feat(google-maps): add input options for google Map component (#16759)
Add inputs to configure Google Map, including the main options, as well as center and zoom. Allow resizing the height and width of the component. Add errors for not loading the API and using malformed inputs.
1 parent 7c472f0 commit a6d1445

File tree

5 files changed

+248
-46
lines changed

5 files changed

+248
-46
lines changed
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
<google-map *ngIf="isReady"></google-map>
1+
<google-map *ngIf="isReady"
2+
height="400px"
3+
width="750px"
4+
[center]="center"
5+
[zoom]="zoom"></google-map>

src/dev-app/google-map/google-map-demo.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {HttpClient} from '@angular/common/http';
1818
export class GoogleMapDemo {
1919
isReady = false;
2020

21+
center = {lat: 24, lng: 12};
22+
zoom = 4;
23+
2124
constructor(httpClient: HttpClient) {
2225
httpClient.jsonp('https://maps.googleapis.com/maps/api/js?', 'callback')
2326
.subscribe(() => {

src/google-maps/google-map/google-map.spec.ts

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,18 @@ import {Component} from '@angular/core';
22
import {async, TestBed} from '@angular/core/testing';
33
import {By} from '@angular/platform-browser';
44

5-
import {createMapConstructorSpy, createMapSpy} from './testing/fake-google-map-utils';
6-
import {GoogleMapModule} from './index';
7-
8-
const DEFAULT_OPTIONS: google.maps.MapOptions = {
9-
center: {lat: 37.421995, lng: -122.084092},
10-
zoom: 17,
11-
};
5+
import {DEFAULT_HEIGHT, DEFAULT_OPTIONS, DEFAULT_WIDTH, GoogleMapModule} from './index';
6+
import {
7+
createMapConstructorSpy,
8+
createMapSpy,
9+
TestingWindow
10+
} from './testing/fake-google-map-utils';
1211

1312
describe('GoogleMap', () => {
1413
let mapConstructorSpy: jasmine.Spy;
1514
let mapSpy: jasmine.SpyObj<google.maps.Map>;
1615

1716
beforeEach(async(() => {
18-
mapSpy = createMapSpy(DEFAULT_OPTIONS);
19-
mapConstructorSpy = createMapConstructorSpy(mapSpy);
20-
2117
TestBed.configureTestingModule({
2218
imports: [GoogleMapModule],
2319
declarations: [TestApp],
@@ -28,17 +24,125 @@ describe('GoogleMap', () => {
2824
TestBed.compileComponents();
2925
});
3026

27+
afterEach(() => {
28+
const testingWindow: TestingWindow = window;
29+
delete testingWindow.google;
30+
});
31+
32+
it('throws an error is the Google Maps JavaScript API was not loaded', () => {
33+
mapSpy = createMapSpy(DEFAULT_OPTIONS);
34+
mapConstructorSpy = createMapConstructorSpy(mapSpy, false);
35+
36+
expect(() => TestBed.createComponent(TestApp))
37+
.toThrow(new Error(
38+
'Namespace google not found, cannot construct embedded google ' +
39+
'map. Please install the Google Maps JavaScript API: ' +
40+
'https://developers.google.com/maps/documentation/javascript/' +
41+
'tutorial#Loading_the_Maps_API'));
42+
});
43+
3144
it('initializes a Google map', () => {
45+
mapSpy = createMapSpy(DEFAULT_OPTIONS);
46+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
47+
3248
const fixture = TestBed.createComponent(TestApp);
49+
fixture.detectChanges();
50+
3351
const container = fixture.debugElement.query(By.css('div'));
52+
expect(container.nativeElement.style.height).toBe(DEFAULT_HEIGHT);
53+
expect(container.nativeElement.style.width).toBe(DEFAULT_WIDTH);
54+
expect(mapConstructorSpy).toHaveBeenCalledWith(container.nativeElement, DEFAULT_OPTIONS);
55+
});
56+
57+
it('sets height and width of the map', () => {
58+
mapSpy = createMapSpy(DEFAULT_OPTIONS);
59+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
60+
61+
const fixture = TestBed.createComponent(TestApp);
62+
fixture.componentInstance.height = '750px';
63+
fixture.componentInstance.width = '400px';
3464
fixture.detectChanges();
3565

66+
const container = fixture.debugElement.query(By.css('div'));
67+
expect(container.nativeElement.style.height).toBe('750px');
68+
expect(container.nativeElement.style.width).toBe('400px');
3669
expect(mapConstructorSpy).toHaveBeenCalledWith(container.nativeElement, DEFAULT_OPTIONS);
70+
71+
fixture.componentInstance.height = '650px';
72+
fixture.componentInstance.width = '350px';
73+
fixture.detectChanges();
74+
75+
expect(container.nativeElement.style.height).toBe('650px');
76+
expect(container.nativeElement.style.width).toBe('350px');
77+
});
78+
79+
it('sets center and zoom of the map', () => {
80+
const options = {center: {lat: 3, lng: 5}, zoom: 7};
81+
mapSpy = createMapSpy(options);
82+
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
83+
84+
const fixture = TestBed.createComponent(TestApp);
85+
fixture.componentInstance.center = options.center;
86+
fixture.componentInstance.zoom = options.zoom;
87+
fixture.detectChanges();
88+
89+
const container = fixture.debugElement.query(By.css('div'));
90+
expect(mapConstructorSpy).toHaveBeenCalledWith(container.nativeElement, options);
91+
92+
fixture.componentInstance.center = {lat: 8, lng: 9};
93+
fixture.componentInstance.zoom = 12;
94+
fixture.detectChanges();
95+
96+
expect(mapSpy.setOptions).toHaveBeenCalledWith({center: {lat: 8, lng: 9}, zoom: 12});
97+
});
98+
99+
it('sets map options', () => {
100+
const options = {center: {lat: 3, lng: 5}, zoom: 7, draggable: false};
101+
mapSpy = createMapSpy(options);
102+
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
103+
104+
const fixture = TestBed.createComponent(TestApp);
105+
fixture.componentInstance.options = options;
106+
fixture.detectChanges();
107+
108+
const container = fixture.debugElement.query(By.css('div'));
109+
expect(mapConstructorSpy).toHaveBeenCalledWith(container.nativeElement, options);
110+
111+
fixture.componentInstance.options = {...options, heading: 170};
112+
fixture.detectChanges();
113+
114+
expect(mapSpy.setOptions).toHaveBeenCalledWith({...options, heading: 170});
115+
});
116+
117+
it('gives precedence to center and zoom over options', () => {
118+
const inputOptions = {center: {lat: 3, lng: 5}, zoom: 7, heading: 170};
119+
const correctedOptions = {center: {lat: 12, lng: 15}, zoom: 5, heading: 170};
120+
mapSpy = createMapSpy(correctedOptions);
121+
mapConstructorSpy = createMapConstructorSpy(mapSpy);
122+
123+
const fixture = TestBed.createComponent(TestApp);
124+
fixture.componentInstance.center = correctedOptions.center;
125+
fixture.componentInstance.zoom = correctedOptions.zoom;
126+
fixture.componentInstance.options = inputOptions;
127+
fixture.detectChanges();
128+
129+
const container = fixture.debugElement.query(By.css('div'));
130+
expect(mapConstructorSpy).toHaveBeenCalledWith(container.nativeElement, correctedOptions);
37131
});
38132
});
39133

40134
@Component({
41135
selector: 'test-app',
42-
template: `<google-map></google-map>`,
136+
template: `<google-map [height]="height"
137+
[width]="width"
138+
[center]="center"
139+
[zoom]="zoom"
140+
[options]="options"></google-map>`,
43141
})
44-
class TestApp {}
142+
class TestApp {
143+
height?: string;
144+
width?: string;
145+
center?: google.maps.LatLngLiteral;
146+
zoom?: number;
147+
options?: google.maps.MapOptions;
148+
}

src/google-maps/google-map/google-map.ts

Lines changed: 107 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,27 @@ import {
33
Component,
44
ElementRef,
55
Input,
6+
OnChanges,
7+
OnDestroy,
68
OnInit,
79
} from '@angular/core';
8-
import {ReplaySubject} from 'rxjs';
10+
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
11+
import {map, take, takeUntil} from 'rxjs/operators';
12+
13+
interface GoogleMapsWindow extends Window {
14+
google?: typeof google;
15+
}
16+
17+
/** default options set to the Googleplex */
18+
export const DEFAULT_OPTIONS: google.maps.MapOptions = {
19+
center: {lat: 37.421995, lng: -122.084092},
20+
zoom: 17,
21+
};
22+
23+
/** Arbitrary default height for the map element */
24+
export const DEFAULT_HEIGHT = '500px';
25+
/** Arbitrary default width for the map element */
26+
export const DEFAULT_WIDTH = '500px';
927

1028
/**
1129
* Angular component that renders a Google Map via the Google Maps JavaScript
@@ -17,28 +35,98 @@ import {ReplaySubject} from 'rxjs';
1735
changeDetection: ChangeDetectionStrategy.OnPush,
1836
template: '<div class="map-container"></div>',
1937
})
20-
export class GoogleMap implements OnInit {
21-
// Arbitrarily chosen default size
22-
@Input() height = '500px';
23-
@Input() width = '500px';
38+
export class GoogleMap implements OnChanges, OnInit, OnDestroy {
39+
@Input() height = DEFAULT_HEIGHT;
40+
41+
@Input() width = DEFAULT_WIDTH;
42+
43+
@Input()
44+
set center(center: google.maps.LatLngLiteral) {
45+
this._center.next(center);
46+
}
47+
@Input()
48+
set zoom(zoom: number) {
49+
this._zoom.next(zoom);
50+
}
51+
@Input()
52+
set options(options: google.maps.MapOptions) {
53+
this._options.next(options || DEFAULT_OPTIONS);
54+
}
55+
56+
// TODO(mbehrlich): Add event handlers, properties, and methods.
57+
58+
private _mapEl?: HTMLElement;
2459

25-
// TODO(mbehrlich): add options, handlers, properties, and methods.
60+
private readonly _options = new BehaviorSubject<google.maps.MapOptions>(DEFAULT_OPTIONS);
61+
private readonly _center = new BehaviorSubject<google.maps.LatLngLiteral|undefined>(undefined);
62+
private readonly _zoom = new BehaviorSubject<number|undefined>(undefined);
2663

27-
private readonly _map$ = new ReplaySubject<google.maps.Map>(1);
64+
private readonly _destroy = new Subject<void>();
2865

29-
constructor(private readonly _elementRef: ElementRef) {}
66+
constructor(private readonly _elementRef: ElementRef) {
67+
const googleMapsWindow: GoogleMapsWindow = window;
68+
if (!googleMapsWindow.google) {
69+
throw Error(
70+
'Namespace google not found, cannot construct embedded google ' +
71+
'map. Please install the Google Maps JavaScript API: ' +
72+
'https://developers.google.com/maps/documentation/javascript/' +
73+
'tutorial#Loading_the_Maps_API');
74+
}
75+
}
76+
77+
ngOnChanges() {
78+
this._setSize();
79+
}
3080

3181
ngOnInit() {
32-
// default options set to the Googleplex
33-
const options: google.maps.MapOptions = {
34-
center: {lat: 37.421995, lng: -122.084092},
35-
zoom: 17,
36-
};
37-
38-
const mapEl = this._elementRef.nativeElement.querySelector('.map-container');
39-
mapEl.style.height = this.height;
40-
mapEl.style.width = this.width;
41-
const map = new google.maps.Map(mapEl, options);
42-
this._map$.next(map);
82+
this._mapEl = this._elementRef.nativeElement.querySelector('.map-container')!;
83+
this._setSize();
84+
85+
const combinedOptionsChanges = this._combineOptions();
86+
87+
const googleMapChanges = this._initializeMap(combinedOptionsChanges);
88+
googleMapChanges.subscribe();
89+
90+
this._watchForOptionsChanges(googleMapChanges, combinedOptionsChanges);
91+
}
92+
93+
private _setSize() {
94+
if (this._mapEl) {
95+
this._mapEl.style.height = this.height || DEFAULT_HEIGHT;
96+
this._mapEl.style.width = this.width || DEFAULT_WIDTH;
97+
}
98+
}
99+
100+
/** Combines the center and zoom and the other map options into a single object */
101+
private _combineOptions(): Observable<google.maps.MapOptions> {
102+
return combineLatest(this._options, this._center, this._zoom)
103+
.pipe(map(([options, center, zoom]) => {
104+
const combinedOptions: google.maps.MapOptions = {
105+
...options,
106+
center: center || options.center,
107+
zoom: zoom !== undefined ? zoom : options.zoom,
108+
};
109+
return combinedOptions;
110+
}));
111+
}
112+
113+
private _initializeMap(optionsChanges: Observable<google.maps.MapOptions>):
114+
Observable<google.maps.Map> {
115+
return optionsChanges.pipe(take(1), map(options => new google.maps.Map(this._mapEl!, options)));
116+
}
117+
118+
private _watchForOptionsChanges(
119+
googleMapChanges: Observable<google.maps.Map>,
120+
optionsChanges: Observable<google.maps.MapOptions>) {
121+
combineLatest(googleMapChanges, optionsChanges)
122+
.pipe(takeUntil(this._destroy))
123+
.subscribe(([googleMap, options]) => {
124+
googleMap.setOptions(options);
125+
});
126+
}
127+
128+
ngOnDestroy() {
129+
this._destroy.next();
130+
this._destroy.complete();
43131
}
44132
}
Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
1-
declare global {
2-
interface Window {
3-
google?: {
4-
maps: {
5-
Map: jasmine.Spy;
6-
};
1+
/** Window interface for testing */
2+
export interface TestingWindow extends Window {
3+
google?: {
4+
maps: {
5+
Map: jasmine.Spy;
76
};
8-
}
7+
};
98
}
109

1110
/** Creates a jasmine.SpyObj for a google.maps.Map. */
1211
export function createMapSpy(options: google.maps.MapOptions): jasmine.SpyObj<google.maps.Map> {
13-
return jasmine.createSpyObj('Map', ['getDiv']);
12+
return jasmine.createSpyObj('google.maps.Map', ['setOptions']);
1413
}
1514

1615
/** Creates a jasmine.Spy to watch for the constructor of a google.maps.Map. */
17-
export function createMapConstructorSpy(mapSpy: jasmine.SpyObj<google.maps.Map>): jasmine.Spy {
16+
export function createMapConstructorSpy(
17+
mapSpy: jasmine.SpyObj<google.maps.Map>, apiLoaded = true): jasmine.Spy {
1818
const mapConstructorSpy =
1919
jasmine.createSpy('Map constructor', (_el: Element, _options: google.maps.MapOptions) => {
2020
return mapSpy;
2121
});
22-
window.google = {
23-
maps: {
24-
'Map': mapConstructorSpy,
25-
}
26-
};
22+
const testingWindow: TestingWindow = window;
23+
if (apiLoaded) {
24+
testingWindow.google = {
25+
maps: {
26+
'Map': mapConstructorSpy,
27+
}
28+
};
29+
}
2730
return mapConstructorSpy;
2831
}

0 commit comments

Comments
 (0)