Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions docs/plugins/plugin-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,36 @@ const { isActive } = context.pluginState
// Update state
context.pluginState.dispatch({ type: 'setActive', payload: true })
```

---

### `setExclusiveControl`
**Type:** `(value: boolean | string | null) => void`

Available to plugin components (InitComponent, panel and control render components) as a prop.

Tells the app that your plugin has taken control of the interface, e.g. while a search form is expanded. You don't pass your plugin's id: it's added for you.

- `setExclusiveControl(true)` adds `im-o-app--exclusive-control-{pluginId}` to the app root.
- `setExclusiveControl('some-name')` adds `im-o-app--exclusive-control-{pluginId}--some-name` instead. The name is used as-is, so pass something class-safe.
- `setExclusiveControl(false)` (or `null`/`undefined`) releases your claim.

Only one class is ever present, for the most recent claim. Claims stack: if another plugin claims control while yours holds it, its class replaces yours, and when it releases, your class comes back. Releasing only removes your own claim, so it never affects another plugin's. Each plugin holds one claim at a time, so claiming with a new name replaces your previous one rather than stacking on it. Release in your effect's cleanup too, so a claim isn't left behind if your component unmounts.

Your plugin's own CSS decides what to hide in response, so it can pick what suits its focus behaviour. For example, it could use `opacity: 0` to keep hidden buttons in the tab order, or `display: none` to free up their space.

```js
// Claim while expanded; release when collapsed or unmounted
useLayoutEffect(() => {
setExclusiveControl(isExpanded)
return () => setExclusiveControl(false)
}, [isExpanded])
```

```scss
.im-o-app--exclusive-control-my-plugin {
.im-o-app__right .im-c-button-wrapper {
display: none;
}
}
```
6 changes: 3 additions & 3 deletions plugins/search/src/Search.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { SubmitButton } from './components/SubmitButton/SubmitButton'
import { createDatasets } from './datasets.js'
import { attachEvents } from './events/index.js'

export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider }) {
export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider, setExclusiveControl }) {
const { id } = appConfig
const { interfaceType } = appState
const { expanded: defaultExpanded, customDatasets, osNamesURL, regions, maxSuggestions } = pluginConfig
Expand Down Expand Up @@ -55,11 +55,11 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap
}, [isExpanded])

// Manage focus outside the search control
// useLayoutEffect (not useEffect) so hasExclusiveControl flips in the same paint as the
// useLayoutEffect (not useEffect) so exclusiveControl flips in the same paint as the
// form expanding - otherwise the browser paints once with the other buttons still visible,
// then again once this dispatch lands, producing a visible flicker.
useLayoutEffect(() => {
appState.dispatch({ type: 'TOGGLE_HAS_EXCLUSIVE_CONTROL', payload: isExpanded })
setExclusiveControl(isExpanded)

if (!searchOpen) {
return undefined
Expand Down
14 changes: 13 additions & 1 deletion plugins/search/src/Search.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ describe('Search component', () => {
},
mapState: { markers: {} },
services: {},
mapProvider: { crs: 'EPSG:3857' }
mapProvider: { crs: 'EPSG:3857' },
setExclusiveControl: jest.fn()
}
})

Expand Down Expand Up @@ -102,6 +103,17 @@ describe('Search component', () => {
expect(container.querySelector('.im-c-search')).not.toHaveClass('im-c-search--collapsed')
})

it('claims exclusive control as search while expanded', () => {
props.pluginState.isExpanded = true
render(<Search {...props} />)
expect(props.setExclusiveControl).toHaveBeenCalledWith(true)
})

it('releases exclusive control when collapsed', () => {
render(<Search {...props} />)
expect(props.setExclusiveControl).toHaveBeenCalledWith(false)
})

it('does not collapse the wrapper in default-expanded mode', () => {
props.pluginConfig.expanded = true
const { container } = render(<Search {...props} />)
Expand Down
2 changes: 1 addition & 1 deletion plugins/search/src/search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
}

// 3. Modifiers
.im-o-app--exclusive-control {
.im-o-app--exclusive-control-search {
.im-o-app__top .im-c-button-wrapper,
.im-o-app__right .im-c-button-wrapper {
opacity: 0;
Expand Down
15 changes: 13 additions & 2 deletions src/App/layout/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@ import { Hints } from '../components/Hints/Hints.jsx'
import { hasOpenModalPanel } from '../renderer/slotHelpers.js'
import { getMapThemeVars } from '../../config/mapTheme.js'

// im-o-app--exclusive-control-{pluginId}, or im-o-app--exclusive-control-{pluginId}--{name} when
// the plugin passed a name, for the most recent claim only.
const getExclusiveControlClass = (exclusiveControl) => {
const top = exclusiveControl.at(-1)
if (!top) {
return null
}
const suffix = top.name ? '--' + top.name : ''
return `im-o-app--exclusive-control-${top.pluginId}${suffix}`
}

// eslint-disable-next-line camelcase, react/jsx-pascal-case
// sonarjs/disable-next-line function-name
export const Layout = () => {
const { id, mapLabel, mapHintText } = useConfig()
const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen, openPanels, panelConfig } = useApp()
const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, exclusiveControl, isFullscreen, openPanels, panelConfig } = useApp()
const { mapStyle } = useMap()
const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint)

Expand All @@ -34,7 +45,7 @@ export const Layout = () => {
`im-o-app--${interfaceType}`,
`im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`,
`im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`,
hasExclusiveControl && 'im-o-app--exclusive-control'
getExclusiveControlClass(exclusiveControl)
].filter(Boolean).join(' ')}
style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
ref={layoutRefs.appContainerRef}
Expand Down
43 changes: 38 additions & 5 deletions src/App/layout/Layout.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('Layout', () => {
preferredColorScheme: 'dark',
layoutRefs: mockRefs,
isLayoutReady: true,
hasExclusiveControl: true,
exclusiveControl: [{ pluginId: 'search', name: null }],
isFullscreen: false
})
useMap.mockReturnValue({
Expand All @@ -76,7 +76,8 @@ describe('Layout', () => {
expect(root.className).toContain('im-o-app--map')
expect(root.className).toContain('im-o-app--inline')
expect(root.className).toContain('im-o-app--light-app')
expect(root.className).toContain('im-o-app--exclusive-control')
expect(root.className).toContain('im-o-app--exclusive-control-search')
expect(root.className).not.toContain('im-o-app--exclusive-control-search--')
expect(root.style.backgroundColor).toBe('pink')
expect(root.style.getPropertyValue('--map-overlay-halo-color')).toBe('#0b0c0c')
expect(root.style.getPropertyValue('--map-overlay-selected-color')).toBe('#ffffff')
Expand All @@ -101,14 +102,46 @@ describe('Layout', () => {
expect(backdrop).not.toHaveClass('im-o-app__modal-backdrop--visible')
})

test('adds a single name-suffixed class when a name is set', () => {
useApp.mockReturnValueOnce({
breakpoint: 'desktop',
interfaceType: 'map',
preferredColorScheme: 'dark',
layoutRefs: mockRefs,
isLayoutReady: true,
exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }],
isFullscreen: false
})
render(<Layout />)
const root = document.getElementById('myApp-im-app')
expect(root).toHaveClass('im-o-app--exclusive-control-draw--edit-point')
expect(root).not.toHaveClass('im-o-app--exclusive-control-draw')
})

test('adds the class for the most recent claim only', () => {
useApp.mockReturnValueOnce({
breakpoint: 'desktop',
interfaceType: 'map',
preferredColorScheme: 'dark',
layoutRefs: mockRefs,
isLayoutReady: true,
exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }],
isFullscreen: false
})
render(<Layout />)
const root = document.getElementById('myApp-im-app')
expect(root).toHaveClass('im-o-app--exclusive-control-search')
expect(root.className).not.toContain('exclusive-control-draw')
})

test('shows the modal backdrop only while a modal-configured panel is open', () => {
useApp.mockReturnValue({
breakpoint: 'mobile',
interfaceType: 'map',
preferredColorScheme: 'dark',
layoutRefs: mockRefs,
isLayoutReady: true,
hasExclusiveControl: false,
exclusiveControl: [],
isFullscreen: true,
openPanels: { settings: { props: {} } },
panelConfig: { settings: { mobile: { modal: true } } }
Expand All @@ -125,7 +158,7 @@ describe('Layout', () => {
preferredColorScheme: 'dark',
layoutRefs: mockRefs,
isLayoutReady: false,
hasExclusiveControl: false,
exclusiveControl: [],
isFullscreen: true
})
render(<Layout />)
Expand All @@ -143,7 +176,7 @@ describe('Layout', () => {
preferredColorScheme: 'dark',
layoutRefs: mockRefs,
isLayoutReady: true,
hasExclusiveControl: false,
exclusiveControl: [],
isFullscreen: false
})

Expand Down
9 changes: 8 additions & 1 deletion src/App/renderer/pluginWrapper.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// src/core/renderers/pluginWrapper.js
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useConfig } from '../store/configContext.js'
import { useApp } from '../store/appContext.js'
import { useMap } from '../store/mapContext.js'
Expand Down Expand Up @@ -48,6 +48,12 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) {
const mapState = useMap()
const services = useService()
const pluginState = usePlugin(pluginId)
// Bound to this plugin's id so authors never pass it: true claims control, a string claims it
// with a name (--{name} class suffix), anything falsy releases it.
const setExclusiveControl = useCallback((value) => appState.dispatch({
type: 'SET_EXCLUSIVE_CONTROL',
payload: { pluginId, name: typeof value === 'string' ? value : null, active: !!value }
}), [appState.dispatch])

return (
<Component
Expand All @@ -60,6 +66,7 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) {
services={services}
mapProvider={appConfig.mapProvider}
iconRegistry={getIconRegistry()}
setExclusiveControl={setExclusiveControl}
buttonConfig={useMemo(() => Object.fromEntries(
Object.entries(appState.buttonConfig).filter(
([_, btn]) => btn.pluginId === pluginId
Expand Down
18 changes: 18 additions & 0 deletions src/App/renderer/pluginWrapper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ describe('withPluginContexts', () => {
}))
})

it.each([
[true, { name: null, active: true }],
['edit-point', { name: 'edit-point', active: true }],
[false, { name: null, active: false }],
[null, { name: null, active: false }],
[undefined, { name: null, active: false }]
])('injects setExclusiveControl bound to the plugin id (%p)', (value, expected) => {
const dispatch = jest.fn()
require('../store/appContext.js').useApp.mockReturnValueOnce({ buttonConfig: {}, dispatch })
const Inner = jest.fn(() => <div>Inner</div>)
const Wrapped = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: {} })

render(<Wrapped />)
Inner.mock.calls[0][0].setExclusiveControl(value)

expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', ...expected } })
})

it('returns the cached wrapper if called again with the same component', () => {
const Inner = jest.fn(() => <div>Inner</div>)
const Wrapped1 = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: { foo: 'bar' } })
Expand Down
2 changes: 1 addition & 1 deletion src/App/store/AppProvider.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('AppProvider', () => {
previousOpenPanels: {},
interfaceType: 'default',
isFullscreen: false,
hasExclusiveControl: false,
exclusiveControl: [],
panelRegistry: { getPanelConfig: getPanelConfigMock } // <-- provide it here!
}))

Expand Down
20 changes: 15 additions & 5 deletions src/App/store/appActionsMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,21 @@ const restorePreviousPanels = (state) => {
}
}

const toggleHasExclusiveControl = (state, payload) => {
return {
...state,
hasExclusiveControl: payload
// A stack of plugins' claims on the interface, most recent last; Layout adds a single
// im-o-app--exclusive-control-{pluginId}[--{name}] class for the top claim. Claiming moves the
// plugin's claim to the top; releasing removes only its own, so an earlier claim underneath
// (e.g. draw's, while search was open) comes back on its own.
const setExclusiveControl = (state, { pluginId, name = null, active }) => {
const stack = state.exclusiveControl
const top = stack.at(-1)
if (active && top?.pluginId === pluginId && top.name === name) {
return state
}
const others = stack.filter(claim => claim.pluginId !== pluginId)
if (!active && others.length === stack.length) {
return state
}
return { ...state, exclusiveControl: active ? [...others, { pluginId, name }] : others }
}

const toggleNudgeStep = (state) => {
Expand Down Expand Up @@ -391,7 +401,7 @@ export const actionsMap = {
CLOSE_ALL_PANELS: closeAllPanels,
RESTORE_PREVIOUS_PANELS: restorePreviousPanels,
TOGGLE_APP_VISIBLE: toggleAppVisible,
TOGGLE_HAS_EXCLUSIVE_CONTROL: toggleHasExclusiveControl,
SET_EXCLUSIVE_CONTROL: setExclusiveControl,
TOGGLE_NUDGE_STEP: toggleNudgeStep,
TOGGLE_BUTTON_DISABLED: toggleButtonDisabled,
TOGGLE_BUTTON_HIDDEN: toggleButtonHidden,
Expand Down
51 changes: 47 additions & 4 deletions src/App/store/appActionsMap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('actionsMap full coverage', () => {
interfaceType: 'default',
openPanels: { panel1: { props: {} } },
previousOpenPanels: {},
hasExclusiveControl: false,
exclusiveControl: [],
nudgeStepSize: 'small',
safeZoneInset: { top: 0, bottom: 0 },
isLayoutReady: false,
Expand Down Expand Up @@ -134,9 +134,52 @@ describe('actionsMap full coverage', () => {
expect(result.previousOpenPanels).toBe(localState.openPanels)
})

test('TOGGLE_HAS_EXCLUSIVE_CONTROL sets flag', () => {
const result = actionsMap.TOGGLE_HAS_EXCLUSIVE_CONTROL(state, true)
expect(result.hasExclusiveControl).toBe(true)
test('SET_EXCLUSIVE_CONTROL adds a claim with a null name by default', () => {
const result = actionsMap.SET_EXCLUSIVE_CONTROL(state, { pluginId: 'search', active: true })
expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }])
})

test('SET_EXCLUSIVE_CONTROL stacks a new plugin\'s claim on top', () => {
const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }] }
const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: true })
expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }])
})

test('SET_EXCLUSIVE_CONTROL reinstates the claim underneath when the top claim is released', () => {
const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }] }
const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false })
expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point' }])
})

test('SET_EXCLUSIVE_CONTROL releasing a claim underneath leaves the top claim in place', () => {
const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }] }
const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', active: false })
expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }])
})

test('SET_EXCLUSIVE_CONTROL keeps draw\'s claim when search, claimed first, releases after draw claims', () => {
const searchClaimed = actionsMap.SET_EXCLUSIVE_CONTROL({ ...state, exclusiveControl: [] }, { pluginId: 'search', active: true })
const drawClaimed = actionsMap.SET_EXCLUSIVE_CONTROL(searchClaimed, { pluginId: 'draw', active: true })
const searchReleased = actionsMap.SET_EXCLUSIVE_CONTROL(drawClaimed, { pluginId: 'search', active: false })
expect(searchReleased.exclusiveControl).toEqual([{ pluginId: 'draw', name: null }])
})

test('SET_EXCLUSIVE_CONTROL re-claiming replaces the plugin\'s own claim and moves it to the top', () => {
const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon' }, { pluginId: 'search', name: null }] }
const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true })
expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }, { pluginId: 'draw', name: 'edit-vertex' }])
})

test('SET_EXCLUSIVE_CONTROL re-claiming the top claim unchanged returns the same state', () => {
const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-vertex' }] }
const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true })
expect(result).toBe(localState)
})

test('SET_EXCLUSIVE_CONTROL releasing without a claim returns the same state', () => {
const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon' }] }
const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false })
expect(result).toBe(localState)
})

test('TOGGLE_NUDGE_STEP flips small to large', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/App/store/appReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const initialState = (config) => {
hiddenButtons: new Set(),
pressedButtons: new Set(),
expandedButtons: new Set(),
hasExclusiveControl: false,
exclusiveControl: [],
nudgeStepSize: 'large',
openPanels,
previousOpenPanels: {},
Expand Down
Loading