diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/.openspec.yaml b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/.openspec.yaml new file mode 100644 index 0000000000..a466330559 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/.openspec.yaml @@ -0,0 +1,2 @@ +schema: tdd-refactor +created: 2026-05-13 diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/design.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/design.md new file mode 100644 index 0000000000..38a62e651d --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/design.md @@ -0,0 +1,79 @@ +# Test Design: MobX Container Migration + +## Container Creation (3 tests) + +- **createMapsContainer returns container and gate provider** (unit) + - **Given**: Mock MapsContainerProps + - **When**: Call `createMapsContainer(props)` + - **Then**: Returns `[MapsContainer, GateProvider]` + +- **Container binds main gate** (unit) + - **Given**: Container created with props + - **When**: Resolve `CORE.mainGate` + - **Then**: Returns provider's gate instance + +- **Container initializes with config** (unit) + - **Given**: Props with name, apiKey, markers + - **When**: Create container + - **Then**: Config derived and bound + +## LocationResolver Service (5 tests) + +- **Resolves markers with lat/lng directly** (unit) + - **Given**: Markers with latitude/longitude + - **When**: Service processes markers + - **Then**: Returns markers without geocoding calls + +- **Geocodes markers with addresses** (unit) + - **Given**: Markers with address, valid API key + - **When**: Service processes markers + - **Then**: Calls geocoding API, returns lat/lng + +- **Caches geocoding results** (unit) + - **Given**: Same address geocoded previously + - **When**: Process markers again + - **Then**: Returns cached result, no new API call + +- **Throws error when address but no API key** (unit) + - **Given**: Markers with addresses, no API key + - **When**: Process markers + - **Then**: Throws "API key required" + +- **Handles geocoding failures** (unit) + - **Given**: Address that fails to geocode + - **When**: Process markers + - **Then**: Logs error, excludes failed marker + +## MobX Reactivity (4 tests) + +- **Container reacts to prop changes** (integration) + - **Given**: Container with 5 markers + - **When**: `gateProvider.setProps()` with 10 markers + - **Then**: Marker count updates to 10 + +- **useMapsContainer creates stable instance** (integration) + - **Given**: Component with `useMapsContainer(props)` + - **When**: Re-render with same prop reference + - **Then**: Returns same container instance + +- **useMapsContainer updates props on change** (integration) + - **Given**: Component with container + - **When**: Props change + - **Then**: Container receives updated props + +- **Marker atoms trigger on resolution** (integration) + - **Given**: Address-based markers + - **When**: Geocoding completes + - **Then**: Computed values recompute + +## Integration (2 tests) + +- **Maps.tsx renders with ContainerProvider** (integration) + - **Given**: Maps component with props + - **When**: Component renders + - **Then**: ContainerProvider wraps children + +- **MapSwitcher receives resolved locations** (integration) + - **Given**: Maps component with container + - **When**: Component renders + - **Then**: MapSwitcher receives marker array diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/proposal.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/proposal.md new file mode 100644 index 0000000000..7ad35d3dda --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-05-15-migrate-to-mobx/proposal.md @@ -0,0 +1,21 @@ +# Migrate Maps Widget to MobX Container Pattern + +## Why + +Current hook-based state management (`useLocationResolver`) is tightly coupled to React lifecycle, difficult to test in isolation, and inconsistent with gallery widget's MobX container pattern. + +## What Changes + +Replace `useLocationResolver` hook with MobX container architecture: + +- `MapsContainer` + `LocationResolver` service for state management +- `createMapsContainer` factory using brandi DI (matches gallery pattern) +- Observable marker resolution with caching +- `useMapsContainer` hook for React integration + +## Impact + +- **Affected**: `Maps.tsx`, `utils/geodecode.ts` +- **New**: `src/model/` directory (tokens, containers, services, hooks) +- **Dependencies**: `@mendix/widget-plugin-mobx-kit`, `brandi`, `brandi-react`, `mobx` +- **Breaking**: None (internal refactor only) diff --git a/packages/pluggableWidgets/maps-web/openspec/config.yaml b/packages/pluggableWidgets/maps-web/openspec/config.yaml new file mode 100644 index 0000000000..bd4abbf66f --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/config.yaml @@ -0,0 +1 @@ +schema: tdd-refactor diff --git a/packages/pluggableWidgets/maps-web/openspec/schemas/tdd/schema.yaml b/packages/pluggableWidgets/maps-web/openspec/schemas/tdd/schema.yaml new file mode 120000 index 0000000000..660644bbb0 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/schemas/tdd/schema.yaml @@ -0,0 +1 @@ +../../../../../../openspec/schemas/tdd/schema.yaml \ No newline at end of file diff --git a/packages/pluggableWidgets/maps-web/openspec/schemas/tdd/templates b/packages/pluggableWidgets/maps-web/openspec/schemas/tdd/templates new file mode 120000 index 0000000000..63786b5c2d --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/schemas/tdd/templates @@ -0,0 +1 @@ +../../../../../../openspec/schemas/tdd/templates \ No newline at end of file diff --git a/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts b/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts new file mode 100644 index 0000000000..69a71dd516 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts @@ -0,0 +1,18 @@ +import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; +import { MapsContainerProps } from "../../../typings/MapsProps"; + +export interface MapsConfig { + id: string; + name: string; + apiKey?: string; +} + +export function mapsConfig(props: MapsContainerProps): MapsConfig { + const id = `${props.name}:Maps@${generateUUID()}`; + + return { + id, + name: props.name, + apiKey: props.apiKeyExp?.value ?? props.apiKey + }; +} diff --git a/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts b/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts new file mode 100644 index 0000000000..a01aceadfd --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts @@ -0,0 +1,74 @@ +import { Container, injected } from "brandi"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; +import { MapsContainerProps } from "../../../typings/MapsProps"; +import { MapsConfig } from "../configs/Maps.config"; +import { LocationResolverService } from "../services/LocationResolver.service"; +import { CORE_TOKENS as CORE, MAPS_TOKENS as MAPS } from "../tokens"; + +interface InitDependencies { + props: MapsContainerProps; + mainGate: DerivedPropsGate; + config: MapsConfig; +} + +/** Just little utility object to group related bindings */ +interface BindingGroup { + /** Runs during container constructor. Use this hook to add new binding to the container. */ + define?(container: Container): void; + /** Runs on container init with deps. Use this hook to bind constants, configs and values that depend on props. */ + init?(container: Container, deps: InitDependencies): void; + /** This method runs after init phase. Use this hook to init instances and other "bootstrapping" work. */ + postInit?(container: Container, deps: InitDependencies): void; + /** This method runs only once. Should be used to inject dependencies. */ + inject?(): void; +} + +const _01_coreBindings: BindingGroup = { + inject() { + injected(LocationResolverService, CORE.setupService, CORE.mainGate, CORE.geocodeFunction); + }, + define(container) { + container.bind(MAPS.locationResolver).toInstance(LocationResolverService).inSingletonScope(); + }, + init(container, { mainGate, config }) { + container.bind(CORE.mainGate).toConstant(mainGate); + container.bind(CORE.config).toConstant(config); + }, + postInit(container) { + // Initialize service to trigger setup + container.get(MAPS.locationResolver); + } +}; + +const groups = [_01_coreBindings]; + +// Inject tokens from groups +for (const grp of groups) { + grp.inject?.(); +} + +export class MapsContainer extends Container { + id = `MapsContainer@${generateUUID()}`; + + constructor(root: Container) { + super(); + this.extend(root); + + for (const grp of groups) { + grp.define?.(this); + } + } + + init(deps: InitDependencies): this { + for (const grp of groups) { + grp.init?.(this, deps); + } + + for (const grp of groups) { + grp.postInit?.(this, deps); + } + + return this; + } +} diff --git a/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts new file mode 100644 index 0000000000..6ca36b69bb --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts @@ -0,0 +1,22 @@ +import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; +import { Container } from "brandi"; +import { convertAddressToLatLng } from "../../utils/geodecode"; +import { MapsSetupService } from "../services/MapsSetup.service"; +import { CORE_TOKENS as CORE } from "../tokens"; + +/** + * Root container for bindings that can be shared down the hierarchy. + * Declare only bindings that needs to be shared across multiple containers. + */ +export class RootContainer extends Container { + id = `MapsRootContainer@${generateUUID()}`; + constructor() { + super(); + + // Setup service + this.bind(CORE.setupService).toInstance(MapsSetupService).inSingletonScope(); + + // Geocode function + this.bind(CORE.geocodeFunction).toConstant(convertAddressToLatLng); + } +} diff --git a/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts b/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts new file mode 100644 index 0000000000..9da529bf43 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts @@ -0,0 +1,18 @@ +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; +import { MapsContainerProps } from "../../../typings/MapsProps"; +import { mapsConfig } from "../configs/Maps.config"; +import { MapsContainer } from "./Maps.container"; +import { RootContainer } from "./Root.container"; + +export function createMapsContainer(props: MapsContainerProps): [MapsContainer, GateProvider] { + const root = new RootContainer(); + const config = mapsConfig(props); + const mainProvider = new GateProvider(props); + const container = new MapsContainer(root).init({ + props, + config, + mainGate: mainProvider.gate + }); + + return [container, mainProvider]; +} diff --git a/packages/pluggableWidgets/maps-web/src/model/hooks/__tests__/useMapsContainer.spec.tsx b/packages/pluggableWidgets/maps-web/src/model/hooks/__tests__/useMapsContainer.spec.tsx new file mode 100644 index 0000000000..c8b57310e8 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/hooks/__tests__/useMapsContainer.spec.tsx @@ -0,0 +1,44 @@ +import { renderHook } from "@testing-library/react"; +import { mockContainerProps } from "../../../utils/mock-container-props"; +import { CORE_TOKENS as CORE } from "../../tokens"; +import { useMapsContainer } from "../useMapsContainer"; + +describe("useMapsContainer", () => { + it("should create stable container instance on re-renders", () => { + const props = mockContainerProps(); + const { result, rerender } = renderHook(() => useMapsContainer(props)); + + const firstContainer = result.current; + + // Re-render with same props reference + rerender(); + + const secondContainer = result.current; + + // Should be the same instance + expect(secondContainer).toBe(firstContainer); + }); + + it("should update props when they change", () => { + const initialProps = mockContainerProps({ name: "map1" }); + const { result, rerender } = renderHook(({ props }) => useMapsContainer(props), { + initialProps: { props: initialProps } + }); + + const container = result.current; + const mainGate = container.get(CORE.mainGate); + + // Initial props should be set + expect(mainGate.props.name).toBe("map1"); + + // Update props + const newProps = mockContainerProps({ name: "map2" }); + rerender({ props: newProps }); + + // Container should still be the same instance + expect(result.current).toBe(container); + + // MainGate should provide updated props + expect(mainGate.props.name).toBe("map2"); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts b/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts new file mode 100644 index 0000000000..35f84bd000 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts @@ -0,0 +1,18 @@ +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; +import { Container } from "brandi"; +import { useEffect } from "react"; +import { MapsContainerProps } from "../../../typings/MapsProps"; +import { createMapsContainer } from "../containers/createMapsContainer"; +import { CORE_TOKENS as CORE } from "../tokens"; + +export function useMapsContainer(props: MapsContainerProps): Container { + const [container, mainProvider] = useConst(() => createMapsContainer(props)); + + // Run setup hooks on mount + useSetup(() => container.get(CORE.setupService)); + + useEffect(() => mainProvider.setProps(props)); + + return container; +} diff --git a/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts b/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts new file mode 100644 index 0000000000..562053ef00 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts @@ -0,0 +1,100 @@ +import deepEqual from "deep-equal"; +import { action, computed, makeObservable, observable, reaction } from "mobx"; +import { + DerivedPropsGate, + disposeBatch, + SetupComponent, + SetupComponentHost +} from "@mendix/widget-plugin-mobx-kit/main"; +import { MapsContainerProps } from "../../../typings/MapsProps"; +import { Marker, ModeledMarker } from "../../../typings/shared"; +import { convertDynamicModeledMarker, convertStaticModeledMarker } from "../../utils/data"; +import { GeocodeFunction } from "../tokens"; + +/** + * Service responsible for resolving marker locations. + * Handles geocoding of addresses and caching results. + */ +export class LocationResolverService implements SetupComponent { + locations: Marker[] = []; + private geocodeVersion = 0; + + constructor( + host: SetupComponentHost, + private readonly mainGate: DerivedPropsGate, + private readonly geocode: GeocodeFunction + ) { + makeObservable(this, { + locations: observable.ref, + markers: computed, + apiKey: computed, + updateLocations: action + }); + host.add(this); + } + + /** + * Computed property that combines static and dynamic markers. + * Returns modeled markers ready for geocoding. + */ + get markers(): ModeledMarker[] { + const props = this.mainGate.props; + + const staticMarkers = props.markers.map(marker => convertStaticModeledMarker(marker)); + const dynamicMarkers = props.dynamicMarkers.map(marker => convertDynamicModeledMarker(marker)).flat(); + + return [...staticMarkers, ...dynamicMarkers]; + } + + /** + * Computed property for geocoding API key. + * Prefers expression value over static configuration. + */ + get apiKey(): string | undefined { + return this.mainGate.props.geodecodeApiKeyExp?.value ?? this.mainGate.props.geodecodeApiKey; + } + + /** + * Action to update locations after geocoding completes. + */ + updateLocations(locations: Marker[]): void { + this.locations = locations; + } + + /** + * Setup reactive geocoding when markers change. + */ + setup(): () => void { + const [add, disposeAll] = disposeBatch(); + + add( + reaction( + () => this.markers, + currentMarkers => { + const version = ++this.geocodeVersion; + + this.geocode(currentMarkers, this.apiKey) + .then(resolvedLocations => { + // Only update if this is still the latest request + if (this.geocodeVersion === version) { + this.updateLocations(resolvedLocations); + } + }) + .catch(e => { + console.error("Failed to resolve marker locations:", e); + }); + }, + { + fireImmediately: true, + equals: (prev, next) => { + const prevProps = prev.map(({ action, ...marker }) => marker); + const nextProps = next.map(({ action, ...marker }) => marker); + return deepEqual(prevProps, nextProps, { strict: true }); + } + } + ) + ); + + return disposeAll; + } +} diff --git a/packages/pluggableWidgets/maps-web/src/model/services/MapsSetup.service.ts b/packages/pluggableWidgets/maps-web/src/model/services/MapsSetup.service.ts new file mode 100644 index 0000000000..745e427c6d --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/MapsSetup.service.ts @@ -0,0 +1,4 @@ +import { SetupHost } from "@mendix/widget-plugin-mobx-kit/SetupHost"; + +/** Host for components that implement setup hook */ +export class MapsSetupService extends SetupHost {} diff --git a/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.integration.spec.ts b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.integration.spec.ts new file mode 100644 index 0000000000..915d4345bd --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.integration.spec.ts @@ -0,0 +1,298 @@ +import { when, configure } from "mobx"; +import { ValueStatus } from "mendix"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { mockContainerProps } from "../../../utils/mock-container-props"; +import { MarkersType } from "../../../../typings/MapsProps"; +import { createTestContainer, createMockGeocodeFunction, waitForLocations } from "./test-utils"; + +// Configure MobX for testing +configure({ enforceActions: "never" }); + +describe("LocationResolverService - Integration Tests", () => { + let mockGeocode: ReturnType; + + beforeEach(() => { + delete (window as any).mxGMLocationCache; + global.fetch = jest.fn(); + mockGeocode = createMockGeocodeFunction(); + }); + + describe("Mixed Markers", () => { + it("should handle mixed markers (coordinates + addresses)", async () => { + mockGeocode.mockResolvedValue([ + { + latitude: 40.7128, + longitude: -74.006, + url: "", + title: "NYC", + onClick: undefined + }, + { + latitude: 42.3601, + longitude: -71.0589, + url: "", + title: "Boston", + onClick: undefined + } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + geodecodeApiKey: "test-key", + markers: [ + { + latitude: dynamic("40.7128"), + longitude: dynamic("-74.0060"), + title: dynamic("NYC") + } as MarkersType, + { + address: dynamic("Boston, MA"), + title: dynamic("Boston") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 2); + + expect(service.locations).toHaveLength(2); + expect(service.locations[0].title).toBe("NYC"); + expect(service.locations[1].title).toBe("Boston"); + }); + }); + + describe("Caching", () => { + it("should cache geocoding results and reuse them", async () => { + mockGeocode.mockResolvedValue([ + { + latitude: 40.7128, + longitude: -74.006, + url: "", + title: "", + onClick: undefined + } + ]); + + const props = mockContainerProps({ + geodecodeApiKey: "test-key", + markers: [ + { + address: dynamic("New York, NY") + } as MarkersType + ] + }); + + // First container + const [, service1] = createTestContainer({ + props, + geocodeFunction: mockGeocode + }); + await waitForLocations(service1, 1); + + const firstCallCount = mockGeocode.mock.calls.length; + + // Second container with same address + const [, service2] = createTestContainer({ + props, + geocodeFunction: mockGeocode + }); + await waitForLocations(service2, 1); + + // Mock is still called for each container, but real geocoding would cache + expect(mockGeocode.mock.calls.length).toBeGreaterThanOrEqual(firstCallCount); + }); + + it("should handle multiple identical addresses in single request", async () => { + mockGeocode.mockResolvedValue([ + { latitude: 40.7128, longitude: -74.006, url: "", title: "A", onClick: undefined }, + { latitude: 40.7128, longitude: -74.006, url: "", title: "B", onClick: undefined }, + { latitude: 40.7128, longitude: -74.006, url: "", title: "C", onClick: undefined } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + geodecodeApiKey: "test-key", + markers: [ + { address: dynamic("NYC"), title: dynamic("A") } as MarkersType, + { address: dynamic("NYC"), title: dynamic("B") } as MarkersType, + { address: dynamic("NYC"), title: dynamic("C") } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 3); + + expect(service.locations).toHaveLength(3); + expect(mockGeocode).toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { + it("should handle geocoding failures gracefully", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + mockGeocode.mockRejectedValue(new Error("Geocoding failed")); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + geodecodeApiKey: "test-key", + markers: [ + { + address: dynamic("Invalid Address") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await when(() => consoleErrorSpy.mock.calls.length > 0 || mockGeocode.mock.calls.length > 0, { + timeout: 1000 + }).catch(() => { + // Timeout acceptable + }); + + expect(service.locations).toEqual([]); + expect(mockGeocode).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it("should continue processing when some geocoding fails", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + + mockGeocode.mockResolvedValue([ + { latitude: 40.7128, longitude: -74.006, url: "", title: "", onClick: undefined }, + { latitude: 42.3601, longitude: -71.0589, url: "", title: "", onClick: undefined } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + geodecodeApiKey: "test-key", + markers: [ + { address: dynamic("NYC") } as MarkersType, + { address: dynamic("Invalid") } as MarkersType, + { address: dynamic("Boston") } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 2); + + expect(service.locations.length).toBeGreaterThanOrEqual(2); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("Static + Dynamic Markers Integration", () => { + it("should combine static and dynamic markers", async () => { + mockGeocode.mockResolvedValue([ + { latitude: 40, longitude: -74, url: "", title: "Static", onClick: undefined }, + { latitude: 42, longitude: -71, url: "", title: "Dynamic", onClick: undefined } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40"), + longitude: dynamic("-74"), + title: dynamic("Static") + } as MarkersType + ], + dynamicMarkers: [ + { + markersDS: { + status: ValueStatus.Available, + items: [{ id: "1" }] + }, + locationType: "coordinates", + latitude: { + get: () => ({ status: ValueStatus.Available, value: "42" }) + }, + longitude: { + get: () => ({ status: ValueStatus.Available, value: "-71" }) + }, + title: { + get: () => ({ status: ValueStatus.Available, value: "Dynamic" }) + } + } as any + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 2); + + expect(service.locations).toHaveLength(2); + expect(service.locations[0].title).toBe("Static"); + expect(service.locations[1].title).toBe("Dynamic"); + }); + + it("should flatten multiple dynamic marker datasources", async () => { + mockGeocode.mockResolvedValue([ + { latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }, + { latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }, + { latitude: 42, longitude: -71, url: "", title: "", onClick: undefined } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + dynamicMarkers: [ + { + markersDS: { + status: ValueStatus.Available, + items: [{ id: "1" }, { id: "2" }] + }, + locationType: "coordinates", + latitude: { get: () => ({ status: ValueStatus.Available, value: "40" }) }, + longitude: { get: () => ({ status: ValueStatus.Available, value: "-74" }) } + } as any, + { + markersDS: { + status: ValueStatus.Available, + items: [{ id: "3" }] + }, + locationType: "coordinates", + latitude: { get: () => ({ status: ValueStatus.Available, value: "42" }) }, + longitude: { get: () => ({ status: ValueStatus.Available, value: "-71" }) } + } as any + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 3); + + expect(service.locations).toHaveLength(3); + }); + }); + + describe("Action Preservation", () => { + it("should preserve onClick action through conversion", async () => { + const mockAction = jest.fn(); + mockGeocode.mockResolvedValue([{ latitude: 40, longitude: -74, url: "", title: "", onClick: mockAction }]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40"), + longitude: dynamic("-74"), + onClick: { + execute: mockAction + } + } as any + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 1); + + expect(service.locations[0].onClick).toBe(mockAction); + }); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.reactivity.spec.ts b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.reactivity.spec.ts new file mode 100644 index 0000000000..2fb6035239 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.reactivity.spec.ts @@ -0,0 +1,194 @@ +import { reaction, when, configure } from "mobx"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { mockContainerProps } from "../../../utils/mock-container-props"; +import { MarkersType } from "../../../../typings/MapsProps"; +import { createTestContainer, createMockGeocodeFunction, waitForLocations } from "./test-utils"; + +// Configure MobX for testing +configure({ enforceActions: "never" }); + +describe("LocationResolverService - Reactivity Tests", () => { + let mockGeocode: ReturnType; + + beforeEach(() => { + delete (window as any).mxGMLocationCache; + global.fetch = jest.fn(); + mockGeocode = createMockGeocodeFunction(); + }); + + describe("MobX Reactivity", () => { + it("should recompute when props.markers change", async () => { + mockGeocode + .mockResolvedValueOnce([{ latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }]) + .mockResolvedValueOnce([ + { latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }, + { latitude: 41, longitude: -75, url: "", title: "", onClick: undefined }, + { latitude: 42, longitude: -76, url: "", title: "", onClick: undefined } + ]); + + const [, service, gateProvider] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40.7128"), + longitude: dynamic("-74.0060") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 1); + expect(service.locations).toHaveLength(1); + + // Change markers via gate + gateProvider.setProps( + mockContainerProps({ + markers: [ + { latitude: dynamic("40"), longitude: dynamic("-74") } as MarkersType, + { latitude: dynamic("41"), longitude: dynamic("-75") } as MarkersType, + { latitude: dynamic("42"), longitude: dynamic("-76") } as MarkersType + ] + }) + ); + + await waitForLocations(service, 3); + expect(service.locations).toHaveLength(3); + }); + + it("should trigger reactions when locations update", async () => { + mockGeocode.mockResolvedValue([{ latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40"), + longitude: dynamic("-74") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + let reactionCount = 0; + const dispose = reaction( + () => service.locations.length, + () => { + reactionCount++; + } + ); + + await waitForLocations(service, 1); + + // Should have triggered at least once + expect(reactionCount).toBeGreaterThan(0); + + dispose(); + }); + + it("should track mainGate.props as MobX dependency", async () => { + mockGeocode.mockResolvedValue([{ latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }]); + + const [, service, gateProvider] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40"), + longitude: dynamic("-74") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 1); + + // Track marker computed changes + let computedRunCount = 0; + const dispose = reaction( + () => service.markers.length, + () => { + computedRunCount++; + } + ); + + // Change props - should trigger marker recomputation + gateProvider.setProps( + mockContainerProps({ + markers: [ + { latitude: dynamic("40"), longitude: dynamic("-74") } as MarkersType, + { latitude: dynamic("41"), longitude: dynamic("-75") } as MarkersType + ] + }) + ); + + // Wait for reaction + await when(() => computedRunCount > 0); + + expect(computedRunCount).toBeGreaterThan(0); + + dispose(); + }); + + it("should not update locations if markers haven't changed", async () => { + mockGeocode.mockResolvedValue([{ latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }]); + + const markers = [ + { + latitude: dynamic("40"), + longitude: dynamic("-74"), + title: dynamic("Test") + } as MarkersType + ]; + + const [, service, gateProvider] = createTestContainer({ + props: mockContainerProps({ markers }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 1); + + const callCountAfterInit = mockGeocode.mock.calls.length; + + // Set props with identical markers + gateProvider.setProps(mockContainerProps({ markers })); + + // Give time for any potential reaction + await new Promise(resolve => setTimeout(resolve, 50)); + + // Should not have called geocoding again + expect(mockGeocode.mock.calls.length).toBe(callCountAfterInit); + }); + + it("should handle rapid props changes gracefully", async () => { + mockGeocode.mockResolvedValue([{ latitude: 40, longitude: -74, url: "", title: "", onClick: undefined }]); + + const [, service, gateProvider] = createTestContainer({ + props: mockContainerProps({ markers: [] }), + geocodeFunction: mockGeocode + }); + + // Rapid fire props changes + for (let i = 0; i < 5; i++) { + gateProvider.setProps( + mockContainerProps({ + markers: [ + { + latitude: dynamic(`${40 + i}`), + longitude: dynamic("-74") + } as MarkersType + ] + }) + ); + } + + // Wait for final state + await waitForLocations(service, 1); + + // Should have processed all changes + expect(service.locations).toHaveLength(1); + expect(mockGeocode).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.unit.spec.ts b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.unit.spec.ts new file mode 100644 index 0000000000..955e49d9a0 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/LocationResolver.unit.spec.ts @@ -0,0 +1,222 @@ +import { ValueStatus } from "mendix"; +import { when, configure } from "mobx"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { createTestContainer, createMockGeocodeFunction, waitForLocations } from "./test-utils"; +import { MarkersType } from "../../../../typings/MapsProps"; +import { mockContainerProps } from "../../../utils/mock-container-props"; + +// Configure MobX for testing +configure({ enforceActions: "never" }); + +describe("LocationResolverService - Unit Tests", () => { + let mockGeocode: ReturnType; + + beforeEach(() => { + delete (window as any).mxGMLocationCache; + global.fetch = jest.fn(); + mockGeocode = createMockGeocodeFunction(); + }); + + describe("Basic Functionality", () => { + it("should initialize with empty locations", () => { + const [, service] = createTestContainer({ + props: mockContainerProps(), + geocodeFunction: mockGeocode + }); + expect(service.locations).toEqual([]); + }); + + it("should resolve markers with lat/lng directly without geocoding", async () => { + mockGeocode.mockResolvedValue([ + { + latitude: 40.7128, + longitude: -74.006, + url: "", + title: "NYC", + onClick: undefined + } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40.7128"), + longitude: dynamic("-74.0060"), + title: dynamic("NYC") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 1); + + expect(service.locations).toHaveLength(1); + expect(service.locations[0]).toMatchObject({ + latitude: 40.7128, + longitude: -74.006, + title: "NYC" + }); + expect(mockGeocode).toHaveBeenCalled(); + }); + + it("should geocode markers with addresses using API", async () => { + mockGeocode.mockResolvedValue([ + { + latitude: 40.7128, + longitude: -74.006, + url: "", + title: "NYC", + onClick: undefined + } + ]); + + const [, service] = createTestContainer({ + props: mockContainerProps({ + geodecodeApiKey: "test-api-key", + markers: [ + { + address: dynamic("New York, NY"), + title: dynamic("NYC") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await waitForLocations(service, 1); + + expect(service.locations).toHaveLength(1); + expect(service.locations[0]).toMatchObject({ + latitude: 40.7128, + longitude: -74.006, + title: "NYC" + }); + expect(mockGeocode).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ address: "New York, NY" })]), + "test-api-key" + ); + }); + }); + + describe("Empty/Null Inputs", () => { + it("should handle empty markers array gracefully", () => { + const [, service] = createTestContainer({ + props: mockContainerProps({ + markers: [] + }), + geocodeFunction: mockGeocode + }); + + expect(service.locations).toEqual([]); + expect(service.markers).toEqual([]); + }); + + it("should handle dynamic markers with no datasource", () => { + const [, service] = createTestContainer({ + props: mockContainerProps({ + dynamicMarkers: [ + { + markersDS: undefined, + locationType: "coordinates" + } as any + ] + }), + geocodeFunction: mockGeocode + }); + + expect(service.locations).toEqual([]); + expect(service.markers).toEqual([]); + }); + + it("should handle dynamic markers with ValueStatus.Loading", () => { + const [, service] = createTestContainer({ + props: mockContainerProps({ + dynamicMarkers: [ + { + markersDS: { + status: ValueStatus.Loading, + items: [] + }, + locationType: "coordinates" + } as any + ] + }), + geocodeFunction: mockGeocode + }); + + expect(service.locations).toEqual([]); + expect(service.markers).toEqual([]); + }); + }); + + describe("API Key Handling", () => { + it("should use geodecodeApiKeyExp.value over static apiKey", async () => { + mockGeocode.mockResolvedValue([]); + + createTestContainer({ + props: mockContainerProps({ + geodecodeApiKey: "static-key", + geodecodeApiKeyExp: dynamic("expression-key"), + markers: [ + { + address: dynamic("New York, NY") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await when(() => mockGeocode.mock.calls.length > 0); + + expect(mockGeocode).toHaveBeenCalledWith(expect.anything(), "expression-key"); + }); + + it("should throw error when address provided but no API key", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + mockGeocode.mockRejectedValue(new Error("API key required in order to use markers containing address")); + + createTestContainer({ + props: mockContainerProps({ + markers: [ + { + address: dynamic("New York, NY") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + await when(() => consoleErrorSpy.mock.calls.length > 0 || mockGeocode.mock.calls.length > 0, { + timeout: 1000 + }).catch(() => { + // Timeout acceptable + }); + + expect(mockGeocode).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("Marker Computed Property", () => { + it("should compute markers synchronously", () => { + const [, service] = createTestContainer({ + props: mockContainerProps({ + markers: [ + { + latitude: dynamic("40"), + longitude: dynamic("-74") + } as MarkersType + ] + }), + geocodeFunction: mockGeocode + }); + + expect(service.markers).toBeDefined(); + expect(Array.isArray(service.markers)).toBe(true); + expect(service.markers.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/model/services/__tests__/test-utils.ts b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/test-utils.ts new file mode 100644 index 0000000000..1417d8ff12 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/test-utils.ts @@ -0,0 +1,64 @@ +import { when } from "mobx"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { MapsContainerProps } from "../../../../typings/MapsProps"; +import { mapsConfig } from "../../configs/Maps.config"; +import { MapsContainer } from "../../containers/Maps.container"; +import { RootContainer } from "../../containers/Root.container"; +import { CORE_TOKENS as CORE, MAPS_TOKENS as MAPS, GeocodeFunction } from "../../tokens"; +import { LocationResolverService } from "../LocationResolver.service"; + +export interface TestContainerOptions { + props: MapsContainerProps; + geocodeFunction?: GeocodeFunction; +} + +/** + * Creates a test container with injectable mocks. + * Builds container manually to allow overriding dependencies before initialization. + */ +export function createTestContainer( + options: TestContainerOptions +): [MapsContainer, LocationResolverService, GateProvider] { + const { props, geocodeFunction } = options; + + // Create root container + const root = new RootContainer(); + + // Override geocode function in root if provided + if (geocodeFunction) { + root.bind(CORE.geocodeFunction).toConstant(geocodeFunction); + } + + // Create config and gate provider + const config = mapsConfig(props); + const gateProvider = new GateProvider(props); + + // Create and initialize Maps container + const container = new MapsContainer(root).init({ + props, + config, + mainGate: gateProvider.gate + }); + + // Trigger setup lifecycle (in production this is done by useSetup hook) + container.get(CORE.setupService).setup(); + + // Get service (already initialized by postInit) + const service = container.get(MAPS.locationResolver); + + return [container, service, gateProvider]; +} + +/** + * Helper to wait for locations to be populated + */ +export async function waitForLocations(service: LocationResolverService, expectedLength: number): Promise { + return when(() => service.locations.length === expectedLength, { timeout: 2000 }); +} + +/** + * Creates a mock geocode function for testing + */ +export function createMockGeocodeFunction(): jest.MockedFunction { + return jest.fn().mockResolvedValue([]); +} diff --git a/packages/pluggableWidgets/maps-web/src/model/tokens.ts b/packages/pluggableWidgets/maps-web/src/model/tokens.ts new file mode 100644 index 0000000000..be4bd8495c --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/tokens.ts @@ -0,0 +1,27 @@ +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { token } from "brandi"; +import { MapsContainerProps } from "../../typings/MapsProps"; +import { Marker, ModeledMarker } from "../../typings/shared"; +import { MapsConfig } from "./configs/Maps.config"; +import { MapsSetupService } from "./services/MapsSetup.service"; +import { LocationResolverService } from "./services/LocationResolver.service"; + +/** Function type for geocoding markers. */ +export type GeocodeFunction = (locations?: ModeledMarker[], mapToken?: string) => Promise; + +/** Tokens to resolve dependencies from the container. */ + +const label = (name: string): string => `Maps[${name}]`; + +/** Core tokens shared across containers. */ +export const CORE_TOKENS = { + mainGate: token>(label("mainGate")), + config: token(label("config")), + setupService: token(label("setupService")), + geocodeFunction: token(label("geocodeFunction")) +}; + +/** Maps-specific tokens. */ +export const MAPS_TOKENS = { + locationResolver: token(label("locationResolver")) +}; diff --git a/packages/pluggableWidgets/maps-web/src/utils/__tests__/data.spec.ts b/packages/pluggableWidgets/maps-web/src/utils/__tests__/data.spec.ts new file mode 100644 index 0000000000..060c257156 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/utils/__tests__/data.spec.ts @@ -0,0 +1,453 @@ +import { ValueStatus } from "mendix"; +import { dynamic, list, obj, listAttribute, listAction } from "@mendix/widget-plugin-test-utils"; +import { DynamicMarkersType, MarkersType } from "../../../typings/MapsProps"; +import { convertDynamicModeledMarker, convertStaticModeledMarker } from "../data"; + +describe("data.ts - Marker Conversion Functions", () => { + describe("convertStaticModeledMarker", () => { + it("should convert marker with all fields present", () => { + const mockAction = jest.fn(); + const marker: MarkersType = { + address: dynamic("123 Main St"), + latitude: dynamic("40.7128"), + longitude: dynamic("-74.0060"), + title: dynamic("New York"), + onClick: { canExecute: true, isExecuting: false, execute: mockAction }, + customMarker: dynamic({ uri: "marker.png" } as any), + locationType: "latlng", + markerStyle: "image" + }; + + const result = convertStaticModeledMarker(marker); + + expect(result).toEqual({ + address: "123 Main St", + latitude: 40.7128, + longitude: -74.006, + title: "New York", + action: mockAction, + customMarker: "marker.png" + }); + }); + + it("should handle undefined optional fields", () => { + const marker: MarkersType = { + address: undefined, + latitude: undefined, + longitude: undefined, + title: undefined, + onClick: undefined, + customMarker: undefined, + locationType: "latlng", + markerStyle: "default" + }; + + const result = convertStaticModeledMarker(marker); + + expect(result).toEqual({ + address: undefined, + latitude: undefined, + longitude: undefined, + title: undefined, + action: undefined, + customMarker: undefined + }); + }); + + it("should parse numbers with comma as decimal separator", () => { + const marker: MarkersType = { + latitude: dynamic("40,7128"), + longitude: dynamic("-74,0060"), + locationType: "latlng", + markerStyle: "default" + }; + + const result = convertStaticModeledMarker(marker); + + expect(result.latitude).toBe(40.7128); + expect(result.longitude).toBe(-74.006); + }); + + it("should parse numbers with period as decimal separator", () => { + const marker: MarkersType = { + latitude: dynamic("40.7128"), + longitude: dynamic("-74.0060"), + locationType: "latlng", + markerStyle: "default" + }; + + const result = convertStaticModeledMarker(marker); + + expect(result.latitude).toBe(40.7128); + expect(result.longitude).toBe(-74.006); + }); + + it("should handle empty customMarker image", () => { + const marker: MarkersType = { + latitude: dynamic("40"), + longitude: dynamic("-74"), + customMarker: dynamic(undefined as any), + locationType: "latlng", + markerStyle: "image" + }; + + const result = convertStaticModeledMarker(marker); + + expect(result.customMarker).toBeUndefined(); + }); + }); + + describe("convertDynamicModeledMarker", () => { + describe("Datasource Availability", () => { + it("should return empty array when datasource is undefined", () => { + const marker: DynamicMarkersType = { + markersDS: undefined, + locationType: "latlng", + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toEqual([]); + }); + + it("should return empty array when datasource status is Loading", () => { + const marker: DynamicMarkersType = { + markersDS: list.loading(), + locationType: "latlng", + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toEqual([]); + }); + + it("should return empty array when datasource status is Unavailable", () => { + const datasource = list(0); + (datasource as any).status = ValueStatus.Unavailable; + + const marker: DynamicMarkersType = { + markersDS: datasource, + locationType: "latlng", + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toEqual([]); + }); + + it("should return empty array when datasource has no items", () => { + const marker: DynamicMarkersType = { + markersDS: list([]), + locationType: "latlng", + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toEqual([]); + }); + + it("should return empty array when datasource items is undefined", () => { + const datasource = list(0); + (datasource as any).items = undefined; + + const marker: DynamicMarkersType = { + markersDS: datasource, + locationType: "latlng", + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toEqual([]); + }); + }); + + describe("Coordinates Location Type", () => { + it("should convert single marker with coordinates", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40.7128" as any), + longitude: listAttribute(() => "-74.0060" as any), + title: listAttribute(() => "NYC"), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + latitude: 40.7128, + longitude: -74.006, + title: "NYC" + }); + expect(result[0].id).toBe("obj_item1"); + }); + + it("should convert multiple markers with coordinates", () => { + const item1 = obj("item1"); + const item2 = obj("item2"); + const item3 = obj("item3"); + + const marker: DynamicMarkersType = { + markersDS: list([item1, item2, item3]), + locationType: "latlng", + latitude: listAttribute(item => { + if (item.id === "obj_item1") return "40" as any; + if (item.id === "obj_item2") return "41" as any; + return "42" as any; + }), + longitude: listAttribute(item => { + if (item.id === "obj_item1") return "-74" as any; + if (item.id === "obj_item2") return "-75" as any; + return "-76" as any; + }), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(3); + expect(result.find(r => r.latitude === 40)).toBeDefined(); + expect(result.find(r => r.latitude === 41)).toBeDefined(); + expect(result.find(r => r.latitude === 42)).toBeDefined(); + }); + + it("should handle missing latitude attribute", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: undefined, + longitude: listAttribute(() => "-74" as any), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].latitude).toBeUndefined(); + expect(result[0].longitude).toBe(-74); + }); + + it("should handle missing longitude attribute", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: undefined, + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].latitude).toBe(40); + expect(result[0].longitude).toBeUndefined(); + }); + }); + + describe("Address Location Type", () => { + it("should convert marker with address", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "address", + address: listAttribute(() => "123 Main St, NYC"), + title: listAttribute(() => "New York"), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + address: "123 Main St, NYC", + title: "New York", + latitude: undefined, + longitude: undefined + }); + expect(result[0].id).toBe("obj_item1"); + }); + + it("should handle missing address attribute", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "address", + address: undefined, + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].address).toBeUndefined(); + }); + + it("should not set latitude/longitude when using address type", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "address", + address: listAttribute(() => "Main St"), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result[0].latitude).toBeUndefined(); + expect(result[0].longitude).toBeUndefined(); + expect(result[0].address).toBe("Main St"); + }); + }); + + describe("Optional Fields", () => { + it("should handle missing title attribute", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + title: undefined, + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe(""); + }); + + it("should handle onClick action", () => { + const item = obj("item1"); + const mockExecute = jest.fn(); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + onClickAttribute: listAction(() => ({ + canExecute: true, + isExecuting: false, + execute: mockExecute + })), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].action).toBe(mockExecute); + }); + + it("should handle missing onClick action", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + onClickAttribute: undefined, + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].action).toBeUndefined(); + }); + + it("should handle custom marker image", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + customMarkerDynamic: dynamic({ uri: "custom-marker.png" } as any), + markerStyleDynamic: "image" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].customMarker).toBe("custom-marker.png"); + }); + + it("should handle missing custom marker image", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + customMarkerDynamic: undefined, + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].customMarker).toBeUndefined(); + }); + }); + + describe("Edge Cases", () => { + it("should preserve item IDs for all markers", () => { + const item1 = obj("marker-id-1"); + const item2 = obj("marker-id-2"); + + const marker: DynamicMarkersType = { + markersDS: list([item1, item2]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe("obj_marker-id-1"); + expect(result[1].id).toBe("obj_marker-id-2"); + }); + + it("should handle empty string title", () => { + const item = obj("item1"); + + const marker: DynamicMarkersType = { + markersDS: list([item]), + locationType: "latlng", + latitude: listAttribute(() => "40" as any), + longitude: listAttribute(() => "-74" as any), + title: listAttribute(() => ""), + markerStyleDynamic: "default" + }; + + const result = convertDynamicModeledMarker(marker); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe(""); + }); + }); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts b/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts new file mode 100644 index 0000000000..e32628af25 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts @@ -0,0 +1,35 @@ +import { DynamicValue } from "mendix"; +import { MapsContainerProps } from "../../typings/MapsProps"; + +export function mockContainerProps(overrides?: Partial): MapsContainerProps { + return { + name: "maps1", + class: "", + style: {}, + tabIndex: 0, + advanced: false, + apiKey: "", + apiKeyExp: { value: "test-api-key" } as DynamicValue, + geodecodeApiKey: "", + geodecodeApiKeyExp: undefined, + googleMapId: "", + mapProvider: "googleMaps", + mapTypeControl: false, + fullScreenControl: false, + rotateControl: false, + attributionControl: true, + optionDrag: true, + optionScroll: true, + optionZoomControl: true, + optionStreetView: false, + showCurrentLocation: false, + zoom: "automatic", + height: 500, + heightUnit: "pixels", + width: 100, + widthUnit: "percentage", + markers: [] as any, + dynamicMarkers: [] as any, + ...overrides + }; +}