Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c85b9fa
refactor(maps): improve test reliability with MobX when() helper
iobuhov May 15, 2026
023a69c
refactor(maps): split LocationResolver tests into focused files
iobuhov May 15, 2026
d7587c0
test(maps): add comprehensive unit tests for data conversion functions
iobuhov May 15, 2026
aa2bf30
test(maps): remove invalid NaN coordinate test
iobuhov May 15, 2026
6b4bb31
test(maps): remove redundant multiple markers test
iobuhov May 15, 2026
e253990
refactor(maps): simplify markers getter with flat() instead of reduce
iobuhov May 15, 2026
310a883
refactor(maps): use MobX reaction equals option for marker comparison
iobuhov May 15, 2026
31ec567
refactor(maps): extract API key to computed getter
iobuhov May 15, 2026
6b4044a
refactor(maps): use version counter and inject geocode function
iobuhov May 15, 2026
cf9c3f4
refactor(maps): move injected() call to container
iobuhov May 15, 2026
660a4ff
test(maps): fix useMapsContainer prop update test
iobuhov May 15, 2026
1d747a5
chore(maps): archive migrate-to-mobx OpenSpec change
iobuhov May 15, 2026
3be0312
refactor(maps-web): use container-based mocking in LocationResolver t…
iobuhov May 15, 2026
1030a4b
chore(maps): update archived OpenSpec change to tdd-refactor schema
iobuhov May 27, 2026
4248370
chore: init openspec in maps
iobuhov May 13, 2026
830b704
chore(maps): compact archived OpenSpec change documentation
iobuhov May 27, 2026
3f2c49d
test(maps): add missing mock-container-props utility
iobuhov May 27, 2026
25a0105
test(maps): add missing Maps.config utility
iobuhov May 27, 2026
f8fa18e
feat(maps): add missing MobX container implementation files
iobuhov May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-05-13
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Test Design: MobX Container Migration

## Container Creation (3 tests)

- **createMapsContainer returns container and gate provider** (unit)
- **Given**: Mock MapsContainerProps
- **When**: Call `createMapsContainer(props)`
- **Then**: Returns `[MapsContainer, GateProvider]`

- **Container binds main gate** (unit)
- **Given**: Container created with props
- **When**: Resolve `CORE.mainGate`
- **Then**: Returns provider's gate instance

- **Container initializes with config** (unit)
- **Given**: Props with name, apiKey, markers
- **When**: Create container
- **Then**: Config derived and bound

## LocationResolver Service (5 tests)

- **Resolves markers with lat/lng directly** (unit)
- **Given**: Markers with latitude/longitude
- **When**: Service processes markers
- **Then**: Returns markers without geocoding calls

- **Geocodes markers with addresses** (unit)
- **Given**: Markers with address, valid API key
- **When**: Service processes markers
- **Then**: Calls geocoding API, returns lat/lng

- **Caches geocoding results** (unit)
- **Given**: Same address geocoded previously
- **When**: Process markers again
- **Then**: Returns cached result, no new API call

- **Throws error when address but no API key** (unit)
- **Given**: Markers with addresses, no API key
- **When**: Process markers
- **Then**: Throws "API key required"

- **Handles geocoding failures** (unit)
- **Given**: Address that fails to geocode
- **When**: Process markers
- **Then**: Logs error, excludes failed marker

## MobX Reactivity (4 tests)

- **Container reacts to prop changes** (integration)
- **Given**: Container with 5 markers
- **When**: `gateProvider.setProps()` with 10 markers
- **Then**: Marker count updates to 10

- **useMapsContainer creates stable instance** (integration)
- **Given**: Component with `useMapsContainer(props)`
- **When**: Re-render with same prop reference
- **Then**: Returns same container instance

- **useMapsContainer updates props on change** (integration)
- **Given**: Component with container
- **When**: Props change
- **Then**: Container receives updated props

- **Marker atoms trigger on resolution** (integration)
- **Given**: Address-based markers
- **When**: Geocoding completes
- **Then**: Computed values recompute

## Integration (2 tests)

- **Maps.tsx renders with ContainerProvider** (integration)
- **Given**: Maps component with props
- **When**: Component renders
- **Then**: ContainerProvider wraps children

- **MapSwitcher receives resolved locations** (integration)
- **Given**: Maps component with container
- **When**: Component renders
- **Then**: MapSwitcher receives marker array
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Migrate Maps Widget to MobX Container Pattern

## Why

Current hook-based state management (`useLocationResolver`) is tightly coupled to React lifecycle, difficult to test in isolation, and inconsistent with gallery widget's MobX container pattern.

## What Changes

Replace `useLocationResolver` hook with MobX container architecture:

- `MapsContainer` + `LocationResolver` service for state management
- `createMapsContainer` factory using brandi DI (matches gallery pattern)
- Observable marker resolution with caching
- `useMapsContainer` hook for React integration

## Impact

- **Affected**: `Maps.tsx`, `utils/geodecode.ts`
- **New**: `src/model/` directory (tokens, containers, services, hooks)
- **Dependencies**: `@mendix/widget-plugin-mobx-kit`, `brandi`, `brandi-react`, `mobx`
- **Breaking**: None (internal refactor only)
1 change: 1 addition & 0 deletions packages/pluggableWidgets/maps-web/openspec/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
schema: tdd-refactor
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
import { MapsContainerProps } from "../../../typings/MapsProps";

export interface MapsConfig {
id: string;
name: string;
apiKey?: string;
}

export function mapsConfig(props: MapsContainerProps): MapsConfig {
const id = `${props.name}:Maps@${generateUUID()}`;

return {
id,
name: props.name,
apiKey: props.apiKeyExp?.value ?? props.apiKey
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Container, injected } from "brandi";
import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
import { MapsContainerProps } from "../../../typings/MapsProps";
import { MapsConfig } from "../configs/Maps.config";
import { LocationResolverService } from "../services/LocationResolver.service";
import { CORE_TOKENS as CORE, MAPS_TOKENS as MAPS } from "../tokens";

interface InitDependencies {
props: MapsContainerProps;
mainGate: DerivedPropsGate<MapsContainerProps>;
config: MapsConfig;
}

/** Just little utility object to group related bindings */
interface BindingGroup {
/** Runs during container constructor. Use this hook to add new binding to the container. */
define?(container: Container): void;
/** Runs on container init with deps. Use this hook to bind constants, configs and values that depend on props. */
init?(container: Container, deps: InitDependencies): void;
/** This method runs after init phase. Use this hook to init instances and other "bootstrapping" work. */
postInit?(container: Container, deps: InitDependencies): void;
/** This method runs only once. Should be used to inject dependencies. */
inject?(): void;
}

const _01_coreBindings: BindingGroup = {
inject() {
injected(LocationResolverService, CORE.setupService, CORE.mainGate, CORE.geocodeFunction);
},
define(container) {
container.bind(MAPS.locationResolver).toInstance(LocationResolverService).inSingletonScope();
},
init(container, { mainGate, config }) {
container.bind(CORE.mainGate).toConstant(mainGate);
container.bind(CORE.config).toConstant(config);
},
postInit(container) {
// Initialize service to trigger setup
container.get(MAPS.locationResolver);
}
};

const groups = [_01_coreBindings];

// Inject tokens from groups
for (const grp of groups) {
grp.inject?.();
}

export class MapsContainer extends Container {
id = `MapsContainer@${generateUUID()}`;

constructor(root: Container) {
super();
this.extend(root);

for (const grp of groups) {
grp.define?.(this);
}
}

init(deps: InitDependencies): this {
for (const grp of groups) {
grp.init?.(this, deps);
}

for (const grp of groups) {
grp.postInit?.(this, deps);
}

return this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
import { Container } from "brandi";
import { convertAddressToLatLng } from "../../utils/geodecode";
import { MapsSetupService } from "../services/MapsSetup.service";
import { CORE_TOKENS as CORE } from "../tokens";

/**
* Root container for bindings that can be shared down the hierarchy.
* Declare only bindings that needs to be shared across multiple containers.
*/
export class RootContainer extends Container {
id = `MapsRootContainer@${generateUUID()}`;
constructor() {
super();

// Setup service
this.bind(CORE.setupService).toInstance(MapsSetupService).inSingletonScope();

// Geocode function
this.bind(CORE.geocodeFunction).toConstant(convertAddressToLatLng);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider";
import { MapsContainerProps } from "../../../typings/MapsProps";
import { mapsConfig } from "../configs/Maps.config";
import { MapsContainer } from "./Maps.container";
import { RootContainer } from "./Root.container";

export function createMapsContainer(props: MapsContainerProps): [MapsContainer, GateProvider<MapsContainerProps>] {
const root = new RootContainer();
const config = mapsConfig(props);
const mainProvider = new GateProvider<MapsContainerProps>(props);
const container = new MapsContainer(root).init({
props,
config,
mainGate: mainProvider.gate
});

return [container, mainProvider];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { renderHook } from "@testing-library/react";
import { mockContainerProps } from "../../../utils/mock-container-props";
import { CORE_TOKENS as CORE } from "../../tokens";
import { useMapsContainer } from "../useMapsContainer";

describe("useMapsContainer", () => {
it("should create stable container instance on re-renders", () => {
const props = mockContainerProps();
const { result, rerender } = renderHook(() => useMapsContainer(props));

const firstContainer = result.current;

// Re-render with same props reference
rerender();

const secondContainer = result.current;

// Should be the same instance
expect(secondContainer).toBe(firstContainer);
});

it("should update props when they change", () => {
const initialProps = mockContainerProps({ name: "map1" });
const { result, rerender } = renderHook(({ props }) => useMapsContainer(props), {
initialProps: { props: initialProps }
});

const container = result.current;
const mainGate = container.get(CORE.mainGate);

// Initial props should be set
expect(mainGate.props.name).toBe("map1");

// Update props
const newProps = mockContainerProps({ name: "map2" });
rerender({ props: newProps });

// Container should still be the same instance
expect(result.current).toBe(container);

// MainGate should provide updated props
expect(mainGate.props.name).toBe("map2");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst";
import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup";
import { Container } from "brandi";
import { useEffect } from "react";
import { MapsContainerProps } from "../../../typings/MapsProps";
import { createMapsContainer } from "../containers/createMapsContainer";
import { CORE_TOKENS as CORE } from "../tokens";

export function useMapsContainer(props: MapsContainerProps): Container {
const [container, mainProvider] = useConst(() => createMapsContainer(props));

// Run setup hooks on mount
useSetup(() => container.get(CORE.setupService));

useEffect(() => mainProvider.setProps(props));

return container;
}
Loading
Loading