From 2dc1288a00f89bae33efc0b7dd07659ad471f088 Mon Sep 17 00:00:00 2001 From: namlh Date: Mon, 4 May 2026 10:55:27 +0700 Subject: [PATCH 1/5] [MOL-20562][NL] Trigger event when pin is clicked --- .../location-field/location-field.spec.tsx | 47 +++++++++++++++++++ .../location-picker/location-picker.tsx | 2 + src/components/fields/location-field/types.ts | 13 +++++ .../location-field-events.stories.tsx | 35 ++++++++++++-- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/__tests__/components/fields/location-field/location-field.spec.tsx b/src/__tests__/components/fields/location-field/location-field.spec.tsx index a8108c0f9..10b88fb47 100644 --- a/src/__tests__/components/fields/location-field/location-field.spec.tsx +++ b/src/__tests__/components/fields/location-field/location-field.spec.tsx @@ -51,6 +51,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", @@ -66,6 +67,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; @@ -124,6 +126,12 @@ const FrontendEngineWithEventListener = ({ handleShowConfirmLocationPrompt(e); }) ); + addFieldEventListener( + UI_TYPE, + ELocationInputEvents.CLICK_SELECTABLE_PIN, + COMPONENT_ID, + handleClickSelectablePin + ); }; const handleRemoveFieldEventListener = () => { @@ -134,6 +142,7 @@ const FrontendEngineWithEventListener = ({ COMPONENT_ID, confirmLocationOnClickSpy ); + removeFieldEventListener(ELocationInputEvents.CLICK_SELECTABLE_PIN, COMPONENT_ID, handleClickSelectablePin); }; handleAddFieldEventListener(); @@ -923,6 +932,44 @@ describe("location-input-group", () => { }); }); }); + 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(); + }); + }); + }); }); describe("Refresh location events", () => { diff --git a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx index b83e61951..56abc0513 100644 --- a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx +++ b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx @@ -204,6 +204,8 @@ export const LocationPicker = ({ 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 }); diff --git a/src/components/fields/location-field/types.ts b/src/components/fields/location-field/types.ts index b5e4dc3c5..622ee4cd1 100644 --- a/src/components/fields/location-field/types.ts +++ b/src/components/fields/location-field/types.ts @@ -110,6 +110,7 @@ export type TLocationFieldEvents = { error: CustomEvent; "error-end": CustomEvent; "set-selectable-pins": CustomEvent<{ pins: IMapPin[] }>; + "click-selectable-pin": CustomEvent<{ pin: IMapPin }>; }; export class GeolocationPositionErrorWrapper extends Error { @@ -242,6 +243,18 @@ function locationFieldEvent( listener: TFieldEventListener, 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() { // } diff --git a/src/stories/3-fields/location-field/location-field-events.stories.tsx b/src/stories/3-fields/location-field/location-field-events.stories.tsx index a70f24e3b..d69a492ff 100644 --- a/src/stories/3-fields/location-field/location-field-events.stories.tsx +++ b/src/stories/3-fields/location-field/location-field-events.stories.tsx @@ -686,17 +686,35 @@ HidePermissionModal.args = { mapApi: defaultMapApi, }; -const SetSelectablePinsTemplate = () => +const createSelectablePinsTemplate = (listenClickSelectablePin = false) => ((args) => { const id = "location-enable-map-click"; const formRef = useRef(); useEffect(() => { const currentFormRef = formRef.current; - currentFormRef.addFieldEventListener("location-field", "get-selectable-pins", id, getPins); + const handleClickSelectablePin = (e: TLocationFieldEvents["click-selectable-pin"]) => + action("click-selectable-pin")(e); + if (listenClickSelectablePin) { + currentFormRef?.addFieldEventListener( + "location-field", + "click-selectable-pin", + id, + handleClickSelectablePin + ); + } + currentFormRef?.addFieldEventListener("location-field", "get-selectable-pins", id, getPins); return () => { - currentFormRef.removeFieldEventListener("location-field", "get-selectable-pins", id, getPins); + currentFormRef?.removeFieldEventListener("location-field", "get-selectable-pins", id, getPins); + if (listenClickSelectablePin) { + currentFormRef?.removeFieldEventListener( + "location-field", + "click-selectable-pin", + id, + handleClickSelectablePin + ); + } }; }, []); @@ -716,7 +734,7 @@ const SetSelectablePinsTemplate = () => }, ]; setTimeout(() => { - formRef.current.dispatchFieldEvent("location-field", "set-selectable-pins", id, { + formRef.current?.dispatchFieldEvent("location-field", "set-selectable-pins", id, { pins: res.map( (r) => ({ @@ -755,13 +773,20 @@ const SetSelectablePinsTemplate = () => }) as StoryFn; /* eslint-enable react-hooks/rules-of-hooks */ -export const SetSelectablePins = SetSelectablePinsTemplate().bind({}); +export const SetSelectablePins = createSelectablePinsTemplate().bind({}); SetSelectablePins.args = { uiType: "location-field", label: "Set Selectable Pins", mapApi: defaultMapApi, }; +export const ClickSelectablePin = createSelectablePinsTemplate(true).bind({}); +ClickSelectablePin.args = { + uiType: "location-field", + label: "Click Selectable Pin", + mapApi: defaultMapApi, +}; + /* eslint-disable react-hooks/rules-of-hooks */ const RefreshLocationAndTriggerGetCurrentLocationTemplate = () => ((args) => { From 738de51cb1f6a024715df37b3464ccc4c04e0cda Mon Sep 17 00:00:00 2001 From: namlh Date: Mon, 4 May 2026 11:08:15 +0700 Subject: [PATCH 2/5] [MOL-20562][NL] Support custom HTML markers via markerHtml for dynamic marker rendering --- public/img/home.png | Bin 0 -> 2748 bytes .../location-field/location-field.spec.tsx | 62 ++++++- .../location-modal/location-picker/helper.ts | 11 ++ .../location-picker/location-picker.tsx | 20 ++- .../location-modal/location-picker/types.ts | 1 + .../location-field/custom-pin/index.styles.ts | 51 ++++++ .../location-field/custom-pin/index.tsx | 28 +++ .../location-field/location-field.stories.tsx | 168 +++++++++++++++--- 8 files changed, 304 insertions(+), 37 deletions(-) create mode 100644 public/img/home.png create mode 100644 src/stories/3-fields/location-field/custom-pin/index.styles.ts create mode 100644 src/stories/3-fields/location-field/custom-pin/index.tsx diff --git a/public/img/home.png b/public/img/home.png new file mode 100644 index 0000000000000000000000000000000000000000..45383507c0a1187569eb69b8cfb8e3bec3c0eb65 GIT binary patch literal 2748 zcmV;t3PbgYP)CWTabN>6k|Ns2wGPVI=jN$kSF8Uq*U-|ARbOIp}#x7mD zB<|k5YxeHl%kj+%g#w?Nnld9JBRrqa8=5#dIcfIo+ZX&kefqTFJb1hz#sG%B0JOzG z-Gc(6$H&Jx5q#y!6>(R-)>V=sZ3NF&xR+L1NQ*N;>wLp>!s2d1d9H2oYO+!+?p1Te z`T2QG44pZ1CM3RXCfd~i$HT%kVwy2)^0}3p_vGe~*jB-C?IpM<4!F+D*cMjBkS0b* zFzsk#T?Rs_19?E;#KeTi9XMd$uJmkWiR1w509eLf*sRo(+|$VlX@txxAg%>Q_L zzfYVvK@7;Lf$!%^w%pRL#Mc808cBQh%mM%2vm-~2=;Oza>wv&@m}rRsB7Sgi(E3+p zOWw^`{cHmvq5Zix`AlJ8U_e0=Ds=Q_^Veix#cV_YcyG+e9lm8{nobt1TXHs{4IAUk z90cYeJhpq~DNA_Jls0?{^1OvP6Kg$JNM3!TbpHXOmy5p|jOZ0c8o-JhT(SEA*W%)$ z{W;tEns1pDYeKkS`XqFH68_g+drZB|Fpj?mR~S1fIEPI1WzOYgrA#4;QY(|Xk=eCl z&*YcW@CgsmE{wIN@$pRnHYld2r>(1A|DdDdi5F6X81qkv_*6~WErH3?8qoAtA@tjE zGkmQx54Qbahy=J6ovUty88dX)ulsfnxFX)>xiB9h7Jk?s;Vh1k(*dp+C~DSs!yg=Y z_UxGqLSalM=TQcT_`beAG2>>k|LN}Ep}5^(W?Y&xQ07tAK>&T9D#ga5;{r+Wz+B;* zY}Tp3C)7#?ubEPVYG89bo6TC&n+9^R#6w!0CY}Jodpk}O8sYk@{w+D8iRh*5Uw>cJ zA>eegzrSBTVk}){)}|;jvRAA=$y$WBzBj^-9opaObTS9{ly1OZ;#TjxF-g=?BBZrO zQ)I5VbLWnH==N-Gz%CW+Rs4>%2p=6g!v6Bxci5kPb(DSZi=VQVfg>P@M!&kpddU|h zRRZ-PwZ;)ipEyh=lU7Nj(oqD$@T1F4w>E6+?_vM=<8Rn|Zyu_R{r;D41Y>ls1wdGC z)bgB4_9PMsdu(h>P(2oEp{4<@4cS&mHnyJh-}~7i_Q@aKT~o?* z?-MZ5f=LBgJvvzN1?3|PWq6HlNguE&(cRrGtJSJ~pKa>*Ik#7>r7@nAT4a`?`2Z|*N<5k$SxF$b8~a*=FOY>+O=!nX#mNB z`@Sz-=G#$Z$hITRi1{@n|JMGdx0}MTAMOvND3|hNVEaXwD}1%@bu|Z-qX_)+@wh{$X=pccwuOG zCONX_dAw9AajF(*j6Q;m_sqzvt1D2u#Txf$l#mNW2M9@?S;yr1AzEN*5u0_ywh%Egd z6xA{VUw!+8eR1!u66DE9&n%P47(^kRWEC&E5C%2DUNIm-HVCcHa{cGe zpKCa>Moq+)X#-HljEJ{w+vIe$K?91U!5eLInFDxvWTrL-M_C(N0 z0w6sbBng$bwM&k?YNaM8<`>wjgUiu@BsMmARdO`|9gCpl6668mf|^M?cka~iYy}VZ zhZ0pMiU2Dv#A%^0zsSCwTYb^ki_RnIbHy7N$e6N^2&%|Zo;-OH^jOJ%Yi+_)r%p*Q zkaS%)E7Iw%&v?E!iUhuZ?nC_kwR2#`wHBX7M}OkaEOyy?3HDz?1ai)uJLeMvVM5T< zXhn=wP>W|Nh6{ zM>KLt3)}tYUhKmZKhsG~5H7+QcW=8t>-7k~3K|`lxQFl=_Dorgfv`*`95#FR>*ee{fr}C_7rK~FI9tYXiL+Rk-s@eW`vWSrn z?fS7pv%z%|Tg?=FCkw7K4^o4PM?BNfbAc%2)sopKmZ}JsWtejXd8rJ)bRR!{>|eZi zQAZoRal`}@XaU(C5+5bCGR&*I%bjsX{W+Ck9_uvN!1+3GkD7%_NIafRmq%v>h?21_ z_NE({uulr^ZB(gAgs&8$KpI!7s$}B6=(7sV{Gv_BE62R9=dl@g9SR;8D&u($GOmLP z!G}dqAc;NXu+~CMDuYw|K*B1Oia}$E3O+xEIRBX28eiRCls!id(y+Vt*mKhR6!(4qTYp60{>qtK!XuSz5+?y3f2)V+6AK9c&jDQ#y(xX-ktN>@*ARJ6d5KLPQ z>h{qMB3uIpT~u$;ro^MHk7j~T-^0VhJ~XYU$~=GmeCN)IL#R793yzM@L74 zmLxUBsOb~}wLj>a=F>(Aa}3q;AXdYdsBBl1^{KXDEt<ALEQEbp4 zo1LAdRwakO1oMi;q5;8X^XAP|^%~qGhIIICUiSTn4TZ?7HN5WC8@3Uz+x#B~jD)&!LFj`30000 { // 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(); @@ -826,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 () => { @@ -870,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, @@ -931,6 +948,49 @@ describe("location-input-group", () => { expect(screen.queryByText("address 2")).toBeDefined(); }); }); + + it("should render custom HTML markers when markerHtml is provided", async () => { + const customMarkerHtml = '
Lift
'; + 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 () => { diff --git a/src/components/fields/location-field/location-modal/location-picker/helper.ts b/src/components/fields/location-field/location-modal/location-picker/helper.ts index 59aeb48e2..b535b924b 100644 --- a/src/components/fields/location-field/location-modal/location-picker/helper.ts +++ b/src/components/fields/location-field/location-modal/location-picker/helper.ts @@ -23,3 +23,14 @@ 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 || "", + iconSize: [1, 1], + iconAnchor: [0, 0], + }), + }); +}; diff --git a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx index 56abc0513..0101d5cc0 100644 --- a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx +++ b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx @@ -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"; @@ -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, @@ -195,12 +196,17 @@ export const LocationPicker = ({ const mapPinIcon = "data:image/svg+xml;base64," + btoa(ReactDOMServer.renderToString()); - 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", () => { diff --git a/src/components/fields/location-field/location-modal/location-picker/types.ts b/src/components/fields/location-field/location-modal/location-picker/types.ts index f36f08a6f..c72ff443f 100644 --- a/src/components/fields/location-field/location-modal/location-picker/types.ts +++ b/src/components/fields/location-field/location-modal/location-picker/types.ts @@ -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 { diff --git a/src/stories/3-fields/location-field/custom-pin/index.styles.ts b/src/stories/3-fields/location-field/custom-pin/index.styles.ts new file mode 100644 index 000000000..5ec0a625c --- /dev/null +++ b/src/stories/3-fields/location-field/custom-pin/index.styles.ts @@ -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; +`; diff --git a/src/stories/3-fields/location-field/custom-pin/index.tsx b/src/stories/3-fields/location-field/custom-pin/index.tsx new file mode 100644 index 000000000..4daee3e2a --- /dev/null +++ b/src/stories/3-fields/location-field/custom-pin/index.tsx @@ -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 ( + + + {icons.map((icon, index) => ( + + ))} + + + {showCount && {count}} + + ); +}; + +export const CustomHomePin = ({ iconUrl = "" }: ICustomHomePinProps) => ; diff --git a/src/stories/3-fields/location-field/location-field.stories.tsx b/src/stories/3-fields/location-field/location-field.stories.tsx index 41832d560..b6d6d166e 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -1,6 +1,7 @@ import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; import { Meta, StoryFn } from "@storybook/react"; import { useEffect, useRef } from "react"; +import ReactDOMServer from "react-dom/server"; import { ILocationCoord, ILocationFieldSchema, ILocationFieldValues } from "../../../components/fields"; import { IMapPin } from "../../../components/fields/location-field/location-modal/location-picker/types"; import { IFrontendEngineRef } from "../../../components/frontend-engine"; @@ -13,6 +14,24 @@ import { SUBMIT_BUTTON_SCHEMA, WarningStoryTemplate, } from "../../common"; +import { CustomHomePin, CustomPin } from "./custom-pin"; + +type TSelectablePinConfig = { + name?: string; + carParkName?: string; + id?: string; + carParkNumber?: string; + carParkType?: string; + parkingSystem?: string; + xCoord?: number; + yCoord?: number; + distanceFromReference?: number; + latitude: number; + longitude: number; + icons?: string[]; + count?: number; + isHomeAddress?: boolean; +}; const recaptchaSiteKey = "6LfCjocsAAAAALM6wuZN3bqarbgbdaLuJIgFSrXT"; @@ -20,6 +39,82 @@ const reverseGeocode = "https://api.dev.lifesg.io/onemap/revgeocode"; const convertLatLngToXY = "https://api.dev.lifesg.io/onemap/4326to3414"; const search = "https://api.dev.lifesg.io/onemap/search"; +const defaultSelectablePins: TSelectablePinConfig[] = [ + { + carParkName: "BLK 120 TO 124 PAYA LEBAR WAY", + carParkNumber: "M32", + carParkType: "SURFACE CAR PARK", + parkingSystem: "ELECTRONIC PARKING", + latitude: 1.3223122045708784, + longitude: 103.88279263612282, + xCoord: 33506.0078, + yCoord: 33840.2109, + distanceFromReference: 109.9831233911331, + }, +]; + +const pinsWithIcons: TSelectablePinConfig[] = [ + { + carParkName: "KAMPONG UBI VIEW", + carParkNumber: "358", + carParkType: "SURFACE CAR PARK", + parkingSystem: "ELECTRONIC PARKING", + xCoord: 35501.9607216, + yCoord: 34191.1578935, + latitude: 1.325486284730739, + longitude: 103.90072773995409, + icons: ["/img/home.png"], + count: 1, + isHomeAddress: true, + }, + { + carParkName: "KAMPONG UBI VIEW", + carParkNumber: "351", + carParkType: "SURFACE CAR PARK", + parkingSystem: "ELECTRONIC PARKING", + xCoord: 35501.9607216, + yCoord: 34191.1578935, + latitude: 1.326386284730739, + longitude: 103.90162773995409, + icons: ["/img/lift.png", "/img/reno.png"], + count: 3, + }, + { + carParkName: "KAMPONG UBI VIEW", + carParkNumber: "349", + carParkType: "SURFACE CAR PARK", + parkingSystem: "ELECTRONIC PARKING", + xCoord: 35485.6405577, + yCoord: 34228.9805541, + latitude: 1.3258283432645641, + longitude: 103.90058110378057, + icons: ["/img/lift.png"], + count: 2, + }, + { + carParkName: "KAMPONG UBI VIEW", + carParkNumber: "352", + carParkType: "SURFACE CAR PARK", + parkingSystem: "ELECTRONIC PARKING", + xCoord: 35354.2225417, + yCoord: 34162.4855169, + latitude: 1.3252270180708603, + longitude: 103.89940022649942, + icons: ["/img/reno.png"], + count: 1, + }, +]; + +const getMarkerHtml = (pin: TSelectablePinConfig) => { + return ReactDOMServer.renderToString( + pin?.isHomeAddress ? ( + + ) : ( + + ) + ); +}; + const defaultMapApi = { reverseGeocode, convertLatLngToXY, @@ -408,46 +503,30 @@ locationSelectionMode.args = { }; /* eslint-disable react-hooks/rules-of-hooks */ -const IndicateCurrentLocationTemplate = () => +const IndicateCurrentLocationTemplate = (res: TSelectablePinConfig[]) => ((args) => { const id = "location-enable-map-click"; const formRef = useRef(); useEffect(() => { const currentFormRef = formRef.current; - currentFormRef.addFieldEventListener("location-field", "get-selectable-pins", id, getPins); + currentFormRef?.addFieldEventListener("location-field", "get-selectable-pins", id, getPins); return () => { - currentFormRef.removeFieldEventListener("location-field", "get-selectable-pins", id, getPins); + currentFormRef?.removeFieldEventListener("location-field", "get-selectable-pins", id, getPins); }; }, []); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const getPins = (e: CustomEvent) => { - const res = [ - { - carParkName: "BLK 120 TO 124 PAYA LEBAR WAY", - carParkNumber: "M32", - carParkType: "SURFACE CAR PARK", - parkingSystem: "ELECTRONIC PARKING", - latitude: 1.3223122045708784, - longitude: 103.88279263612282, - xCoord: 33506.0078, - yCoord: 33840.2109, - distanceFromReference: 109.9831233911331, - }, - ]; + const getPins = () => { setTimeout(() => { - formRef.current.dispatchFieldEvent("location-field", "set-selectable-pins", id, { - pins: res.map( - (r) => - ({ - lat: r.latitude, - lng: r.longitude, - resultListItemText: r.carParkName, - address: `${r.carParkName} (${r.carParkNumber})`, - } as IMapPin) - ), + formRef.current?.dispatchFieldEvent("location-field", "set-selectable-pins", id, { + pins: res.map((r) => ({ + lat: r.latitude, + lng: r.longitude, + resultListItemText: r.carParkName, + address: `${r.carParkName} (${r.carParkNumber})`, + markerHtml: r.icons ? getMarkerHtml(r) : undefined, + })), }); }, 2000); }; @@ -475,7 +554,7 @@ const IndicateCurrentLocationTemplate = () => ); }) as StoryFn; -export const pinsOnlyIndicateCurrentLocation = IndicateCurrentLocationTemplate().bind({}); +export const pinsOnlyIndicateCurrentLocation = IndicateCurrentLocationTemplate(defaultSelectablePins).bind({}); pinsOnlyIndicateCurrentLocation.args = { uiType: "location-field", label: "Indicate Current Location", @@ -486,6 +565,37 @@ pinsOnlyIndicateCurrentLocation.args = { "https://dev.eservices.lifesg.io/report-neighbourhood-issue/img/icons/car-location-pin.svg", }; +export const CustomHtmlMarkers = IndicateCurrentLocationTemplate(pinsWithIcons).bind({}); +CustomHtmlMarkers.args = { + uiType: "location-field", + label: "Custom HTML Markers", + mapApi: defaultMapApi, + locationSelectionMode: "pins-only", + legendItems: [ + { + id: "lift", + label: "Lift fault", + icon: "/img/lift.png", + }, + { + id: "reno", + label: "Renovation", + icon: "/img/reno.png", + }, + ], + defaultAddress: { + lat: 1.325486284730739, + lng: 103.90072773995409, + }, +}; +CustomHtmlMarkers.parameters = { + docs: { + description: { + story: "Custom HTML markers can be rendered by passing a markerHtml property for each marker. This HTML string is used to display a custom marker, enabling dynamic rendering based on pin data—for example, showing different icons or badges for various location types.", + }, + }, +}; + export const LegendComponent = DefaultStoryTemplate( "location-field-legend", false, From c5c6a71df1415e9679c795c5770a6afda17ba21c Mon Sep 17 00:00:00 2001 From: namlh023 Date: Mon, 4 May 2026 18:42:56 +0700 Subject: [PATCH 3/5] [MOL-20562][NL] center on selectable pin matching defaultAddress when no location is selected --- .../location-picker/location-picker.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx index 0101d5cc0..d0826f13b 100644 --- a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx +++ b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx @@ -135,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 && 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; From 365d5182fb48e0a6fa137c13fd5a5fa655e8b709 Mon Sep 17 00:00:00 2001 From: namlh023 Date: Tue, 5 May 2026 09:03:31 +0700 Subject: [PATCH 4/5] [MOL-20562][NL] remove unused iconSize props --- .../location-field/location-modal/location-picker/helper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/fields/location-field/location-modal/location-picker/helper.ts b/src/components/fields/location-field/location-modal/location-picker/helper.ts index b535b924b..2c306c3ff 100644 --- a/src/components/fields/location-field/location-modal/location-picker/helper.ts +++ b/src/components/fields/location-field/location-modal/location-picker/helper.ts @@ -29,7 +29,6 @@ export const markerFromHtml = ({ lat, lng }: IMapPin, html: string, className?: icon: L.divIcon({ html, className: className || "", - iconSize: [1, 1], iconAnchor: [0, 0], }), }); From c2447cbc1bad170552992bae45605601ff220626 Mon Sep 17 00:00:00 2001 From: namlh023 Date: Tue, 5 May 2026 09:45:51 +0700 Subject: [PATCH 5/5] [MOL-20562][NL] Fix custom icons disappearing on location refresh --- .../fields/location-field/location-field.spec.tsx | 2 +- .../location-modal/location-picker/location-picker.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/__tests__/components/fields/location-field/location-field.spec.tsx b/src/__tests__/components/fields/location-field/location-field.spec.tsx index cd71163e8..77aed8655 100644 --- a/src/__tests__/components/fields/location-field/location-field.spec.tsx +++ b/src/__tests__/components/fields/location-field/location-field.spec.tsx @@ -8,6 +8,7 @@ import { ILocationFieldSchema, TSetCurrentLocationDetail } from "../../../../com 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"; @@ -34,7 +35,6 @@ import { mockReverseGeoCodeResponse, mockStaticMapDataUri, } from "./mock-values"; -import * as markerHelper from "../../../../components/fields/location-field/location-modal/location-picker/helper"; jest.mock("../../../../services/onemap/onemap-service.ts"); diff --git a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx index d0826f13b..b5b245829 100644 --- a/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx +++ b/src/components/fields/location-field/location-modal/location-picker/location-picker.tsx @@ -137,7 +137,7 @@ export const LocationPicker = ({ if (!selectedLocationCoord?.lat || !selectedLocationCoord?.lng) { // 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 && defaultAddress?.lat && defaultAddress?.lng) { + if (selectablePins.length > 0 && defaultAddress?.lat && defaultAddress?.lng) { const zoomCenter = selectablePins.find( ({ lat, lng }) => lat === defaultAddress.lat && lng === defaultAddress.lng ); @@ -267,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();