Merge pull request #832 from Safelite/feature/digital/SSR-776

SSR-776 Make Google Maps pins clickable
This commit is contained in:
Josh Dassinger 2024-09-03 11:46:58 -05:00 committed by GitHub
commit aab351ca6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 183 additions and 52 deletions

View file

@ -20,5 +20,6 @@ Object {
], ],
}, },
}, },
"infoWindow": null,
} }
`; `;

View file

@ -21,12 +21,19 @@ const mockGeocoderInstance2 = {
}; };
const mockGeocoder = jest.fn(() => (mockGeocoderInstance1)); const mockGeocoder = jest.fn(() => (mockGeocoderInstance1));
const mockAdvancedMarkerElement = jest.fn(); const mockAdvancedMarkerInstance = {
addListener: jest.fn()
};
const mockAdvancedMarkerElement = jest.fn(() => (mockAdvancedMarkerInstance));
const mockPinElement = jest.fn();
const mockInfoWindow = jest.fn();
const mockImportLibrary = jest.fn().mockImplementation(() => (Promise.resolve({ const mockImportLibrary = jest.fn().mockImplementation(() => (Promise.resolve({
Map: mockMap, Map: mockMap,
Geocoder: mockGeocoder, Geocoder: mockGeocoder,
AdvancedMarkerElement: mockAdvancedMarkerElement AdvancedMarkerElement: mockAdvancedMarkerElement,
PinElement: mockPinElement
}))); })));
const mockExtend = jest.fn(); const mockExtend = jest.fn();
@ -36,7 +43,8 @@ const setupGoogleMock = () => {
global.window.google = { global.window.google = {
maps: { maps: {
importLibrary: mockImportLibrary, importLibrary: mockImportLibrary,
LatLngBounds: jest.fn(() => (mockLatLngBound)) LatLngBounds: jest.fn(() => (mockLatLngBound)),
InfoWindow: mockInfoWindow
} }
}; };
}; };
@ -46,6 +54,7 @@ beforeEach(() => {
mockMap.mockClear(); mockMap.mockClear();
mockGeocoder.mockClear(); mockGeocoder.mockClear();
mockAdvancedMarkerElement.mockClear(); mockAdvancedMarkerElement.mockClear();
mockPinElement.mockClear();
// clear methods // clear methods
mockExtend.mockClear(); mockExtend.mockClear();
@ -150,7 +159,13 @@ describe('Google Map', () => {
const zipCode = '00882'; const zipCode = '00882';
const [address1, address2, position1, position2] = ['a1', 'a2', 'p1', 'p2']; const [address1, address2, position1, position2] = ['a1', 'a2', 'p1', 'p2'];
const addresses = [address1, address2]; const markers = [
{
fullAddress: address1
},
{
fullAddress: address2
}];
mockGeocode.mockImplementation((obj) => { mockGeocode.mockImplementation((obj) => {
let result = {}; let result = {};
if (obj.address.includes(zipCode)) { if (obj.address.includes(zipCode)) {
@ -171,7 +186,7 @@ describe('Google Map', () => {
return Promise.resolve(result); return Promise.resolve(result);
}); });
const propsData = { addresses, zipCode }; const propsData = { markers, zipCode };
const { wrapper } = getMountedComponent({}, {}, propsData); const { wrapper } = getMountedComponent({}, {}, propsData);
await awaitingSetupTicks(wrapper); await awaitingSetupTicks(wrapper);
@ -198,8 +213,6 @@ describe('Google Map', () => {
test('watch addresses creates map with expected markers', async () => { test('watch addresses creates map with expected markers', async () => {
// Arrange // Arrange
const zipCode = '00882'; const zipCode = '00882';
const { wrapper } = getMountedComponent({}, { zipCode });
await awaitingSetupTicks(wrapper);
const mockGetCenter = jest.fn(); const mockGetCenter = jest.fn();
const mockNorthEast = jest.fn(); const mockNorthEast = jest.fn();
@ -238,10 +251,18 @@ describe('Google Map', () => {
} }
return Promise.resolve(result); return Promise.resolve(result);
}); });
const newAddresses = [address1, address2]; const markers = [
{
fullAddress: address1
},
{
fullAddress: address2
}];
// Act // Act
await wrapper.vm.$options.watch.addresses.call(wrapper.vm, newAddresses); const { wrapper } = getMountedComponent({}, { zipCode });
await awaitingSetupTicks(wrapper);
await wrapper.vm.$options.watch.markers.call(wrapper.vm, markers);
await awaitingSetupTicks(wrapper); await awaitingSetupTicks(wrapper);
// Assert // Assert
@ -260,9 +281,9 @@ describe('Google Map', () => {
expect(mockExtend).toBeCalledTimes(6); expect(mockExtend).toBeCalledTimes(6);
expect(mockFitBounds).toBeCalledTimes(2); expect(mockFitBounds).toBeCalledTimes(2);
expect(mockGetCenter).toBeCalledTimes(1); expect(mockGetCenter).toBeCalledTimes(2);
expect(mockNorthEast).toBeCalledTimes(1); expect(mockNorthEast).toBeCalledTimes(2);
expect(mockSouthWest).toBeCalledTimes(1); expect(mockSouthWest).toBeCalledTimes(2);
}); });
describe('method', () => { describe('method', () => {
test('getMap calls map constructor with expected parameters', async () => { test('getMap calls map constructor with expected parameters', async () => {
@ -295,12 +316,20 @@ describe('Google Map', () => {
await awaitingSetupTicks(wrapper); await awaitingSetupTicks(wrapper);
const numberOfExtendCallsDuringMount = 2; const numberOfExtendCallsDuringMount = 2;
mockGeocode.mockImplementation(() => {
const result = {
results: [
{ geometry: { location: 'p1' } }
]
};
return Promise.resolve(result);
});
// Act // Act
const result = wrapper.vm.getBounds(locations); const result = wrapper.vm.getBounds(locations);
// Assert // Assert
expect(mockExtend).toHaveBeenCalledTimes( expect(mockExtend).toHaveBeenCalledTimes(numberOfExtendCallsDuringMount + numberOfExtendCallsPostMount);
numberOfExtendCallsDuringMount + numberOfExtendCallsPostMount);
expect(result).toBe(mockLatLngBound); expect(result).toBe(mockLatLngBound);
} }
); );
@ -362,7 +391,7 @@ describe('Google Map', () => {
expect(mockGeocode).toHaveBeenCalledTimes(numberOfCallsFromMount); expect(mockGeocode).toHaveBeenCalledTimes(numberOfCallsFromMount);
// Act // Act
await wrapper.vm.getLocationsFromAddresses(addresses); await wrapper.vm.getLocationsFromAddresses(addresses?.map((a) => ({ fullAddress: a })));
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// Assert // Assert
@ -374,7 +403,7 @@ describe('Google Map', () => {
// Arrange // Arrange
const wrapper = shallowMount(googleMap, {}); const wrapper = shallowMount(googleMap, {});
await awaitingSetupTicks(wrapper); await awaitingSetupTicks(wrapper);
const addresses = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8']; const addresses = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8'].map((a) => ({ fullAddress: a }));
mockGeocode.mockImplementationOnce(() => null) mockGeocode.mockImplementationOnce(() => null)
.mockImplementationOnce(() => ({ results: null })) .mockImplementationOnce(() => ({ results: null }))
.mockImplementationOnce(() => ({ results: [] })) .mockImplementationOnce(() => ({ results: [] }))
@ -402,7 +431,7 @@ describe('Google Map', () => {
const expectedResult = ['some value', 'some other value']; const expectedResult = ['some value', 'some other value'];
// Act // Act
const result = await wrapper.vm.getLocationsFromAddresses(addresses); const result = (await wrapper.vm.getLocationsFromAddresses(addresses)).map((a) => a.position);
// Assert // Assert
expect(result.length).toBe(2); expect(result.length).toBe(2);
@ -477,7 +506,7 @@ describe('Google Map', () => {
Promise.resolve(obj.address.includes(zipCode) Promise.resolve(obj.address.includes(zipCode)
? zipCodeGeocodeResult ? zipCodeGeocodeResult
: shopGeocodeResult)); : shopGeocodeResult));
const addresses = ['a1', 'a2']; const addresses = [{ fullAddress: 'a1' }, { fullAddress: 'a2' }];
const { wrapper } = getMountedComponent({}, { zipCode }); const { wrapper } = getMountedComponent({}, { zipCode });
await awaitingSetupTicks(wrapper); await awaitingSetupTicks(wrapper);

View file

@ -8,13 +8,14 @@
export default { export default {
name: 'google-map', name: 'google-map',
props: { props: {
addresses: Array, markers: Array,
zipCode: String zipCode: String
}, },
data() { data() {
return { return {
country: 'USA', country: 'USA',
geocoder: null geocoder: null,
infoWindow: null
}; };
}, },
computed: { computed: {
@ -23,12 +24,12 @@ export default {
} }
}, },
watch: { watch: {
async addresses(newAddresses) { async markers(newMarkers) {
this.createMapWithMarkersForAddresses(newAddresses); await this.createMapWithMarkersForAddresses(newMarkers);
} }
}, },
beforeMount() { async beforeMount() {
this.createMapWithMarkersForAddresses(this.addresses); await this.createMapWithMarkersForAddresses(this.markers);
}, },
methods: { methods: {
async getMap(center) { async getMap(center) {
@ -50,20 +51,58 @@ export default {
this.geocoder = new Geocoder(); this.geocoder = new Geocoder();
} }
}, },
async addMarkersToMap(map, positions) { async addMarkersToMap(map, markers) {
const { AdvancedMarkerElement } = await window.google.maps.importLibrary('marker'); const { AdvancedMarkerElement, PinElement } = await window.google.maps.importLibrary('marker');
positions?.forEach((position) => new AdvancedMarkerElement({ map, position })); markers?.forEach((marker, index) => {
const pinElement = new PinElement({
glyph: String.fromCharCode('A'.charCodeAt(0) + index),
glyphColor: '#000000',
borderColor: '#000000'
});
const markerElement = new AdvancedMarkerElement({
map,
position: marker.position,
title: marker.title,
content: pinElement.element,
zIndex: 1000 - index
});
this.createMarkerInfoWindows(markerElement, marker);
});
}, },
async getLocationsFromAddresses(addresses) { createMarkerInfoWindows(markerElement, marker) {
const directionsUrl = this.createGetDirectionsUrl(marker);
const content = `<div>
<span style="color: black; font-weight:bold"> ${marker.title} </span>
${marker.addressLines?.map((address) => `<br/> ${address} `).join()}
<br /> <a href="${directionsUrl}" target="_blank">Get directions</a>
</div>`;
const infoWindow = new window.google.maps.InfoWindow({ content });
markerElement.addListener('click', () => {
this.infoWindow?.close();
this.infoWindow = infoWindow;
infoWindow.open(markerElement.map, markerElement);
});
},
createGetDirectionsUrl(marker) {
return `https://maps.google.com/maps?saddr=&daddr=${encodeURIComponent(marker.fullAddress)}`;
},
async getLocationsFromAddresses(markers) {
await this.setGeocoder(); await this.setGeocoder();
const geocodeShopAddressPromises = addresses?.map((address) => this.geocoder.geocode({ address })) ?? []; const geocodeShopAddressPromises = markers?.map((marker) => this.geocoder.geocode({ address: marker.fullAddress })) ?? [];
const geocodeShopAddressResults = await Promise.all(geocodeShopAddressPromises); const geocodeShopAddressResults = await Promise.all(geocodeShopAddressPromises);
return geocodeShopAddressResults.map((result) =>
(result?.results?.length > 0 return markers?.map((marker, index) => {
? result.results[0].geometry?.location const results = geocodeShopAddressResults[index]?.results;
: null)) let position = null;
.filter((loc) => loc != null); if (results && results.length > 0) {
position = results[0].geometry?.location;
}
return { ...marker, position };
}).filter((marker) => marker.position != null) ?? [];
}, },
async getBoundsFromAddress(address) { async getBoundsFromAddress(address) {
await this.setGeocoder(); await this.setGeocoder();
@ -72,16 +111,17 @@ export default {
? result.results[0].geometry?.bounds ? result.results[0].geometry?.bounds
: null; : null;
}, },
async createMapWithMarkersForAddresses(addresses) { async createMapWithMarkersForAddresses(markers) {
const shopPositions = await this.getLocationsFromAddresses(addresses); const markerPositions = await this.getLocationsFromAddresses(markers);
const zipBounds = await this.getBoundsFromAddress(this.zipCodeAddress); const zipBounds = await this.getBoundsFromAddress(this.zipCodeAddress);
const map = await this.getMap(zipBounds?.getCenter()); const map = await this.getMap(zipBounds?.getCenter());
await this.addMarkersToMap(map, shopPositions); await this.addMarkersToMap(map, markerPositions);
const positionsToDisplay = shopPositions.concat(zipBounds?.getNorthEast(), zipBounds?.getSouthWest()); const positionsToDisplay = markerPositions.map((marker) => marker.position)
.concat(zipBounds?.getNorthEast(), zipBounds?.getSouthWest());
const bounds = this.getBounds(positionsToDisplay); const bounds = this.getBounds(positionsToDisplay);
map?.fitBounds(bounds); map.fitBounds(bounds);
} }
} }
}; };

View file

@ -177,7 +177,7 @@ describe('TPA search page', () => {
// Assert // Assert
expect(map.exists()).toBeTruthy(); expect(map.exists()).toBeTruthy();
expect(map.classes()).toContain('mb-4'); expect(map.classes()).toContain('mb-4');
expect(map.props().addresses).toEqual(expectedAddresses); expect(map.props().markers.map((p) => p.fullAddress)).toEqual(expectedAddresses);
expect(map.props().zipCode).toBe(zipCode); expect(map.props().zipCode).toBe(zipCode);
}); });
test('search radius filter', async () => { test('search radius filter', async () => {
@ -582,7 +582,7 @@ describe('TPA search page', () => {
]; ];
// Act // Act
const result = wrapper.vm.providerAddresses; const result = wrapper.vm.providerAddresses.map((pa) => pa.fullAddress);
// Assert // Assert
expect(result).toEqual(expectedResult); expect(result).toEqual(expectedResult);
@ -1015,6 +1015,33 @@ describe('TPA search page', () => {
['address', 'city', null, 'zip', 'Address, City zip'], ['address', 'city', null, 'zip', 'Address, City zip'],
['address', 'city', 'ST', null, 'Address, City, ST'], ['address', 'city', 'ST', null, 'Address, City, ST'],
[null, null, null, null, ''] [null, null, null, null, '']
])(
'getFullProviderAddress returns expected',
(streetAddress, city, state, zipCode, expected) => {
// Arrange
const { wrapper } = getMountedComponent();
const provider = {
address: {
streetAddress,
city,
state,
zipCode
}
};
// Act
const result = wrapper.vm.getFullProviderAddress(provider);
// Assert
expect(result).toEqual(expected);
}
);
test.each([
['address', 'city', 'ST', 'zip', 'Address'],
['address', null, 'ST', 'zip', 'Address'],
['address', 'city', null, 'zip', 'Address'],
['address', 'city', 'ST', null, 'Address'],
[null, null, null, null, '']
])( ])(
'getProviderAddress returns expected', 'getProviderAddress returns expected',
(streetAddress, city, state, zipCode, expected) => { (streetAddress, city, state, zipCode, expected) => {
@ -1036,6 +1063,33 @@ describe('TPA search page', () => {
expect(result).toEqual(expected); expect(result).toEqual(expected);
} }
); );
test.each([
['address', 'city', 'ST', 'zip', 'City, ST zip'],
['address', null, 'ST', 'zip', 'ST zip'],
['address', 'city', null, 'zip', 'City zip'],
['address', 'city', 'ST', null, 'City, ST'],
[null, null, null, null, '']
])(
'getProviderCityZipState returns expected',
(streetAddress, city, state, zipCode, expected) => {
// Arrange
const { wrapper } = getMountedComponent();
const provider = {
address: {
streetAddress,
city,
state,
zipCode
}
};
// Act
const result = wrapper.vm.getProviderCityZipState(provider);
// Assert
expect(result).toEqual(expected);
}
);
describe('getProviderButtonData', () => { describe('getProviderButtonData', () => {
test.each([null, undefined, {}])( test.each([null, undefined, {}])(
'returns empty list when getTpaProviders returns no data', 'returns empty list when getTpaProviders returns no data',

View file

@ -46,7 +46,7 @@
<googleMap <googleMap
id="map" id="map"
class="mb-4" class="mb-4"
:addresses="providerAddresses" :markers="providerAddresses"
:zipCode="mapZipCode" /> :zipCode="mapZipCode" />
<Form <Form
id="providerSelectionForm" id="providerSelectionForm"
@ -281,10 +281,11 @@ export default {
)?.replaceAll('{custom:radiusInMiles}', this.radiusInMiles); )?.replaceAll('{custom:radiusInMiles}', this.radiusInMiles);
}, },
providerAddresses() { providerAddresses() {
return ( return this.providers?.map((provider) => ({
this.providers?.map((provider) => title: provider.companyName,
this.getProviderAddress(provider)) ?? [] fullAddress: this.getFullProviderAddress(provider),
); addressLines: [this.getProviderAddress(provider), this.getProviderCityZipState(provider)]
})) ?? [];
}, },
providerButtonData() { providerButtonData() {
return ( return (
@ -350,7 +351,17 @@ export default {
this.providers = providers; this.providers = providers;
this.selectedProviderNumber = providerNumber; this.selectedProviderNumber = providerNumber;
}, },
getFullProviderAddress(provider) {
const addressLine1 = this.getProviderAddress(provider);
const addressLine2 = this.getProviderCityZipState(provider);
const joinString =
addressLine1.length > 0 && addressLine2.length > 0 ? ', ' : '';
return [addressLine1, addressLine2].join(joinString);
},
getProviderAddress(provider) { getProviderAddress(provider) {
return toTitleCase(provider?.address?.streetAddress);
},
getProviderCityZipState(provider) {
const city = toTitleCase(provider?.address?.city); const city = toTitleCase(provider?.address?.city);
const state = provider?.address?.state ?? ''; const state = provider?.address?.state ?? '';
const zipCode = provider?.address?.zipCode ?? ''; const zipCode = provider?.address?.zipCode ?? '';
@ -368,11 +379,7 @@ export default {
if (zipCode) { if (zipCode) {
addressLine2 += zipCode; addressLine2 += zipCode;
} }
return addressLine2;
const addressLine1 = toTitleCase(provider?.address?.streetAddress);
const joinString =
addressLine1.length > 0 && addressLine2.length > 0 ? ', ' : '';
return [addressLine1, addressLine2].join(joinString);
}, },
async getProviderButtonData() { async getProviderButtonData() {
this.reloadingProviders = true; this.reloadingProviders = true;
@ -423,7 +430,7 @@ export default {
return { return {
buttonLabel: provider?.companyName ?? '', buttonLabel: provider?.companyName ?? '',
buttonLabelSubCopy: distance === null ? '' : `${distance} mi`, buttonLabelSubCopy: distance === null ? '' : `${distance} mi`,
buttonBodyCopy: `${this.getProviderAddress(provider)}<br>${ buttonBodyCopy: `${this.getFullProviderAddress(provider)}<br>${
cellNumber ?? '' cellNumber ?? ''
}`, }`,
value: provider?.providerNumber ?? '' value: provider?.providerNumber ?? ''