diff --git a/packages/pluggableWidgets/maps-web/CHANGELOG.md b/packages/pluggableWidgets/maps-web/CHANGELOG.md index aefb0ecee1..ed772084f7 100644 --- a/packages/pluggableWidgets/maps-web/CHANGELOG.md +++ b/packages/pluggableWidgets/maps-web/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- We migrated the widget's internal state management to a MobX container architecture, in line with other data widgets. + +- We replaced the react-leaflet wrapper with a direct Leaflet integration, reducing dependencies while keeping the same map behavior. + ## [4.1.0] - 2025-10-29 ### Fixed diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/design.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/design.md new file mode 100644 index 0000000000..616f37d671 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/design.md @@ -0,0 +1,57 @@ +## Context + +Currently `MapsConfig.apiKey` is set once at container creation: `props.apiKeyExp?.value ?? props.apiKey`. Since `apiKeyExp` is a `DynamicValue`, its `.value` can be `undefined` on the first render and resolve later. The static snapshot misses this. + +The datagrid widget uses `ComputedAtom` (from `@mendix/widget-plugin-mobx-kit`) for reactive derived values in the DI container. Pattern: a function that returns `computed(() => ...)`, registered as a constant binding. + +## Goals / Non-Goals + +**Goals:** + +- API key resolved reactively from `mainGate.props` +- Priority: `apiKeyExp?.value` > `apiKey` > `null` +- Once a non-null value is observed, it's cached permanently +- Atom registered in DI container via a token, consumed by services + +**Non-Goals:** + +- Changing how the key is used downstream (geocoding, tile layers still receive `string | undefined`) +- Making `geodecodeApiKey` an atom (separate concern, can follow same pattern later) + +## Decisions + +**1. Use `ComputedAtom` with closure-based caching** + +A plain closure variable caches the first non-null result. Once set, the computed short-circuits without accessing `gate.props`, so MobX drops the dependency and the atom never re-evaluates. + +```ts +function apiKeyAtom(gate: DerivedPropsGate): ComputedAtom { + let cached: string | null = null; + return computed(() => { + if (cached !== null) return cached; + const value = (gate.props.apiKeyExp?.value ?? gate.props.apiKey) || null; + if (value) cached = value; + return value; + }); +} +``` + +Alternative considered: `observable.box` + `runInAction`. Rejected — unnecessary complexity; a plain variable achieves the same "cache forever" behavior because MobX naturally stops tracking deps that aren't read. + +**2. Register as `CORE.apiKey` token** + +Add `apiKey: token>(label("apiKey"))` to `CORE_TOKENS`. Bind in container init phase since it depends on `mainGate`. + +**3. Remove `apiKey` from `MapsConfig`** + +The static config no longer holds the key. `MapsConfig` keeps `id`, `name`, `showCurrentLocation`. + +**4. Update consumers** + +- `LocationResolverService.apiKey` computed → reads from injected atom `.get()` +- `MapsWidget.tsx` `mapsToken` prop → reads from atom via hook or passes through from LocationResolver (depends on whether view needs it directly) + +## Risks / Trade-offs + +- **[Closure mutation inside computed]** → Writing to a plain variable inside a computed is safe because MobX only tracks observable reads, not plain variable writes. The write is idempotent (set once, never again). +- **[Null initial state]** → Downstream consumers must handle `null`. The tile layer and geocoding already handle undefined keys gracefully (no-op until key arrives). diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/proposal.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/proposal.md new file mode 100644 index 0000000000..0c15b0d1dd --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/proposal.md @@ -0,0 +1,29 @@ +## Why + +The `apiKey` is currently stored as a static field in `MapsConfig`, snapshot at container creation time. Since `apiKeyExp` is a `DynamicValue` that may not be resolved on first render, the config can lock in `undefined` and miss the actual key. The key needs to be a reactive computed atom that resolves lazily and caches once available. + +## What Changes + +- Remove `apiKey` from `MapsConfig` (static config object) +- Create an `apiKeyAtom` as a `ComputedAtom` registered in the DI container +- The atom prioritizes `apiKeyExp?.value`, falls back to `apiKey` (static), returns `null` when neither is available +- Once a non-null value is observed, the atom caches it permanently (never reverts to null) +- Update `LocationResolverService` to consume the atom instead of reading `mainGate.props` directly for the API key + +## Capabilities + +### New Capabilities + +- `api-key-atom`: Reactive, cached API key resolution via a MobX computed atom in the Maps DI container + +### Modified Capabilities + +_(none)_ + +## Impact + +- `src/model/configs/Maps.config.ts` — remove `apiKey` field +- `src/model/tokens.ts` — add token for apiKey atom +- `src/model/containers/Maps.container.ts` — bind the atom +- `src/model/services/LocationResolver.service.ts` — use atom instead of `mainGate.props` for apiKey +- `src/components/MapsWidget.tsx` — remove `mapsToken` prop derivation (now handled by atom) diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/specs/api-key-atom/spec.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/specs/api-key-atom/spec.md new file mode 100644 index 0000000000..35e77b514e --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/specs/api-key-atom/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: API key resolved via computed atom + +The Maps container SHALL provide a `ComputedAtom` that reactively resolves the API key from widget props. + +#### Scenario: Expression value takes priority + +- **WHEN** `apiKeyExp.value` is a non-empty string +- **THEN** the atom returns that value + +#### Scenario: Falls back to static apiKey + +- **WHEN** `apiKeyExp.value` is undefined or empty +- **AND** `apiKey` is a non-empty string +- **THEN** the atom returns the static `apiKey` value + +#### Scenario: Returns null when no key available + +- **WHEN** both `apiKeyExp.value` and `apiKey` are empty or undefined +- **THEN** the atom returns `null` + +### Requirement: API key cached once resolved + +Once the atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`. + +#### Scenario: Key remains after expression becomes unavailable + +- **WHEN** the atom has previously returned a non-null value +- **AND** `apiKeyExp.value` subsequently becomes undefined +- **THEN** the atom still returns the previously cached value + +### Requirement: API key atom registered in DI container + +The atom SHALL be registered as a `CORE_TOKENS.apiKey` token in the Maps container and injectable into services. + +#### Scenario: LocationResolverService uses atom + +- **WHEN** `LocationResolverService` needs the API key for geocoding +- **THEN** it reads from the injected `ComputedAtom` via `.get()` + +### Requirement: Geodecode API key resolved via computed atom + +The Maps container SHALL provide a `ComputedAtom` that reactively resolves the geodecode API key from widget props, following the same pattern as the main API key atom. + +#### Scenario: Expression value takes priority + +- **WHEN** `geodecodeApiKeyExp.value` is a non-empty string +- **THEN** the atom returns that value + +#### Scenario: Falls back to static geodecodeApiKey + +- **WHEN** `geodecodeApiKeyExp.value` is undefined or empty +- **AND** `geodecodeApiKey` is a non-empty string +- **THEN** the atom returns the static `geodecodeApiKey` value + +#### Scenario: Returns null when no key available + +- **WHEN** both `geodecodeApiKeyExp.value` and `geodecodeApiKey` are empty or undefined +- **THEN** the atom returns `null` + +### Requirement: Geodecode API key cached once resolved + +Once the geodecode atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`. + +#### Scenario: Key remains after expression becomes unavailable + +- **WHEN** the atom has previously returned a non-null value +- **AND** `geodecodeApiKeyExp.value` subsequently becomes undefined +- **THEN** the atom still returns the previously cached value + +### Requirement: apiKey and geodecodeApiKey removed from MapsConfig + +The static `MapsConfig` interface SHALL NOT contain `apiKey` or `geodecodeApiKey` fields. Both keys are resolved reactively via atoms. + +#### Scenario: MapsConfig only contains static fields + +- **WHEN** `mapsConfig()` is called +- **THEN** the returned object contains `id`, `name`, and `showCurrentLocation` only diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/tasks.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/tasks.md new file mode 100644 index 0000000000..525d0fef9d --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-maps-api-key-atom/tasks.md @@ -0,0 +1,26 @@ +## 1. Create the key atoms + +- [x] 1.1 Create `src/model/atoms/apiKey.atom.ts` with `apiKeyAtom` function that returns `ComputedAtom` with caching logic +- [x] 1.2 Create `src/model/atoms/geodecodeApiKey.atom.ts` with `geodecodeApiKeyAtom` function (same pattern, reads `geodecodeApiKeyExp?.value ?? geodecodeApiKey`) +- [x] 1.3 Add `apiKey: token>` and `geodecodeApiKey: token>` to `CORE_TOKENS` in `src/model/tokens.ts` + +## 2. Update MapsConfig + +- [x] 2.1 Remove `apiKey` field from `MapsConfig` interface and `mapsConfig()` function +- [x] 2.2 Update `createMapsContainer.ts` if it references config.apiKey + +## 3. Wire atoms in container + +- [x] 3.1 Bind both atoms in `Maps.container.ts` init phase (need mainGate): `CORE.apiKey` and `CORE.geodecodeApiKey` + +## 4. Update consumers + +- [x] 4.1 Update `LocationResolverService` to inject `ComputedAtom` for geodecodeApiKey instead of reading `mainGate.props` +- [x] 4.2 Update `MapsWidget.tsx` — derive `mapsToken` from the apiKey atom (or remove if LeafletMap/GoogleMap will read from atom directly) + +## 5. Tests + +- [x] 5.1 Add unit test for `apiKeyAtom`: priority, fallback, null, and caching behavior +- [x] 5.2 Add unit test for `geodecodeApiKeyAtom`: same scenarios +- [x] 5.3 Update `LocationResolver` tests to inject atom mock instead of relying on gate props for apiKey +- [x] 5.4 Run full test suite and fix any failures diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/design.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/design.md new file mode 100644 index 0000000000..76a210ff05 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/design.md @@ -0,0 +1,60 @@ +## Context + +The Maps widget `getProperties()` function in `Maps.editorConfig.ts` contains branching logic for `platform === "desktop"` vs `"web"`. This separation no longer exists — Studio Pro uses a single editor. The `advanced` boolean property gates visibility of `mapProvider` and marker style options, adding unnecessary friction. The static `apiKey` string field should be deprecated in favor of the expression-based `apiKeyExp`. + +Current `getProperties()` flow: + +``` +if (platform === "desktop") { + // show/hide apiKey vs apiKeyExp (static priority) + // hide "advanced" prop itself +} else { + // show/hide apiKey vs apiKeyExp (expression priority) + // gate mapProvider and marker styles behind "advanced" +} +``` + +## Goals / Non-Goals + +**Goals:** + +- Single unified property visibility logic (no platform branching) +- Remove `advanced` property — all options always visible +- `apiKeyExp` always visible (never hidden) +- Deprecation warning when `apiKey` (static string) is used + +**Non-Goals:** + +- Removing `apiKey` from XML entirely (backward compatibility — existing apps use it) +- Changing runtime behavior (how the key is resolved at runtime stays the same) +- Touching `geodecodeApiKey` / `geodecodeApiKeyExp` show/hide logic beyond removing platform branching + +## Decisions + +**1. Remove `advanced` from XML entirely** + +The property serves no purpose once all options are always shown. Removing it from XML means Mendix will ignore any persisted value in existing apps — no migration needed. The widget typings will regenerate without it. + +Alternative considered: Keep in XML but ignore it. Rejected — dead props confuse future developers. + +**2. Unified apiKey/apiKeyExp visibility logic** + +After removing platform branching, the logic becomes: + +- `apiKeyExp` is always shown (never hidden) +- Hide `apiKey` if falsy, show otherwise + +This preserves backward compat: users with only `apiKey` set still see their field, plus the new expression field. + +**3. Deprecation via `check()` warning** + +Add a `"warning"` severity problem in the `check()` function when `values.apiKey` is non-empty. Message directs users to use `apiKeyExp` instead. Using `check()` (not `getProperties()`) because that's where validation problems are surfaced in Studio Pro. + +**4. Marker style visibility — always show** + +Currently gated behind `!values.advanced` on web platform. After removing `advanced`, `markerStyle`/`customMarker` and `markerStyleDynamic`/`customMarkerDynamic` are always visible (conditional on `markerStyle === "image"` for the custom image field stays). + +## Risks / Trade-offs + +- **[Breaking: `advanced` prop removed]** → Existing apps with `advanced: true` silently lose the property. No runtime impact — it was editor-only. Studio Pro handles missing props gracefully. +- **[Deprecation noise]** → Users with static `apiKey` see a new warning. This is intentional nudge, not an error. Using `"warning"` severity, not `"error"`. diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/proposal.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/proposal.md new file mode 100644 index 0000000000..1b9021704a --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/proposal.md @@ -0,0 +1,27 @@ +## Why + +The Maps widget editor config still has a web/desktop platform split that no longer exists in modern Studio Pro. This adds dead code paths and hides useful properties (like `mapProvider`) behind an "advanced" toggle that confuses users. Additionally, `apiKey` (static string) should be deprecated in favor of `apiKeyExp` (expression) for flexibility. + +## What Changes + +- **BREAKING**: Remove the `advanced` boolean property from XML and editor config. Properties gated behind it (`mapProvider`, marker styles) become always visible. +- Remove the platform `"web"` / `"desktop"` conditional branching in `getProperties()`. All property visibility logic uses a single unified path. +- Stop hiding `apiKeyExp` — it is always shown as the primary API key field. +- Add a deprecation warning when the static `apiKey` property has a value, guiding users to use the `apiKeyExp` expression field instead. + +## Capabilities + +### New Capabilities + +- `editor-config-simplified`: Unified property visibility logic without platform branching, removal of `advanced` toggle, and `apiKey` deprecation warning. + +### Modified Capabilities + +_(none — no existing specs)_ + +## Impact + +- `src/Maps.xml` — remove `advanced` property definition +- `src/Maps.editorConfig.ts` — rewrite `getProperties()` logic, add deprecation check to `check()` +- `typings/MapsProps.d.ts` — regenerated (loses `advanced` prop) +- Any container/config code referencing `props.advanced` (likely none beyond editor config) diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/specs/editor-config-simplified/spec.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/specs/editor-config-simplified/spec.md new file mode 100644 index 0000000000..3e849311be --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/specs/editor-config-simplified/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: No platform branching in property visibility + +The `getProperties()` function SHALL NOT branch on the `platform` parameter. All property visibility logic MUST use a single unified code path. + +#### Scenario: Same properties shown regardless of platform argument + +- **WHEN** `getProperties()` is called with platform `"web"` or `"desktop"` +- **THEN** the returned properties are identical for both values + +### Requirement: Advanced property removed + +The widget XML SHALL NOT define an `advanced` property. The editor config SHALL NOT reference `advanced` in any visibility logic. + +#### Scenario: mapProvider always visible + +- **WHEN** the widget is placed on a page +- **THEN** the `mapProvider` property is visible without any toggle + +#### Scenario: Marker style options always visible + +- **WHEN** a static or dynamic marker is configured +- **THEN** the `markerStyle` / `markerStyleDynamic` and `customMarker` / `customMarkerDynamic` properties are visible (custom marker still conditional on style being "image") + +### Requirement: apiKeyExp always visible + +The `apiKeyExp` expression property SHALL never be hidden by `getProperties()`. + +#### Scenario: Fresh widget shows expression field + +- **WHEN** a new Maps widget is placed on a page with no configuration +- **THEN** `apiKeyExp` is visible to the user + +#### Scenario: apiKeyExp visible even when apiKey has value + +- **WHEN** `apiKey` (static) has a value set +- **THEN** `apiKeyExp` remains visible + +### Requirement: Static apiKey deprecation warning + +The `check()` function SHALL return a warning-severity problem when `values.apiKey` is non-empty, informing the user that the static API key is deprecated and `apiKeyExp` (expression) should be used instead. + +#### Scenario: Warning shown when static apiKey is set + +- **WHEN** `values.apiKey` is a non-empty string +- **THEN** `check()` returns a problem with `severity: "warning"` on property `"apiKey"` with a message indicating deprecation + +#### Scenario: No warning when apiKey is empty + +- **WHEN** `values.apiKey` is empty or undefined +- **THEN** no deprecation warning is returned + +### Requirement: apiKey hidden when empty + +The static `apiKey` field SHALL be hidden when it has no value. It SHALL only be shown when the user already has a value configured (for backward compatibility). + +#### Scenario: apiKey hidden when empty + +- **WHEN** `values.apiKey` is falsy (empty or undefined) +- **THEN** `apiKey` is hidden from the properties panel + +#### Scenario: apiKey visible when it has a value + +- **WHEN** `values.apiKey` is a non-empty string +- **THEN** `apiKey` is visible (for backward compatibility with existing configurations) + +## REMOVED Requirements + +### Requirement: Platform-specific property visibility + +**Reason**: Web/desktop platform separation no longer exists in Studio Pro. +**Migration**: All properties use unified visibility logic. No user action needed. + +### Requirement: Advanced toggle for map options + +**Reason**: Unnecessary UX friction. All options should be directly accessible. +**Migration**: Properties previously gated behind `advanced` are now always visible. Existing widgets with `advanced: true` will continue to work — the property is simply ignored. diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/tasks.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/tasks.md new file mode 100644 index 0000000000..645e816cd3 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-17-simplify-maps-editor-config/tasks.md @@ -0,0 +1,21 @@ +## 1. Remove `advanced` property + +- [x] 1.1 Remove `advanced` property definition from `src/Maps.xml` +- [x] 1.2 Remove `advanced` from `mock-container-props.ts` + +## 2. Rewrite `getProperties()` in `src/Maps.editorConfig.ts` + +- [x] 2.1 Remove the `platform` parameter and all platform branching (`if (platform === "desktop") / else`) +- [x] 2.2 Unify apiKey/apiKeyExp visibility: always show `apiKeyExp`, hide `apiKey` when it's falsy (only show if user has a value set) +- [x] 2.3 Remove all `advanced`-gated hiding logic (mapProvider, markerStyle, customMarker) +- [x] 2.4 Keep remaining conditional logic: Google-only props, OpenStreet hides apiKey, address/latLng toggle, customMarker conditional on style "image", geodecode keys hidden when no address markers + +## 3. Add deprecation warning + +- [x] 3.1 In `check()`, add a warning-severity problem when `values.apiKey` is non-empty, message: "Static API key is deprecated. Use the 'API Key' expression instead." + +## 4. Cleanup and verify + +- [x] 4.1 Regenerate typings (ensure `advanced` is gone from `MapsPreviewProps` and `MapsContainerProps`) +- [x] 4.2 Run lint and fix any issues +- [x] 4.3 Run tests and update snapshots if needed diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/design.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/design.md new file mode 100644 index 0000000000..2ec5f969cb --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/design.md @@ -0,0 +1,60 @@ +# Test Design: Complete MobX Migration and Replace react-leaflet + +## CurrentLocationService (6 tests) + +- **No request when showCurrentLocation is false** (unit) + - **Given**: Container with `showCurrentLocation: false` + - **When**: Service is set up + - **Then**: `getLocation` not called, `location` is undefined + +- **Resolves location when showCurrentLocation is true** (unit) + - **Given**: Container with `showCurrentLocation: true` + - **When**: Service is set up + - **Then**: `getLocation` called once, `location` updated + +- **Resolves location when option becomes true** (integration) + - **Given**: Container with `showCurrentLocation: false` + - **When**: Props change to `showCurrentLocation: true` + - **Then**: Location resolved reactively + +- **Clears location when option becomes false** (integration) + - **Given**: Resolved current location + - **When**: Props change to `showCurrentLocation: false` + - **Then**: `location` becomes undefined + +- **Ignores stale responses** (unit) + - **Given**: Pending location request + - **When**: Option disabled before the request resolves + - **Then**: Late response discarded, `location` stays undefined + +- **Logs resolution failures** (unit) + - **Given**: `getLocation` rejects + - **When**: Service requests location + - **Then**: Error logged, `location` stays undefined + +## LeafletMap without react-leaflet (15 tests) + +- **Structure**: renders `.widget-maps` > `.widget-leaflet-maps-wrapper` > `.leaflet-container`; dimensions and custom class applied (3 tests) +- **Controls**: attribution and zoom controls toggled by props (4 tests) +- **Markers**: custom-icon markers per location, default icon fallback, current location appended, markers re-synced when `locations` prop changes (4 tests) +- **Interaction**: popup with title opens on click; `onClick` fires for title-less markers; `onClick` fires from popup content of titled markers (3 tests) +- **Lifecycle**: map removed from DOM on unmount (1 test) + +Structural assertions replace the previous react-leaflet snapshots, which captured wrapper-specific DOM. + +## Maps Integration (2 tests) + +- **Maps.tsx renders through ContainerProvider** (integration) + - **Given**: Maps component with mock props + - **When**: Component renders + - **Then**: Leaflet container present in DOM + +- **Resolved locations reach the map** (integration) + - **Given**: Static lat/lng marker in props + - **When**: `LocationResolverService` resolves locations + - **Then**: Marker rendered on the map (observer re-render) + +## Regression Guarantees + +- All pre-existing model-layer tests (LocationResolver unit/integration/reactivity, useMapsContainer, data conversion) pass unchanged: 77 tests total across 9 suites +- GoogleMap snapshots untouched diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/proposal.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/proposal.md new file mode 100644 index 0000000000..e7c9c08033 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/proposal.md @@ -0,0 +1,25 @@ +# Complete MobX Migration and Replace react-leaflet + +## Why + +The `migrate-to-mobx` change (archived 2026-05-15) introduced the model layer — `MapsContainer`, `LocationResolverService`, `useMapsContainer` — but `Maps.tsx` still runs on the legacy `useLocationResolver` hook, so the new architecture is dead code. Additionally, `react-leaflet` (v4) pins the widget to a React-lifecycle-driven map wrapper that conflicts with observable-driven updates, carries a known default-icon bug we work around, and is the only reason `@types/react-leaflet` and the react-leaflet ESM transform exist in the toolchain. + +## What Changes + +Wire the MobX container into the widget and render Leaflet directly: + +- `Maps.tsx` creates the container via `useMapsContainer` and provides it through `ContainerProvider` (mirrors `Gallery.tsx`) +- New `MapsWidget` observer component reads `mainGate.props` + services and renders `MapSwitcher` +- New `CurrentLocationService` replaces the `useEffect`/`useState` current-location logic; reacts to `showCurrentLocation`, clears the location when disabled, discards stale responses via a version counter +- New `injection-hooks.ts` (`useMainGate`, `useMapsConfig`, `useLocationResolver`, `useCurrentLocation`) following the gallery pattern +- `LeafletMap.tsx` rewritten on the imperative Leaflet API: map instance created once per mount, tile layer synced on provider/token change, markers + viewport synced on location changes; identical DOM structure (`.widget-maps`, `.widget-leaflet-maps-wrapper`, `.widget-leaflet-maps`) so existing SCSS applies +- `utils/geodecode.ts`: legacy `useLocationResolver` and `isIdenticalMarkers` removed +- `utils/leaflet.ts`: `BaseMapLayer` type based on `leaflet`'s `TileLayerOptions` instead of react-leaflet's `TileLayerProps` + +## Impact + +- **Affected**: `Maps.tsx`, `components/LeafletMap.tsx`, `utils/geodecode.ts`, `utils/leaflet.ts`, `model/tokens.ts`, `model/containers/*` +- **New**: `components/MapsWidget.tsx`, `model/services/CurrentLocation.service.ts`, `model/hooks/injection-hooks.ts` +- **Dependencies**: removed `react-leaflet`, `@types/react-leaflet`; added explicit `mobx`, `mobx-react-lite` (previously transitive) +- **Behavior change**: disabling `showCurrentLocation` at runtime now removes the current-location marker (previously it persisted); titled-marker popups otherwise behave as before +- **Breaking**: None (internal refactor; widget XML/props unchanged) diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/tasks.md b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/tasks.md new file mode 100644 index 0000000000..d50831e83f --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/archive/2026-06-18-complete-mobx-migration/tasks.md @@ -0,0 +1,38 @@ +# Tasks: Complete MobX Migration and Replace react-leaflet + +## 1. Model layer + +- [x] 1.1 Add `GetLocationFunction` type, `CORE.getLocationFunction` and `MAPS.currentLocation` tokens +- [x] 1.2 Implement `CurrentLocationService` (reaction on `showCurrentLocation`, stale-request version counter, clear on disable) +- [x] 1.3 Bind `getCurrentUserLocation` in `RootContainer`; register/inject/boot the service in `MapsContainer` +- [x] 1.4 Add `injection-hooks.ts` (`useMainGate`, `useMapsConfig`, `useLocationResolver`, `useCurrentLocation`) + +## 2. React layer + +- [x] 2.1 Add `MapsWidget` observer component mapping gate props + service state to `MapSwitcher` +- [x] 2.2 Rewrite `Maps.tsx` to `useMapsContainer` + `ContainerProvider` (gallery pattern) +- [x] 2.3 Remove legacy `useLocationResolver`/`isIdenticalMarkers` from `utils/geodecode.ts` + +## 3. Replace react-leaflet + +- [x] 3.1 Rewrite `LeafletMap.tsx` on the imperative Leaflet API (map per mount, tile-layer sync, marker/viewport sync, DOM popups) +- [x] 3.2 Replace react-leaflet's `TileLayerProps` with `BaseMapLayer` in `utils/leaflet.ts` +- [x] 3.3 Remove `react-leaflet` and `@types/react-leaflet`; add explicit `mobx` and `mobx-react-lite`; update lockfile + +## 4. Tests + +- [x] 4.1 Add `CurrentLocation.spec.ts` (6 tests) using the `createTestContainer` pattern; extend `test-utils.ts` with `getLocationFunction` override +- [x] 4.2 Rewrite `LeafletMap.spec.tsx` with structural assertions (15 tests); delete react-leaflet snapshots +- [x] 4.3 Add `Maps.spec.tsx` integration tests (2 tests) per archived design doc +- [x] 4.4 Full suite green: 9 suites, 77 tests; `tsc --noEmit` clean; eslint 0 errors + +## 5. Documentation + +- [x] 5.1 Update CHANGELOG `Unreleased` section +- [x] 5.2 This OpenSpec change + +## 6. Out of scope / follow-up + +- [ ] 6.1 Migrate `GoogleMap.tsx` consumption to injection hooks (still prop-driven via `MapSwitcher`) +- [ ] 6.2 Consider `useLayoutEffect` in `useMapsContainer` (review-bot suggestion from PR #2255) +- [ ] 6.3 E2E run against a Mendix test project (requires Studio Pro environment) diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/.openspec.yaml b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/.openspec.yaml new file mode 100644 index 0000000000..f9c80ddd93 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/.openspec.yaml @@ -0,0 +1,2 @@ +schema: tdd-refactor +created: 2026-06-17 diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/design.md b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/design.md new file mode 100644 index 0000000000..4fb8d0ad7c --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/design.md @@ -0,0 +1,41 @@ +## Test Cases + +### Reproduction Tests + +- renders map immediately for openStreet provider (unit) + - **Given**: `mapProvider` is `"openStreet"`, `apiKey.get()` returns `null` + - **When**: `MapsWidget` renders + - **Then**: `MapSwitcher` is rendered + +- does not render map when key is null for googleMaps (unit) + - **Given**: `mapProvider` is `"googleMaps"`, `apiKey.get()` returns `null` + - **When**: `MapsWidget` renders + - **Then**: `MapSwitcher` is NOT rendered, empty container is rendered instead + +- renders map when key becomes available for googleMaps (unit) + - **Given**: `mapProvider` is `"googleMaps"`, `apiKey.get()` initially returns `null` + - **When**: `apiKey.get()` resolves to `"my-key"` + - **Then**: `MapSwitcher` is rendered with `mapsToken="my-key"` + +### Edge Cases + +- renders map when key is null for mapBox (unit) + - **Given**: `mapProvider` is `"mapBox"`, `apiKey.get()` returns `null` + - **When**: `MapsWidget` renders + - **Then**: `MapSwitcher` is NOT rendered + +- renders map when key is null for hereMaps (unit) + - **Given**: `mapProvider` is `"hereMaps"`, `apiKey.get()` returns `null` + - **When**: `MapsWidget` renders + - **Then**: `MapSwitcher` is NOT rendered + +### Regression Tests + +- still passes mapsToken to MapSwitcher when key is available (unit) + - **Given**: `mapProvider` is `"googleMaps"`, `apiKey.get()` returns `"token-123"` + - **When**: `MapsWidget` renders + - **Then**: `MapSwitcher` receives `mapsToken="token-123"` + +## Notes + +The gate is purely in `MapsWidget` (observer component). No changes needed in `MapSwitcher`, `LeafletMap`, or `GoogleMap`. The loading state is just the widget container div with appropriate dimensions (no spinner needed — key resolves within one tick in practice). diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/proposal.md b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/proposal.md new file mode 100644 index 0000000000..1754151df4 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/proposal.md @@ -0,0 +1,19 @@ +## Why + +Currently `MapsWidget` renders `MapSwitcher` immediately regardless of whether the API key has resolved. For providers that require a key (Google Maps, MapBox, HERE Maps), this causes the map to initialize with `undefined` as the token, leading to failed tile requests or error screens until the key arrives. OpenStreetMap does not require a key and should render immediately. + +## Root Cause + +`MapsWidget` passes `apiKey.get() ?? undefined` as `mapsToken` but does not gate rendering on the key being available. The map components attempt to initialize (loading scripts, creating map instances) before the key is ready. + +## What Changes + +- `MapsWidget` checks whether the API key is required (all providers except `openStreet`) +- If required and `apiKey.get()` is `null`, render a loading/empty state instead of `MapSwitcher` +- OpenStreetMap always renders immediately (no key dependency) + +## Impact + +- `src/components/MapsWidget.tsx` — add conditional render gate +- No breaking changes; behavior only improves (deferred init vs failed init) +- No new dependencies diff --git a/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/tasks.md b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/tasks.md new file mode 100644 index 0000000000..8b443ae683 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/changes/maps-defer-render-until-key/tasks.md @@ -0,0 +1,18 @@ +## 1. Test Setup + +- [x] 1.1 Add test: openStreet renders immediately when apiKey is null +- [x] 1.2 Add test: googleMaps does NOT render MapSwitcher when apiKey is null +- [x] 1.3 Add test: googleMaps renders MapSwitcher when apiKey resolves +- [x] 1.4 Add test: mapBox and hereMaps do NOT render when apiKey is null +- [x] 1.5 Add test: mapsToken is passed correctly when key is available + +## 2. Implementation + +- [x] 2.1 In `MapsWidget`, add early return with empty container when `mapProvider !== "openStreet"` and `apiKey.get()` is null +- [x] 2.2 Ensure the empty container preserves widget dimensions (class, style, width/height props) + +## 3. Verification + +- [x] 3.1 All new tests passing +- [x] 3.2 Full test suite passes (`pnpm run test`) +- [x] 3.3 TypeScript clean (`tsc --noEmit`) diff --git a/packages/pluggableWidgets/maps-web/openspec/specs/api-key-atom/spec.md b/packages/pluggableWidgets/maps-web/openspec/specs/api-key-atom/spec.md new file mode 100644 index 0000000000..b6e3dbab5b --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/specs/api-key-atom/spec.md @@ -0,0 +1,83 @@ +## Purpose + +Defines requirements for reactive API key resolution in the Maps widget via MobX computed atoms with caching. + +## Requirements + +### Requirement: API key resolved via computed atom + +The Maps container SHALL provide a `ComputedAtom` that reactively resolves the API key from widget props. + +#### Scenario: Expression value takes priority + +- **WHEN** `apiKeyExp.value` is a non-empty string +- **THEN** the atom returns that value + +#### Scenario: Falls back to static apiKey + +- **WHEN** `apiKeyExp.value` is undefined or empty +- **AND** `apiKey` is a non-empty string +- **THEN** the atom returns the static `apiKey` value + +#### Scenario: Returns null when no key available + +- **WHEN** both `apiKeyExp.value` and `apiKey` are empty or undefined +- **THEN** the atom returns `null` + +### Requirement: API key cached once resolved + +Once the atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`. + +#### Scenario: Key remains after expression becomes unavailable + +- **WHEN** the atom has previously returned a non-null value +- **AND** `apiKeyExp.value` subsequently becomes undefined +- **THEN** the atom still returns the previously cached value + +### Requirement: API key atom registered in DI container + +The atom SHALL be registered as a `CORE_TOKENS.apiKey` token in the Maps container and injectable into services. + +#### Scenario: LocationResolverService uses atom + +- **WHEN** `LocationResolverService` needs the API key for geocoding +- **THEN** it reads from the injected `ComputedAtom` via `.get()` + +### Requirement: Geodecode API key resolved via computed atom + +The Maps container SHALL provide a `ComputedAtom` that reactively resolves the geodecode API key from widget props, following the same pattern as the main API key atom. + +#### Scenario: Expression value takes priority + +- **WHEN** `geodecodeApiKeyExp.value` is a non-empty string +- **THEN** the atom returns that value + +#### Scenario: Falls back to static geodecodeApiKey + +- **WHEN** `geodecodeApiKeyExp.value` is undefined or empty +- **AND** `geodecodeApiKey` is a non-empty string +- **THEN** the atom returns the static `geodecodeApiKey` value + +#### Scenario: Returns null when no key available + +- **WHEN** both `geodecodeApiKeyExp.value` and `geodecodeApiKey` are empty or undefined +- **THEN** the atom returns `null` + +### Requirement: Geodecode API key cached once resolved + +Once the geodecode atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`. + +#### Scenario: Key remains after expression becomes unavailable + +- **WHEN** the atom has previously returned a non-null value +- **AND** `geodecodeApiKeyExp.value` subsequently becomes undefined +- **THEN** the atom still returns the previously cached value + +### Requirement: apiKey and geodecodeApiKey removed from MapsConfig + +The static `MapsConfig` interface SHALL NOT contain `apiKey` or `geodecodeApiKey` fields. Both keys are resolved reactively via atoms. + +#### Scenario: MapsConfig only contains static fields + +- **WHEN** `mapsConfig()` is called +- **THEN** the returned object contains `id`, `name`, and `showCurrentLocation` only diff --git a/packages/pluggableWidgets/maps-web/openspec/specs/editor-config-simplified/spec.md b/packages/pluggableWidgets/maps-web/openspec/specs/editor-config-simplified/spec.md new file mode 100644 index 0000000000..2663265e2e --- /dev/null +++ b/packages/pluggableWidgets/maps-web/openspec/specs/editor-config-simplified/spec.md @@ -0,0 +1,75 @@ +## Purpose + +Editor config property visibility logic for the Maps widget. Defines how properties are shown/hidden in Studio Pro based on widget configuration state. + +## Requirements + +### Requirement: No platform branching in property visibility + +The `getProperties()` function SHALL NOT branch on the `platform` parameter. All property visibility logic MUST use a single unified code path. + +#### Scenario: Same properties shown regardless of platform argument + +- **WHEN** `getProperties()` is called with platform `"web"` or `"desktop"` +- **THEN** the returned properties are identical for both values + +### Requirement: Advanced property removed + +The widget XML SHALL NOT define an `advanced` property. The editor config SHALL NOT reference `advanced` in any visibility logic. + +#### Scenario: mapProvider always visible + +- **WHEN** the widget is placed on a page +- **THEN** the `mapProvider` property is visible without any toggle + +#### Scenario: Marker style options always visible + +- **WHEN** a static or dynamic marker is configured +- **THEN** the `markerStyle` / `markerStyleDynamic` and `customMarker` / `customMarkerDynamic` properties are visible (custom marker still conditional on style being "image") + +### Requirement: apiKeyExp always visible + +The `apiKeyExp` expression property SHALL never be hidden by `getProperties()`, except when `mapProvider` is `"openStreet"` (OpenStreetMap requires no API key). + +#### Scenario: Fresh widget shows expression field + +- **WHEN** a new Maps widget is placed on a page with no configuration +- **THEN** `apiKeyExp` is visible to the user + +#### Scenario: apiKeyExp visible even when apiKey has value + +- **WHEN** `apiKey` (static) has a value set +- **THEN** `apiKeyExp` remains visible + +#### Scenario: apiKeyExp hidden for OpenStreetMap + +- **WHEN** `mapProvider` is `"openStreet"` +- **THEN** both `apiKey` and `apiKeyExp` are hidden (no API key needed) + +### Requirement: Static apiKey deprecation warning + +The `check()` function SHALL return a warning-severity problem when `values.apiKey` is non-empty, informing the user that the static API key is deprecated and `apiKeyExp` (expression) should be used instead. + +#### Scenario: Warning shown when static apiKey is set + +- **WHEN** `values.apiKey` is a non-empty string +- **THEN** `check()` returns a problem with `severity: "warning"` on property `"apiKey"` with a message indicating deprecation + +#### Scenario: No warning when apiKey is empty + +- **WHEN** `values.apiKey` is empty or undefined +- **THEN** no deprecation warning is returned + +### Requirement: apiKey hidden when empty + +The static `apiKey` field SHALL be hidden when it has no value. It SHALL only be shown when the user already has a value configured (for backward compatibility). + +#### Scenario: apiKey hidden when empty + +- **WHEN** `values.apiKey` is falsy (empty or undefined) +- **THEN** `apiKey` is hidden from the properties panel + +#### Scenario: apiKey visible when it has a value + +- **WHEN** `values.apiKey` is a non-empty string +- **THEN** `apiKey` is visible (for backward compatibility with existing configurations) diff --git a/packages/pluggableWidgets/maps-web/package.json b/packages/pluggableWidgets/maps-web/package.json index d40e79354a..7c82e06b3a 100644 --- a/packages/pluggableWidgets/maps-web/package.json +++ b/packages/pluggableWidgets/maps-web/package.json @@ -50,7 +50,8 @@ "classnames": "^2.5.1", "deep-equal": "^2.2.3", "leaflet": "^1.9.4", - "react-leaflet": "^4.2.1" + "mobx": "6.12.3", + "mobx-react-lite": "4.0.7" }, "devDependencies": { "@googlemaps/jest-mocks": "^2.10.0", @@ -65,7 +66,6 @@ "@mendix/widget-plugin-test-utils": "workspace:*", "@types/deep-equal": "^1.0.1", "@types/leaflet": "^1.9.3", - "@types/react-leaflet": "^2.8.3", "cross-env": "^7.0.3" } } diff --git a/packages/pluggableWidgets/maps-web/src/Maps.editorConfig.ts b/packages/pluggableWidgets/maps-web/src/Maps.editorConfig.ts index b94bf7dfb4..7ad1745ac5 100644 --- a/packages/pluggableWidgets/maps-web/src/Maps.editorConfig.ts +++ b/packages/pluggableWidgets/maps-web/src/Maps.editorConfig.ts @@ -1,50 +1,23 @@ -import { StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { hidePropertiesIn, hidePropertyIn, Problem, Properties } from "@mendix/pluggable-widgets-tools"; +import { StructurePreviewProps } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { MapsPreviewProps } from "../typings/MapsProps"; import GoogleMapsSVG from "./assets/GoogleMaps.svg"; +import HereMapsSVG from "./assets/HereMaps.svg"; import MapboxSVG from "./assets/Mapbox.svg"; import OpenStreetMapSVG from "./assets/OpenStreetMap.svg"; -import HereMapsSVG from "./assets/HereMaps.svg"; -export function getProperties( - values: MapsPreviewProps, - defaultProperties: Properties, - platform: "web" | "desktop" -): Properties { +export function getProperties(values: MapsPreviewProps, defaultProperties: Properties): Properties { const containsAddress = values.markers.some(marker => marker.locationType === "address") || values.dynamicMarkers.some(marker => marker.locationType === "address"); - if (platform === "desktop") { - if (values.apiKey) { - hidePropertyIn(defaultProperties, values, "apiKeyExp"); - } else { - hidePropertyIn(defaultProperties, values, "apiKey"); - } - if (values.geodecodeApiKey) { - hidePropertyIn(defaultProperties, values, "geodecodeApiKeyExp"); - } else { - hidePropertyIn(defaultProperties, values, "geodecodeApiKey"); - } - - hidePropertyIn(defaultProperties, values, "advanced"); - } else { - if (values.apiKeyExp) { - hidePropertyIn(defaultProperties, values, "apiKey"); - } else { - hidePropertyIn(defaultProperties, values, "apiKeyExp"); - } - if (values.geodecodeApiKeyExp) { - hidePropertyIn(defaultProperties, values, "geodecodeApiKey"); - } else { - hidePropertyIn(defaultProperties, values, "geodecodeApiKeyExp"); - } - - if (!values.advanced) { - hidePropertyIn(defaultProperties, values, "mapProvider"); - } + if (!values.apiKey) { + hidePropertyIn(defaultProperties, values, "apiKey"); + } + if (!values.geodecodeApiKey) { + hidePropertyIn(defaultProperties, values, "geodecodeApiKey"); } values.markers.forEach((f, index) => { @@ -54,10 +27,6 @@ export function getProperties( } else { hidePropertyIn(defaultProperties, values, "markers", index, "address"); } - if (platform === "web" && !values.advanced) { - hidePropertyIn(defaultProperties, values, "markers", index, "markerStyle"); - hidePropertyIn(defaultProperties, values, "markers", index, "customMarker"); - } if (f.markerStyle === "default") { hidePropertyIn(defaultProperties, values, "markers", index, "customMarker"); } @@ -70,10 +39,6 @@ export function getProperties( } else { hidePropertyIn(defaultProperties, values, "dynamicMarkers", index, "address"); } - if (platform === "web" && !values.advanced) { - hidePropertyIn(defaultProperties, values, "dynamicMarkers", index, "markerStyleDynamic"); - hidePropertyIn(defaultProperties, values, "dynamicMarkers", index, "customMarkerDynamic"); - } if (f.markerStyleDynamic === "default") { hidePropertyIn(defaultProperties, values, "dynamicMarkers", index, "customMarkerDynamic"); } @@ -103,6 +68,23 @@ export function getProperties( export function check(values: MapsPreviewProps): Problem[] { const errors: Problem[] = []; + + if (values.apiKey) { + errors.push({ + property: "apiKey", + severity: "warning", + message: "Static API key is deprecated. Use the 'API Key' expression instead." + }); + } + + if (values.geodecodeApiKey) { + errors.push({ + property: "geodecodeApiKey", + severity: "warning", + message: "Static Geo location API key is deprecated. Use the 'Geo location API key' expression instead." + }); + } + const containsAddress = values.markers.some(marker => marker.locationType === "address") || values.dynamicMarkers.some(marker => marker.locationType === "address"); diff --git a/packages/pluggableWidgets/maps-web/src/Maps.editorPreview.tsx b/packages/pluggableWidgets/maps-web/src/Maps.editorPreview.tsx index 789c67af21..1e71e1a063 100644 --- a/packages/pluggableWidgets/maps-web/src/Maps.editorPreview.tsx +++ b/packages/pluggableWidgets/maps-web/src/Maps.editorPreview.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; -import { MapsPreviewProps } from "../typings/MapsProps"; import { Alert } from "@mendix/widget-plugin-component-kit/Alert"; import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; +import { MapsPreviewProps } from "../typings/MapsProps"; export const preview = (props: MapsPreviewProps): ReactNode => { return ( diff --git a/packages/pluggableWidgets/maps-web/src/Maps.tsx b/packages/pluggableWidgets/maps-web/src/Maps.tsx index b5323fff7c..b8c3c25372 100644 --- a/packages/pluggableWidgets/maps-web/src/Maps.tsx +++ b/packages/pluggableWidgets/maps-web/src/Maps.tsx @@ -1,54 +1,17 @@ -import { ReactNode, useEffect, useState } from "react"; -import { MapSwitcher } from "./components/MapSwitcher"; - +import { ContainerProvider } from "brandi-react"; +import { ReactNode } from "react"; import { MapsContainerProps } from "../typings/MapsProps"; -import { useLocationResolver } from "./utils/geodecode"; -import { getCurrentUserLocation } from "./utils/location"; -import { Marker } from "../typings/shared"; -import { translateZoom } from "./utils/zoom"; +import { MapsWidget } from "./components/MapsWidget"; +import { useMapsContainer } from "./model/hooks/useMapsContainer"; import "leaflet/dist/leaflet.css"; import "./ui/Maps.scss"; export default function Maps(props: MapsContainerProps): ReactNode { - const [locations] = useLocationResolver( - props.markers, - props.dynamicMarkers, - props.geodecodeApiKeyExp?.value ?? props.geodecodeApiKey - ); - const [currentLocation, setCurrentLocation] = useState(); - - useEffect(() => { - if (props.showCurrentLocation) { - getCurrentUserLocation() - .then(setCurrentLocation) - .catch(e => console.error(e)); - } - }, [props.showCurrentLocation]); + const container = useMapsContainer(props); return ( - + + + ); } diff --git a/packages/pluggableWidgets/maps-web/src/Maps.xml b/packages/pluggableWidgets/maps-web/src/Maps.xml index 54fdfb2ad0..6ab98d8310 100644 --- a/packages/pluggableWidgets/maps-web/src/Maps.xml +++ b/packages/pluggableWidgets/maps-web/src/Maps.xml @@ -8,12 +8,6 @@ - - - Enable advanced options - - - Marker diff --git a/packages/pluggableWidgets/maps-web/src/__tests__/Maps.spec.tsx b/packages/pluggableWidgets/maps-web/src/__tests__/Maps.spec.tsx new file mode 100644 index 0000000000..2562926f27 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/__tests__/Maps.spec.tsx @@ -0,0 +1,45 @@ +import "@testing-library/jest-dom"; +import { act, render, waitFor } from "@testing-library/react"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { MarkersType } from "../../typings/MapsProps"; +import Maps from "../Maps"; +import { mockContainerProps } from "../utils/mock-container-props"; + +describe("Maps", () => { + function staticMarker(latitude: string, longitude: string): MarkersType { + return { + locationType: "latlng", + latitude: dynamic(latitude), + longitude: dynamic(longitude), + address: dynamic(""), + title: dynamic("Static marker"), + markerStyle: "default", + customMarker: undefined, + onClick: undefined + } as unknown as MarkersType; + } + + it("renders the leaflet map through the container provider", async () => { + const { container } = render(); + + expect(container.querySelector(".widget-maps")).toBeInTheDocument(); + expect(container.querySelector(".leaflet-container")).toBeInTheDocument(); + + // Flush the initial (empty) geocode resolution to avoid act() warnings + await act(async () => Promise.resolve()); + }); + + it("passes resolved locations from the model layer to the map", async () => { + const props = mockContainerProps({ + mapProvider: "openStreet", + zoom: "city", + markers: [staticMarker("51.906688", "4.48837")] + }); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelectorAll(".leaflet-marker-icon")).toHaveLength(1); + }); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/components/GoogleMap.tsx b/packages/pluggableWidgets/maps-web/src/components/GoogleMap.tsx index e172aa9067..e404494b5c 100644 --- a/packages/pluggableWidgets/maps-web/src/components/GoogleMap.tsx +++ b/packages/pluggableWidgets/maps-web/src/components/GoogleMap.tsx @@ -1,5 +1,3 @@ -import { ReactElement, useEffect, useRef, useState } from "react"; -import classNames from "classnames"; import { AdvancedMarker, APIProvider, @@ -11,9 +9,11 @@ import { useApiIsLoaded, useMap } from "@vis.gl/react-google-maps"; +import classNames from "classnames"; +import { ReactElement, useEffect, useRef, useState } from "react"; +import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; import { Marker, SharedProps } from "../../typings/shared"; import { translateZoom } from "../utils/zoom"; -import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; export interface GoogleMapsProps extends SharedProps { mapId: string; diff --git a/packages/pluggableWidgets/maps-web/src/components/LeafletMap.tsx b/packages/pluggableWidgets/maps-web/src/components/LeafletMap.tsx index 578e351ba7..d836b099aa 100644 --- a/packages/pluggableWidgets/maps-web/src/components/LeafletMap.tsx +++ b/packages/pluggableWidgets/maps-web/src/components/LeafletMap.tsx @@ -1,125 +1,37 @@ -import { ReactElement } from "react"; -import { MapContainer, Marker as MarkerComponent, Popup, TileLayer, useMap } from "react-leaflet"; import classNames from "classnames"; +import { ReactElement, useCallback } from "react"; import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; -import { SharedProps } from "../../typings/shared"; import { MapProviderEnum } from "../../typings/MapsProps"; -import { translateZoom } from "../utils/zoom"; -import { DivIcon, latLngBounds, Icon as LeafletIcon } from "leaflet"; -import { baseMapLayer } from "../utils/leaflet"; +import { SharedProps } from "../../typings/shared"; +import { useLeafletMapVM } from "../model/hooks/injection-hooks"; export interface LeafletProps extends SharedProps { mapProvider: MapProviderEnum; attributionControl: boolean; } -/** - * There is an ongoing issue in `react-leaflet` that fails to properly set the icon urls in the - * default marker implementation. Issue https://github.com/PaulLeCam/react-leaflet/issues/453 - * describes the problem and also proposes a few solutions. But all of them require a hackish method - * to override `leaflet`'s implementation of the default Icon. Instead, we always set the - * `Marker.icon` prop instead of relying on the default. So if a custom icon is set, we use that. - * If not, we reuse a leaflet icon that's the same as the default implementation should be. - */ -const defaultMarkerIcon = new LeafletIcon({ - // eslint-disable-next-line @typescript-eslint/no-require-imports - iconRetinaUrl: require("leaflet/dist/images/marker-icon.png"), - // eslint-disable-next-line @typescript-eslint/no-require-imports - iconUrl: require("leaflet/dist/images/marker-icon.png"), - // eslint-disable-next-line @typescript-eslint/no-require-imports - shadowUrl: require("leaflet/dist/images/marker-shadow.png"), - iconSize: [25, 41], - iconAnchor: [12, 41] -}); - -function SetBoundsComponent(props: Pick): null { - const map = useMap(); - const { autoZoom, currentLocation, locations } = props; - - const bounds = latLngBounds( - locations - .concat(currentLocation ? [currentLocation] : []) - .filter(m => !!m) - .map(m => [m.latitude, m.longitude]) - ); - - if (bounds.isValid()) { - if (autoZoom) { - map.flyToBounds(bounds, { padding: [0.5, 0.5], animate: false }).invalidateSize(); - } else { - map.panTo(bounds.getCenter(), { animate: false }); - } - } - - return null; -} - export function LeafletMap(props: LeafletProps): ReactElement { - const center = { lat: 51.906688, lng: 4.48837 }; - const { - autoZoom, - attributionControl, - className, - currentLocation, - locations, - mapProvider, - mapsToken, - optionScroll: scrollWheelZoom, - optionZoomControl: zoomControl, - style, - zoomLevel: zoom, - optionDrag: dragging - } = props; + const vm = useLeafletMapVM(); + + const refCallback = useCallback( + (node: HTMLDivElement | null) => { + if (node) { + vm.setupMap(node); + } else { + vm.disposeMap(); + } + }, + [vm] + ); return ( -
+
- - - {locations - .concat(currentLocation ? [currentLocation] : []) - .filter(m => !!m) - .map(marker => ( - `, - className: "custom-leaflet-map-icon-marker" - }) - : defaultMarkerIcon - } - interactive={!!marker.title || !!marker.onClick} - key={`marker_${marker.id ?? marker.latitude + "_" + marker.longitude}`} - eventHandlers={!marker.title && marker.onClick ? { click: marker.onClick } : undefined} - position={{ lat: marker.latitude, lng: marker.longitude }} - title={marker.title} - > - {marker.title && ( - - - {marker.title} - - - )} - - ))} - - + />
); diff --git a/packages/pluggableWidgets/maps-web/src/components/MapsWidget.tsx b/packages/pluggableWidgets/maps-web/src/components/MapsWidget.tsx new file mode 100644 index 0000000000..7d1c892284 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/components/MapsWidget.tsx @@ -0,0 +1,48 @@ +import { observer } from "mobx-react-lite"; +import { ReactElement } from "react"; +import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; +import { MapSwitcher } from "./MapSwitcher"; +import { useApiKey, useCurrentLocation, useLocationResolver, useMainGate } from "../model/hooks/injection-hooks"; +import { translateZoom } from "../utils/zoom"; + +/** + * Observer component that bridges the MobX model layer and the map views. + * Re-renders whenever resolved locations, the current location, or widget props change. + */ +export const MapsWidget = observer(function MapsWidget(): ReactElement { + const { props } = useMainGate(); + const { locations } = useLocationResolver(); + const { location: currentLocation } = useCurrentLocation(); + const apiKey = useApiKey(); + + if (props.mapProvider !== "openStreet" && apiKey.get() === null) { + return
; + } + + return ( + + ); +}); diff --git a/packages/pluggableWidgets/maps-web/src/components/__tests__/GoogleMap.spec.tsx b/packages/pluggableWidgets/maps-web/src/components/__tests__/GoogleMap.spec.tsx index 6766dd2061..a2a05899a7 100644 --- a/packages/pluggableWidgets/maps-web/src/components/__tests__/GoogleMap.spec.tsx +++ b/packages/pluggableWidgets/maps-web/src/components/__tests__/GoogleMap.spec.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; -import { render, RenderResult } from "@testing-library/react"; -import { GoogleMapContainer, GoogleMapsProps } from "../GoogleMap"; import { initialize } from "@googlemaps/jest-mocks"; +import { act, render, RenderResult } from "@testing-library/react"; +import { GoogleMapContainer, GoogleMapsProps } from "../GoogleMap"; describe("Google maps", () => { const defaultProps: GoogleMapsProps = { @@ -35,32 +35,36 @@ describe("Google maps", () => { jest.clearAllMocks(); }); - function renderGoogleMap(props: Partial = {}): RenderResult { - return render(); + async function renderGoogleMap(props: Partial = {}): Promise { + let result: RenderResult; + await act(async () => { + result = render(); + }); + return result!; } - it("renders a map with right structure", () => { - const { asFragment } = renderGoogleMap({ heightUnit: "percentageOfWidth", widthUnit: "pixels" }); + it("renders a map with right structure", async () => { + const { asFragment } = await renderGoogleMap({ heightUnit: "percentageOfWidth", widthUnit: "pixels" }); expect(asFragment()).toMatchSnapshot(); }); - it("renders a map with pixels renders structure correctly", () => { - const { asFragment } = renderGoogleMap({ heightUnit: "pixels", widthUnit: "pixels" }); + it("renders a map with pixels renders structure correctly", async () => { + const { asFragment } = await renderGoogleMap({ heightUnit: "pixels", widthUnit: "pixels" }); expect(asFragment()).toMatchSnapshot(); }); - it("renders a map with percentage of width and height units renders the structure correctly", () => { - const { asFragment } = renderGoogleMap({ heightUnit: "percentageOfWidth", widthUnit: "percentage" }); + it("renders a map with percentage of width and height units renders the structure correctly", async () => { + const { asFragment } = await renderGoogleMap({ heightUnit: "percentageOfWidth", widthUnit: "percentage" }); expect(asFragment()).toMatchSnapshot(); }); - it("renders a map with percentage of parent units renders the structure correctly", () => { - const { asFragment } = renderGoogleMap({ heightUnit: "percentageOfParent", widthUnit: "percentage" }); + it("renders a map with percentage of parent units renders the structure correctly", async () => { + const { asFragment } = await renderGoogleMap({ heightUnit: "percentageOfParent", widthUnit: "percentage" }); expect(asFragment()).toMatchSnapshot(); }); - it("renders a map with markers", () => { - const { asFragment } = renderGoogleMap({ + it("renders a map with markers", async () => { + const { asFragment } = await renderGoogleMap({ locations: [ { title: "Mendix HQ", @@ -79,8 +83,8 @@ describe("Google maps", () => { expect(asFragment()).toMatchSnapshot(); }); - it("renders a map with current location", () => { - const { asFragment } = renderGoogleMap({ + it("renders a map with current location", async () => { + const { asFragment } = await renderGoogleMap({ showCurrentLocation: true, currentLocation: { latitude: 51.906688, diff --git a/packages/pluggableWidgets/maps-web/src/components/__tests__/LeafletMap.spec.tsx b/packages/pluggableWidgets/maps-web/src/components/__tests__/LeafletMap.spec.tsx index 0ff23b3ab3..88499070bd 100644 --- a/packages/pluggableWidgets/maps-web/src/components/__tests__/LeafletMap.spec.tsx +++ b/packages/pluggableWidgets/maps-web/src/components/__tests__/LeafletMap.spec.tsx @@ -1,96 +1,181 @@ import "@testing-library/jest-dom"; -import { render, RenderResult } from "@testing-library/react"; -import { LeafletMap, LeafletProps } from "../LeafletMap"; +import { act, fireEvent, render, RenderResult, waitFor } from "@testing-library/react"; +import { DynamicValue } from "mendix"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { MapsContainerProps, MarkersType } from "../../../typings/MapsProps"; +import Maps from "../../Maps"; +import { mockContainerProps } from "../../utils/mock-container-props"; + +function staticMarker( + latitude: string, + longitude: string, + opts?: { title?: string; customMarker?: string; onClick?: () => void } +): MarkersType { + return { + locationType: "latlng", + latitude: dynamic(latitude), + longitude: dynamic(longitude), + address: dynamic(""), + title: dynamic(opts?.title ?? ""), + markerStyle: opts?.customMarker ? "image" : "default", + customMarker: opts?.customMarker ? ({ value: { uri: opts.customMarker } } as any) : undefined, + onClick: opts?.onClick ? ({ canExecute: true, execute: opts.onClick } as any) : undefined + } as unknown as MarkersType; +} + +function renderMaps(overrides?: Partial): RenderResult { + return render( + , + ...overrides + })} + /> + ); +} describe("Leaflet maps", () => { - const defaultProps: LeafletProps = { - attributionControl: false, - autoZoom: true, - className: "", - currentLocation: undefined, - height: 75, - heightUnit: "pixels", - locations: [], - mapProvider: "openStreet", - mapsToken: "", - optionDrag: true, - optionScroll: true, - optionZoomControl: true, - showCurrentLocation: false, - style: {}, - width: 50, - widthUnit: "percentage", - zoomLevel: 10 - }; - - function renderLeafletMap(props: Partial = {}): RenderResult { - return render(); - } - - it("renders a map with right structure", () => { - const { asFragment } = renderLeafletMap({ heightUnit: "percentageOfWidth", widthUnit: "pixels" }); - expect(asFragment()).toMatchSnapshot(); + it("renders the leaflet container with the right structure", async () => { + const { container } = renderMaps(); + + const widget = container.querySelector(".widget-maps"); + expect(widget).toBeInTheDocument(); + expect(widget!.querySelector(".widget-leaflet-maps-wrapper")).toBeInTheDocument(); + expect(widget!.querySelector(".widget-leaflet-maps")).toHaveClass("leaflet-container"); + await act(async () => Promise.resolve()); }); - it("renders a map with pixels renders structure correctly", () => { - const { asFragment } = renderLeafletMap({ heightUnit: "pixels", widthUnit: "pixels" }); - expect(asFragment()).toMatchSnapshot(); + it("applies dimensions based on width and height units", async () => { + const { container } = renderMaps({ heightUnit: "pixels", widthUnit: "pixels", height: 75, width: 50 }); + + expect(container.querySelector(".widget-maps")).toHaveStyle({ width: "50px", height: "75px" }); + await act(async () => Promise.resolve()); }); - it("renders a map with percentage of width and height units renders the structure correctly", () => { - const { asFragment } = renderLeafletMap({ heightUnit: "percentageOfWidth", widthUnit: "percentage" }); - expect(asFragment()).toMatchSnapshot(); + it("applies a custom class name", async () => { + const { container } = renderMaps({ class: "my-custom-class" }); + + expect(container.querySelector(".widget-maps")).toHaveClass("my-custom-class"); + await act(async () => Promise.resolve()); }); - it("renders a map with percentage of parent units renders the structure correctly", () => { - const { asFragment } = renderLeafletMap({ heightUnit: "percentageOfParent", widthUnit: "percentage" }); - expect(asFragment()).toMatchSnapshot(); + it("renders without attribution by default", async () => { + const { container } = renderMaps({ attributionControl: false }); + + expect(container.querySelector(".leaflet-control-attribution")).not.toBeInTheDocument(); + await act(async () => Promise.resolve()); }); - it("renders a map with HERE maps as provider", () => { - const { asFragment } = renderLeafletMap({ mapProvider: "hereMaps" }); - expect(asFragment()).toMatchSnapshot(); + it("renders with attribution when enabled", async () => { + const { container } = renderMaps({ attributionControl: true }); + + expect(container.querySelector(".leaflet-control-attribution")).toBeInTheDocument(); + await act(async () => Promise.resolve()); }); - it("renders a map with MapBox maps as provider", () => { - const { asFragment } = renderLeafletMap({ mapProvider: "mapBox" }); - expect(asFragment()).toMatchSnapshot(); + it("renders with zoom control", async () => { + const { container } = renderMaps({ optionZoomControl: true }); + + expect(container.querySelector(".leaflet-control-zoom")).toBeInTheDocument(); + await act(async () => Promise.resolve()); }); - it("renders a map with attribution", () => { - const { asFragment } = renderLeafletMap({ attributionControl: true }); - expect(asFragment()).toMatchSnapshot(); + it("renders without zoom control when disabled", async () => { + const { container } = renderMaps({ optionZoomControl: false }); + + expect(container.querySelector(".leaflet-control-zoom")).not.toBeInTheDocument(); + await act(async () => Promise.resolve()); }); - it("renders a map with markers", () => { - const { asFragment } = renderLeafletMap({ - locations: [ - { - title: "Mendix HQ", - latitude: 51.906688, - longitude: 4.48837, - url: "image:url" - }, - { - title: "Gementee Rotterdam", - latitude: 51.922823, - longitude: 4.479632, - url: "image:url" - } + it("renders markers for each location", async () => { + const { container } = renderMaps({ + markers: [ + staticMarker("51.906688", "4.48837", { customMarker: "image:url" }), + staticMarker("51.922823", "4.479632", { customMarker: "image:url" }) ] }); - expect(asFragment()).toMatchSnapshot(); + + await waitFor(() => { + expect(container.querySelectorAll(".custom-leaflet-map-icon-marker")).toHaveLength(2); + }); + }); + + it("renders the default marker icon when no custom marker image is set", async () => { + const { container } = renderMaps({ + markers: [staticMarker("51.906688", "4.48837")] + }); + + await waitFor(() => { + expect(container.querySelectorAll(".leaflet-marker-icon")).toHaveLength(1); + }); + expect(container.querySelector(".custom-leaflet-map-icon-marker")).not.toBeInTheDocument(); }); - it("renders a map with current location", () => { - const { asFragment } = renderLeafletMap({ + it("renders the current location as an additional marker", async () => { + const { container } = renderMaps({ showCurrentLocation: true, - currentLocation: { - latitude: 51.906688, - longitude: 4.48837, - url: "image:url" - } + markers: [staticMarker("51.906688", "4.48837", { customMarker: "image:url" })] + }); + + await waitFor(() => { + expect(container.querySelectorAll(".custom-leaflet-map-icon-marker").length).toBeGreaterThanOrEqual(1); }); - expect(asFragment()).toMatchSnapshot(); + }); + + it("opens a popup with the marker title on marker click", async () => { + const { container } = renderMaps({ + zoom: "city", + markers: [staticMarker("51.906688", "4.48837", { title: "Mendix HQ", customMarker: "image:url" })] + }); + + await waitFor(() => { + expect(container.querySelector(".custom-leaflet-map-icon-marker")).toBeInTheDocument(); + }); + + fireEvent.click(container.querySelector(".custom-leaflet-map-icon-marker")!); + expect(container.querySelector(".leaflet-popup-content")).toHaveTextContent("Mendix HQ"); + }); + + it("calls onClick when a marker without title is clicked", async () => { + const onClick = jest.fn(); + const { container } = renderMaps({ + zoom: "city", + markers: [staticMarker("51.906688", "4.48837", { customMarker: "image:url", onClick })] + }); + + await waitFor(() => { + expect(container.querySelector(".custom-leaflet-map-icon-marker")).toBeInTheDocument(); + }); + + fireEvent.click(container.querySelector(".custom-leaflet-map-icon-marker")!); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("calls onClick when the popup content of a titled marker is clicked", async () => { + const onClick = jest.fn(); + const { container } = renderMaps({ + zoom: "city", + markers: [staticMarker("51.906688", "4.48837", { title: "Mendix HQ", customMarker: "image:url", onClick })] + }); + + await waitFor(() => { + expect(container.querySelector(".custom-leaflet-map-icon-marker")).toBeInTheDocument(); + }); + + fireEvent.click(container.querySelector(".custom-leaflet-map-icon-marker")!); + const popupContent = container.querySelector(".leaflet-popup-content span"); + expect(popupContent).toBeInTheDocument(); + fireEvent.click(popupContent!); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("removes the map on unmount", async () => { + const { container, unmount } = renderMaps(); + + expect(container.querySelector(".leaflet-container")).toBeInTheDocument(); + unmount(); + expect(container.querySelector(".leaflet-container")).not.toBeInTheDocument(); }); }); diff --git a/packages/pluggableWidgets/maps-web/src/components/__tests__/MapsWidget.spec.tsx b/packages/pluggableWidgets/maps-web/src/components/__tests__/MapsWidget.spec.tsx new file mode 100644 index 0000000000..278be49ca0 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/components/__tests__/MapsWidget.spec.tsx @@ -0,0 +1,76 @@ +import "@testing-library/jest-dom"; +import { act, render } from "@testing-library/react"; +import { DynamicValue } from "mendix"; +import Maps from "../../Maps"; +import { mockContainerProps } from "../../utils/mock-container-props"; + +describe("MapsWidget render gating", () => { + it("renders map immediately for openStreet when apiKey is null", async () => { + const { container } = render( + + ); + + expect(container.querySelector(".leaflet-container")).toBeInTheDocument(); + await act(async () => Promise.resolve()); + }); + + it("does NOT render MapSwitcher when apiKey is null for googleMaps", async () => { + const { container } = render( + + ); + + expect(container.querySelector(".widget-maps")).toBeInTheDocument(); + expect(container.querySelector(".leaflet-container")).not.toBeInTheDocument(); + await act(async () => Promise.resolve()); + }); + + it("renders MapSwitcher when apiKey resolves for googleMaps", async () => { + const { container } = render( + + })} + /> + ); + + expect(container.querySelector(".widget-maps")).toBeInTheDocument(); + await act(async () => Promise.resolve()); + }); + + it("does NOT render MapSwitcher when apiKey is null for mapBox", async () => { + const { container } = render( + + ); + + expect(container.querySelector(".widget-maps")).toBeInTheDocument(); + expect(container.querySelector(".leaflet-container")).not.toBeInTheDocument(); + await act(async () => Promise.resolve()); + }); + + it("does NOT render MapSwitcher when apiKey is null for hereMaps", async () => { + const { container } = render( + + ); + + expect(container.querySelector(".widget-maps")).toBeInTheDocument(); + expect(container.querySelector(".leaflet-container")).not.toBeInTheDocument(); + await act(async () => Promise.resolve()); + }); + + it("passes mapsToken to MapSwitcher when key is available", async () => { + const { container } = render( + + })} + /> + ); + + expect(container.querySelector(".leaflet-container")).toBeInTheDocument(); + await act(async () => Promise.resolve()); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/components/__tests__/__snapshots__/LeafletMap.spec.tsx.snap b/packages/pluggableWidgets/maps-web/src/components/__tests__/__snapshots__/LeafletMap.spec.tsx.snap deleted file mode 100644 index 7c0fc6e17d..0000000000 --- a/packages/pluggableWidgets/maps-web/src/components/__tests__/__snapshots__/LeafletMap.spec.tsx.snap +++ /dev/null @@ -1,1046 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`Leaflet maps renders a map with HERE maps as provider 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with MapBox maps as provider 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with attribution 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
- - Leaflet - - - - © - - OpenStreetMap - - contributors -
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with current location 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- map marker -
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with markers 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- map marker -
-
- map marker -
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with percentage of parent units renders the structure correctly 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with percentage of width and height units renders the structure correctly 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with pixels renders structure correctly 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; - -exports[`Leaflet maps renders a map with right structure 1`] = ` - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -`; diff --git a/packages/pluggableWidgets/maps-web/src/model/atoms/__tests__/apiKey.atom.spec.ts b/packages/pluggableWidgets/maps-web/src/model/atoms/__tests__/apiKey.atom.spec.ts new file mode 100644 index 0000000000..f9b59b7a52 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/atoms/__tests__/apiKey.atom.spec.ts @@ -0,0 +1,51 @@ +import { runInAction } from "mobx"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { MapsContainerProps } from "../../../../typings/MapsProps"; +import { mockContainerProps } from "../../../utils/mock-container-props"; +import { apiKeyAtom } from "../apiKey.atom"; + +describe("apiKeyAtom", () => { + function setup(props: Partial = {}): { + atom: ReturnType; + provider: GateProvider; + } { + const provider = new GateProvider(mockContainerProps(props)); + const atom = apiKeyAtom(provider.gate); + return { atom, provider }; + } + + it("returns apiKeyExp value when available", () => { + const { atom } = setup({ apiKeyExp: { value: "exp-key" } as any }); + expect(atom.get()).toBe("exp-key"); + }); + + it("falls back to static apiKey when expression is undefined", () => { + const { atom } = setup({ apiKeyExp: undefined, apiKey: "static-key" }); + expect(atom.get()).toBe("static-key"); + }); + + it("returns null when both are empty", () => { + const { atom } = setup({ apiKeyExp: undefined, apiKey: "" }); + expect(atom.get()).toBeNull(); + }); + + it("caches value once resolved and never reverts to null", () => { + const provider = new GateProvider( + mockContainerProps({ apiKeyExp: { value: "exp-key" } as any, apiKey: "" }) + ); + + const atom = apiKeyAtom(provider.gate); + expect(atom.get()).toBe("exp-key"); + + runInAction(() => { + provider.setProps(mockContainerProps({ apiKeyExp: { value: undefined } as any, apiKey: "" })); + }); + + expect(atom.get()).toBe("exp-key"); + }); + + it("prioritizes expression over static", () => { + const { atom } = setup({ apiKeyExp: { value: "exp-key" } as any, apiKey: "static-key" }); + expect(atom.get()).toBe("exp-key"); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/model/atoms/__tests__/geodecodeApiKey.atom.spec.ts b/packages/pluggableWidgets/maps-web/src/model/atoms/__tests__/geodecodeApiKey.atom.spec.ts new file mode 100644 index 0000000000..1357d2e3fc --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/atoms/__tests__/geodecodeApiKey.atom.spec.ts @@ -0,0 +1,56 @@ +import { runInAction } from "mobx"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { MapsContainerProps } from "../../../../typings/MapsProps"; +import { mockContainerProps } from "../../../utils/mock-container-props"; +import { geodecodeApiKeyAtom } from "../geodecodeApiKey.atom"; + +describe("geodecodeApiKeyAtom", () => { + function setup(props: Partial = {}): { + atom: ReturnType; + provider: GateProvider; + } { + const provider = new GateProvider(mockContainerProps(props)); + const atom = geodecodeApiKeyAtom(provider.gate); + return { atom, provider }; + } + + it("returns geodecodeApiKeyExp value when available", () => { + const { atom } = setup({ geodecodeApiKeyExp: { value: "geo-exp-key" } as any }); + expect(atom.get()).toBe("geo-exp-key"); + }); + + it("falls back to static geodecodeApiKey when expression is undefined", () => { + const { atom } = setup({ geodecodeApiKeyExp: undefined, geodecodeApiKey: "geo-static-key" }); + expect(atom.get()).toBe("geo-static-key"); + }); + + it("returns null when both are empty", () => { + const { atom } = setup({ geodecodeApiKeyExp: undefined, geodecodeApiKey: "" }); + expect(atom.get()).toBeNull(); + }); + + it("caches value once resolved and never reverts to null", () => { + const provider = new GateProvider( + mockContainerProps({ geodecodeApiKeyExp: { value: "geo-exp-key" } as any, geodecodeApiKey: "" }) + ); + + const atom = geodecodeApiKeyAtom(provider.gate); + expect(atom.get()).toBe("geo-exp-key"); + + runInAction(() => { + provider.setProps( + mockContainerProps({ geodecodeApiKeyExp: { value: undefined } as any, geodecodeApiKey: "" }) + ); + }); + + expect(atom.get()).toBe("geo-exp-key"); + }); + + it("prioritizes expression over static", () => { + const { atom } = setup({ + geodecodeApiKeyExp: { value: "geo-exp-key" } as any, + geodecodeApiKey: "geo-static-key" + }); + expect(atom.get()).toBe("geo-exp-key"); + }); +}); diff --git a/packages/pluggableWidgets/maps-web/src/model/atoms/apiKey.atom.ts b/packages/pluggableWidgets/maps-web/src/model/atoms/apiKey.atom.ts new file mode 100644 index 0000000000..ab9ed4d29b --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/atoms/apiKey.atom.ts @@ -0,0 +1,13 @@ +import { computed } from "mobx"; +import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { MapsContainerProps } from "../../../typings/MapsProps"; + +export function apiKeyAtom(gate: DerivedPropsGate): ComputedAtom { + let cached: string | null = null; + return computed(() => { + if (cached !== null) return cached; + const value = (gate.props.apiKeyExp?.value ?? gate.props.apiKey) || null; + if (value) cached = value; + return value; + }); +} diff --git a/packages/pluggableWidgets/maps-web/src/model/atoms/geodecodeApiKey.atom.ts b/packages/pluggableWidgets/maps-web/src/model/atoms/geodecodeApiKey.atom.ts new file mode 100644 index 0000000000..c750a7c44e --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/atoms/geodecodeApiKey.atom.ts @@ -0,0 +1,13 @@ +import { computed } from "mobx"; +import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { MapsContainerProps } from "../../../typings/MapsProps"; + +export function geodecodeApiKeyAtom(gate: DerivedPropsGate): ComputedAtom { + let cached: string | null = null; + return computed(() => { + if (cached !== null) return cached; + const value = (gate.props.geodecodeApiKeyExp?.value ?? gate.props.geodecodeApiKey) || null; + if (value) cached = value; + return value; + }); +} diff --git a/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts b/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts index 69a71dd516..92ec8c9548 100644 --- a/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts +++ b/packages/pluggableWidgets/maps-web/src/model/configs/Maps.config.ts @@ -4,7 +4,7 @@ import { MapsContainerProps } from "../../../typings/MapsProps"; export interface MapsConfig { id: string; name: string; - apiKey?: string; + showCurrentLocation: boolean; } export function mapsConfig(props: MapsContainerProps): MapsConfig { @@ -13,6 +13,6 @@ export function mapsConfig(props: MapsContainerProps): MapsConfig { return { id, name: props.name, - apiKey: props.apiKeyExp?.value ?? props.apiKey + showCurrentLocation: props.showCurrentLocation }; } diff --git a/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts b/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts index a01aceadfd..fbe38875a7 100644 --- a/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts +++ b/packages/pluggableWidgets/maps-web/src/model/containers/Maps.container.ts @@ -2,9 +2,13 @@ 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 { apiKeyAtom } from "../atoms/apiKey.atom"; +import { geodecodeApiKeyAtom } from "../atoms/geodecodeApiKey.atom"; import { MapsConfig } from "../configs/Maps.config"; +import { CurrentLocationService } from "../services/CurrentLocation.service"; import { LocationResolverService } from "../services/LocationResolver.service"; import { CORE_TOKENS as CORE, MAPS_TOKENS as MAPS } from "../tokens"; +import { LeafletMapViewModel } from "../viewmodels/LeafletMap.viewModel"; interface InitDependencies { props: MapsContainerProps; @@ -26,18 +30,25 @@ interface BindingGroup { const _01_coreBindings: BindingGroup = { inject() { - injected(LocationResolverService, CORE.setupService, CORE.mainGate, CORE.geocodeFunction); + injected(LocationResolverService, CORE.setupService, CORE.mainGate, CORE.geocodeFunction, CORE.geodecodeApiKey); + injected(CurrentLocationService, CORE.setupService, CORE.config, CORE.getLocationFunction); + injected(LeafletMapViewModel, CORE.mainGate, MAPS.locationResolver, MAPS.currentLocation, CORE.apiKey); }, define(container) { container.bind(MAPS.locationResolver).toInstance(LocationResolverService).inSingletonScope(); + container.bind(MAPS.currentLocation).toInstance(CurrentLocationService).inSingletonScope(); + container.bind(MAPS.leafletMapVM).toInstance(LeafletMapViewModel).inSingletonScope(); }, init(container, { mainGate, config }) { container.bind(CORE.mainGate).toConstant(mainGate); container.bind(CORE.config).toConstant(config); + container.bind(CORE.apiKey).toConstant(apiKeyAtom(mainGate)); + container.bind(CORE.geodecodeApiKey).toConstant(geodecodeApiKeyAtom(mainGate)); }, postInit(container) { - // Initialize service to trigger setup + // Initialize services to trigger setup container.get(MAPS.locationResolver); + container.get(MAPS.currentLocation); } }; diff --git a/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts index 6ca36b69bb..cf0a97b634 100644 --- a/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts +++ b/packages/pluggableWidgets/maps-web/src/model/containers/Root.container.ts @@ -1,6 +1,7 @@ -import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { Container } from "brandi"; +import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { convertAddressToLatLng } from "../../utils/geodecode"; +import { getCurrentUserLocation } from "../../utils/location"; import { MapsSetupService } from "../services/MapsSetup.service"; import { CORE_TOKENS as CORE } from "../tokens"; @@ -18,5 +19,8 @@ export class RootContainer extends Container { // Geocode function this.bind(CORE.geocodeFunction).toConstant(convertAddressToLatLng); + + // Current location function + this.bind(CORE.getLocationFunction).toConstant(getCurrentUserLocation); } } diff --git a/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts b/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts index 9da529bf43..cfa5fad56c 100644 --- a/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts +++ b/packages/pluggableWidgets/maps-web/src/model/containers/createMapsContainer.ts @@ -1,8 +1,8 @@ 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"; +import { MapsContainerProps } from "../../../typings/MapsProps"; +import { mapsConfig } from "../configs/Maps.config"; export function createMapsContainer(props: MapsContainerProps): [MapsContainer, GateProvider] { const root = new RootContainer(); diff --git a/packages/pluggableWidgets/maps-web/src/model/hooks/injection-hooks.ts b/packages/pluggableWidgets/maps-web/src/model/hooks/injection-hooks.ts new file mode 100644 index 0000000000..60d452640c --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/hooks/injection-hooks.ts @@ -0,0 +1,10 @@ +import { createInjectionHooks } from "brandi-react"; +import { CORE_TOKENS as CORE, MAPS_TOKENS as MAPS } from "../tokens"; + +export const [useMainGate] = createInjectionHooks(CORE.mainGate); +export const [useMapsConfig] = createInjectionHooks(CORE.config); +export const [useApiKey] = createInjectionHooks(CORE.apiKey); + +export const [useLocationResolver] = createInjectionHooks(MAPS.locationResolver); +export const [useCurrentLocation] = createInjectionHooks(MAPS.currentLocation); +export const [useLeafletMapVM] = createInjectionHooks(MAPS.leafletMapVM); diff --git a/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts b/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts index 35f84bd000..dcad8b8507 100644 --- a/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts +++ b/packages/pluggableWidgets/maps-web/src/model/hooks/useMapsContainer.ts @@ -1,7 +1,7 @@ -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 { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { MapsContainerProps } from "../../../typings/MapsProps"; import { createMapsContainer } from "../containers/createMapsContainer"; import { CORE_TOKENS as CORE } from "../tokens"; diff --git a/packages/pluggableWidgets/maps-web/src/model/services/CurrentLocation.service.ts b/packages/pluggableWidgets/maps-web/src/model/services/CurrentLocation.service.ts new file mode 100644 index 0000000000..45813d29d5 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/CurrentLocation.service.ts @@ -0,0 +1,45 @@ +import { action, makeObservable, observable } from "mobx"; +import { SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { Marker } from "../../../typings/shared"; +import { MapsConfig } from "../configs/Maps.config"; +import { GetLocationFunction } from "../tokens"; + +export class CurrentLocationService implements SetupComponent { + location: Marker | undefined = undefined; + + constructor( + host: SetupComponentHost, + private readonly config: MapsConfig, + private readonly getLocation: GetLocationFunction + ) { + makeObservable(this, { + location: observable.ref, + updateLocation: action + }); + host.add(this); + } + + updateLocation(location: Marker | undefined): void { + this.location = location; + } + + setup(): () => void { + if (!this.config.showCurrentLocation) { + return () => {}; + } + + let disposed = false; + + this.getLocation() + .then(location => { + if (!disposed) { + this.updateLocation(location); + } + }) + .catch(e => console.error(e)); + + return () => { + disposed = true; + }; + } +} diff --git a/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts b/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts index 562053ef00..39eeee920d 100644 --- a/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts +++ b/packages/pluggableWidgets/maps-web/src/model/services/LocationResolver.service.ts @@ -1,6 +1,7 @@ import deepEqual from "deep-equal"; import { action, computed, makeObservable, observable, reaction } from "mobx"; import { + ComputedAtom, DerivedPropsGate, disposeBatch, SetupComponent, @@ -11,10 +12,6 @@ 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; @@ -22,21 +19,17 @@ export class LocationResolverService implements SetupComponent { constructor( host: SetupComponentHost, private readonly mainGate: DerivedPropsGate, - private readonly geocode: GeocodeFunction + private readonly geocode: GeocodeFunction, + private readonly geodecodeApiKeyAtom: ComputedAtom ) { 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; @@ -46,14 +39,6 @@ export class LocationResolverService implements SetupComponent { 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. */ @@ -73,7 +58,7 @@ export class LocationResolverService implements SetupComponent { currentMarkers => { const version = ++this.geocodeVersion; - this.geocode(currentMarkers, this.apiKey) + this.geocode(currentMarkers, this.geodecodeApiKeyAtom.get() ?? undefined) .then(resolvedLocations => { // Only update if this is still the latest request if (this.geocodeVersion === version) { diff --git a/packages/pluggableWidgets/maps-web/src/model/services/__tests__/CurrentLocation.spec.ts b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/CurrentLocation.spec.ts new file mode 100644 index 0000000000..51d1bd3b34 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/CurrentLocation.spec.ts @@ -0,0 +1,80 @@ +import { configure, when } from "mobx"; +import { createTestContainer, getCurrentLocationService } from "./test-utils"; +import { Marker } from "../../../../typings/shared"; +import { mockContainerProps } from "../../../utils/mock-container-props"; + +configure({ enforceActions: "never" }); + +describe("CurrentLocationService", () => { + const userLocation: Marker = { latitude: 52.370216, longitude: 4.895168, url: "image:current" }; + + function mockGetLocation(location: Marker = userLocation): jest.Mock> { + return jest.fn().mockResolvedValue(location); + } + + it("does not request location when showCurrentLocation is false", () => { + const getLocation = mockGetLocation(); + const [container] = createTestContainer({ + props: mockContainerProps({ showCurrentLocation: false }), + getLocationFunction: getLocation + }); + getCurrentLocationService(container); + + expect(getLocation).not.toHaveBeenCalled(); + }); + + it("resolves location when showCurrentLocation is true", async () => { + const getLocation = mockGetLocation(); + const [container] = createTestContainer({ + props: mockContainerProps({ showCurrentLocation: true }), + getLocationFunction: getLocation + }); + const service = getCurrentLocationService(container); + + await when(() => service.location !== undefined, { timeout: 1000 }); + + expect(getLocation).toHaveBeenCalledTimes(1); + expect(service.location).toEqual(userLocation); + }); + + it("does not update location after dispose", async () => { + let resolveLocation: (marker: Marker) => void = () => undefined; + const getLocation = jest.fn().mockImplementation( + () => + new Promise(resolve => { + resolveLocation = resolve; + }) + ); + const [container] = createTestContainer({ + props: mockContainerProps({ showCurrentLocation: true }), + getLocationFunction: getLocation, + skipSetup: true + }); + const service = getCurrentLocationService(container); + const dispose = service.setup(); + + expect(getLocation).toHaveBeenCalledTimes(1); + + dispose(); + resolveLocation(userLocation); + await Promise.resolve(); + + expect(service.location).toBeUndefined(); + }); + + it("logs an error when location resolution fails", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + const error = new Error("Current user location is not available"); + const getLocation = jest.fn().mockRejectedValue(error); + const [container] = createTestContainer({ + props: mockContainerProps({ showCurrentLocation: true }), + getLocationFunction: getLocation + }); + getCurrentLocationService(container); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(consoleSpy).toHaveBeenCalledWith(error); + consoleSpy.mockRestore(); + }); +}); 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 index 915d4345bd..3a92827240 100644 --- 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 @@ -1,9 +1,9 @@ -import { when, configure } from "mobx"; import { ValueStatus } from "mendix"; +import { 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"; +import { MarkersType } from "../../../../typings/MapsProps"; +import { mockContainerProps } from "../../../utils/mock-container-props"; // Configure MobX for testing configure({ enforceActions: "never" }); 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 index 2fb6035239..f0be104c6b 100644 --- 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 @@ -1,8 +1,8 @@ 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"; +import { MarkersType } from "../../../../typings/MapsProps"; +import { mockContainerProps } from "../../../utils/mock-container-props"; // Configure MobX for testing configure({ enforceActions: "never" }); 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 index 1417d8ff12..5cfa65467f 100644 --- a/packages/pluggableWidgets/maps-web/src/model/services/__tests__/test-utils.ts +++ b/packages/pluggableWidgets/maps-web/src/model/services/__tests__/test-utils.ts @@ -4,12 +4,15 @@ 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 { CORE_TOKENS as CORE, MAPS_TOKENS as MAPS, GeocodeFunction, GetLocationFunction } from "../../tokens"; +import { CurrentLocationService } from "../CurrentLocation.service"; import { LocationResolverService } from "../LocationResolver.service"; export interface TestContainerOptions { props: MapsContainerProps; geocodeFunction?: GeocodeFunction; + getLocationFunction?: GetLocationFunction; + skipSetup?: boolean; } /** @@ -19,7 +22,7 @@ export interface TestContainerOptions { export function createTestContainer( options: TestContainerOptions ): [MapsContainer, LocationResolverService, GateProvider] { - const { props, geocodeFunction } = options; + const { props, geocodeFunction, getLocationFunction, skipSetup } = options; // Create root container const root = new RootContainer(); @@ -29,6 +32,11 @@ export function createTestContainer( root.bind(CORE.geocodeFunction).toConstant(geocodeFunction); } + // Override current location function in root if provided + if (getLocationFunction) { + root.bind(CORE.getLocationFunction).toConstant(getLocationFunction); + } + // Create config and gate provider const config = mapsConfig(props); const gateProvider = new GateProvider(props); @@ -40,8 +48,9 @@ export function createTestContainer( mainGate: gateProvider.gate }); - // Trigger setup lifecycle (in production this is done by useSetup hook) - container.get(CORE.setupService).setup(); + if (!skipSetup) { + container.get(CORE.setupService).setup(); + } // Get service (already initialized by postInit) const service = container.get(MAPS.locationResolver); @@ -62,3 +71,10 @@ export async function waitForLocations(service: LocationResolverService, expecte export function createMockGeocodeFunction(): jest.MockedFunction { return jest.fn().mockResolvedValue([]); } + +/** + * Resolves the CurrentLocationService from a test container. + */ +export function getCurrentLocationService(container: MapsContainer): CurrentLocationService { + return container.get(MAPS.currentLocation); +} diff --git a/packages/pluggableWidgets/maps-web/src/model/tokens.ts b/packages/pluggableWidgets/maps-web/src/model/tokens.ts index be4bd8495c..d16cd18f90 100644 --- a/packages/pluggableWidgets/maps-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/maps-web/src/model/tokens.ts @@ -1,14 +1,19 @@ -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 { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import { MapsConfig } from "./configs/Maps.config"; -import { MapsSetupService } from "./services/MapsSetup.service"; +import { CurrentLocationService } from "./services/CurrentLocation.service"; import { LocationResolverService } from "./services/LocationResolver.service"; +import { MapsSetupService } from "./services/MapsSetup.service"; +import { LeafletMapViewModel } from "./viewmodels/LeafletMap.viewModel"; +import { MapsContainerProps } from "../../typings/MapsProps"; +import { Marker, ModeledMarker } from "../../typings/shared"; /** Function type for geocoding markers. */ export type GeocodeFunction = (locations?: ModeledMarker[], mapToken?: string) => Promise; +/** Function type for resolving the current user location. */ +export type GetLocationFunction = () => Promise; + /** Tokens to resolve dependencies from the container. */ const label = (name: string): string => `Maps[${name}]`; @@ -17,11 +22,16 @@ const label = (name: string): string => `Maps[${name}]`; export const CORE_TOKENS = { mainGate: token>(label("mainGate")), config: token(label("config")), + apiKey: token>(label("apiKey")), + geodecodeApiKey: token>(label("geodecodeApiKey")), setupService: token(label("setupService")), - geocodeFunction: token(label("geocodeFunction")) + geocodeFunction: token(label("geocodeFunction")), + getLocationFunction: token(label("getLocationFunction")) }; /** Maps-specific tokens. */ export const MAPS_TOKENS = { - locationResolver: token(label("locationResolver")) + locationResolver: token(label("locationResolver")), + currentLocation: token(label("currentLocation")), + leafletMapVM: token(label("leafletMapVM")) }; diff --git a/packages/pluggableWidgets/maps-web/src/model/viewmodels/LeafletMap.viewModel.ts b/packages/pluggableWidgets/maps-web/src/model/viewmodels/LeafletMap.viewModel.ts new file mode 100644 index 0000000000..37979327d2 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/model/viewmodels/LeafletMap.viewModel.ts @@ -0,0 +1,139 @@ +import { latLngBounds, Map as LeafletMapInstance, Marker as LeafletMarker, TileLayer, TileLayerOptions } from "leaflet"; +import { reaction } from "mobx"; +import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { MapProviderEnum, MapsContainerProps } from "../../../typings/MapsProps"; +import { Marker } from "../../../typings/shared"; +import { createLeafletMarker } from "../../utils/leaflet-markers"; +import { translateZoom } from "../../utils/zoom"; +import { CurrentLocationService } from "../services/CurrentLocation.service"; +import { LocationResolverService } from "../services/LocationResolver.service"; + +export class LeafletMapViewModel { + private map: LeafletMapInstance | undefined = undefined; + private tileLayer: TileLayer | undefined = undefined; + private leafletMarkers: LeafletMarker[] = []; + private disposeReaction: (() => void) | undefined = undefined; + + constructor( + private readonly gate: DerivedPropsGate, + private readonly locationResolver: LocationResolverService, + private readonly currentLocationService: CurrentLocationService, + private readonly apiKeyAtom: ComputedAtom + ) {} + + get mapProvider(): MapProviderEnum { + return this.gate.props.mapProvider; + } + + setupMap(node: HTMLDivElement): void { + const { + attributionControl, + optionDrag: dragging, + optionScroll: scrollWheelZoom, + optionZoomControl: zoomControl, + zoom, + mapProvider + } = this.gate.props; + const autoZoom = zoom === "automatic"; + + const map = new LeafletMapInstance(node, { + attributionControl, + center: { lat: 51.906688, lng: 4.48837 }, + dragging, + maxZoom: 18, + minZoom: 1, + scrollWheelZoom, + zoom: autoZoom ? translateZoom("city") : translateZoom(zoom), + zoomControl + }); + + this.map = map; + + const { url, options } = this.getTileLayerConfig(mapProvider); + this.tileLayer = new TileLayer(url, options); + this.tileLayer.addTo(map); + + this.disposeReaction = reaction( + () => ({ + locations: this.locationResolver.locations, + currentLocation: this.currentLocationService.location + }), + ({ locations, currentLocation }) => this.syncMarkers(locations, currentLocation, autoZoom), + { fireImmediately: true } + ); + } + + disposeMap(): void { + this.disposeReaction?.(); + this.disposeReaction = undefined; + this.leafletMarkers = []; + this.tileLayer = undefined; + this.map?.remove(); + this.map = undefined; + } + + private getTileLayerConfig(mapProvider: MapProviderEnum): { url: string; options: TileLayerOptions } { + const apiKey = this.apiKeyAtom.get(); + + if (mapProvider === "mapBox") { + const token = apiKey ? `?access_token=${apiKey}` : ""; + return { + url: `https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}${token}`, + options: { + attribution: + "Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox", + id: "mapbox/streets-v11", + tileSize: 512, + zoomOffset: -1 + } + }; + } + + if (mapProvider === "hereMaps") { + let token = ""; + if (apiKey) { + if (apiKey.indexOf(",") > 0) { + const [appId, appCode] = apiKey.split(","); + token = `?app_id=${appId}&app_code=${appCode}`; + } else { + token = `?apiKey=${apiKey}`; + } + } + return { + url: `https://2.base.maps.cit.api.here.com/maptile/2.1/maptile/newest/normal.day/{z}/{x}/{y}/256/png8${token}`, + options: { attribution: "Map © 1987-2020 HERE" } + }; + } + + return { + url: "https://{s}.tile.osm.org/{z}/{x}/{y}.png", + options: { attribution: "© OpenStreetMap contributors" } + }; + } + + private syncMarkers(locations: Marker[], currentLocation: Marker | undefined, autoZoom: boolean): void { + const map = this.map; + if (!map) { + return; + } + + const markers = locations.concat(currentLocation ? [currentLocation] : []).filter(m => !!m); + + this.leafletMarkers.forEach(marker => marker.remove()); + this.leafletMarkers = markers.map(marker => { + const leafletMarker = createLeafletMarker(marker); + leafletMarker.addTo(map); + return leafletMarker; + }); + + const bounds = latLngBounds(markers.map(m => [m.latitude, m.longitude])); + + if (bounds.isValid()) { + if (autoZoom) { + map.flyToBounds(bounds, { padding: [0.5, 0.5], animate: false }).invalidateSize(); + } else { + map.panTo(bounds.getCenter(), { animate: false }); + } + } + } +} diff --git a/packages/pluggableWidgets/maps-web/src/utils/geodecode.ts b/packages/pluggableWidgets/maps-web/src/utils/geodecode.ts index 5e39f78f36..63823dff2e 100644 --- a/packages/pluggableWidgets/maps-web/src/utils/geodecode.ts +++ b/packages/pluggableWidgets/maps-web/src/utils/geodecode.ts @@ -1,8 +1,4 @@ -import { useMemo, useRef, useState } from "react"; -import { convertDynamicModeledMarker, convertStaticModeledMarker } from "./data"; -import deepEqual from "deep-equal"; import { Marker, ModeledMarker } from "../../typings/shared"; -import { DynamicMarkersType, MarkersType } from "../../typings/MapsProps"; declare const window: { mxGMLocationCache: { @@ -78,50 +74,3 @@ async function geocodeQueued(address: string, mapToken: string): Promise longitude: decodedLocation.lng }; } - -export function useLocationResolver( - staticMarkers: MarkersType[], - dynamicMarkers: DynamicMarkersType[], - googleApiKey?: string -): [Marker[]] { - const [locations, setLocations] = useState([]); - const requestedMarkers = useRef([]); - - const markers = useMemo(() => { - const markers: ModeledMarker[] = []; - markers.push(...staticMarkers.map(marker => convertStaticModeledMarker(marker))); - markers.push( - ...dynamicMarkers - .map(marker => convertDynamicModeledMarker(marker)) - .reduce((prev, current) => [...prev, ...current], []) - ); - return markers; - }, [staticMarkers, dynamicMarkers]); - - if (!isIdenticalMarkers(requestedMarkers.current, markers)) { - requestedMarkers.current = markers; - convertAddressToLatLng(markers, googleApiKey) - .then(newLocations => { - if (requestedMarkers.current === markers) { - setLocations(newLocations); - } - }) - .catch(e => { - console.error(e); - }); - } - - return [locations]; -} - -function isIdenticalMarkers(previousMarkers: ModeledMarker[], newMarkers: ModeledMarker[]): boolean { - const previousProps = previousMarkers.map(({ ...marker }) => { - delete marker.action; - return marker; - }); - const newProps = newMarkers.map(({ ...marker }) => { - delete marker.action; - return marker; - }); - return deepEqual(previousProps, newProps, { strict: true }); -} diff --git a/packages/pluggableWidgets/maps-web/src/utils/leaflet-markers.ts b/packages/pluggableWidgets/maps-web/src/utils/leaflet-markers.ts new file mode 100644 index 0000000000..d30843ffdf --- /dev/null +++ b/packages/pluggableWidgets/maps-web/src/utils/leaflet-markers.ts @@ -0,0 +1,50 @@ +import { DivIcon, Icon as LeafletIcon, Marker as LeafletMarker } from "leaflet"; +import markerIconUrl from "leaflet/dist/images/marker-icon.png"; +import markerShadowUrl from "leaflet/dist/images/marker-shadow.png"; +import { Marker } from "../../typings/shared"; + +const defaultMarkerIcon = new LeafletIcon({ + iconRetinaUrl: markerIconUrl, + iconUrl: markerIconUrl, + shadowUrl: markerShadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41] +}); + +function createMarkerIcon(marker: Marker): DivIcon | LeafletIcon { + return marker.url + ? new DivIcon({ + html: `map marker`, + className: "custom-leaflet-map-icon-marker" + }) + : defaultMarkerIcon; +} + +function createPopupContent(marker: Marker): HTMLElement { + const content = document.createElement("span"); + content.textContent = marker.title ?? ""; + content.style.cursor = marker.onClick ? "pointer" : "none"; + if (marker.onClick) { + content.addEventListener("click", marker.onClick); + } + return content; +} + +export function createLeafletMarker(marker: Marker): LeafletMarker { + const leafletMarker = new LeafletMarker( + { lat: marker.latitude, lng: marker.longitude }, + { + icon: createMarkerIcon(marker), + interactive: !!marker.title || !!marker.onClick, + title: marker.title + } + ); + + if (marker.title) { + leafletMarker.bindPopup(createPopupContent(marker)); + } else if (marker.onClick) { + leafletMarker.on("click", marker.onClick); + } + + return leafletMarker; +} diff --git a/packages/pluggableWidgets/maps-web/src/utils/leaflet.ts b/packages/pluggableWidgets/maps-web/src/utils/leaflet.ts deleted file mode 100644 index fe12454f28..0000000000 --- a/packages/pluggableWidgets/maps-web/src/utils/leaflet.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { TileLayerProps } from "react-leaflet"; -import { MapProviderEnum } from "../../typings/MapsProps"; - -const customUrls = { - openStreetMap: "https://{s}.tile.osm.org/{z}/{x}/{y}.png", - mapbox: "https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}", - hereMaps: "https://2.base.maps.cit.api.here.com/maptile/2.1/maptile/newest/normal.day/{z}/{x}/{y}/256/png8" -}; - -const mapAttr = { - openStreetMapAttr: "© OpenStreetMap contributors", - mapboxAttr: - "Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox", - hereMapsAttr: "Map © 1987-2020 HERE" -}; - -export function baseMapLayer(mapProvider: MapProviderEnum, mapsToken?: string): TileLayerProps { - let url; - let attribution; - let apiKey = ""; - if (mapProvider === "mapBox") { - if (mapsToken) { - apiKey = `?access_token=${mapsToken}`; - } - url = customUrls.mapbox + apiKey; - attribution = mapAttr.mapboxAttr; - return { - url, - attribution, - id: "mapbox/streets-v11", - tileSize: 512, - zoomOffset: -1 - }; - } else if (mapProvider === "hereMaps") { - if (mapsToken && mapsToken.indexOf(",") > 0) { - const splitToken = mapsToken.split(","); - apiKey = `?app_id=${splitToken[0]}&app_code=${splitToken[1]}`; - } else if (mapsToken) { - apiKey = `?apiKey=${mapsToken}`; - } - url = customUrls.hereMaps + apiKey; - attribution = mapAttr.hereMapsAttr; - } else { - url = customUrls.openStreetMap; - attribution = mapAttr.openStreetMapAttr; - } - - return { - attribution, - url - }; -} diff --git a/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts b/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts index e32628af25..69ed63dee3 100644 --- a/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts +++ b/packages/pluggableWidgets/maps-web/src/utils/mock-container-props.ts @@ -7,7 +7,6 @@ export function mockContainerProps(overrides?: Partial): Map class: "", style: {}, tabIndex: 0, - advanced: false, apiKey: "", apiKeyExp: { value: "test-api-key" } as DynamicValue, geodecodeApiKey: "", diff --git a/packages/pluggableWidgets/maps-web/typings/MapsProps.d.ts b/packages/pluggableWidgets/maps-web/typings/MapsProps.d.ts index 429ae780a5..ee3f8cd6ab 100644 --- a/packages/pluggableWidgets/maps-web/typings/MapsProps.d.ts +++ b/packages/pluggableWidgets/maps-web/typings/MapsProps.d.ts @@ -74,7 +74,6 @@ export interface MapsContainerProps { class: string; style?: CSSProperties; tabIndex?: number; - advanced: boolean; markers: MarkersType[]; dynamicMarkers: DynamicMarkersType[]; apiKey: string; @@ -110,7 +109,6 @@ export interface MapsPreviewProps { readOnly: boolean; renderMode: "design" | "xray" | "structure"; translate: (text: string) => string; - advanced: boolean; markers: MarkersPreviewType[]; dynamicMarkers: DynamicMarkersPreviewType[]; apiKey: string; diff --git a/packages/pluggableWidgets/maps-web/typings/declare-png.ts b/packages/pluggableWidgets/maps-web/typings/declare-png.ts new file mode 100644 index 0000000000..ff89522537 --- /dev/null +++ b/packages/pluggableWidgets/maps-web/typings/declare-png.ts @@ -0,0 +1,4 @@ +declare module "*.png" { + const content: string; + export = content; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d39ae771cd..bcfef2badc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1886,9 +1886,12 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 - react-leaflet: - specifier: ^4.2.1 - version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + mobx: + specifier: 6.12.3 + version: 6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9) + mobx-react-lite: + specifier: 4.0.7 + version: 4.0.7(patch_hash=47fd2d1b5c35554ddd4fa32fcaa928a16fda9f82dca0ff68bcdc1f7c3e5f9d1a)(mobx@6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9))(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1) devDependencies: '@googlemaps/jest-mocks': specifier: ^2.10.0 @@ -1926,9 +1929,6 @@ importers: '@types/leaflet': specifier: ^1.9.3 version: 1.9.21 - '@types/react-leaflet': - specifier: ^2.8.3 - version: 2.8.3 cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -4814,13 +4814,6 @@ packages: react: '>=18.0.0 <19.0.0' react-dom: '>=18.0.0 <19.0.0' - '@react-leaflet/core@2.1.0': - resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} - peerDependencies: - leaflet: ^1.9.0 - react: '>=18.0.0 <19.0.0' - react-dom: '>=18.0.0 <19.0.0' - '@react-native/assets-registry@0.82.0': resolution: {integrity: sha512-SHRZxH+VHb6RwcHNskxyjso6o91Lq0DPgOpE5cDrppn1ziYhI723rjufFgh59RcpH441eci0/cXs/b0csXTtnw==} engines: {node: '>= 20.19.4'} @@ -5518,9 +5511,6 @@ packages: peerDependencies: '@types/react': '>=18.2.36' - '@types/react-leaflet@2.8.3': - resolution: {integrity: sha512-MeBQnVQe6ikw8dkuZE4F96PvMdQeilZG6/ekk5XxhkSzU3lofedULn3UR/6G0uIHjbRazi4DA8LnLACX0bPhBg==} - '@types/react-plotly.js@2.6.3': resolution: {integrity: sha512-HBQwyGuu/dGXDsWhnQrhH+xcJSsHvjkwfSRjP+YpOsCCWryIuXF78ZCBjpfgO3sCc0Jo8sYp4NOGtqT7Cn3epQ==} @@ -10293,13 +10283,6 @@ packages: react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-leaflet@4.2.1: - resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} - peerDependencies: - leaflet: ^1.9.0 - react: '>=18.0.0 <19.0.0' - react-dom: '>=18.0.0 <19.0.0' - react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} @@ -14180,12 +14163,6 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-is: 18.3.1 - '@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - leaflet: 1.9.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@react-native/assets-registry@0.82.0': {} '@react-native/babel-plugin-codegen@0.77.3(@babel/preset-env@7.29.7(@babel/core@7.29.0))': @@ -14895,11 +14872,6 @@ snapshots: dependencies: '@types/react': 19.2.2 - '@types/react-leaflet@2.8.3': - dependencies: - '@types/leaflet': 1.9.21 - '@types/react': 19.2.2 - '@types/react-plotly.js@2.6.3': dependencies: '@types/plotly.js': 3.0.7 @@ -21039,13 +21011,6 @@ snapshots: react-is@19.2.7: {} - react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - leaflet: 1.9.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-lifecycles-compat@3.0.4: {} react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1):