config.group?.label })
- const ctx = { breakpoint, appState, appConfig, evaluateProp, isHiddenByApplicationMode }
const result = []
for (const [key, members] of buckets) {
@@ -242,6 +240,19 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp, isHiddenByApplic
return result
}
+// Maps buttons for the given slot or, with no slot, for every slot. Exclusivity and groups apply
+// within a slot, so matching buttons are split by slot first.
+function mapButtons ({ slot, appState, appConfig, evaluateProp, isHiddenByApplicationMode = hidesNothing }) {
+ const { buttonConfig, breakpoint } = appState
+
+ const raw = getMatchingButtons({ appState, appConfig, buttonConfig, slot, evaluateProp })
+ const ctx = { breakpoint, appState, appConfig, evaluateProp, isHiddenByApplicationMode }
+
+ const bySlot = groupBySlot(raw, ([, config]) => config[breakpoint].slot)
+
+ return [...bySlot.values()].flatMap(entries => buildSlotItems(entries, ctx))
+}
+
export {
mapButtons,
getMatchingButtons,
diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js
index 462fe35f..c0234dc4 100755
--- a/src/App/renderer/mapButtons.test.js
+++ b/src/App/renderer/mapButtons.test.js
@@ -257,7 +257,29 @@ describe('mapButtons module', () => {
it('returns a flat list of buttons with type and order', () => {
appState.buttonConfig = ({ b1: baseBtn })
const result = map()
- expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 1 })
+ expect(result[0]).toMatchObject({ id: 'b1', type: 'button', slot: 'header', order: 1 })
+ })
+
+ it('maps buttons for every slot, each tagged with its slot, when no slot is given', () => {
+ appState.buttonConfig = ({ b1: baseBtn, b2: { ...baseBtn, desktop: { slot: 'sidebar' } } })
+ const result = mapButtons({ appState, appConfig, evaluateProp })
+ expect(result.map(({ id, slot }) => ({ id, slot }))).toEqual([{ id: 'b1', slot: 'header' }, { id: 'b2', slot: 'sidebar' }])
+ })
+
+ it('applies slot exclusivity within each slot when mapping every slot', () => {
+ appState.buttonConfig = {
+ drawCancel: { ...baseBtn, pluginId: 'draw', exclusiveSlot: true },
+ journeyBack: { ...baseBtn },
+ sidebarBtn: { ...baseBtn, desktop: { slot: 'sidebar' } }
+ }
+ const result = mapButtons({ appState, appConfig, evaluateProp })
+ expect(result.map(r => r.id)).toEqual(['drawCancel', 'sidebarBtn'])
+ })
+
+ it('tags a button group with its members\' slot', () => {
+ const group = { label: 'G1' }
+ appState.buttonConfig = ({ b1: { ...baseBtn, group }, b2: { ...baseBtn, group } })
+ expect(map()[0]).toMatchObject({ id: 'group-g1', type: 'group', slot: 'header' })
})
it('renders grouped buttons as a single group item with role=group, keyed by kebab-cased label', () => {
diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js
index d7a745c8..c6d1bee8 100755
--- a/src/App/renderer/mapControls.js
+++ b/src/App/renderer/mapControls.js
@@ -7,8 +7,8 @@ import { hidesNothing } from './applicationModes.js'
import { stringToKebab } from '../../utils/stringToKebab.js'
/**
- * Map controls for a given slot and app state.
- * Returns an array of control descriptors.
+ * Map controls for a given slot and app state — or, with no slot, for every slot.
+ * Returns an array of control descriptors, each with the slot it renders in.
*/
export function mapControls ({ slot, appState, evaluateProp, isHiddenByApplicationMode = hidesNothing }) {
const { breakpoint, pluginRegistry, controlConfig } = appState
@@ -40,7 +40,7 @@ export function mapControls ({ slot, appState, evaluateProp, isHiddenByApplicati
}
// Only include controls allowed in this slot
- return bpConfig.slot === slot && slotAllowed
+ return (slot === undefined || bpConfig.slot === slot) && slotAllowed
})
.map(control => {
// Detect plugin owning this control
@@ -87,6 +87,7 @@ export function mapControls ({ slot, appState, evaluateProp, isHiddenByApplicati
return {
id: control.id,
type: 'control',
+ slot: control[breakpoint].slot,
order: control[breakpoint]?.order ?? 0,
tab: control[breakpoint]?.tab,
element
diff --git a/src/App/renderer/mapControls.test.js b/src/App/renderer/mapControls.test.js
index 3c30ba12..263eb531 100755
--- a/src/App/renderer/mapControls.test.js
+++ b/src/App/renderer/mapControls.test.js
@@ -202,6 +202,16 @@ describe('mapControls', () => {
expect(result).toEqual([])
})
+ it('maps controls for every slot, each tagged with its slot, when no slot is given', () => {
+ defaultAppState.controlConfig = ({
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'header' } },
+ ctrl2: { id: 'ctrl2', desktop: { slot: 'map-styles-panel' } },
+ ctrl3: { id: 'ctrl3', desktop: { slot: 'not-a-slot' } }
+ })
+ const result = mapControls({ appState: defaultAppState, evaluateProp: (p) => p })
+ expect(result.map(({ id, slot }) => ({ id, slot }))).toEqual([{ id: 'ctrl1', slot: 'header' }, { id: 'ctrl2', slot: 'map-styles-panel' }])
+ })
+
it('passes through the tab field from the breakpoint config', () => {
defaultAppState.controlConfig = ({
ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', tab: 'Styles' } }
diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js
index 2a39440e..ad18587b 100755
--- a/src/App/renderer/mapPanels.js
+++ b/src/App/renderer/mapPanels.js
@@ -11,8 +11,8 @@ import { stringToKebab } from '../../utils/stringToKebab.js'
import { logger } from '../../services/logger.js'
/**
- * Maps every configured panel eligible for the given layout slot to a renderable entry — open or
- * closed alike, so a button's aria-controls id always resolves to a stable, permanently-mounted
+ * Maps every configured panel eligible for the given layout slot (or, with no slot, for every
+ * slot) to a renderable entry — open or closed alike, so a button's aria-controls id always resolves to a stable, permanently-mounted
*
(hidden when closed). buildPanelBody only runs while open, so nothing expensive mounts
* before then.
*/
@@ -31,14 +31,16 @@ const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, isFull
if (!isPanelSlotEligible(config, { targetSlot, slot, isFullscreen })) {
return null
}
- return { config, bpConfig }
+ return { config, bpConfig, targetSlot }
}
// A losing modal panel (see getAllowedModalPanelId) still gets a shell — it's just not open.
const resolveIsOpen = (openEntry, bpConfig, panelId, allowedModalPanelId) =>
Boolean(openEntry) && (!bpConfig.modal || panelId === allowedModalPanelId)
-export function mapPanels ({ slot, appState, evaluateProp, isHiddenByApplicationMode = hidesNothing }) {
+// controlsBySlot (optional): controls already mapped for every slot, so a panel body can look up
+// its `-panel` controls instead of mapping them again.
+export function mapPanels ({ slot, appState, evaluateProp, isHiddenByApplicationMode = hidesNothing, controlsBySlot }) {
const { breakpoint, pluginRegistry, panelConfig, openPanels } = appState
// Only the most-recently-opened modal panel is ever actually shown — see isOpen below.
@@ -49,7 +51,7 @@ export function mapPanels ({ slot, appState, evaluateProp, isHiddenByApplication
if (!eligible) {
return null
}
- const { config, bpConfig } = eligible
+ const { config, bpConfig, targetSlot } = eligible
const openEntry = openPanels[panelId]
const isOpen = resolveIsOpen(openEntry, bpConfig, panelId, allowedModalPanelId)
@@ -66,6 +68,7 @@ export function mapPanels ({ slot, appState, evaluateProp, isHiddenByApplication
return {
id: panelId,
type: 'panel',
+ slot: targetSlot,
order: bpConfig.order ?? 0,
element: (
@@ -96,13 +99,11 @@ export function mapPanels ({ slot, appState, evaluateProp, isHiddenByApplication
* @returns {{ items?: object[], tabs?: object[] }} spread directly onto `` — exactly one
* of `items`/`tabs` is set (or neither, for a static-html panel).
*/
-function buildPanelBody ({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp, isHiddenByApplicationMode }) {
- const injectedItems = mapControls({
- slot: `${stringToKebab(panelId)}-panel`,
- appState,
- evaluateProp,
- isHiddenByApplicationMode
- })
+function buildPanelBody ({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp, isHiddenByApplicationMode, controlsBySlot }) {
+ const panelSlot = `${stringToKebab(panelId)}-panel`
+ const injectedItems = controlsBySlot
+ ? controlsBySlot.get(panelSlot) ?? []
+ : mapControls({ slot: panelSlot, appState, evaluateProp, isHiddenByApplicationMode })
if (html) {
/* istanbul ignore next */
diff --git a/src/App/renderer/mapPanels.test.js b/src/App/renderer/mapPanels.test.js
index 76f0564f..e775f7c0 100755
--- a/src/App/renderer/mapPanels.test.js
+++ b/src/App/renderer/mapPanels.test.js
@@ -158,6 +158,36 @@ describe('mapPanels', () => {
expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1', 'p1'])
})
+ it('maps panels for every slot, each tagged with its resolved slot, when no slot is given', () => {
+ defaultAppState.panelConfig = ({
+ p1: baseConfig,
+ p2: { desktop: { slot: 'left-top' } },
+ p3: { desktop: { slot: 'search-button' } },
+ p4: { desktop: { slot: 'header', modal: true } }
+ })
+ const result = mapPanels({ appState: defaultAppState, evaluateProp: (prop) => prop })
+ expect(result.map(({ id, slot }) => ({ id, slot }))).toEqual([
+ { id: 'p1', slot: 'header' },
+ { id: 'p2', slot: 'left-top' },
+ { id: 'p3', slot: 'search-button' },
+ { id: 'p4', slot: 'modal' }
+ ])
+ })
+
+ it('takes injected controls from controlsBySlot, when given, instead of mapping them again', () => {
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } })
+ const controlsBySlot = new Map([['p1-panel', [{ id: 'injected1', order: 0, element: injected }]]])
+ const result = mapPanels({ slot: 'header', appState: defaultAppState, evaluateProp: (prop) => prop, controlsBySlot })
+ expect(mapControls).not.toHaveBeenCalled()
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1'])
+ })
+
+ it('builds an empty body when controlsBySlot has nothing for the panel', () => {
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } })
+ const result = mapPanels({ slot: 'header', appState: defaultAppState, evaluateProp: (prop) => prop, controlsBySlot: new Map() })
+ expect(result[0].element.props.items).toEqual([])
+ })
+
it('returns just the injected controls when the panel has neither render nor html', () => {
mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: injected }])
defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } })
diff --git a/src/App/renderer/slotAggregator.js b/src/App/renderer/slotAggregator.js
index b9941392..3bd7f8e4 100755
--- a/src/App/renderer/slotAggregator.js
+++ b/src/App/renderer/slotAggregator.js
@@ -3,13 +3,26 @@ import { mapControls } from './mapControls.js'
import { mapPanels } from './mapPanels.js'
import { mapButtons } from './mapButtons.js'
import { orderItems } from './orderItems.js'
+import { groupBySlot } from './slotHelpers.js'
-// args include isHiddenByApplicationMode (see useApplicationModeFilter), so every renderer asks the
-// same precomputed application mode rules whether to hide each of its items
-export function getSlotItems (args) {
+/**
+ * Builds the items for every slot in one pass, so each item's excludeWhen runs once per render
+ * rather than once per slot. Includes the nested `-button` slots; controls in a
+ * `-panel` slot are rendered by their panel's body.
+ *
+ * args include isHiddenByApplicationMode (see useApplicationModeFilter), so every mapper asks the
+ * same precomputed application mode rules whether to hide each of its items.
+ *
+ * @returns {Map} each slot's ordered items; slots with no items are absent
+ */
+export function getItemsBySlot (args) {
const controls = mapControls(args)
- const panels = mapPanels(args)
+ const panels = mapPanels({ ...args, controlsBySlot: groupBySlot(controls) })
const buttons = mapButtons(args)
- return orderItems([...controls, ...panels, ...buttons])
+ const bySlot = groupBySlot([...controls, ...panels, ...buttons])
+ for (const [slot, items] of bySlot) {
+ bySlot.set(slot, orderItems(items))
+ }
+ return bySlot
}
diff --git a/src/App/renderer/slotAggregator.test.js b/src/App/renderer/slotAggregator.test.js
index 5177a61e..8aaa6467 100755
--- a/src/App/renderer/slotAggregator.test.js
+++ b/src/App/renderer/slotAggregator.test.js
@@ -1,31 +1,74 @@
-import { getSlotItems } from './slotAggregator.js'
-import * as mapControlsModule from './mapControls.js'
-import * as mapPanelsModule from './mapPanels.js'
-import * as mapButtonsModule from './mapButtons.js'
+import { getItemsBySlot } from './slotAggregator.js'
+jest.mock('../components/MapButton/MapButton.jsx', () => ({ MapButton: () => null }))
+jest.mock('../components/Panel/Panel.jsx', () => ({ Panel: () => null }))
+jest.mock('./pluginWrapper.js', () => ({ withPluginContexts: jest.fn((Comp) => Comp || (() => null)) }))
+
+describe('getItemsBySlot', () => {
+ let appState
+ let excludeWhen
+
+ const evaluateProp = jest.fn((prop) => typeof prop === 'function' ? prop({}) : prop)
+ const build = () => getItemsBySlot({ appState, appConfig: { id: 'test' }, evaluateProp })
+ const ids = (items) => items.map(item => item.id)
-describe('getSlotItems', () => {
beforeEach(() => {
- jest.resetAllMocks()
+ jest.clearAllMocks()
+ excludeWhen = {
+ kept: jest.fn(() => false),
+ excluded: jest.fn(() => true),
+ search: jest.fn(() => false),
+ resultsControl: jest.fn(() => false),
+ topControl: jest.fn(() => false)
+ }
+ appState = {
+ breakpoint: 'desktop',
+ isFullscreen: true,
+ openPanels: { results: { props: {} } },
+ disabledButtons: new Set(),
+ hiddenButtons: new Set(),
+ pressedButtons: new Set(),
+ expandedButtons: new Set(),
+ pluginRegistry: { registeredPlugins: [] },
+ buttonConfig: {
+ kept: { label: 'Kept', excludeWhen: excludeWhen.kept, desktop: { slot: 'top-left' } },
+ excluded: { label: 'Excluded', excludeWhen: excludeWhen.excluded, desktop: { slot: 'top-left' } },
+ search: { label: 'Search', panelId: 'results', excludeWhen: excludeWhen.search, desktop: { slot: 'top-right' } },
+ menuItem: { label: 'Menu item', isMenuItem: true, desktop: { slot: 'top-left' } }
+ },
+ panelConfig: {
+ results: { label: 'Results', desktop: { slot: 'search-button' } }
+ },
+ controlConfig: {
+ resultsControl: { id: 'resultsControl', excludeWhen: excludeWhen.resultsControl, desktop: { slot: 'results-panel' } },
+ topControl: { id: 'topControl', excludeWhen: excludeWhen.topControl, desktop: { slot: 'top-right', order: 1 } }
+ }
+ }
+ })
+
+ it('groups items by slot, including a button\'s nested -button slot', () => {
+ const bySlot = build()
+ expect(ids(bySlot.get('top-left'))).toEqual(['kept'])
+ expect(ids(bySlot.get('search-button'))).toEqual(['results'])
+ expect(bySlot.has('header')).toBe(false)
+ })
+
+ it('orders each slot\'s controls, panels and buttons together', () => {
+ // topControl's order 1 splices it ahead of the unordered search button
+ expect(ids(build().get('top-right'))).toEqual(['topControl', 'search'])
+ })
+
+ it('renders a control in a -panel slot in that panel\'s body', () => {
+ const [panel] = build().get('search-button')
+ expect(ids(panel.element.props.items)).toEqual(['resultsControl'])
+ })
+
+ it('runs each excludeWhen once, however many slots there are', () => {
+ build()
+ Object.values(excludeWhen).forEach(fn => expect(fn).toHaveBeenCalledTimes(1))
})
- it('returns combined and sorted slot items from controls, panels, and buttons', () => {
- jest.spyOn(mapControlsModule, 'mapControls').mockReturnValue([
- { id: 'control1', order: 3, type: 'control' }
- ])
- jest.spyOn(mapPanelsModule, 'mapPanels').mockReturnValue([
- { id: 'panel1', order: 1, type: 'panel' }
- ])
- jest.spyOn(mapButtonsModule, 'mapButtons').mockReturnValue([
- { id: 'button1', order: 2, type: 'button' }
- ])
-
- const args = { slot: 'header', breakpoint: 'desktop', mode: 'view', openPanels: {} }
- const result = getSlotItems(args)
-
- expect(result).toEqual([
- { id: 'panel1', order: 1, type: 'panel' },
- { id: 'button1', order: 2, type: 'button' },
- { id: 'control1', order: 3, type: 'control' }
- ])
+ it('returns an empty map when nothing is configured', () => {
+ appState = { ...appState, buttonConfig: {}, panelConfig: {}, controlConfig: {} }
+ expect(build().size).toBe(0)
})
})
diff --git a/src/App/renderer/slotHelpers.js b/src/App/renderer/slotHelpers.js
index dc9c2a6e..3b08718b 100644
--- a/src/App/renderer/slotHelpers.js
+++ b/src/App/renderer/slotHelpers.js
@@ -73,3 +73,16 @@ export const getAllowedModalPanelId = (openPanels, panelConfig, breakpoint) => {
export const hasOpenModalPanel = (openPanels, panelConfig, breakpoint) => {
return getAllowedModalPanelId(openPanels, panelConfig, breakpoint) !== null
}
+
+// Splits items into a Map of slot -> items, keeping each slot's items in their original order.
+export const groupBySlot = (items, getSlot = item => item.slot) => {
+ const bySlot = new Map()
+ for (const item of items) {
+ const slot = getSlot(item)
+ if (!bySlot.has(slot)) {
+ bySlot.set(slot, [])
+ }
+ bySlot.get(slot).push(item)
+ }
+ return bySlot
+}
diff --git a/src/App/renderer/slotHelpers.test.js b/src/App/renderer/slotHelpers.test.js
index b0d9ce7b..209e78e0 100644
--- a/src/App/renderer/slotHelpers.test.js
+++ b/src/App/renderer/slotHelpers.test.js
@@ -1,4 +1,4 @@
-import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel } from './slotHelpers.js'
+import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel, groupBySlot } from './slotHelpers.js'
jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'], panel: ['header', 'modal', 'left-top'] } }))
@@ -136,3 +136,18 @@ describe('hasOpenModalPanel', () => {
expect(hasOpenModalPanel({ p1: { props: {} } }, panelConfig, 'desktop')).toBe(true)
})
})
+
+describe('groupBySlot', () => {
+ it('groups items by their slot, keeping each slot\'s items in order', () => {
+ const items = [{ id: 'a', slot: 'header' }, { id: 'b', slot: 'banner' }, { id: 'c', slot: 'header' }]
+ expect([...groupBySlot(items)]).toEqual([
+ ['header', [items[0], items[2]]],
+ ['banner', [items[1]]]
+ ])
+ })
+
+ it('reads the slot with getSlot when given', () => {
+ const entries = [['b1', { slot: 'header' }]]
+ expect([...groupBySlot(entries, ([, config]) => config.slot)]).toEqual([['header', entries]])
+ })
+})