Skip to content
Closed
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
Binary file added public/img/home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Breakpoint, LifeSGTheme } from "@lifesg/react-design-system/theme";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MockViewport, mockIntersectionObserver, mockViewport, mockViewportForTestGroup } from "jsdom-testing-mocks";
import { Marker } from "leaflet";
import { useEffect, useRef, useState } from "react";
import { FrontendEngine, IFrontendEngineData, IFrontendEngineProps, IFrontendEngineRef } from "../../../../components";
import { ILocationFieldSchema, TSetCurrentLocationDetail } from "../../../../components/fields";
import { LocationHelper } from "../../../../components/fields/location-field/location-helper";
import { ERROR_SVG } from "../../../../components/fields/location-field/location-modal/location-modal.data";
import { ErrorImage } from "../../../../components/fields/location-field/location-modal/location-modal.styles";
import * as markerHelper from "../../../../components/fields/location-field/location-modal/location-picker/helper";
import { IMapPin } from "../../../../components/fields/location-field/location-modal/location-picker/types";
import { ERROR_MESSAGES, Prompt } from "../../../../components/shared";
import { GeoLocationHelper, TestHelper } from "../../../../utils";
Expand All @@ -33,6 +35,7 @@ import {
mockReverseGeoCodeResponse,
mockStaticMapDataUri,
} from "./mock-values";

jest.mock("../../../../services/onemap/onemap-service.ts");

window.HTMLElement.prototype.scrollTo = jest.fn; // required for .scrollTo in location-search
Expand All @@ -51,6 +54,7 @@ const LEGEND_ITEMS = [
const setCurrentLocationSpy = jest.fn();
const editButtonOnClickSpy = jest.fn();
const confirmLocationOnClickSpy = jest.fn();
const handleClickSelectablePin = jest.fn();

enum ELocationInputEvents {
"SET_CURRENT_LOCATION" = "set-current-location",
Expand All @@ -66,6 +70,7 @@ enum ELocationInputEvents {
"HIDE_PERMISSION_MODAL" = "hide-permission-modal",
"DISMISS_LOCATION_MODAL" = "dismiss-location-modal",
"CLICK_REFRESH_CURRENT_LOCATION" = "click-refresh-current-location",
"CLICK_SELECTABLE_PIN" = "click-selectable-pin",
}
interface ICustomFrontendEngineProps extends IFrontendEngineProps {
locationDetails?: TSetCurrentLocationDetail;
Expand Down Expand Up @@ -124,6 +129,12 @@ const FrontendEngineWithEventListener = ({
handleShowConfirmLocationPrompt(e);
})
);
addFieldEventListener(
UI_TYPE,
ELocationInputEvents.CLICK_SELECTABLE_PIN,
COMPONENT_ID,
handleClickSelectablePin
);
};

const handleRemoveFieldEventListener = () => {
Expand All @@ -134,6 +145,7 @@ const FrontendEngineWithEventListener = ({
COMPONENT_ID,
confirmLocationOnClickSpy
);
removeFieldEventListener(ELocationInputEvents.CLICK_SELECTABLE_PIN, COMPONENT_ID, handleClickSelectablePin);
};

handleAddFieldEventListener();
Expand Down Expand Up @@ -808,6 +820,12 @@ describe("location-input-group", () => {
// Selectable Pin Events
describe("Selectable Pin events", () => {
const getSelectablePinsEvent = "get-selectable-pins";
const createMarkerStub = () =>
({
addTo: jest.fn().mockReturnThis(),
on: jest.fn().mockReturnThis(),
remove: jest.fn(),
} as unknown as Marker);

describe("get-selectable-pins", () => {
const getSelectablePins = jest.fn();
Expand All @@ -817,7 +835,7 @@ describe("location-input-group", () => {
};

beforeEach(() => {
jest.clearAllMocks;
jest.clearAllMocks();
});

it("should fire get-selectable-pins event if default location is set", async () => {
Expand Down Expand Up @@ -861,6 +879,14 @@ describe("location-input-group", () => {
});

describe("set-selectable-pins", () => {
let markerFromSpy: jest.SpyInstance;
let markerFromHtmlSpy: jest.SpyInstance;

afterEach(() => {
markerFromHtmlSpy?.mockRestore();
markerFromSpy?.mockRestore();
});

it("should show error modal if selectable pins is not an array", async () => {
renderComponent({
eventType: getSelectablePinsEvent,
Expand Down Expand Up @@ -922,6 +948,87 @@ describe("location-input-group", () => {
expect(screen.queryByText("address 2")).toBeDefined();
});
});

it("should render custom HTML markers when markerHtml is provided", async () => {
const customMarkerHtml = '<div class="custom-marker"><img src="/img/lift.png" alt="Lift" /></div>';
markerFromSpy = jest.spyOn(markerHelper, "markerFrom").mockImplementation(() => createMarkerStub());
markerFromHtmlSpy = jest
.spyOn(markerHelper, "markerFromHtml")
.mockImplementation(() => createMarkerStub());
renderComponent({
eventType: getSelectablePinsEvent,
eventListener: (formRef) => () => {
formRef.dispatchFieldEvent(UI_TYPE, "set-selectable-pins", COMPONENT_ID, {
pins: [
{
lat: 1.21,
lng: 103.78,
resultListItemText: "address 1",
address: "address 1",
markerHtml: customMarkerHtml,
},
],
});
},
overrideSchema: {
defaultValues: {
[COMPONENT_ID]: {
lat: 1.29994179707526,
lng: 103.789404349716,
},
},
},
});

getLocationInput()?.focus();

await waitFor(() => {
expect(markerFromHtmlSpy).toHaveBeenCalledWith(
expect.objectContaining({
markerHtml: expect.stringContaining(customMarkerHtml),
}),
expect.any(String)
);
});
});
});
describe("click-selectable-pin", () => {
it("should fire click-selectable-pin event when selectable pin is clicked", async () => {
const selectablePin = {
lat: 1.21,
lng: 103.78,
resultListItemText: "address 1",
address: "address 1",
};
renderComponent({
withEvents: true,
locationDetails: { payload: { lat: 1, lng: 1 } },
eventType: getSelectablePinsEvent,
eventListener: (formRef) => () => {
formRef.dispatchFieldEvent(UI_TYPE, "set-selectable-pins", COMPONENT_ID, {
pins: [selectablePin],
});
},
overrideSchema: {
defaultValues: {
[COMPONENT_ID]: {
lat: 1.29994179707526,
lng: 103.789404349716,
},
},
},
});

getLocationInput()?.focus();

await waitFor(() => {
const markerIcon = document.querySelector(".leaflet-marker-icon");
expect(markerIcon).toBeInTheDocument();

markerIcon && fireEvent.click(markerIcon);
expect(handleClickSelectablePin).toHaveBeenCalled();
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,13 @@ export const removeMarkers = (markers: L.Marker[] | undefined) => {
if (!markers) return;
markers.forEach((marker) => marker.remove());
};

export const markerFromHtml = ({ lat, lng }: IMapPin, html: string, className?: string | undefined): L.Marker => {
return L.marker([lat, lng], {
icon: L.divIcon({
html,
className: className || "",
iconAnchor: [0, 0],
}),
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NavigationFillIcon } from "@lifesg/react-icons/navigation-fill";
import { ICircleFillIcon } from "@lifesg/react-icons";
import { PinFillIcon } from "@lifesg/react-icons/pin-fill";
import * as L from "leaflet";
import sanitizeHtml from "sanitize-html";
import "leaflet/dist/leaflet.css";
import { useContext, useEffect, useRef } from "react";
import ReactDOMServer from "react-dom/server";
Expand All @@ -14,7 +15,7 @@ import { TestHelper } from "../../../../../utils";
import { useFieldEvent } from "../../../../../utils/hooks";
import { LocationHelper } from "../../location-helper";
import { ILocationCoord } from "../../types";
import { markerFrom, removeMarkers } from "./helper";
import { markerFrom, markerFromHtml, removeMarkers } from "./helper";
import { Legend } from "./legend";
import {
Banner,
Expand Down Expand Up @@ -134,10 +135,17 @@ export const LocationPicker = ({
*/
useEffect(() => {
if (!selectedLocationCoord?.lat || !selectedLocationCoord?.lng) {
// If there is no selected location, zoom to default address if available, else reset to default view
if (defaultAddress?.lat && defaultAddress?.lng) {
zoomWithMarkers([defaultAddress], false);
return;
// When no location is selected, center the map on the selectable pin that matches
// defaultAddress so we keep the selectable pin markers (including markerHtml) visible.
if (selectablePins.length > 0 && defaultAddress?.lat && defaultAddress?.lng) {
const zoomCenter = selectablePins.find(
({ lat, lng }) => lat === defaultAddress.lat && lng === defaultAddress.lng
);

if (zoomCenter) {
zoomWithMarkers(selectablePins, true, zoomCenter, true, false);
return;
}
}
resetView();
return;
Expand Down Expand Up @@ -195,15 +203,22 @@ export const LocationPicker = ({
const mapPinIcon =
"data:image/svg+xml;base64," +
btoa(ReactDOMServer.renderToString(<PinFillIcon color={Colour["icon-primary"]({ theme })} />));
const marker = markerFrom(
target,
interactiveMapPinIconUrl && !target.isCurrentLocation ? interactiveMapPinIconUrl : mapPinIcon,
isSelected,
target.isCurrentLocation
).addTo(map);
const resolvedPinIcon =
interactiveMapPinIconUrl && !target.isCurrentLocation ? interactiveMapPinIconUrl : mapPinIcon;
const marker = target.markerHtml
? markerFromHtml(
target,
sanitizeHtml(target.markerHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
allowedAttributes: false,
})
).addTo(map)
: markerFrom(target, resolvedPinIcon, isSelected, target.isCurrentLocation).addTo(map);

return shouldSelectOnClick && !target.isCurrentLocation
? marker.on("click", () => {
const shouldPreventDefault = !dispatchFieldEvent("click-selectable-pin", id, { pin: target });
if (shouldPreventDefault) return;
// To accurately identify the pin, use the original lat & lng from the pin
// instead of the ones from the LeafletMouseEvent.
zoomAndCenter(map, { lat: target.lat, lng: target.lng });
Expand Down Expand Up @@ -252,7 +267,10 @@ export const LocationPicker = ({
const shouldPreventDefault = !dispatchFieldEvent("click-refresh-current-location", id);
if (!shouldPreventDefault) {
if (defaultAddress?.lat && defaultAddress?.lng) {
zoomWithMarkers([defaultAddress], false);
const zoomCenter = selectablePins.find(
({ lat, lng }) => lat === defaultAddress.lat && lng === defaultAddress.lng
);
zoomWithMarkers(selectablePins, true, zoomCenter ?? defaultAddress, true, false);
return;
}
getCurrentLocation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface IMapPin extends ILocationCoord {
address?: string | undefined;
resultListItemText?: string | undefined;
isCurrentLocation?: boolean | undefined;
markerHtml?: string | undefined;
}

export interface ILocationPickerProps extends React.InputHTMLAttributes<HTMLDivElement> {
Expand Down
13 changes: 13 additions & 0 deletions src/components/fields/location-field/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type TLocationFieldEvents = {
error: CustomEvent<TLocationFieldErrorDetail>;
"error-end": CustomEvent<TLocationFieldErrorDetail>;
"set-selectable-pins": CustomEvent<{ pins: IMapPin[] }>;
"click-selectable-pin": CustomEvent<{ pin: IMapPin }>;
};

export class GeolocationPositionErrorWrapper extends Error {
Expand Down Expand Up @@ -242,6 +243,18 @@ function locationFieldEvent(
listener: TFieldEventListener<TLocationFieldErrorDetail>,
options?: boolean | AddEventListenerOptions | undefined
): void;
/**
* fired on clicking a selectable pin on the map
*
* `event.preventDefault()` will stop the default selection behavior
* */
function locationFieldEvent(
uiType: "location-field",
type: "click-selectable-pin",
id: string,
listener: TFieldEventListener<{ pin: IMapPin }>,
options?: boolean | AddEventListenerOptions | undefined
): void;
function locationFieldEvent() {
//
}
Expand Down
51 changes: 51 additions & 0 deletions src/stories/3-fields/location-field/custom-pin/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Colour, Font, Radius, Shadow, Spacing } from "@lifesg/react-design-system/theme";
import styled from "styled-components";

export const CustomPinContainer = styled.div`
position: relative;
display: inline-flex;
align-items: center;
gap: ${Spacing["spacing-4"]};
padding: ${Spacing["spacing-4"]};
background: ${Colour["bg-primary"]};
border-radius: ${Radius.full};
box-shadow: ${Shadow["sm-subtle"]};
color: ${Colour["text-inverse"]};

&::after {
content: "";
position: absolute;
top: calc(100% - 1px);
left: 50%;
transform: translateX(-50%);
border-width: 8px;
border-style: solid;
border-color: ${Colour["bg-primary"]} transparent transparent transparent;
}
`;

export const PinIconWrapper = styled.span`
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
`;

export const PinIconImage = styled.img`
width: ${Spacing["spacing-24"]};
height: ${Spacing["spacing-24"]};
object-fit: contain;
flex-shrink: 0;
`;

export const PinCount = styled.span`
${Font["body-sm-semibold"]};
font-weight: ${Font.Spec["weight-semibold"]};
`;

export const HomePinImage = styled.img`
display: block;
width: ${Spacing["spacing-40"]};
height: ${Spacing["spacing-40"]};
object-fit: contain;
`;
28 changes: 28 additions & 0 deletions src/stories/3-fields/location-field/custom-pin/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CustomPinContainer, HomePinImage, PinCount, PinIconImage, PinIconWrapper } from "./index.styles";

export interface ICustomPinProps {
icons?: string[];
count?: number | undefined;
}

export interface ICustomHomePinProps {
iconUrl?: string | undefined;
}

export const CustomPin = ({ icons = [], count }: ICustomPinProps) => {
const showCount = count !== undefined && count !== null;

return (
<CustomPinContainer>
<PinIconWrapper>
{icons.map((icon, index) => (
<PinIconImage key={`${icon}-${index}`} src={icon} alt="" />
))}
</PinIconWrapper>

{showCount && <PinCount>{count}</PinCount>}
</CustomPinContainer>
);
};

export const CustomHomePin = ({ iconUrl = "" }: ICustomHomePinProps) => <HomePinImage src={iconUrl} alt="" />;
Loading