diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 30051e0d..0ba9b88a 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -177,14 +177,6 @@ interactiveMap.on('draw:cancelled', function (e) { interactPlugin.enable() }) -interactiveMap.on('interact:done', function (e) { - console.log('interact:done', e) -}) - -interactiveMap.on('interact:cancel', function (e) { - console.log('interact:cancel', e) - interactPlugin.enable() -}) interactiveMap.on('interact:selectionchange', function (e) { const singleFeature = e.selectedFeatures.length === 1 diff --git a/demo/js/draw.js b/demo/js/draw.js index f878b7e1..0de80a73 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -85,7 +85,7 @@ const datasetsPlugin = createDatasetsPlugin({ }) const interactiveMap = new InteractiveMap('map', { - behaviour: 'mapOnly', + behaviour: 'hybrid', mapProvider: maplibreProvider(), reverseGeocodeProvider: openNamesProvider({ url: process.env.OS_NEAREST_URL, @@ -108,6 +108,11 @@ const interactiveMap = new InteractiveMap('map', { readMapText: true, // enableFullscreen: true, // hasExitButton: true, + backAndContinue: { + backLabel: 'Back', + continueLabel: 'Continue', + continueEnabledWhen: ({ pluginStates }) => pluginStates.interact?.selectedFeatures.length > 0 + }, // markers: [{ // id: 'location', // coords: [-2.9592267, 54.9045977], @@ -146,6 +151,7 @@ interactiveMap.on('app:ready', function (e) { // console.log('app:ready') }) + interactiveMap.on('map:ready', function (e) { // framePlugin.addFrame('test', { // aspectRatio: 1 @@ -269,14 +275,6 @@ interactiveMap.on('draw:cancelled', function (e) { interactPlugin.enable() }) -interactiveMap.on('interact:done', function (e) { - console.log('interact:done', e) -}) - -interactiveMap.on('interact:cancel', function (e) { - console.log('interact:cancel', e) - interactPlugin.enable() -}) interactiveMap.on('interact:selectionchange', function (e) { const drawLayers = ['stroke-inactive.cold', 'fill-inactive.cold'] @@ -310,4 +308,9 @@ interactiveMap.on('search:match', function (e) { // Hide selected feature interactiveMap.on('search:clear', function (e) { // console.log('Search clear') +}) + +interactiveMap.on('app:continue', function (payload) { + console.log('app:continue') + console.log(payload) }) \ No newline at end of file diff --git a/demo/js/farming.js b/demo/js/farming.js index 424cc2b8..4dba54e3 100755 --- a/demo/js/farming.js +++ b/demo/js/farming.js @@ -153,13 +153,6 @@ interactiveMap.on('draw:ready', function () { // drawPlugin.editFeature('test1234') }) -interactiveMap.on('interact:done', function (e) { - console.log('interact:done', e) -}) - -interactiveMap.on('interact:cancel', function (e) { - console.log('interact:cancel', e) -}) interactiveMap.on('interact:selectionchange', function (e) { console.log('interact:selectionchange', e) diff --git a/demo/js/index.js b/demo/js/index.js index d329c299..d58afb71 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -351,14 +351,6 @@ interactiveMap.on('datasets:ready', function () { // Ref to the selected features let selectedFeatureIds = [] -interactiveMap.on('interact:done', function (e) { - console.log('interact:done', e) -}) - -interactiveMap.on('interact:cancel', function (e) { - console.log('interact:cancel', e) - interactPlugin.enable() -}) interactiveMap.on('interact:selectionchange', function (e) { const drawLayers = ['stroke-inactive.cold', 'fill-inactive.cold'] diff --git a/docs/api.md b/docs/api.md index d5f355c9..bee52348 100644 --- a/docs/api.md +++ b/docs/api.md @@ -66,6 +66,34 @@ Whether to automatically determine the colour scheme based on the user's system --- +### `backAndContinue` +**Type:** `BackAndContinueConfig | null` +**Default:** `null` + +Shows Back and/or Continue navigation buttons in the actions bar when the map is fullscreen. Intended for multi-step journey flows where the map is one step a user must complete before proceeding. + +Provide `backLabel` to render a Back button; provide `continueLabel` to render a Continue button. Both are optional — include either or both. + +The Continue button always starts **disabled**. Enable it either declaratively via the `continueEnabledWhen` function or imperatively via [`setContinueEnabled()`](#setcontinueenabledenabled). + +```js +new InteractiveMap('map', { + behaviour: 'mapOnly', + backAndContinue: { + backLabel: 'Back', + continueLabel: 'Continue', + // Enable Continue once the user has selected at least one feature + continueEnabledWhen: ({ pluginStates }) => + pluginStates.interact?.selectedFeatures.length > 0 + } +}) +``` + +> [!NOTE] +> In `mapOnly` behaviour the Back button is hidden when there is no browser history to go back to (direct link / bookmark). In `buttonFirst` or `hybrid` behaviour the Back button navigates back in browser history if the map was opened via the button, or collapses the map if arrived via a direct link or bookmark. + +--- + ### `backgroundColor` **Type:** `string | Object` **Default:** `var(--background-color)` @@ -311,19 +339,6 @@ URL query parameter used to control fullscreen/hybrid/buttonFirst state. Overrid --- -### `urlPosition` -**Type:** `'sync' | 'readOnly' | 'none'` -**Default:** `'sync'` - -Controls how map center and zoom interact with the page URL. - -| Value | Behaviour | -|---|---| -| `'sync'` | Reads center/zoom from the URL on load and writes back on pan/zoom — enables bookmarking and sharing the current map view | -| `'readOnly'` | Seeds the initial view from the URL but never writes back | -| `'none'` | Ignores the URL entirely — use when you don't want the user's pan/zoom to be persisted or shared | ---- - ### `markers` **Type:** `MarkerConfig[]` @@ -333,30 +348,6 @@ See [MarkerConfig](./api/marker-config.md) for full details. --- -### `symbolDefaults` -**Type:** `Partial` - -App-wide defaults for symbol and marker appearance. - -| Property | Default | -|---|---| -| `symbol` | `'pin'` | -| `backgroundColor` | `'#ca3535'` | -| `foregroundColor` | `'#ffffff'` | - -```js -new InteractiveMap('map', { - symbolDefaults: { - symbol: 'circle', - backgroundColor: { outdoor: '#1d70b8', dark: '#4c9ed9' } - } -}) -``` - -See [Symbol Config](./api/symbol-config.md) for the full property list. - ---- - ### `maxExtent` **Type:** `[number, number, number, number]` @@ -481,6 +472,30 @@ new InteractiveMap('map', { --- +### `symbolDefaults` +**Type:** `Partial` + +App-wide defaults for symbol and marker appearance. + +| Property | Default | +|---|---| +| `symbol` | `'pin'` | +| `backgroundColor` | `'#ca3535'` | +| `foregroundColor` | `'#ffffff'` | + +```js +new InteractiveMap('map', { + symbolDefaults: { + symbol: 'circle', + backgroundColor: { outdoor: '#1d70b8', dark: '#4c9ed9' } + } +}) +``` + +See [Symbol Config](./api/symbol-config.md) for the full property list. + +--- + ### `transformRequest` **Type:** `function` @@ -497,6 +512,20 @@ See the [MapLibre documentation](https://maplibre.org/maplibre-gl-js/docs/API/ty --- +### `urlPosition` +**Type:** `'sync' | 'readOnly' | 'none'` +**Default:** `'sync'` + +Controls how map center and zoom interact with the page URL. + +| Value | Behaviour | +|---|---| +| `'sync'` | Reads center/zoom from the URL on load and writes back on pan/zoom — enables bookmarking and sharing the current map view | +| `'readOnly'` | Seeds the initial view from the URL but never writes back | +| `'none'` | Ignores the URL entirely — use when you don't want the user's pan/zoom to be persisted or shared | + +--- + ### `zoom` **Type:** `number` @@ -720,6 +749,31 @@ See [ControlDefinition](./api/control-definition.md) for configuration options. --- +### `setContinueEnabled(enabled)` + +Enable or disable the Continue button added by [`backAndContinue`](#backandcontinue). Use this for imperative control — for example, enabling Continue after an async operation or in response to an external event. For reactive state-derived conditions, prefer the `continueEnabledWhen` function in `backAndContinue` instead. + +> [!NOTE] +> If `continueEnabledWhen` is configured alongside `setContinueEnabled`, the function will override the imperative call on the next state change. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `enabled` | `boolean` | `true` to enable the Continue button, `false` to disable it | + +```js +// Enable Continue after a draw operation completes +interactiveMap.on('draw:merged', () => { + interactiveMap.setContinueEnabled(true) +}) + +// Disable it again if the merge is undone +interactiveMap.on('draw:unmerged', () => { + interactiveMap.setContinueEnabled(false) +}) +``` + +--- + ### `setMode(mode)` Programmatically set the application mode. See the [`mode`](#mode) option for more detail. @@ -860,6 +914,26 @@ interactiveMap.on('app:ready', () => { --- +### `app:continue` + +Emitted when the user clicks the Continue button added by [`backAndContinue`](#backandcontinue). Includes a snapshot of all plugin states and the current map state at the moment Continue was clicked. + +**Payload:** + +| Property | Type | Description | +|---|---|---| +| `pluginStates` | `object` | All plugin states keyed by plugin ID (e.g. `pluginStates.interact.selectedFeatures`) | +| `mapState` | `object` | Current map state including `zoom`, `center`, `bounds` and other map properties | + +```js +interactiveMap.on('app:continue', ({ pluginStates, mapState }) => { + const selectedFeatures = pluginStates.interact?.selectedFeatures ?? [] + console.log('User continued with', selectedFeatures.length, 'features at zoom', mapState.zoom) +}) +``` + +--- + ### `map:ready` Emitted when the underlying map is ready and initial app state (style and size) has settled. diff --git a/plugins/beta/draw-ml/src/manifest.js b/plugins/beta/draw-ml/src/manifest.js index e37aee6d..60a04393 100755 --- a/plugins/beta/draw-ml/src/manifest.js +++ b/plugins/beta/draw-ml/src/manifest.js @@ -27,18 +27,21 @@ export const manifest = { id: 'drawCancel', label: 'Cancel', variant: 'tertiary', + exclusiveSlot: true, hiddenWhen: ({ pluginState }) => !pluginState.mode, ...createButtonSlots(true) }, { id: 'drawAddPoint', label: 'Add point', variant: 'primary', + exclusiveSlot: true, hiddenWhen: ({ appState, pluginState }) => !['draw_polygon', 'draw_line'].includes(pluginState.mode) || appState.interfaceType !== 'touch', ...createButtonSlots(true) }, { id: 'drawDone', label: 'Done', variant: 'primary', + exclusiveSlot: true, hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => pluginState.numVertecies >= (pluginState.mode === 'draw_polygon' ? 3 : 2), ...createButtonSlots(true) diff --git a/plugins/interact/src/InteractInit.jsx b/plugins/interact/src/InteractInit.jsx index 8a87ee9c..e75af2b9 100755 --- a/plugins/interact/src/InteractInit.jsx +++ b/plugins/interact/src/InteractInit.jsx @@ -28,7 +28,7 @@ export const InteractInit = ({ pluginState }) => { const { dispatch, enabled, selectedFeatures, interactionModes, layers } = pluginState - const { eventBus, closeApp } = services + const { eventBus } = services const { crossHair, mapStyle, markers } = mapState const selectMarkerOnly = isSelectMarkerOnly(interactionModes) @@ -66,7 +66,7 @@ export const InteractInit = ({ useCrossHairVisibility({ crossHair, enabled, selectMarkerOnly, appState }) - useAttachEvents({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction, closeApp }) + useAttachEvents({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction }) return null } diff --git a/plugins/interact/src/InteractInit.test.js b/plugins/interact/src/InteractInit.test.js index 3457b6b1..7451c664 100644 --- a/plugins/interact/src/InteractInit.test.js +++ b/plugins/interact/src/InteractInit.test.js @@ -29,7 +29,7 @@ beforeEach(() => { props = { appState: { interfaceType: 'mouse', layoutRefs: { viewportRef: { current: document.createElement('div') }, appContainerRef: { current: document.createElement('div') } } }, mapState: { crossHair: { fixAtCenter: jest.fn(), hide: jest.fn() }, mapStyle: {} }, - services: { eventBus: { emit: jest.fn() }, closeApp: jest.fn() }, + services: { eventBus: { emit: jest.fn() } }, buttonConfig: {}, mapProvider: { setHoverCursor: jest.fn() }, pluginState: { diff --git a/plugins/interact/src/events.js b/plugins/interact/src/events.js index f6775119..a5a33e46 100755 --- a/plugins/interact/src/events.js +++ b/plugins/interact/src/events.js @@ -1,10 +1,3 @@ -const buildDonePayload = (coords, selectedFeatures, selectedMarkers, selectionBounds) => ({ - ...(coords && { coords }), - ...(!coords && selectedFeatures && { selectedFeatures }), - ...(!coords && selectedMarkers?.length && { selectedMarkers }), - ...(!coords && selectionBounds && { selectionBounds }) -}) - // Helper for feature toggling logic const createFeatureHandler = (mapState, getPluginState) => (args, addToExisting) => { const pluginState = getPluginState() @@ -39,31 +32,15 @@ export function attachEvents ({ events, eventBus, handleInteraction, - clickReadyRef, - closeApp + clickReadyRef }) { - const { selectDone, selectAtTarget, selectCancel } = buttonConfig + const { selectAtTarget } = buttonConfig const handleSelectAtTarget = () => handleInteraction(mapState.crossHair.getDetail()) const handleMapClick = (mapEvent) => { if (clickReadyRef.current) { handleInteraction(mapEvent) } } const { handleKeydown, handleKeyup } = createKeyboardHandlers(getAppState().layoutRefs.viewportRef, handleSelectAtTarget) - const handleSelectDone = () => { - const pluginState = getPluginState() - const marker = mapState.markers.getMarker('location') - const { coords } = marker || {} - const { selectionBounds, selectedFeatures, selectedMarkers } = pluginState - if (getAppState().disabledButtons.has('selectDone')) { return } - eventBus.emit('interact:done', buildDonePayload(coords, selectedFeatures, selectedMarkers, selectionBounds)) - if (pluginState.closeOnAction ?? true) { closeApp() } - } - - const handleSelectCancel = () => { - eventBus.emit('interact:cancel') - if (getPluginState().closeOnAction ?? true) { closeApp() } - } - const toggleFeature = createFeatureHandler(mapState, getPluginState) const handleSelect = (args) => toggleFeature(args, true) const handleUnselect = (args) => toggleFeature(args, false) @@ -74,13 +51,9 @@ export function attachEvents ({ eventBus.on('interact:selectFeature', handleSelect) eventBus.on('interact:unselectFeature', handleUnselect) selectAtTarget.onClick = handleSelectAtTarget - selectDone.onClick = handleSelectDone - selectCancel.onClick = handleSelectCancel return () => { - selectDone.onClick = null selectAtTarget.onClick = null - selectCancel.onClick = null document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keyup', handleKeyup) eventBus.off(events.MAP_CLICK, handleMapClick) diff --git a/plugins/interact/src/events.test.js b/plugins/interact/src/events.test.js index 5ac4ea95..a348510c 100644 --- a/plugins/interact/src/events.test.js +++ b/plugins/interact/src/events.test.js @@ -2,11 +2,10 @@ import { attachEvents } from './events.js' const MOCK_POINT = { x: 1, y: 2 } const MOCK_COORDS = [1, 2] -const INTERACT_DONE = 'interact:done' const createParams = () => { const appState = { layoutRefs: { viewportRef: { current: document.body } }, disabledButtons: new Set() } - const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], selectedMarkers: [], closeOnAction: true, multiSelect: false } + const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], selectedMarkers: [], multiSelect: false } const clickReadyRef = { current: false } return { appState, @@ -18,11 +17,10 @@ const createParams = () => { markers: { remove: jest.fn(), getMarker: jest.fn(() => null) }, crossHair: { getDetail: jest.fn(() => ({ point: { x: 0, y: 0 }, coords: [0, 0] })) } }, - buttonConfig: { selectDone: {}, selectAtTarget: {}, selectCancel: {} }, + buttonConfig: { selectAtTarget: {} }, events: { MAP_CLICK: 'map:click' }, eventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }, - handleInteraction: jest.fn(), - closeApp: jest.fn() + handleInteraction: jest.fn() } } @@ -109,102 +107,6 @@ describe('attachEvents — click handling', () => { }) }) -describe('attachEvents — button actions', () => { - let cleanup = null - - beforeEach(() => { jest.useFakeTimers() }) - afterEach(() => { cleanup?.(); jest.useRealTimers() }) - - it('selectDone emits correct payload and respects closeOnAction', () => { - const params = createParams() - cleanup = attachEvents(params) - - params.mapState.markers.getMarker.mockReturnValue({ coords: MOCK_COORDS }) - params.buttonConfig.selectDone.onClick() - expect(params.closeApp).toHaveBeenCalled() - - params.closeApp.mockClear() - params.pluginState.closeOnAction = false - params.buttonConfig.selectDone.onClick() - expect(params.closeApp).not.toHaveBeenCalled() - }) - - it('selectDone emits selectedFeatures and selectionBounds when no marker', () => { - const params = createParams() - cleanup = attachEvents(params) - - params.pluginState.selectedFeatures = [{ id: 'f1' }] - params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] } - params.buttonConfig.selectDone.onClick() - - expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE, { - selectedFeatures: [{ id: 'f1' }], - selectionBounds: { sw: [0, 0], ne: [1, 1] } - }) - }) - - it('selectDone includes selectedMarkers in payload when present', () => { - const params = createParams() - cleanup = attachEvents(params) - - params.pluginState.selectedMarkers = ['m1', 'm2'] - params.buttonConfig.selectDone.onClick() - - expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE, - expect.objectContaining({ selectedMarkers: ['m1', 'm2'] }) - ) - }) - - it('selectDone omits selectedMarkers from payload when empty', () => { - const params = createParams() - cleanup = attachEvents(params) - - params.buttonConfig.selectDone.onClick() - - const payload = params.eventBus.emit.mock.calls.find(c => c[0] === INTERACT_DONE)[1] - expect(payload).not.toHaveProperty('selectedMarkers') - }) - - it('does not emit or closeApp if selectDone button is disabled', () => { - const params = createParams() - cleanup = attachEvents(params) - - params.appState.disabledButtons.add('selectDone') - params.buttonConfig.selectDone.onClick() - - expect(params.eventBus.emit).not.toHaveBeenCalled() - expect(params.closeApp).not.toHaveBeenCalled() - }) - - it('selectCancel emits cancel and respects closeOnAction', () => { - const params = createParams() - cleanup = attachEvents(params) - - params.buttonConfig.selectCancel.onClick() - expect(params.closeApp).toHaveBeenCalled() - - cleanup() - const params2 = createParams() - cleanup = attachEvents(params2) - params2.pluginState.closeOnAction = false - params2.buttonConfig.selectCancel.onClick() - expect(params2.closeApp).not.toHaveBeenCalled() - }) - - it('respects default closeOnAction when value is nullish', () => { - const params = createParams() - params.pluginState.closeOnAction = null - cleanup = attachEvents(params) - - params.buttonConfig.selectDone.onClick() - expect(params.closeApp).toHaveBeenCalledTimes(1) - - params.closeApp.mockClear() - params.buttonConfig.selectCancel.onClick() - expect(params.closeApp).toHaveBeenCalledTimes(1) - }) -}) - describe('attachEvents — programmatic selection', () => { let cleanup = null @@ -232,7 +134,7 @@ describe('attachEvents — cleanup', () => { const params = createParams() const cleanup = attachEvents(params) cleanup() - Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull()) + expect(params.buttonConfig.selectAtTarget.onClick).toBeNull() jest.useRealTimers() }) }) diff --git a/plugins/interact/src/hooks/useAttachEvents.js b/plugins/interact/src/hooks/useAttachEvents.js index be83457e..0bb5985a 100644 --- a/plugins/interact/src/hooks/useAttachEvents.js +++ b/plugins/interact/src/hooks/useAttachEvents.js @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { EVENTS } from '../../../../src/config/events.js' import { attachEvents } from '../events.js' -export function useAttachEvents ({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction, closeApp }) { +export function useAttachEvents ({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction }) { // Refs updated synchronously each render — keeps callbacks fresh without re-attaching events const handleInteractionRef = useRef(handleInteraction) handleInteractionRef.current = handleInteraction @@ -37,10 +37,9 @@ export function useAttachEvents ({ pluginState, appState, mapState, buttonConfig events: EVENTS, eventBus, handleInteraction: (event) => handleInteractionRef.current(event), - clickReadyRef, - closeApp + clickReadyRef }) return cleanupEvents - }, [pluginState.enabled, buttonConfig, eventBus, closeApp]) + }, [pluginState.enabled, buttonConfig, eventBus]) } diff --git a/plugins/interact/src/hooks/useAttachEvents.test.js b/plugins/interact/src/hooks/useAttachEvents.test.js index 3d762b56..e244d44a 100644 --- a/plugins/interact/src/hooks/useAttachEvents.test.js +++ b/plugins/interact/src/hooks/useAttachEvents.test.js @@ -21,7 +21,6 @@ beforeEach(() => { mapState: { mapStyle: {} }, buttonConfig: {}, eventBus: { emit: jest.fn() }, - closeApp: jest.fn(), handleInteraction: handleInteractionMock } }) @@ -36,8 +35,7 @@ describe('useAttachEvents', () => { mapState: props.mapState, buttonConfig: props.buttonConfig, events: EVENTS, - eventBus: props.eventBus, - closeApp: props.closeApp + eventBus: props.eventBus })) const { getAppState, getPluginState, handleInteraction } = attachEvents.mock.calls.at(-1)[0] diff --git a/plugins/interact/src/manifest.js b/plugins/interact/src/manifest.js index 37d7c0d5..885d6d9c 100755 --- a/plugins/interact/src/manifest.js +++ b/plugins/interact/src/manifest.js @@ -21,23 +21,6 @@ export const manifest = { }, buttons: [{ - id: 'selectCancel', - label: 'Back', - variant: 'tertiary', - hiddenWhen: ({ appConfig, appState, pluginState }) => !pluginState.enabled || !(['hybrid', 'buttonFirst'].includes(appConfig.behaviour) && appState.isFullscreen), - mobile: { - slot: 'actions', - showLabel: true - }, - tablet: { - slot: 'actions', - showLabel: true - }, - desktop: { - slot: 'actions', - showLabel: true - } - }, { id: 'selectAtTarget', label: 'Select', variant: 'primary', @@ -54,24 +37,6 @@ export const manifest = { desktop: { slot: 'actions' } - }, { - id: 'selectDone', - label: 'Continue', - variant: 'primary', - excludeWhen: ({ appState, pluginState }) => !pluginState.enabled || !appState.isFullscreen, - enableWhen: ({ mapState, pluginState }) => !!mapState.markers.items.some(m => m.id === 'location') || !!pluginState.selectionBounds, - mobile: { - slot: 'actions', - showLabel: true - }, - tablet: { - slot: 'actions', - showLabel: true - }, - desktop: { - slot: 'actions', - showLabel: true - } }], keyboardShortcuts: [ diff --git a/plugins/interact/src/manifest.test.js b/plugins/interact/src/manifest.test.js index 7b4fe2d4..e3e592d6 100644 --- a/plugins/interact/src/manifest.test.js +++ b/plugins/interact/src/manifest.test.js @@ -18,7 +18,9 @@ describe('manifest', () => { it('defines buttons with correct ids', () => { const ids = manifest.buttons.map(b => b.id) - expect(ids).toEqual(expect.arrayContaining(['selectDone', 'selectAtTarget', 'selectCancel'])) + expect(ids).toEqual(expect.arrayContaining(['selectAtTarget'])) + expect(ids).not.toContain('selectDone') + expect(ids).not.toContain('selectCancel') }) it('buttons have slots and showLabel', () => { @@ -33,54 +35,12 @@ describe('manifest', () => { }) it('button logic functions cover all branches', () => { - const done = manifest.buttons.find(b => b.id === 'selectDone') const atTarget = manifest.buttons.find(b => b.id === 'selectAtTarget') - const cancel = manifest.buttons.find(b => b.id === 'selectCancel') - - // selectDone.excludeWhen - expect(done.excludeWhen({ appState: { isFullscreen: false }, pluginState: { enabled: true } })).toBe(true) - expect(done.excludeWhen({ appState: { isFullscreen: true }, pluginState: { enabled: true } })).toBe(false) - - // selectDone.enableWhen - expect(done.enableWhen({ - mapState: { markers: { items: [{ id: 'location' }] } }, - pluginState: { selectionBounds: null } - })).toBe(true) - expect(done.enableWhen({ - mapState: { markers: { items: [] } }, - pluginState: { selectionBounds: null } - })).toBe(false) - expect(done.enableWhen({ - mapState: { markers: { items: [] } }, - pluginState: { selectionBounds: { sw: [0, 0], ne: [1, 1] } } - })).toBe(true) // selectAtTarget.hiddenWhen expect(atTarget.hiddenWhen({ appState: { interfaceType: 'pointer' }, pluginState: { enabled: true } })).toBe(true) expect(atTarget.hiddenWhen({ appState: { interfaceType: 'touch' }, pluginState: { enabled: true } })).toBe(false) expect(atTarget.hiddenWhen({ appState: { interfaceType: 'touch' }, pluginState: { enabled: false } })).toBe(true) - - // selectCancel.hiddenWhen - expect(cancel.hiddenWhen({ - appConfig: { behaviour: 'always' }, - appState: { isFullscreen: true }, - pluginState: { enabled: true } - })).toBe(true) - expect(cancel.hiddenWhen({ - appConfig: { behaviour: 'hybrid' }, - appState: { isFullscreen: true }, - pluginState: { enabled: true } - })).toBe(false) - expect(cancel.hiddenWhen({ - appConfig: { behaviour: 'hybrid' }, - appState: { isFullscreen: false }, - pluginState: { enabled: true } - })).toBe(true) - expect(cancel.hiddenWhen({ - appConfig: { behaviour: 'hybrid' }, - appState: { isFullscreen: true }, - pluginState: { enabled: false } - })).toBe(true) }) it('keyboardShortcuts array exists', () => { diff --git a/src/App/components/MapButton/MapButton.module.scss b/src/App/components/MapButton/MapButton.module.scss index 9079fe71..408cfb0d 100755 --- a/src/App/components/MapButton/MapButton.module.scss +++ b/src/App/components/MapButton/MapButton.module.scss @@ -189,4 +189,15 @@ background-color: var(--pressed-button-background-color); } -// 5. Responsive tweaks \ No newline at end of file +// 5. Responsive tweaks + +.im-o-app--tablet, .im-o-app--desktop { + .im-c-map-button--journey-back, + .im-c-map-button--journey-continue, + .im-c-map-button--select-at-target, + .im-c-map-button--draw-cancel, + .im-c-map-button--draw-done, + .im-c-map-button--draw-add-point { + min-width: var(--action-button-min-width); + } +} \ No newline at end of file diff --git a/src/App/hooks/useContinueEnabledEvaluator.js b/src/App/hooks/useContinueEnabledEvaluator.js new file mode 100644 index 00000000..2e92ad68 --- /dev/null +++ b/src/App/hooks/useContinueEnabledEvaluator.js @@ -0,0 +1,31 @@ +import { useLayoutEffect, useContext, useRef } from 'react' +import { useApp } from '../store/appContext.js' +import { useConfig } from '../store/configContext.js' +import { useMap } from '../store/mapContext.js' +import { PluginContext } from '../store/PluginProvider.jsx' + +export function useContinueEnabledEvaluator () { + const backAndContinue = useConfig()?.backAndContinue + const { dispatch } = useApp() + const mapState = useMap() + const pluginContext = useContext(PluginContext) + const lastIsDisabledRef = useRef(true) + + useLayoutEffect(() => { + if (typeof backAndContinue?.continueEnabledWhen !== 'function') { return } + let isEnabled = false + try { + isEnabled = backAndContinue.continueEnabledWhen({ + pluginStates: pluginContext?.state ?? {}, + mapState + }) + } catch (err) { + console.warn('continueEnabledWhen error:', err) + } + const isDisabled = !isEnabled + if (lastIsDisabledRef.current !== isDisabled) { + lastIsDisabledRef.current = isDisabled + dispatch({ type: 'TOGGLE_BUTTON_DISABLED', payload: { id: 'journeyContinue', isDisabled } }) + } + }, [backAndContinue, pluginContext, mapState, dispatch]) +} diff --git a/src/App/hooks/useContinueEnabledEvaluator.test.js b/src/App/hooks/useContinueEnabledEvaluator.test.js new file mode 100644 index 00000000..119a46a0 --- /dev/null +++ b/src/App/hooks/useContinueEnabledEvaluator.test.js @@ -0,0 +1,118 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { useContinueEnabledEvaluator } from './useContinueEnabledEvaluator.js' +import { PluginContext } from '../store/PluginProvider.jsx' +import * as configStore from '../store/configContext.js' +import * as appStore from '../store/appContext.js' +import * as mapStore from '../store/mapContext.js' + +jest.mock('../store/configContext.js', () => ({ useConfig: jest.fn() })) +jest.mock('../store/appContext.js', () => ({ useApp: jest.fn() })) +jest.mock('../store/mapContext.js', () => ({ useMap: jest.fn() })) + +const pluginStates = { interact: { selectedFeatures: ['f1', 'f2'] } } +const mockMapState = { zoom: 10, center: [0, 0] } + +const wrapper = ({ children }) => ( + + {children} + +) + +let dispatch + +beforeEach(() => { + jest.clearAllMocks() + dispatch = jest.fn() + appStore.useApp.mockReturnValue({ dispatch }) + mapStore.useMap.mockReturnValue(mockMapState) +}) + +describe('useContinueEnabledEvaluator — no-op cases', () => { + it('does nothing when continueEnabledWhen is not provided', () => { + configStore.useConfig.mockReturnValue({ backAndContinue: { continueLabel: 'Continue' } }) + renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('does nothing when backAndContinue is null', () => { + configStore.useConfig.mockReturnValue({ backAndContinue: null }) + renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('useContinueEnabledEvaluator — dispatching', () => { + it('dispatches enable on first render when continueEnabledWhen returns true', () => { + configStore.useConfig.mockReturnValue({ + backAndContinue: { continueEnabledWhen: ({ pluginStates: ps }) => ps.interact.selectedFeatures.length > 1 } + }) + renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'TOGGLE_BUTTON_DISABLED', + payload: { id: 'journeyContinue', isDisabled: false } + }) + }) + + it('does not dispatch on first render when continueEnabledWhen returns false (already disabled)', () => { + configStore.useConfig.mockReturnValue({ + backAndContinue: { continueEnabledWhen: () => false } + }) + renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('dispatches disable after button was enabled', () => { + let enabled = true + configStore.useConfig.mockReturnValue({ + backAndContinue: { continueEnabledWhen: () => enabled } + }) + const { rerender } = renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + dispatch.mockClear() + enabled = false + rerender() + expect(dispatch).toHaveBeenCalledWith({ + type: 'TOGGLE_BUTTON_DISABLED', + payload: { id: 'journeyContinue', isDisabled: true } + }) + }) + + it('does not dispatch again if the enabled state has not changed', () => { + configStore.useConfig.mockReturnValue({ + backAndContinue: { continueEnabledWhen: () => true } + }) + const { rerender } = renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + dispatch.mockClear() + rerender() + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('useContinueEnabledEvaluator — no PluginContext', () => { + it('passes empty pluginStates when PluginContext is not provided', () => { + const continueEnabledWhen = jest.fn(() => true) + configStore.useConfig.mockReturnValue({ backAndContinue: { continueEnabledWhen } }) + renderHook(() => useContinueEnabledEvaluator()) + expect(continueEnabledWhen).toHaveBeenCalledWith({ pluginStates: {}, mapState: mockMapState }) + }) +}) + +describe('useContinueEnabledEvaluator — error handling and context', () => { + it('warns and leaves button disabled if continueEnabledWhen throws on first render', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + configStore.useConfig.mockReturnValue({ + backAndContinue: { continueEnabledWhen: () => { throw new Error('oops') } } + }) + renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + expect(console.warn).toHaveBeenCalledWith('continueEnabledWhen error:', expect.any(Error)) + expect(dispatch).not.toHaveBeenCalled() + console.warn.mockRestore() + }) + + it('passes pluginStates and mapState to continueEnabledWhen', () => { + const continueEnabledWhen = jest.fn(() => true) + configStore.useConfig.mockReturnValue({ backAndContinue: { continueEnabledWhen } }) + renderHook(() => useContinueEnabledEvaluator(), { wrapper }) + expect(continueEnabledWhen).toHaveBeenCalledWith({ pluginStates, mapState: mockMapState }) + }) +}) diff --git a/src/App/hooks/useEvaluateProp.js b/src/App/hooks/useEvaluateProp.js index f9ec34c2..7e51bab8 100644 --- a/src/App/hooks/useEvaluateProp.js +++ b/src/App/hooks/useEvaluateProp.js @@ -23,25 +23,25 @@ export function useEvaluateProp () { iconRegistry: getIconRegistry() } - function evaluateProp (prop, pluginId) { - let pluginConfig - let pluginState - - if (pluginId) { - const pluginEntry = appConfig.pluginRegistry.registeredPlugins.find(p => p.id === pluginId) - pluginConfig = pluginEntry - ? { - pluginId: pluginEntry.id, - ...pluginEntry.config - } - : {} - // Only include this plugin's state + dispatch + function buildPluginContext (pluginId) { + if (!pluginId) { return { pluginStates: pluginContext?.state ?? {} } } + + const pluginEntry = appConfig.pluginRegistry.registeredPlugins.find(p => p.id === pluginId) + const pluginConfig = pluginEntry ? { pluginId: pluginEntry.id, ...pluginEntry.config } : {} + + // Only include isolated plugin state when the plugin has registered a reducer. + // Framework entries like 'appConfig' have no reducer so their buttons get pluginStates instead. + if (Object.hasOwn(pluginContext?.state ?? {}, pluginId)) { const stateForPlugin = pluginContext?.state?.[pluginId] ?? {} - pluginState = { ...stateForPlugin, dispatch: pluginContext?.dispatch } + const pluginState = { ...stateForPlugin, dispatch: pluginContext?.dispatch } + return { pluginConfig, pluginState } } - const fullContext = { ...ctx, pluginConfig, pluginState } + return { pluginConfig, pluginStates: pluginContext?.state ?? {} } + } + function evaluateProp (prop, pluginId) { + const fullContext = { ...ctx, ...buildPluginContext(pluginId) } return typeof prop === 'function' ? prop(fullContext) : prop } diff --git a/src/App/hooks/useEvaluateProp.test.js b/src/App/hooks/useEvaluateProp.test.js index c4e0a2c0..65b8a788 100644 --- a/src/App/hooks/useEvaluateProp.test.js +++ b/src/App/hooks/useEvaluateProp.test.js @@ -8,57 +8,50 @@ import * as serviceStore from '../store/serviceContext.js' import { PluginContext } from '../store/PluginProvider.jsx' import * as iconRegistryModule from '../registry/iconRegistry.js' -// --- Mock the dependencies --- jest.mock('../store/configContext.js', () => ({ useConfig: jest.fn() })) jest.mock('../store/appContext.js', () => ({ useApp: jest.fn() })) jest.mock('../store/mapContext.js', () => ({ useMap: jest.fn() })) jest.mock('../store/serviceContext.js', () => ({ useService: jest.fn() })) jest.mock('../registry/iconRegistry.js', () => ({ getIconRegistry: jest.fn() })) -describe('useEvaluateProp hook', () => { - const pluginDispatch = jest.fn() - const mockPluginRegistry = { - registeredPlugins: [], - registerPlugin: jest.fn(), - clear: jest.fn() - } - - const pluginContextValue = { - state: { myPlugin: { foo: 'bar' } }, - dispatch: pluginDispatch - } - - beforeEach(() => { - jest.clearAllMocks() - mockPluginRegistry.registeredPlugins = [] - configStore.useConfig.mockReturnValue({ - mapProvider: { name: 'leaflet' }, - pluginRegistry: mockPluginRegistry - }) - appStore.useApp.mockReturnValue({ user: 'alice' }) - mapStore.useMap.mockReturnValue({ zoom: 5 }) - serviceStore.useService.mockReturnValue({ reverseGeocode: jest.fn() }) - iconRegistryModule.getIconRegistry.mockReturnValue({ close: '' }) - }) +const RAW_VALUE = 'raw-test-value' +const pluginDispatch = jest.fn() +const pluginContextValue = { + state: { myPlugin: { foo: 'bar' } }, + dispatch: pluginDispatch +} +const mockPluginRegistry = { + registeredPlugins: [], + registerPlugin: jest.fn(), + clear: jest.fn() +} + +const withPluginContext = ({ children }) => ( + + {children} + +) + +beforeEach(() => { + jest.clearAllMocks() + mockPluginRegistry.registeredPlugins = [] + configStore.useConfig.mockReturnValue({ mapProvider: { name: 'leaflet' }, pluginRegistry: mockPluginRegistry }) + appStore.useApp.mockReturnValue({ user: 'alice' }) + mapStore.useMap.mockReturnValue({ zoom: 5 }) + serviceStore.useService.mockReturnValue({ reverseGeocode: jest.fn() }) + iconRegistryModule.getIconRegistry.mockReturnValue({ close: '' }) +}) +describe('useEvaluateProp — basic evaluation', () => { it('returns the raw prop if it is not a function', () => { const { result } = renderHook(() => useEvaluateProp()) - const evaluate = result.current - const value = evaluate(42) - expect(value).toBe(42) + expect(result.current(RAW_VALUE)).toBe(RAW_VALUE) }) it('calls prop function with full context when no pluginId provided', () => { const { result } = renderHook(() => useEvaluateProp()) - const evaluate = result.current - const fn = jest.fn(ctx => ctx.appState.user) - - const value = evaluate(fn) - - expect(fn).toHaveBeenCalled() - expect(value).toBe('alice') - + expect(result.current(fn)).toBe('alice') expect(fn.mock.calls[0][0]).toMatchObject({ appConfig: { mapProvider: { name: 'leaflet' } }, appState: { user: 'alice' }, @@ -69,56 +62,67 @@ describe('useEvaluateProp hook', () => { }) }) - it('includes pluginConfig and pluginState when pluginId provided', () => { - mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: { includeModes: ['edit'], excludeModes: ['view'] } }) + it('exposes ctx property on the returned evaluateProp function', () => { + const { result } = renderHook(() => useEvaluateProp()) + expect(result.current.ctx).toMatchObject({ + appConfig: { mapProvider: { name: 'leaflet' } }, + appState: { user: 'alice' }, + mapState: { zoom: 5 }, + services: { reverseGeocode: expect.any(Function) }, + mapProvider: { name: 'leaflet' }, + iconRegistry: { close: '' } + }) + }) +}) - const wrapper = ({ children }) => ( - - {children} - - ) +describe('useEvaluateProp — pluginStates (core/framework buttons)', () => { + it('includes pluginStates in context when no pluginId provided', () => { + const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext }) + expect(result.current(ctx => ctx.pluginStates)).toEqual({ myPlugin: { foo: 'bar' } }) + }) - const { result } = renderHook(() => useEvaluateProp(), { wrapper }) - const evaluate = result.current + it('includes pluginStates for appConfig plugin (no reducer, not in state)', () => { + mockPluginRegistry.registeredPlugins.push({ id: 'appConfig', config: {} }) + const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext }) + expect(result.current(ctx => ctx.pluginStates, 'appConfig')).toEqual({ myPlugin: { foo: 'bar' } }) + }) - const fn = jest.fn(ctx => ctx.pluginState.foo) - const value = evaluate(fn, 'myPlugin') + it('falls back to empty pluginStates when PluginContext is not provided', () => { + mockPluginRegistry.registeredPlugins.push({ id: 'appConfig', config: {} }) + const { result } = renderHook(() => useEvaluateProp()) + expect(result.current(ctx => ctx.pluginStates, 'appConfig')).toEqual({}) + }) +}) - expect(fn).toHaveBeenCalled() - expect(value).toBe('bar') - const ctxPassed = fn.mock.calls[0][0] - expect(ctxPassed.pluginConfig).toEqual({ - pluginId: 'myPlugin', - includeModes: ['edit'], - excludeModes: ['view'] - }) - expect(ctxPassed.pluginState).toMatchObject({ foo: 'bar', dispatch: pluginDispatch }) +describe('useEvaluateProp — plugin state isolation', () => { + it('includes pluginConfig and pluginState for real plugin buttons', () => { + mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: { includeModes: ['edit'], excludeModes: ['view'] } }) + const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext }) + const ctx = result.current(c => c, 'myPlugin') + expect(ctx.pluginConfig).toEqual({ pluginId: 'myPlugin', includeModes: ['edit'], excludeModes: ['view'] }) + expect(ctx.pluginState).toMatchObject({ foo: 'bar', dispatch: pluginDispatch }) + }) + + it('does not include pluginStates for real plugin buttons', () => { + mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: {} }) + const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext }) + expect(result.current(ctx => ctx.pluginStates, 'myPlugin')).toBeUndefined() }) it('returns empty pluginConfig if plugin not registered', () => { + const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext }) + expect(result.current(ctx => ctx.pluginConfig, 'unknownPlugin')).toEqual({}) + }) + + it('falls back to empty object when plugin state entry is null', () => { + mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: {} }) const wrapper = ({ children }) => ( - + {children} ) const { result } = renderHook(() => useEvaluateProp(), { wrapper }) - const evaluate = result.current - - const fn = jest.fn(ctx => ctx.pluginConfig) - const value = evaluate(fn, 'unknownPlugin') - expect(value).toEqual({}) - }) - - it('exposes ctx property on the returned evaluateProp function', () => { - const { result } = renderHook(() => useEvaluateProp()) - const evaluate = result.current - expect(evaluate.ctx).toMatchObject({ - appConfig: { mapProvider: { name: 'leaflet' } }, - appState: { user: 'alice' }, - mapState: { zoom: 5 }, - services: { reverseGeocode: expect.any(Function) }, - mapProvider: { name: 'leaflet' }, - iconRegistry: { close: '' } - }) + const ctx = result.current(c => c, 'myPlugin') + expect(ctx.pluginState).toMatchObject({ dispatch: pluginDispatch }) }) }) diff --git a/src/App/renderer/PluginInits.jsx b/src/App/renderer/PluginInits.jsx index a7336d0d..fb78d0f8 100755 --- a/src/App/renderer/PluginInits.jsx +++ b/src/App/renderer/PluginInits.jsx @@ -7,6 +7,7 @@ import { useApp } from '../store/appContext.js' import { useConfig } from '../store/configContext.js' import { useEvaluateProp } from '../hooks/useEvaluateProp.js' import { useButtonStateEvaluator } from '../hooks/useButtonStateEvaluator.js' +import { useContinueEnabledEvaluator } from '../hooks/useContinueEnabledEvaluator.js' // Create a component for each plugin to handle its hooks properly const PluginInit = ({ plugin, mode }) => { @@ -55,6 +56,7 @@ export const PluginInits = () => { // Evaluate reactive button states globally const evaluateProp = useEvaluateProp() useButtonStateEvaluator(evaluateProp) + useContinueEnabledEvaluator() return ( <> diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index 334df2bf..df3ed835 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -116,6 +116,22 @@ function resolveGroupOrder (group) { return group.order ?? 0 } +function applySlotExclusivity (matching, appState) { + const exclusivePluginIds = new Set() + for (const [id, config] of matching) { + if (config.exclusiveSlot && !appState.hiddenButtons.has(id) && config.pluginId) { + exclusivePluginIds.add(config.pluginId) + } + } + if (exclusivePluginIds.size === 0) { return matching } + if (exclusivePluginIds.size > 1) { + logger.warn(`Slot exclusivity conflict: plugins [${[...exclusivePluginIds].join(', ')}] are both claiming exclusive slot ownership. Showing all buttons.`) + return matching + } + const [exclusivePluginId] = exclusivePluginIds + return matching.filter(([_, config]) => config.pluginId === exclusivePluginId) +} + function renderButton ({ btn, appState, appConfig, evaluateProp }) { const [buttonId, config] = btn const bpConfig = config[appState.breakpoint] ?? {} @@ -148,7 +164,8 @@ function renderButton ({ btn, appState, appConfig, evaluateProp }) { function mapButtons ({ slot, appState, appConfig, evaluateProp }) { const { buttonConfig, breakpoint } = appState - const matching = getMatchingButtons({ appState, appConfig, buttonConfig, slot, evaluateProp }) + const raw = getMatchingButtons({ appState, appConfig, buttonConfig, slot, evaluateProp }) + const matching = applySlotExclusivity(raw, appState) if (!matching.length) { return [] @@ -243,6 +260,7 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) { export { mapButtons, getMatchingButtons, + applySlotExclusivity, renderButton, resolveGroupName, resolveGroupLabel, diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js index fb54153c..8171a693 100755 --- a/src/App/renderer/mapButtons.test.js +++ b/src/App/renderer/mapButtons.test.js @@ -1,7 +1,9 @@ import React from 'react' -import { mapButtons, getMatchingButtons, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js' +import { mapButtons, getMatchingButtons, applySlotExclusivity, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js' +import { logger } from '../../services/logger.js' import { getPanelConfig } from '../registry/panelRegistry.js' +jest.mock('../../services/logger.js', () => ({ logger: { warn: jest.fn() } })) jest.mock('../registry/buttonRegistry.js') jest.mock('../registry/panelRegistry.js') jest.mock('../components/MapButton/MapButton.jsx', () => ({ @@ -350,6 +352,49 @@ describe('mapButtons module', () => { expect(map()[0].order).toBe(0) }) + it('filters to exclusive plugin when one plugin has a visible exclusiveSlot button', () => { + appState.buttonConfig = { + drawCancel: { ...baseBtn, pluginId: 'draw', exclusiveSlot: true }, + journeyBack: { ...baseBtn } + } + const matching = [['drawCancel', appState.buttonConfig.drawCancel], ['journeyBack', appState.buttonConfig.journeyBack]] + const result = applySlotExclusivity(matching, appState) + expect(result).toHaveLength(1) + expect(result[0][0]).toBe('drawCancel') + }) + + it('returns all buttons when no exclusiveSlot buttons are visible', () => { + appState.buttonConfig = { + drawCancel: { ...baseBtn, pluginId: 'draw', exclusiveSlot: true }, + journeyBack: { ...baseBtn } + } + appState.hiddenButtons = new Set(['drawCancel']) + const matching = [['drawCancel', appState.buttonConfig.drawCancel], ['journeyBack', appState.buttonConfig.journeyBack]] + const result = applySlotExclusivity(matching, appState) + expect(result).toHaveLength(2) + }) + + it('returns all buttons when no buttons have exclusiveSlot', () => { + const matching = [['b1', { ...baseBtn }], ['b2', { ...baseBtn }]] + expect(applySlotExclusivity(matching, appState)).toHaveLength(2) + }) + + it('ignores exclusiveSlot on buttons without a pluginId', () => { + const matching = [['hostBtn', { ...baseBtn, exclusiveSlot: true }]] + expect(applySlotExclusivity(matching, appState)).toHaveLength(1) + }) + + it('warns and returns all when multiple plugins claim exclusivity', () => { + const matching = [ + ['drawCancel', { ...baseBtn, pluginId: 'draw', exclusiveSlot: true }], + ['otherBtn', { ...baseBtn, pluginId: 'other', exclusiveSlot: true }] + ] + const result = applySlotExclusivity(matching, appState) + expect(result).toHaveLength(2) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('draw')) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('other')) + }) + it('excludes menu items from slot rendering even when they have a matching slot', () => { appState.buttonConfig = ({ parent: baseBtn, diff --git a/src/App/store/appReducer.js b/src/App/store/appReducer.js index c47c3527..99fdd3ba 100755 --- a/src/App/store/appReducer.js +++ b/src/App/store/appReducer.js @@ -40,7 +40,7 @@ export const initialState = (config) => { mode: mode || null, previousMode: null, safeZoneInset: null, - disabledButtons: new Set(), + disabledButtons: config.backAndContinue?.continueLabel ? new Set(['journeyContinue']) : new Set(), hiddenButtons: new Set(), pressedButtons: new Set(), expandedButtons: new Set(), diff --git a/src/App/store/appReducer.test.js b/src/App/store/appReducer.test.js index 57f10bc5..96f5ab76 100755 --- a/src/App/store/appReducer.test.js +++ b/src/App/store/appReducer.test.js @@ -70,6 +70,43 @@ describe('initialState', () => { expect(result.prefersReducedMotion).toBe(false) }) + test('pre-disables journeyContinue when backAndContinue has a continueLabel', () => { + mockMedia() + mockPanels({}) + mockFullscreen(false) + + const config = { + behaviour: 'buttonFirst', + initialBreakpoint: 'md', + initialInterfaceType: 'desktop', + appColorScheme: 'light', + autoColorScheme: false, + backAndContinue: { continueLabel: 'Continue' }, + ...createMockRegistries() + } + + const result = initialState(config) + expect(result.disabledButtons.has('journeyContinue')).toBe(true) + }) + + test('does not pre-disable journeyContinue when backAndContinue is null', () => { + mockMedia() + mockPanels({}) + mockFullscreen(false) + + const config = { + behaviour: 'buttonFirst', + initialBreakpoint: 'md', + initialInterfaceType: 'desktop', + appColorScheme: 'light', + autoColorScheme: false, + ...createMockRegistries() + } + + const result = initialState(config) + expect(result.disabledButtons.has('journeyContinue')).toBe(false) + }) + test('defaults mode to null when missing', () => { mockMedia({ prefersReducedMotion: false }) mockPanels({}) diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index bdc4d41e..0c9d63ec 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -415,6 +415,16 @@ export default class InteractiveMap { this.eventBus.emit(events.APP_TOGGLE_BUTTON_STATE, { id, prop, value }) } + /** + * Enable or disable the journey continue button. + * Only has effect when backAndContinue is configured. + * + * @param {boolean} enabled + */ + setContinueEnabled (enabled) { + this.toggleButtonState('journeyContinue', 'disabled', !enabled) + } + /** * Add a panel to the UI. * diff --git a/src/InteractiveMap/InteractiveMap.test.js b/src/InteractiveMap/InteractiveMap.test.js index 191b740e..14e5c9e7 100755 --- a/src/InteractiveMap/InteractiveMap.test.js +++ b/src/InteractiveMap/InteractiveMap.test.js @@ -563,6 +563,14 @@ describe('InteractiveMap — Public API Methods', () => { expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'btn-1', prop: 'disabled', value: true }) }) + it('setContinueEnabled enables and disables the journey continue button', () => { + map.setContinueEnabled(true) + expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: false }) + + map.setContinueEnabled(false) + expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: true }) + }) + it('fitToBounds and setView emit correct events', () => { const bbox = [-0.489, 51.28, 0.236, 51.686] const center = [-0.1276, 51.5074] diff --git a/src/config/appConfig.js b/src/config/appConfig.js index faf20490..f407b667 100755 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -17,9 +17,34 @@ const exitButtonSlots = { showLabel: false } +const journeyBackSlots = { slot: 'actions', showLabel: true } +const journeyContinueSlots = { slot: 'actions', showLabel: true, order: 10 } + // Default app buttons, panels and icons export const defaultAppConfig = { buttons: [{ + id: 'journeyBack', + label: ({ appConfig }) => appConfig.backAndContinue?.backLabel, + variant: 'tertiary', + onClick: (_e, { appConfig, services }) => + appConfig.behaviour === 'mapOnly' ? globalThis.history.back() : services.closeApp(), + excludeWhen: ({ appConfig, appState }) => + !appConfig.backAndContinue?.backLabel || + !appState.isFullscreen || + (appConfig.behaviour === 'mapOnly' && globalThis.history.length <= 1), + mobile: journeyBackSlots, + tablet: journeyBackSlots, + desktop: journeyBackSlots + }, { + id: 'journeyContinue', + label: ({ appConfig }) => appConfig.backAndContinue?.continueLabel, + variant: 'primary', + onClick: (_e, { services, pluginStates, mapState }) => services.eventBus.emit('app:continue', { pluginStates, mapState }), + excludeWhen: ({ appConfig, appState }) => !appConfig.backAndContinue?.continueLabel || !appState.isFullscreen, + mobile: journeyContinueSlots, + tablet: journeyContinueSlots, + desktop: journeyContinueSlots + }, { id: 'exit', label: 'Exit', iconId: 'close', diff --git a/src/config/appConfig.test.js b/src/config/appConfig.test.js index 2e1044dc..d50b2099 100755 --- a/src/config/appConfig.test.js +++ b/src/config/appConfig.test.js @@ -15,6 +15,8 @@ describe('defaultAppConfig', () => { const buttons = defaultAppConfig.buttons const fullscreenBtn = buttons.find(b => b.id === 'fullscreen') const exitBtn = buttons.find(b => b.id === 'exit') + const journeyBackBtn = buttons.find(b => b.id === 'journeyBack') + const journeyContinueBtn = buttons.find(b => b.id === 'journeyContinue') const zoomInBtn = buttons.find(b => b.id === 'zoomIn') const zoomOutBtn = buttons.find(b => b.id === 'zoomOut') @@ -49,6 +51,54 @@ describe('defaultAppConfig', () => { expect(servicesMock.closeApp).toHaveBeenCalled() }) + // --- JOURNEY BACK BUTTON --- + it('covers all branches of journeyBack excludeWhen', () => { + const base = { appConfig: { backAndContinue: { backLabel: 'Back' }, behaviour: 'buttonFirst' }, appState: { isFullscreen: true } } + expect(journeyBackBtn.excludeWhen({ appConfig: { backAndContinue: null, behaviour: 'buttonFirst' }, appState: { isFullscreen: true } })).toBe(true) + expect(journeyBackBtn.excludeWhen({ appConfig: { backAndContinue: { backLabel: 'Back' }, behaviour: 'buttonFirst' }, appState: { isFullscreen: false } })).toBe(true) + expect(journeyBackBtn.excludeWhen({ appConfig: { backAndContinue: { backLabel: 'Back' }, behaviour: 'mapOnly' }, appState: { isFullscreen: true } })).toBe(globalThis.history.length <= 1) + expect(journeyBackBtn.excludeWhen(base)).toBe(false) + }) + + it('journeyBack onClick calls history.back for mapOnly and closeApp otherwise', () => { + const historySpy = jest.spyOn(globalThis.history, 'back').mockImplementation(() => {}) + const servicesMock = { closeApp: jest.fn() } + + journeyBackBtn.onClick({}, { appConfig: { behaviour: 'mapOnly' }, services: servicesMock }) + expect(historySpy).toHaveBeenCalled() + expect(servicesMock.closeApp).not.toHaveBeenCalled() + + journeyBackBtn.onClick({}, { appConfig: { behaviour: 'buttonFirst' }, services: servicesMock }) + expect(servicesMock.closeApp).toHaveBeenCalled() + + historySpy.mockRestore() + }) + + // --- JOURNEY CONTINUE BUTTON --- + it('covers all branches of journeyContinue excludeWhen', () => { + expect(journeyContinueBtn.excludeWhen({ appConfig: { backAndContinue: null }, appState: { isFullscreen: true } })).toBe(true) + expect(journeyContinueBtn.excludeWhen({ appConfig: { backAndContinue: { continueLabel: 'Continue' } }, appState: { isFullscreen: false } })).toBe(true) + expect(journeyContinueBtn.excludeWhen({ appConfig: { backAndContinue: { continueLabel: 'Continue' } }, appState: { isFullscreen: true } })).toBe(false) + }) + + it('journeyBack label returns backLabel from config', () => { + expect(journeyBackBtn.label({ appConfig: { backAndContinue: { backLabel: 'Back' } } })).toBe('Back') + expect(journeyBackBtn.label({ appConfig: { backAndContinue: null } })).toBeUndefined() + }) + + it('journeyContinue label returns continueLabel from config', () => { + expect(journeyContinueBtn.label({ appConfig: { backAndContinue: { continueLabel: 'Continue' } } })).toBe('Continue') + expect(journeyContinueBtn.label({ appConfig: { backAndContinue: null } })).toBeUndefined() + }) + + it('journeyContinue onClick emits app:continue with pluginStates and mapState', () => { + const eventBusMock = { emit: jest.fn() } + const pluginStates = { interact: { selectedFeatures: [] } } + const mapState = { zoom: 12, center: [-1.5, 53.0] } + journeyContinueBtn.onClick({}, { services: { eventBus: eventBusMock }, pluginStates, mapState }) + expect(eventBusMock.emit).toHaveBeenCalledWith('app:continue', { pluginStates, mapState }) + }) + // --- FULLSCREEN BUTTON (Line 39 Coverage) --- it('evaluates fullscreen label and icon states', () => { const containerMock = { requestFullscreen: jest.fn() } diff --git a/src/config/defaults.js b/src/config/defaults.js index 78e6b943..f1091cb7 100755 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -19,6 +19,7 @@ const defaults = { enableZoomControls: true, genericErrorText: 'There was a problem loading the map. Please try again later.', hasExitButton: false, + backAndContinue: null, hybridWidth: null, // Defaults to maxMobileWidth if not set keyboardHintText: 'Press Shift + ? to view keyboard shortcuts', mapLabel: 'Interactive map application', diff --git a/src/scss/settings/_dimensions.scss b/src/scss/settings/_dimensions.scss index 5dfa6c4f..94445925 100755 --- a/src/scss/settings/_dimensions.scss +++ b/src/scss/settings/_dimensions.scss @@ -43,6 +43,7 @@ // Action bar (Needs to fit evently if tools are in right hand column) --action-bar-max-width: calc(100% - (var(--button-size) * 2) - (var(--primary-gap) * 4)); + --action-button-min-width: 120px; // Attributions --attributions-padding: 2px 5px; diff --git a/src/types.js b/src/types.js index d6d60d87..bf80d7d1 100644 --- a/src/types.js +++ b/src/types.js @@ -680,6 +680,13 @@ * @property {string} [genericErrorText] * Fallback error message shown when the map fails to load. * + * @property {{ backLabel?: string, continueLabel?: string, continueEnabledWhen?: function }} [backAndContinue=null] + * When set, shows Back and/or Continue buttons in the actions bar when the map is fullscreen. + * Omit `backLabel` to suppress the Back button; omit `continueLabel` to suppress the Continue button. + * `continueEnabledWhen({ pluginStates, mapState })` is a reactive predicate that controls the enabled + * state of the Continue button — use this instead of `setContinueEnabled()` for declarative control. + * In `mapOnly` behaviour the Back button is hidden when there is no browser history to go back to. + * * @property {boolean} [hasExitButton=false] * Whether an exit map button is displayed when the app is fullscreen. *