diff --git a/src/App/App.jsx b/src/App/App.jsx index 253a2334..bbca9807 100755 --- a/src/App/App.jsx +++ b/src/App/App.jsx @@ -5,6 +5,7 @@ import { ServiceProvider } from './store/ServiceProvider.jsx' import { removeLoadingState } from '..//InteractiveMap/domStateManager.js' import { PluginProvider } from './store/PluginProvider.jsx' import { PluginInits } from './renderer/PluginInits.jsx' +import { SlotItemsProvider } from './renderer/SlotItemsProvider.jsx' import { Layout } from './layout/Layout.jsx' import { EVENTS as events } from '../config/events.js' @@ -22,7 +23,9 @@ export const App = (props) => { - + + + diff --git a/src/App/App.test.jsx b/src/App/App.test.jsx index 8c2236af..c1ec4345 100755 --- a/src/App/App.test.jsx +++ b/src/App/App.test.jsx @@ -53,6 +53,12 @@ jest.mock('./renderer/PluginInits.jsx', () => ({ PluginInits: jest.fn(() =>
) })) +jest.mock('./renderer/SlotItemsProvider.jsx', () => ({ + SlotItemsProvider: jest.fn(({ children }) => ( +
{children}
+ )) +})) + jest.mock('./layout/Layout.jsx', () => ({ Layout: jest.fn(() =>
) })) diff --git a/src/App/renderer/SlotItemsProvider.jsx b/src/App/renderer/SlotItemsProvider.jsx new file mode 100644 index 00000000..f0cfb702 --- /dev/null +++ b/src/App/renderer/SlotItemsProvider.jsx @@ -0,0 +1,33 @@ +// src/core/renderers/SlotItemsProvider.jsx +import React, { createContext, useContext } from 'react' +import { useConfig } from '../store/configContext.js' +import { useApp } from '../store/appContext.js' +import { getItemsBySlot } from './slotAggregator.js' +import { useEvaluateProp } from '../hooks/useEvaluateProp.js' +import { useApplicationModeFilter } from '../hooks/useApplicationModeFilter.js' + +const SlotItemsContext = createContext(new Map()) + +/** + * Builds every slot's items once per render and shares them with each SlotRenderer below it. + * Kept separate from Layout so that a plugin state change re-renders only this provider and the + * slots, not the rest of the layout. + */ +export const SlotItemsProvider = ({ children }) => { + const appConfig = useConfig() + const appState = useApp() + const evaluateProp = useEvaluateProp() + const isHiddenByApplicationMode = useApplicationModeFilter() + + const itemsBySlot = getItemsBySlot({ appConfig, appState, evaluateProp, isHiddenByApplicationMode }) + + return ( + + {children} + + ) +} + +const noItems = [] + +export const useSlotItems = (slot) => useContext(SlotItemsContext).get(slot) ?? noItems diff --git a/src/App/renderer/SlotItemsProvider.test.jsx b/src/App/renderer/SlotItemsProvider.test.jsx new file mode 100644 index 00000000..d5633442 --- /dev/null +++ b/src/App/renderer/SlotItemsProvider.test.jsx @@ -0,0 +1,59 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { SlotItemsProvider, useSlotItems } from './SlotItemsProvider.jsx' +import { getItemsBySlot } from './slotAggregator.js' +import { useApp } from '../store/appContext.js' +import { useConfig } from '../store/configContext.js' +import { useEvaluateProp } from '../hooks/useEvaluateProp.js' +import { useApplicationModeFilter } from '../hooks/useApplicationModeFilter.js' + +jest.mock('./slotAggregator.js', () => ({ getItemsBySlot: jest.fn() })) +jest.mock('../store/appContext.js', () => ({ useApp: jest.fn() })) +jest.mock('../store/configContext.js', () => ({ useConfig: jest.fn() })) +jest.mock('../hooks/useEvaluateProp.js', () => ({ useEvaluateProp: jest.fn() })) +jest.mock('../hooks/useApplicationModeFilter.js', () => ({ useApplicationModeFilter: jest.fn() })) + +const SlotIds = ({ slot }) =>
{useSlotItems(slot).map(item => item.id).join(',')}
+ +describe('SlotItemsProvider', () => { + const appConfig = { id: 'test' } + const appState = { breakpoint: 'desktop' } + const evaluateProp = jest.fn() + const isHiddenByApplicationMode = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + useConfig.mockReturnValue(appConfig) + useApp.mockReturnValue(appState) + useEvaluateProp.mockReturnValue(evaluateProp) + useApplicationModeFilter.mockReturnValue(isHiddenByApplicationMode) + getItemsBySlot.mockReturnValue(new Map([['header', [{ id: 'a' }, { id: 'b' }]]])) + }) + + it('builds every slot\'s items once per render, however many slots read them', () => { + render( + + + + + ) + expect(getItemsBySlot).toHaveBeenCalledTimes(1) + expect(getItemsBySlot).toHaveBeenCalledWith({ appConfig, appState, evaluateProp, isHiddenByApplicationMode }) + }) + + it('gives each slot its own items, and none to a slot with no items', () => { + const { getByTestId } = render( + + + + + ) + expect(getByTestId('header')).toHaveTextContent('a,b') + expect(getByTestId('banner')).toBeEmptyDOMElement() + }) + + it('gives no items outside a provider', () => { + const { getByTestId } = render() + expect(getByTestId('header')).toBeEmptyDOMElement() + }) +}) diff --git a/src/App/renderer/SlotRenderer.jsx b/src/App/renderer/SlotRenderer.jsx index 895ac5f3..07697bab 100755 --- a/src/App/renderer/SlotRenderer.jsx +++ b/src/App/renderer/SlotRenderer.jsx @@ -1,22 +1,11 @@ // src/core/renderers/SlotRenderer.jsx import React from 'react' -import { useConfig } from '../store/configContext.js' -import { useApp } from '../store/appContext.js' -import { getSlotItems } from './slotAggregator.js' +import { useSlotItems } from './SlotItemsProvider.jsx' import { Actions } from '../components/Actions/Actions.jsx' -import { useEvaluateProp } from '../hooks/useEvaluateProp.js' -import { useApplicationModeFilter } from '../hooks/useApplicationModeFilter.js' export const SlotRenderer = ({ slot }) => { - const appConfig = useConfig() - const appState = useApp() - - // Shared evaluateProp hook for this render cycle - const evaluateProp = useEvaluateProp() - const isHiddenByApplicationMode = useApplicationModeFilter() - - // Get all slot items (controls, panels, buttons) - const slotItems = getSlotItems({ slot, appConfig, appState, evaluateProp, isHiddenByApplicationMode }) + // Built for every slot at once by SlotItemsProvider + const slotItems = useSlotItems(slot) if (!slotItems.length) { return null diff --git a/src/App/renderer/SlotRenderer.test.jsx b/src/App/renderer/SlotRenderer.test.jsx index f8f31f11..84832465 100755 --- a/src/App/renderer/SlotRenderer.test.jsx +++ b/src/App/renderer/SlotRenderer.test.jsx @@ -1,57 +1,30 @@ import React from 'react' import { render } from '@testing-library/react' import { SlotRenderer } from './SlotRenderer.jsx' -import { getSlotItems } from './slotAggregator' -import { useApp } from '../store/appContext' -import { useConfig } from '../store/configContext' -import { useEvaluateProp } from '../hooks/useEvaluateProp' -import { useApplicationModeFilter } from '../hooks/useApplicationModeFilter.js' +import { useSlotItems } from './SlotItemsProvider.jsx' -jest.mock('./slotAggregator', () => ({ - getSlotItems: jest.fn() +jest.mock('./SlotItemsProvider.jsx', () => ({ + useSlotItems: jest.fn() })) +// Actions reads the open panels from app state jest.mock('../store/appContext', () => ({ - useApp: jest.fn() -})) -jest.mock('../store/configContext', () => ({ - useConfig: jest.fn() -})) -jest.mock('../hooks/useEvaluateProp', () => ({ - useEvaluateProp: jest.fn() -})) -jest.mock('../hooks/useApplicationModeFilter.js', () => ({ - useApplicationModeFilter: jest.fn() + useApp: () => ({ breakpoint: 'desktop', openPanels: {}, panelConfig: {} }) })) describe('SlotRenderer', () => { - const mockAppConfig = { id: 'testId' } - const mockAppState = { - breakpoint: 'desktop', - mode: 'view', - openPanels: {}, - dispatch: jest.fn(), - disabledButtons: new Set() - } - const mockEvaluateProp = jest.fn(x => x) - const mockIsHiddenByApplicationMode = jest.fn(() => false) - beforeEach(() => { jest.clearAllMocks() - useConfig.mockReturnValue(mockAppConfig) - useApp.mockReturnValue(mockAppState) - useEvaluateProp.mockReturnValue(mockEvaluateProp) - useApplicationModeFilter.mockReturnValue(mockIsHiddenByApplicationMode) }) it('renders nothing if no slot items', () => { - getSlotItems.mockReturnValue([]) + useSlotItems.mockReturnValue([]) const { container } = render() expect(container.firstChild).toBeNull() }) it('wraps slot items in for actions slot', () => { const items = [{ element:
Item1
}] - getSlotItems.mockReturnValue(items) + useSlotItems.mockReturnValue(items) const { getByText } = render() expect(getByText('Item1')).toBeInTheDocument() @@ -62,22 +35,16 @@ describe('SlotRenderer', () => { { element:
Item1
}, { element: Item2 } ] - getSlotItems.mockReturnValue(items) + useSlotItems.mockReturnValue(items) const { getByText } = render() expect(getByText('Item1')).toBeInTheDocument() expect(getByText('Item2')).toBeInTheDocument() }) - it('calls getSlotItems with correct arguments including evaluateProp and the application mode filter', () => { - getSlotItems.mockReturnValue([]) + it('looks up the items for its own slot', () => { + useSlotItems.mockReturnValue([]) render() - expect(getSlotItems).toHaveBeenCalledWith({ - slot: 'sidebar', - appState: mockAppState, - appConfig: mockAppConfig, - evaluateProp: mockEvaluateProp, - isHiddenByApplicationMode: mockIsHiddenByApplicationMode - }) + expect(useSlotItems).toHaveBeenCalledWith('sidebar') }) }) diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index bd0c109a..45dba5cf 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -6,7 +6,9 @@ import { orderItems } from './orderItems.js' import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js' import { logger } from '../../services/logger.js' import { hidesNothing } from './applicationModes.js' +import { groupBySlot } from './slotHelpers.js' +// With no slot, matches buttons for every slot function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { const { breakpoint } = appState if (!buttonConfig) { @@ -39,7 +41,7 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { } } - if (bpConfig?.slot !== slot || !allowedSlots.button.includes(bpConfig.slot)) { + if ((slot !== undefined && bpConfig?.slot !== slot) || !allowedSlots.button.includes(bpConfig?.slot)) { return false } @@ -153,6 +155,7 @@ function buildUngroupedItems (members, ctx) { return members.map(([buttonId, config]) => ({ id: buttonId, type: 'button', + slot: config[ctx.breakpoint].slot, order: config[ctx.breakpoint]?.order ?? 0, element: })) @@ -183,6 +186,7 @@ function buildGroupItem (key, members, ctx) { return { id: buttonId, type: 'button', + slot: config[ctx.breakpoint].slot, order, element: } @@ -200,6 +204,7 @@ function buildGroupItem (key, members, ctx) { return { id: `group-${key}`, type: 'group', + slot: firstConfig[ctx.breakpoint].slot, order, element: (
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]]) + }) +})