@defra/interactive-map 0.0.9-alpha → 0.0.10-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS_README.md +39 -0
- package/docs/api.md +1 -1
- package/docs/architecture/architecture-diagrams.md +1 -3
- package/docs/architecture/diagrams-viewer.mdx +12 -0
- package/docs/govuk-prototype.md +23 -0
- package/docs/index.md +23 -0
- package/docusaurus.config.cjs +106 -0
- package/mise.toml +2 -0
- package/package.json +21 -4
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
- package/plugins/beta/draw-es/src/events.js +2 -2
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
- package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
- package/plugins/beta/draw-ml/src/events.js +18 -10
- package/plugins/search/src/Search.test.jsx +170 -0
- package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
- package/plugins/search/src/components/Form/Form.test.jsx +158 -0
- package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
- package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
- package/plugins/search/src/datasets.test.js +46 -0
- package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
- package/plugins/search/src/events/formHandlers.test.js +232 -0
- package/plugins/search/src/events/index.test.js +118 -0
- package/plugins/search/src/events/inputHandlers.test.js +104 -0
- package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
- package/plugins/search/src/index.test.js +47 -0
- package/plugins/search/src/reducer.test.js +80 -0
- package/plugins/search/src/utils/parseOsNamesResults.js +2 -1
- package/plugins/search/src/utils/parseOsNamesResults.test.js +140 -0
- package/plugins/search/src/utils/updateMap.test.js +52 -0
|
@@ -17,8 +17,8 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapPro
|
|
|
17
17
|
return
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// Emit draw:
|
|
21
|
-
eventBus.emit('draw:
|
|
20
|
+
// Emit draw:started
|
|
21
|
+
eventBus.emit('draw:started', { mode: 'draw_line' })
|
|
22
22
|
|
|
23
23
|
// Determin snapLayers from pluginConfig or runtime config
|
|
24
24
|
let snapLayers = null
|
|
@@ -17,8 +17,8 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map
|
|
|
17
17
|
return
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// Emit draw:
|
|
21
|
-
eventBus.emit('draw:
|
|
20
|
+
// Emit draw:started
|
|
21
|
+
eventBus.emit('draw:started', { mode: 'draw_polygon' })
|
|
22
22
|
|
|
23
23
|
// Determin snapLayers from pluginConfig or runtime config
|
|
24
24
|
let snapLayers = null
|
|
@@ -29,14 +29,14 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
29
29
|
// --- Button handlers
|
|
30
30
|
const handleDone = () => {
|
|
31
31
|
const mode = draw.getMode()
|
|
32
|
-
const features = draw.getAll().features
|
|
33
|
-
const feature = features?.[0]
|
|
34
32
|
|
|
35
33
|
disableSnap()
|
|
36
34
|
mapProvider.undoStack?.clear()
|
|
37
35
|
|
|
36
|
+
const features = draw.getAll().features
|
|
37
|
+
|
|
38
38
|
if (mode === 'edit_vertex') {
|
|
39
|
-
map.fire('draw.editfinish', { features: [
|
|
39
|
+
map.fire('draw.editfinish', { features: [draw.get(tempFeature.id)] })
|
|
40
40
|
return
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -44,7 +44,9 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
44
44
|
return
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const feature = features?.[0]
|
|
47
48
|
const geom = feature.geometry
|
|
49
|
+
|
|
48
50
|
if (geom.type === 'Polygon') {
|
|
49
51
|
const ring = geom.coordinates[0]
|
|
50
52
|
geom.coordinates[0] = [...ring.slice(0, -2), ring[0]]
|
|
@@ -56,6 +58,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const handleCancel = () => {
|
|
61
|
+
draw.trash()
|
|
59
62
|
if (tempFeature?.id) {
|
|
60
63
|
draw.delete(tempFeature.id)
|
|
61
64
|
}
|
|
@@ -65,7 +68,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
65
68
|
disableSnap()
|
|
66
69
|
draw.changeMode('disabled')
|
|
67
70
|
resetDrawModeAndFeature()
|
|
68
|
-
eventBus.emit('draw:
|
|
71
|
+
eventBus.emit('draw:cancelled', feature)
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
const handleUndo = () => {
|
|
@@ -122,14 +125,19 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
122
125
|
const newFeature = e.features[0]
|
|
123
126
|
resetDrawModeAndFeature()
|
|
124
127
|
setTimeout(() => draw.changeMode('disabled'), 0)
|
|
125
|
-
eventBus.emit(eventName,
|
|
128
|
+
eventBus.emit(eventName, newFeature)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Draw update handler
|
|
132
|
+
const handleUpdate = (e) => {
|
|
133
|
+
const tempFeature = e.features[0]
|
|
134
|
+
eventBus.emit('draw:updated', tempFeature)
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
const onCreate = handleDrawCompletion('draw:
|
|
129
|
-
const onEditFinish = handleDrawCompletion('draw:
|
|
137
|
+
const onCreate = handleDrawCompletion('draw:created')
|
|
138
|
+
const onEditFinish = handleDrawCompletion('draw:edited')
|
|
130
139
|
|
|
131
140
|
// --- Other map events
|
|
132
|
-
const onUpdate = (e) => eventBus.emit('draw:update', e)
|
|
133
141
|
const onVertexSelection = (e) => {
|
|
134
142
|
dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e })
|
|
135
143
|
eventBus.emit('draw:vertexselection', e)
|
|
@@ -151,7 +159,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
151
159
|
map.on('draw.cancel', handleCancel)
|
|
152
160
|
map.on('draw.create', onCreate)
|
|
153
161
|
map.on('draw.editfinish', onEditFinish)
|
|
154
|
-
map.on('draw.update',
|
|
162
|
+
map.on('draw.update', handleUpdate)
|
|
155
163
|
map.on('draw.vertexselection', onVertexSelection)
|
|
156
164
|
map.on('draw.vertexchange', onVertexChange)
|
|
157
165
|
map.on('draw.undochange', onUndoChange)
|
|
@@ -165,7 +173,7 @@ export function attachEvents({ pluginState, mapProvider, buttonConfig, eventBus
|
|
|
165
173
|
map.off('draw.cancel', handleCancel)
|
|
166
174
|
map.off('draw.create', onCreate)
|
|
167
175
|
map.off('draw.editfinish', onEditFinish)
|
|
168
|
-
map.off('draw.update',
|
|
176
|
+
map.off('draw.update', handleUpdate)
|
|
169
177
|
map.off('draw.vertexselection', onVertexSelection)
|
|
170
178
|
map.off('draw.vertexchange', onVertexChange)
|
|
171
179
|
map.off('draw.undochange', onUndoChange)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// /plugins/search/Search.test.jsx
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
3
|
+
import { Search } from './Search'
|
|
4
|
+
import { attachEvents } from './events/index.js'
|
|
5
|
+
import { createDatasets } from './datasets.js'
|
|
6
|
+
|
|
7
|
+
// Mock sub-components
|
|
8
|
+
jest.mock('./components/OpenButton/OpenButton', () => ({
|
|
9
|
+
OpenButton: ({ id, isExpanded, onClick }) => (
|
|
10
|
+
<button data-testid="open-button" onClick={onClick}>
|
|
11
|
+
OpenButton-{id}-{isExpanded ? 'expanded' : 'collapsed'}
|
|
12
|
+
</button>
|
|
13
|
+
),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.mock('./components/CloseButton/CloseButton', () => ({
|
|
17
|
+
CloseButton: ({ defaultExpanded, onClick }) => (
|
|
18
|
+
<button data-testid="close-button" onClick={onClick}>
|
|
19
|
+
CloseButton-{defaultExpanded ? 'defaultExpanded' : 'collapsed'}
|
|
20
|
+
</button>
|
|
21
|
+
),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
jest.mock('./components/Form/Form', () => ({
|
|
25
|
+
Form: ({ children }) => <div data-testid="form">{children}</div>,
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
// Mock external logic
|
|
29
|
+
jest.mock('./datasets.js', () => ({
|
|
30
|
+
createDatasets: jest.fn(() => ['dataset1', 'dataset2']),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
jest.mock('./events/index.js', () => ({
|
|
34
|
+
attachEvents: jest.fn(() => ({
|
|
35
|
+
handleOpenClick: jest.fn(),
|
|
36
|
+
handleCloseClick: jest.fn(),
|
|
37
|
+
handleOutside: jest.fn(),
|
|
38
|
+
})),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
describe('Search component', () => {
|
|
42
|
+
let props
|
|
43
|
+
let viewportRef
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
// Clear mock history before every test to prevent call count accumulation
|
|
47
|
+
jest.clearAllMocks()
|
|
48
|
+
|
|
49
|
+
viewportRef = { current: { style: { pointerEvents: 'auto' } } }
|
|
50
|
+
|
|
51
|
+
props = {
|
|
52
|
+
appConfig: { id: 'search' },
|
|
53
|
+
iconRegistry: { close: '<svg>close</svg>', search: '<svg>search</svg>' },
|
|
54
|
+
pluginState: {
|
|
55
|
+
dispatch: jest.fn(),
|
|
56
|
+
isExpanded: false,
|
|
57
|
+
areSuggestionsVisible: false,
|
|
58
|
+
suggestions: [],
|
|
59
|
+
},
|
|
60
|
+
pluginConfig: {
|
|
61
|
+
isExpanded: false, // This is destructured as defaultExpanded in the component
|
|
62
|
+
customDatasets: [],
|
|
63
|
+
osNamesURL: 'url',
|
|
64
|
+
},
|
|
65
|
+
appState: {
|
|
66
|
+
dispatch: jest.fn(),
|
|
67
|
+
interfaceType: 'keyboard',
|
|
68
|
+
layoutRefs: { viewportRef },
|
|
69
|
+
},
|
|
70
|
+
mapState: { markers: {} },
|
|
71
|
+
services: {},
|
|
72
|
+
mapProvider: { crs: 'EPSG:3857' },
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
cleanup()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders OpenButton when defaultExpanded/isExpanded is false', () => {
|
|
81
|
+
render(<Search {...props} />)
|
|
82
|
+
expect(screen.getByTestId('open-button')).toBeInTheDocument()
|
|
83
|
+
expect(screen.getByTestId('form')).toBeInTheDocument()
|
|
84
|
+
expect(screen.getByTestId('close-button')).toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not render OpenButton when defaultExpanded/isExpanded is true', () => {
|
|
88
|
+
props.pluginConfig.isExpanded = true
|
|
89
|
+
render(<Search {...props} />)
|
|
90
|
+
expect(screen.queryByTestId('open-button')).not.toBeInTheDocument()
|
|
91
|
+
expect(screen.getByTestId('close-button')).toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('calls attachEvents once and persists it across re-renders (useRef coverage)', () => {
|
|
95
|
+
const { rerender } = render(<Search {...props} />)
|
|
96
|
+
|
|
97
|
+
expect(createDatasets).toHaveBeenCalledWith({
|
|
98
|
+
customDatasets: [],
|
|
99
|
+
osNamesURL: 'url',
|
|
100
|
+
crs: 'EPSG:3857',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Trigger a re-render with a prop change
|
|
104
|
+
rerender(<Search {...props} appState={{ ...props.appState, interfaceType: 'touch' }} />)
|
|
105
|
+
|
|
106
|
+
// attachEvents should still only have been called once due to the useRef check
|
|
107
|
+
expect(attachEvents).toHaveBeenCalledTimes(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('OpenButton click triggers handleOpenClick', () => {
|
|
111
|
+
render(<Search {...props} />)
|
|
112
|
+
const events = attachEvents.mock.results[0].value
|
|
113
|
+
fireEvent.click(screen.getByTestId('open-button'))
|
|
114
|
+
expect(events.handleOpenClick).toHaveBeenCalledTimes(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('CloseButton click triggers handleCloseClick', () => {
|
|
118
|
+
render(<Search {...props} />)
|
|
119
|
+
const events = attachEvents.mock.results[0].value
|
|
120
|
+
fireEvent.click(screen.getByTestId('close-button'))
|
|
121
|
+
expect(events.handleCloseClick).toHaveBeenCalledTimes(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('focuses input when pluginState.isExpanded is true', () => {
|
|
125
|
+
// We have to mock the implementation because inputRef is internal
|
|
126
|
+
// This is a bit of a workaround for testing internal refs
|
|
127
|
+
props.pluginState.isExpanded = true
|
|
128
|
+
render(<Search {...props} />)
|
|
129
|
+
expect(screen.getByTestId('form')).toBeInTheDocument()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('searchOpen logic (Line 46 coverage)', () => {
|
|
133
|
+
it('is true when isExpanded is true', () => {
|
|
134
|
+
props.pluginState.isExpanded = true
|
|
135
|
+
render(<Search {...props} />)
|
|
136
|
+
// If searchOpen is true, pointerEvents becomes 'none'
|
|
137
|
+
expect(viewportRef.current.style.pointerEvents).toBe('none')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('is true when defaultExpanded is true and suggestions exist', () => {
|
|
141
|
+
props.pluginConfig.isExpanded = true // defaultExpanded
|
|
142
|
+
props.pluginState.isExpanded = false
|
|
143
|
+
props.pluginState.areSuggestionsVisible = true
|
|
144
|
+
props.pluginState.suggestions = ['item 1']
|
|
145
|
+
|
|
146
|
+
render(<Search {...props} />)
|
|
147
|
+
expect(viewportRef.current.style.pointerEvents).toBe('none')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('is false when defaultExpanded is true but suggestions are empty', () => {
|
|
151
|
+
props.pluginConfig.isExpanded = true
|
|
152
|
+
props.pluginState.isExpanded = false
|
|
153
|
+
props.pluginState.areSuggestionsVisible = true
|
|
154
|
+
props.pluginState.suggestions = []
|
|
155
|
+
|
|
156
|
+
render(<Search {...props} />)
|
|
157
|
+
// Should remain 'auto' (or not 'none')
|
|
158
|
+
expect(viewportRef.current.style.pointerEvents).toBe('auto')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('cleans up effects and restores pointerEvents on unmount', () => {
|
|
163
|
+
props.pluginState.isExpanded = true
|
|
164
|
+
const { unmount } = render(<Search {...props} />)
|
|
165
|
+
expect(viewportRef.current.style.pointerEvents).toBe('none')
|
|
166
|
+
|
|
167
|
+
unmount()
|
|
168
|
+
expect(viewportRef.current.style.pointerEvents).toBe('auto')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/plugins/search/components/CloseButton/CloseButton.test.jsx
|
|
2
|
+
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { CloseButton } from './CloseButton'
|
|
5
|
+
|
|
6
|
+
describe('CloseButton', () => {
|
|
7
|
+
it('renders the button and calls onClick', () => {
|
|
8
|
+
const onClick = jest.fn()
|
|
9
|
+
|
|
10
|
+
render(
|
|
11
|
+
<CloseButton
|
|
12
|
+
defaultExpanded={false}
|
|
13
|
+
onClick={onClick}
|
|
14
|
+
closeIcon={null}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
fireEvent.click(
|
|
19
|
+
screen.getByRole('button', { name: /close search/i })
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('applies display:none when defaultExpanded is true', () => {
|
|
26
|
+
render(
|
|
27
|
+
<CloseButton
|
|
28
|
+
defaultExpanded
|
|
29
|
+
onClick={jest.fn()}
|
|
30
|
+
closeIcon={null}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const button = screen.getByLabelText('Close search', {
|
|
35
|
+
selector: 'button',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(button).toHaveStyle({ display: 'none' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders the close icon SVG when closeIcon is provided', () => {
|
|
42
|
+
const svgContent = '<path d="M1 1L23 23" />'
|
|
43
|
+
const { container } = render(
|
|
44
|
+
<CloseButton
|
|
45
|
+
defaultExpanded={false}
|
|
46
|
+
onClick={jest.fn()}
|
|
47
|
+
closeIcon={svgContent}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const svg = container.querySelector('svg')
|
|
52
|
+
expect(svg).toBeTruthy()
|
|
53
|
+
expect(svg.innerHTML).toContain('M1 1L23 23')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not render an svg when closeIcon is not provided', () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<CloseButton
|
|
59
|
+
defaultExpanded={false}
|
|
60
|
+
onClick={jest.fn()}
|
|
61
|
+
closeIcon={null}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/plugins/search/Form.test.jsx
|
|
2
|
+
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { Form } from './Form'
|
|
5
|
+
|
|
6
|
+
// Mock Suggestions to simulate user interactions
|
|
7
|
+
jest.mock('../Suggestions/Suggestions', () => ({
|
|
8
|
+
Suggestions: ({ handleSuggestionClick, id }) => (
|
|
9
|
+
<button
|
|
10
|
+
data-testid="suggestion"
|
|
11
|
+
onClick={() => handleSuggestionClick('clicked-suggestion')}
|
|
12
|
+
id={`${id}-search-suggestions`}
|
|
13
|
+
>
|
|
14
|
+
Suggestion
|
|
15
|
+
</button>
|
|
16
|
+
),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
describe('Form', () => {
|
|
20
|
+
const baseProps = {
|
|
21
|
+
id: 'test',
|
|
22
|
+
inputRef: { current: null },
|
|
23
|
+
pluginState: {
|
|
24
|
+
isExpanded: false,
|
|
25
|
+
value: '',
|
|
26
|
+
suggestionsVisible: false,
|
|
27
|
+
selectedIndex: -1,
|
|
28
|
+
hasKeyboardFocusWithin: false,
|
|
29
|
+
},
|
|
30
|
+
pluginConfig: {
|
|
31
|
+
isExpanded: false,
|
|
32
|
+
width: '400px',
|
|
33
|
+
},
|
|
34
|
+
appState: {
|
|
35
|
+
breakpoint: 'desktop',
|
|
36
|
+
interfaceType: 'keyboard',
|
|
37
|
+
},
|
|
38
|
+
events: {
|
|
39
|
+
handleSubmit: jest.fn(),
|
|
40
|
+
handleInputClick: jest.fn(),
|
|
41
|
+
handleInputChange: jest.fn(),
|
|
42
|
+
handleInputFocus: jest.fn(),
|
|
43
|
+
handleInputBlur: jest.fn(),
|
|
44
|
+
handleInputKeyDown: jest.fn(),
|
|
45
|
+
handleSuggestionClick: jest.fn(),
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders the form element with correct role, ID, and base classes', () => {
|
|
54
|
+
render(<Form {...baseProps} />)
|
|
55
|
+
const form = screen.getByRole('search')
|
|
56
|
+
expect(form).toHaveAttribute('id', 'test-search-form')
|
|
57
|
+
expect(form.className).toContain('im-c-search-form')
|
|
58
|
+
expect(form.className).toContain('im-c-panel')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('applies expanded styles and width when the pluginConfig is expanded', () => {
|
|
62
|
+
render(
|
|
63
|
+
<Form
|
|
64
|
+
{...baseProps}
|
|
65
|
+
pluginConfig={{ ...baseProps.pluginConfig, isExpanded: true }}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
const form = screen.getByRole('search')
|
|
69
|
+
expect(form).toHaveStyle({ display: 'flex', width: '400px' })
|
|
70
|
+
expect(form.className).toContain('im-c-search-form--default-expanded')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('calls handleSubmit with the event, appState, and pluginState when form is submitted', () => {
|
|
74
|
+
render(<Form {...baseProps} />)
|
|
75
|
+
const form = screen.getByRole('search')
|
|
76
|
+
fireEvent.submit(form)
|
|
77
|
+
expect(baseProps.events.handleSubmit).toHaveBeenCalledTimes(1)
|
|
78
|
+
expect(baseProps.events.handleSubmit.mock.calls[0][1]).toBe(baseProps.appState)
|
|
79
|
+
expect(baseProps.events.handleSubmit.mock.calls[0][2]).toBe(baseProps.pluginState)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders the search input with correct ARIA attributes when suggestions are visible and an item is selected', () => {
|
|
83
|
+
render(
|
|
84
|
+
<Form
|
|
85
|
+
{...baseProps}
|
|
86
|
+
pluginState={{
|
|
87
|
+
...baseProps.pluginState,
|
|
88
|
+
suggestionsVisible: true,
|
|
89
|
+
selectedIndex: 2,
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
const input = screen.getByRole('combobox')
|
|
94
|
+
expect(input).toHaveAttribute('aria-expanded', 'true')
|
|
95
|
+
expect(input).toHaveAttribute('aria-activedescendant', 'test-search-suggestion-2')
|
|
96
|
+
expect(input).toHaveAttribute('aria-controls', 'test-search-suggestions')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('adds keyboard focus class when the input container has focus within', () => {
|
|
100
|
+
render(
|
|
101
|
+
<Form
|
|
102
|
+
{...baseProps}
|
|
103
|
+
pluginState={{ ...baseProps.pluginState, hasKeyboardFocusWithin: true }}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
const container = screen.getByRole('search').querySelector('.im-c-search__input-container')
|
|
107
|
+
expect(container.className).toContain('im-c-search__input-container--keyboard-focus-within')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not set aria-describedby when the search input has a value', () => {
|
|
111
|
+
render(
|
|
112
|
+
<Form
|
|
113
|
+
{...baseProps}
|
|
114
|
+
pluginState={{ ...baseProps.pluginState, value: 'something' }}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
const input = screen.getByRole('combobox')
|
|
118
|
+
expect(input).not.toHaveAttribute('aria-describedby')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('wires input event handlers correctly (click, change, focus, blur, keydown)', () => {
|
|
122
|
+
render(<Form {...baseProps} />)
|
|
123
|
+
const input = screen.getByRole('combobox')
|
|
124
|
+
fireEvent.click(input)
|
|
125
|
+
fireEvent.change(input, { target: { value: 'abc' } })
|
|
126
|
+
fireEvent.focus(input)
|
|
127
|
+
fireEvent.blur(input)
|
|
128
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
129
|
+
expect(baseProps.events.handleInputClick).toHaveBeenCalled()
|
|
130
|
+
expect(baseProps.events.handleInputChange).toHaveBeenCalled()
|
|
131
|
+
expect(baseProps.events.handleInputFocus).toHaveBeenCalledWith('keyboard')
|
|
132
|
+
expect(baseProps.events.handleInputBlur).toHaveBeenCalledWith('keyboard')
|
|
133
|
+
expect(baseProps.events.handleInputKeyDown).toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('renders children passed into the input container (e.g., CloseButton)', () => {
|
|
137
|
+
render(
|
|
138
|
+
<Form {...baseProps}>
|
|
139
|
+
<div data-testid="close-button" />
|
|
140
|
+
</Form>
|
|
141
|
+
)
|
|
142
|
+
expect(screen.getByTestId('close-button')).toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('renders the Suggestions component', () => {
|
|
146
|
+
render(<Form {...baseProps} />)
|
|
147
|
+
expect(screen.getByTestId('suggestion')).toBeInTheDocument()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('calls events.handleSuggestionClick when a suggestion is clicked', () => {
|
|
151
|
+
render(<Form {...baseProps} />)
|
|
152
|
+
fireEvent.click(screen.getByTestId('suggestion'))
|
|
153
|
+
expect(baseProps.events.handleSuggestionClick).toHaveBeenCalledWith(
|
|
154
|
+
'clicked-suggestion',
|
|
155
|
+
baseProps.appState
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/plugins/search/OpenButton.test.jsx
|
|
2
|
+
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { OpenButton } from './OpenButton'
|
|
5
|
+
|
|
6
|
+
describe('OpenButton', () => {
|
|
7
|
+
const baseProps = {
|
|
8
|
+
id: 'test',
|
|
9
|
+
isExpanded: false,
|
|
10
|
+
onClick: jest.fn(),
|
|
11
|
+
buttonRef: { current: null },
|
|
12
|
+
searchIcon: null,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.clearAllMocks()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('renders the button with correct ARIA attributes and calls onClick', () => {
|
|
20
|
+
render(<OpenButton {...baseProps} />)
|
|
21
|
+
const button = screen.getByRole('button', { name: /open search/i })
|
|
22
|
+
expect(button).toHaveAttribute('aria-controls', 'test-search-form')
|
|
23
|
+
fireEvent.click(button)
|
|
24
|
+
expect(baseProps.onClick).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('applies display:none when isExpanded is true', () => {
|
|
28
|
+
render(<OpenButton {...baseProps} isExpanded />)
|
|
29
|
+
const button = screen.getByLabelText('Open search')
|
|
30
|
+
expect(button).toHaveStyle({ display: 'none' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders the search icon SVG when searchIcon is provided', () => {
|
|
34
|
+
const svgContent = '<path d="M1 1L23 23" />'
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<OpenButton {...baseProps} searchIcon={svgContent} />
|
|
37
|
+
)
|
|
38
|
+
const svg = container.querySelector('svg')
|
|
39
|
+
expect(svg).toBeTruthy()
|
|
40
|
+
expect(svg.innerHTML).toContain('M1 1L23 23')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not render an SVG when searchIcon is not provided', () => {
|
|
44
|
+
const { container } = render(<OpenButton {...baseProps} />)
|
|
45
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/plugins/search/Suggestions.test.jsx
|
|
2
|
+
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { Suggestions } from './Suggestions'
|
|
5
|
+
|
|
6
|
+
describe('Suggestions', () => {
|
|
7
|
+
const baseProps = {
|
|
8
|
+
id: 'test',
|
|
9
|
+
pluginState: {
|
|
10
|
+
areSuggestionsVisible: true,
|
|
11
|
+
suggestions: [
|
|
12
|
+
{ id: '1', marked: 'First' },
|
|
13
|
+
{ id: '2', marked: 'Second' },
|
|
14
|
+
],
|
|
15
|
+
selectedIndex: 0,
|
|
16
|
+
},
|
|
17
|
+
handleSuggestionClick: jest.fn(),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders the listbox with correct attributes', () => {
|
|
25
|
+
render(<Suggestions {...baseProps} />)
|
|
26
|
+
const listbox = screen.getByRole('listbox')
|
|
27
|
+
expect(listbox).toHaveAttribute('id', 'test-search-suggestions')
|
|
28
|
+
expect(listbox).toHaveAttribute('aria-labelledby', 'test-search')
|
|
29
|
+
expect(listbox.className).toContain('im-c-search-suggestions')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('hides the listbox when suggestions are not visible', () => {
|
|
33
|
+
render(
|
|
34
|
+
<Suggestions
|
|
35
|
+
{...baseProps}
|
|
36
|
+
pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: false }}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
const listbox = screen.getByRole('listbox', { hidden: true })
|
|
40
|
+
expect(listbox).toHaveStyle({ display: 'none' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('hides the listbox when suggestions array is empty', () => {
|
|
44
|
+
render(
|
|
45
|
+
<Suggestions
|
|
46
|
+
{...baseProps}
|
|
47
|
+
pluginState={{ ...baseProps.pluginState, suggestions: [] }}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
const listbox = screen.getByRole('listbox', { hidden: true })
|
|
51
|
+
expect(listbox).toHaveStyle({ display: 'none' })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders all suggestion items with correct ARIA attributes', () => {
|
|
55
|
+
render(<Suggestions {...baseProps} />)
|
|
56
|
+
const items = screen.getAllByRole('option')
|
|
57
|
+
expect(items).toHaveLength(2)
|
|
58
|
+
|
|
59
|
+
items.forEach((item, i) => {
|
|
60
|
+
expect(item).toHaveAttribute('id', `test-search-suggestion-${i}`)
|
|
61
|
+
expect(item).toHaveClass('im-c-search-suggestions__item')
|
|
62
|
+
expect(item).toHaveAttribute('aria-setsize', '2')
|
|
63
|
+
expect(item).toHaveAttribute('aria-posinset', `${i + 1}`)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// First item should be selected
|
|
67
|
+
expect(items[0]).toHaveAttribute('aria-selected', 'true')
|
|
68
|
+
expect(items[1]).toHaveAttribute('aria-selected', 'false')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('calls handleSuggestionClick when a suggestion is clicked', () => {
|
|
72
|
+
render(<Suggestions {...baseProps} />)
|
|
73
|
+
const items = screen.getAllByRole('option')
|
|
74
|
+
fireEvent.click(items[1])
|
|
75
|
+
expect(baseProps.handleSuggestionClick).toHaveBeenCalledWith(
|
|
76
|
+
baseProps.pluginState.suggestions[1]
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/plugins/search/datasets.test.js
|
|
2
|
+
|
|
3
|
+
import { createDatasets } from './datasets'
|
|
4
|
+
import * as parseModule from './utils/parseOsNamesResults.js'
|
|
5
|
+
|
|
6
|
+
describe('createDatasets', () => {
|
|
7
|
+
const osNamesURL = 'https://example.com/osnames'
|
|
8
|
+
const crs = 'EPSG:4326'
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.clearAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns default dataset with correct properties', () => {
|
|
15
|
+
const datasets = createDatasets({ osNamesURL, crs })
|
|
16
|
+
expect(datasets).toHaveLength(1)
|
|
17
|
+
const ds = datasets[0]
|
|
18
|
+
|
|
19
|
+
expect(ds.name).toBe('osNames')
|
|
20
|
+
expect(ds.urlTemplate).toBe(osNamesURL)
|
|
21
|
+
expect(ds.includeRegex).toEqual(/^[a-zA-Z0-9\s,-]+$/)
|
|
22
|
+
expect(ds.excludeRegex).toEqual(
|
|
23
|
+
/^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i
|
|
24
|
+
)
|
|
25
|
+
expect(typeof ds.parseResults).toBe('function')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('merge custom datasets with default dataset', () => {
|
|
29
|
+
const custom = [{ name: 'custom1', urlTemplate: 'https://custom.com' }]
|
|
30
|
+
const datasets = createDatasets({ osNamesURL, crs, customDatasets: custom })
|
|
31
|
+
expect(datasets).toHaveLength(2)
|
|
32
|
+
expect(datasets[1]).toEqual(custom[0])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('parseResults calls parseOsNamesResults with correct arguments', () => {
|
|
36
|
+
const parseMock = jest.spyOn(parseModule, 'parseOsNamesResults').mockReturnValue('parsed')
|
|
37
|
+
const datasets = createDatasets({ osNamesURL, crs })
|
|
38
|
+
const json = { some: 'data' }
|
|
39
|
+
const query = 'test query'
|
|
40
|
+
|
|
41
|
+
const result = datasets[0].parseResults(json, query)
|
|
42
|
+
expect(parseMock).toHaveBeenCalledWith(json, query, crs)
|
|
43
|
+
expect(result).toBe('parsed')
|
|
44
|
+
parseMock.mockRestore()
|
|
45
|
+
})
|
|
46
|
+
})
|