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
5 changes: 4 additions & 1 deletion src/App/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -22,7 +23,9 @@ export const App = (props) => {
<ServiceProvider eventBus={props.eventBus} pluginRegistry={props.pluginRegistry}>
<PluginProvider>
<PluginInits />
<Layout />
<SlotItemsProvider>
<Layout />
</SlotItemsProvider>
</PluginProvider>
</ServiceProvider>
</MapProvider>
Expand Down
6 changes: 6 additions & 0 deletions src/App/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ jest.mock('./renderer/PluginInits.jsx', () => ({
PluginInits: jest.fn(() => <div data-testid='plugin-inits' />)
}))

jest.mock('./renderer/SlotItemsProvider.jsx', () => ({
SlotItemsProvider: jest.fn(({ children }) => (
<div data-testid='slot-items-provider'>{children}</div>
))
}))

jest.mock('./layout/Layout.jsx', () => ({
Layout: jest.fn(() => <div data-testid='layout' />)
}))
Expand Down
33 changes: 33 additions & 0 deletions src/App/renderer/SlotItemsProvider.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<SlotItemsContext.Provider value={itemsBySlot}>
{children}
</SlotItemsContext.Provider>
)
}

const noItems = []

export const useSlotItems = (slot) => useContext(SlotItemsContext).get(slot) ?? noItems
59 changes: 59 additions & 0 deletions src/App/renderer/SlotItemsProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div data-testid={slot}>{useSlotItems(slot).map(item => item.id).join(',')}</div>

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(
<SlotItemsProvider>
<SlotIds slot='header' />
<SlotIds slot='banner' />
</SlotItemsProvider>
)
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(
<SlotItemsProvider>
<SlotIds slot='header' />
<SlotIds slot='banner' />
</SlotItemsProvider>
)
expect(getByTestId('header')).toHaveTextContent('a,b')
expect(getByTestId('banner')).toBeEmptyDOMElement()
})

it('gives no items outside a provider', () => {
const { getByTestId } = render(<SlotIds slot='header' />)
expect(getByTestId('header')).toBeEmptyDOMElement()
})
})
17 changes: 3 additions & 14 deletions src/App/renderer/SlotRenderer.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
55 changes: 11 additions & 44 deletions src/App/renderer/SlotRenderer.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<SlotRenderer slot='header' />)
expect(container.firstChild).toBeNull()
})

it('wraps slot items in <Actions> for actions slot', () => {
const items = [{ element: <div key='1'>Item1</div> }]
getSlotItems.mockReturnValue(items)
useSlotItems.mockReturnValue(items)

const { getByText } = render(<SlotRenderer slot='actions' />)
expect(getByText('Item1')).toBeInTheDocument()
Expand All @@ -62,22 +35,16 @@ describe('SlotRenderer', () => {
{ element: <div key='1'>Item1</div> },
{ element: <span key='2'>Item2</span> }
]
getSlotItems.mockReturnValue(items)
useSlotItems.mockReturnValue(items)

const { getByText } = render(<SlotRenderer slot='header' />)
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(<SlotRenderer slot='sidebar' />)
expect(getSlotItems).toHaveBeenCalledWith({
slot: 'sidebar',
appState: mockAppState,
appConfig: mockAppConfig,
evaluateProp: mockEvaluateProp,
isHiddenByApplicationMode: mockIsHiddenByApplicationMode
})
expect(useSlotItems).toHaveBeenCalledWith('sidebar')
})
})
33 changes: 22 additions & 11 deletions src/App/renderer/mapButtons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, isHidden: isButtonHidden(buttonId, config, ctx), ...ctx })} />
}))
Expand Down Expand Up @@ -183,6 +186,7 @@ function buildGroupItem (key, members, ctx) {
return {
id: buttonId,
type: 'button',
slot: config[ctx.breakpoint].slot,
order,
element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, isHidden: isButtonHidden(buttonId, config, ctx), ...ctx })} />
}
Expand All @@ -200,6 +204,7 @@ function buildGroupItem (key, members, ctx) {
return {
id: `group-${key}`,
type: 'group',
slot: firstConfig[ctx.breakpoint].slot,
order,
element: (
<div // NOSONAR - div with role="group" is correct for a button group
Expand All @@ -216,19 +221,12 @@ function buildGroupItem (key, members, ctx) {
}
}

function mapButtons ({ slot, appState, appConfig, evaluateProp, isHiddenByApplicationMode = hidesNothing }) {
const { buttonConfig, breakpoint } = appState

const raw = getMatchingButtons({ appState, appConfig, buttonConfig, slot, evaluateProp })
const matching = applySlotExclusivity(raw, appState)

if (!matching.length) {
return []
}
// Builds one slot's items from the buttons matched for it
function buildSlotItems (raw, ctx) {
const matching = applySlotExclusivity(raw, ctx.appState)

// Partition into named groups (keyed by kebab-cased group.label) plus one ungrouped bucket
const buckets = groupByKey({ items: matching, keyFn: ([, config]) => config.group?.label })
const ctx = { breakpoint, appState, appConfig, evaluateProp, isHiddenByApplicationMode }

const result = []
for (const [key, members] of buckets) {
Expand All @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/App/renderer/mapButtons.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 4 additions & 3 deletions src/App/renderer/mapControls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading