@defra/interactive-map 0.0.8-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/dist/esm/im-core.js +1 -0
- package/dist/esm/im-shell.js +1 -0
- package/dist/esm/index.js +1 -2
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/button-definition.md +104 -3
- package/docs/api.md +22 -2
- package/docs/architecture/architecture-diagrams.md +1 -3
- package/docs/architecture/diagrams-viewer.mdx +12 -0
- package/docs/getting-started.md +78 -8
- 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 +51 -27
- package/plugins/beta/datasets/dist/css/index.css +50 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -0
- package/plugins/beta/datasets/dist/esm/index.js +1 -2
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -0
- package/plugins/beta/draw-es/dist/esm/index.js +1 -2
- 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 -0
- package/plugins/beta/draw-ml/dist/esm/index.js +1 -2
- 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/beta/frame/dist/css/index.css +11 -1
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -0
- package/plugins/beta/frame/dist/esm/index.js +1 -2
- package/plugins/beta/map-styles/dist/css/index.css +79 -1
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -0
- package/plugins/beta/map-styles/dist/esm/index.js +1 -2
- package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -0
- package/plugins/beta/scale-bar/dist/esm/index.js +1 -2
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -0
- package/plugins/beta/use-location/dist/esm/index.js +1 -2
- package/plugins/beta/use-location/dist/umd/index.js +1 -1
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -0
- package/plugins/interact/dist/esm/index.js +1 -2
- package/plugins/search/dist/esm/im-search-plugin.js +1 -0
- package/plugins/search/dist/esm/index.js +1 -2
- 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.module.scss +1 -1
- 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/search.scss +1 -1
- 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
- package/providers/beta/esri/dist/css/index.css +30 -1
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -0
- package/providers/beta/esri/dist/esm/index.js +1 -2
- package/providers/beta/open-names/dist/esm/im-reverse-geocode.js +1 -0
- package/providers/beta/open-names/dist/esm/index.js +1 -2
- package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +61 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -0
- package/providers/maplibre/dist/esm/index.js +1 -2
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/appEvents.test.js +44 -0
- package/providers/maplibre/src/index.test.js +60 -0
- package/providers/maplibre/src/mapEvents.test.js +115 -0
- package/providers/maplibre/src/maplibreProvider.test.js +205 -0
- package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +31 -0
- package/providers/maplibre/src/utils/detectWebgl.test.js +63 -0
- package/providers/maplibre/src/utils/highlightFeatures.test.js +126 -0
- package/providers/maplibre/src/utils/labels.js +1 -3
- package/providers/maplibre/src/utils/labels.test.js +231 -0
- package/providers/maplibre/src/utils/maplibreFixes.test.js +66 -0
- package/providers/maplibre/src/utils/queryFeatures.test.js +60 -0
- package/providers/maplibre/src/utils/spatial.js +5 -4
- package/providers/maplibre/src/utils/spatial.test.js +96 -0
- package/rollup.esm.mjs +288 -0
- package/src/App/store/appActionsMap.js +1 -1
- package/src/InteractiveMap/InteractiveMap.js +3 -2
- package/webpack.dev.mjs +9 -1
- package/webpack.prod.mjs +8 -1
- package/webpack.umd.mjs +1 -2
- package/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/datasets/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/draw-es/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/draw-ml/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/frame/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/map-styles/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/scale-bar/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/beta/use-location/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/interact/dist/esm/index.js.LICENSE.txt +0 -1
- package/plugins/search/dist/esm/index.js.LICENSE.txt +0 -1
- package/providers/beta/esri/dist/esm/index.js.LICENSE.txt +0 -1
- package/providers/beta/open-names/dist/esm/index.js.LICENSE.txt +0 -1
- package/providers/maplibre/dist/esm/index.js.LICENSE.txt +0 -6
- package/webpack.esm.mjs +0 -154
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { attachEvents } from './index.js'
|
|
5
|
+
import { fetchSuggestions } from './fetchSuggestions.js'
|
|
6
|
+
import { createFormHandlers } from './formHandlers.js'
|
|
7
|
+
import { createInputHandlers } from './inputHandlers.js'
|
|
8
|
+
import { createSuggestionHandlers } from './suggestionHandlers.js'
|
|
9
|
+
import { debounce } from '../../../../src/utils/debounce.js'
|
|
10
|
+
|
|
11
|
+
jest.mock('./fetchSuggestions.js')
|
|
12
|
+
jest.mock('./formHandlers.js')
|
|
13
|
+
jest.mock('./inputHandlers.js')
|
|
14
|
+
jest.mock('./suggestionHandlers.js')
|
|
15
|
+
jest.mock('../../../../src/utils/debounce.js')
|
|
16
|
+
|
|
17
|
+
describe('attachEvents', () => {
|
|
18
|
+
let dispatch
|
|
19
|
+
let services
|
|
20
|
+
let searchContainerRef
|
|
21
|
+
let args
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dispatch = jest.fn()
|
|
25
|
+
|
|
26
|
+
services = {
|
|
27
|
+
eventBus: { emit: jest.fn() }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
searchContainerRef = {
|
|
31
|
+
current: {
|
|
32
|
+
contains: jest.fn()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Mock debounce to return the function immediately
|
|
37
|
+
debounce.mockImplementation(fn => fn)
|
|
38
|
+
|
|
39
|
+
createFormHandlers.mockReturnValue({ formHandler: jest.fn() })
|
|
40
|
+
createInputHandlers.mockReturnValue({ inputHandler: jest.fn() })
|
|
41
|
+
createSuggestionHandlers.mockReturnValue({ suggestionHandler: jest.fn() })
|
|
42
|
+
|
|
43
|
+
args = {
|
|
44
|
+
dispatch,
|
|
45
|
+
services,
|
|
46
|
+
searchContainerRef,
|
|
47
|
+
datasets: [],
|
|
48
|
+
transformRequest: jest.fn()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
jest.clearAllMocks()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('composes handlers from all handler factories', () => {
|
|
55
|
+
const handlers = attachEvents(args)
|
|
56
|
+
|
|
57
|
+
expect(createFormHandlers).toHaveBeenCalledWith(args)
|
|
58
|
+
expect(createSuggestionHandlers).toHaveBeenCalledWith(args)
|
|
59
|
+
|
|
60
|
+
expect(createInputHandlers).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
debouncedFetchSuggestions: expect.any(Function)
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(handlers.formHandler).toBeDefined()
|
|
67
|
+
expect(handlers.inputHandler).toBeDefined()
|
|
68
|
+
expect(handlers.suggestionHandler).toBeDefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('debouncedFetchSuggestions calls fetchSuggestions with correct args', () => {
|
|
72
|
+
const handlers = attachEvents(args)
|
|
73
|
+
|
|
74
|
+
// grab the debounced function passed to input handlers
|
|
75
|
+
const { debouncedFetchSuggestions } =
|
|
76
|
+
createInputHandlers.mock.calls[0][0]
|
|
77
|
+
|
|
78
|
+
debouncedFetchSuggestions('query')
|
|
79
|
+
|
|
80
|
+
expect(fetchSuggestions).toHaveBeenCalledWith(
|
|
81
|
+
'query',
|
|
82
|
+
args.datasets,
|
|
83
|
+
dispatch,
|
|
84
|
+
args.transformRequest
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(debounce).toHaveBeenCalledWith(
|
|
88
|
+
expect.any(Function),
|
|
89
|
+
350
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('handleOutside does nothing when click is inside container', () => {
|
|
94
|
+
searchContainerRef.current.contains.mockReturnValue(true)
|
|
95
|
+
|
|
96
|
+
const handlers = attachEvents(args)
|
|
97
|
+
|
|
98
|
+
handlers.handleOutside({ target: 'inside' })
|
|
99
|
+
|
|
100
|
+
expect(dispatch).not.toHaveBeenCalled()
|
|
101
|
+
expect(services.eventBus.emit).not.toHaveBeenCalled()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('handleOutside collapses search when click is outside container', () => {
|
|
105
|
+
searchContainerRef.current.contains.mockReturnValue(false)
|
|
106
|
+
|
|
107
|
+
const handlers = attachEvents(args)
|
|
108
|
+
|
|
109
|
+
handlers.handleOutside({ target: 'outside' })
|
|
110
|
+
|
|
111
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
112
|
+
type: 'TOGGLE_EXPANDED',
|
|
113
|
+
payload: false
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { createInputHandlers } from './inputHandlers.js'
|
|
5
|
+
import { DEFAULTS } from '../defaults.js'
|
|
6
|
+
|
|
7
|
+
describe('createInputHandlers', () => {
|
|
8
|
+
let dispatch
|
|
9
|
+
let debouncedFetchSuggestions
|
|
10
|
+
let handlers
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
dispatch = jest.fn()
|
|
14
|
+
|
|
15
|
+
debouncedFetchSuggestions = jest.fn()
|
|
16
|
+
debouncedFetchSuggestions.cancel = jest.fn()
|
|
17
|
+
|
|
18
|
+
handlers = createInputHandlers({
|
|
19
|
+
dispatch,
|
|
20
|
+
debouncedFetchSuggestions
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('handleInputClick shows suggestions', () => {
|
|
25
|
+
handlers.handleInputClick()
|
|
26
|
+
|
|
27
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
28
|
+
type: 'SHOW_SUGGESTIONS'
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('handleInputFocus sets keyboard focus when interface is keyboard', () => {
|
|
33
|
+
handlers.handleInputFocus('keyboard')
|
|
34
|
+
|
|
35
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
36
|
+
type: 'SET_KEYBOARD_FOCUS_WITHIN',
|
|
37
|
+
payload: true
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('handleInputFocus clears keyboard focus when interface is not keyboard', () => {
|
|
42
|
+
handlers.handleInputFocus('mouse')
|
|
43
|
+
|
|
44
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
45
|
+
type: 'SET_KEYBOARD_FOCUS_WITHIN',
|
|
46
|
+
payload: false
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('handleInputBlur dispatches blur event', () => {
|
|
51
|
+
handlers.handleInputBlur('keyboard')
|
|
52
|
+
|
|
53
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
54
|
+
type: 'INPUT_BLUR',
|
|
55
|
+
payload: 'keyboard'
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('handleInputChange below min length cancels debounce and hides suggestions', () => {
|
|
60
|
+
const value = 'a'.repeat(DEFAULTS.minSearchLength - 1)
|
|
61
|
+
|
|
62
|
+
handlers.handleInputChange({
|
|
63
|
+
target: { value }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
67
|
+
type: 'SET_VALUE',
|
|
68
|
+
payload: value
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expect(debouncedFetchSuggestions.cancel).toHaveBeenCalled()
|
|
72
|
+
|
|
73
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
74
|
+
type: 'UPDATE_SUGGESTIONS',
|
|
75
|
+
payload: []
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
79
|
+
type: 'HIDE_SUGGESTIONS'
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(debouncedFetchSuggestions).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('handleInputChange at or above min length shows suggestions and fetches', () => {
|
|
86
|
+
const value = 'a'.repeat(DEFAULTS.minSearchLength)
|
|
87
|
+
|
|
88
|
+
handlers.handleInputChange({
|
|
89
|
+
target: { value }
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
93
|
+
type: 'SET_VALUE',
|
|
94
|
+
payload: value
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
98
|
+
type: 'SHOW_SUGGESTIONS'
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(debouncedFetchSuggestions).toHaveBeenCalledWith(value)
|
|
102
|
+
expect(debouncedFetchSuggestions.cancel).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { createSuggestionHandlers } from './suggestionHandlers.js'
|
|
5
|
+
import { updateMap } from '../utils/updateMap.js'
|
|
6
|
+
|
|
7
|
+
jest.mock('../utils/updateMap.js')
|
|
8
|
+
|
|
9
|
+
describe('createSuggestionHandlers', () => {
|
|
10
|
+
let dispatch
|
|
11
|
+
let services
|
|
12
|
+
let handlers
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
dispatch = jest.fn()
|
|
16
|
+
|
|
17
|
+
services = {
|
|
18
|
+
eventBus: { emit: jest.fn() },
|
|
19
|
+
announce: jest.fn()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
handlers = createSuggestionHandlers({
|
|
23
|
+
dispatch,
|
|
24
|
+
services,
|
|
25
|
+
mapProvider: 'map',
|
|
26
|
+
markers: 'markers',
|
|
27
|
+
showMarker: true,
|
|
28
|
+
markerColor: 'blue'
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
jest.clearAllMocks()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ---------- Suggestion click ----------
|
|
35
|
+
|
|
36
|
+
test('handleSuggestionClick (desktop)', () => {
|
|
37
|
+
const suggestion = { text: 'Paris', bounds: 'b', point: 'p' }
|
|
38
|
+
|
|
39
|
+
handlers.handleSuggestionClick(suggestion, { breakpoint: 'desktop' })
|
|
40
|
+
|
|
41
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_VALUE', payload: 'Paris' })
|
|
42
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'HIDE_SUGGESTIONS' })
|
|
43
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
|
|
44
|
+
expect(updateMap).toHaveBeenCalledWith(expect.objectContaining({ bounds: 'b', point: 'p' }))
|
|
45
|
+
expect(services.eventBus.emit).toHaveBeenCalledWith(
|
|
46
|
+
'search:match',
|
|
47
|
+
expect.objectContaining({ query: 'Paris' })
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('handleSuggestionClick (mobile closes search)', () => {
|
|
52
|
+
const suggestion = { text: 'Berlin', bounds: 'b', point: 'p' }
|
|
53
|
+
|
|
54
|
+
handlers.handleSuggestionClick(suggestion, { breakpoint: 'mobile' })
|
|
55
|
+
|
|
56
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_EXPANDED', payload: false })
|
|
57
|
+
expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ---------- ArrowDown ----------
|
|
61
|
+
|
|
62
|
+
test('ArrowDown selects next suggestion', () => {
|
|
63
|
+
const e = { key: 'ArrowDown', preventDefault: jest.fn() }
|
|
64
|
+
|
|
65
|
+
handlers.handleInputKeyDown(e, {
|
|
66
|
+
suggestions: [{ text: 'A' }, { text: 'B' }],
|
|
67
|
+
selectedIndex: 0
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
71
|
+
expect(services.announce).toHaveBeenCalledWith('B. 2 of 2 is highlighted')
|
|
72
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: 1 })
|
|
73
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_KEYBOARD_FOCUS_WITHIN', payload: false })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('ArrowDown does nothing when no suggestions', () => {
|
|
77
|
+
const e = { key: 'ArrowDown', preventDefault: jest.fn() }
|
|
78
|
+
|
|
79
|
+
handlers.handleInputKeyDown(e, { suggestions: [], selectedIndex: 0 })
|
|
80
|
+
|
|
81
|
+
expect(e.preventDefault).not.toHaveBeenCalled()
|
|
82
|
+
expect(dispatch).not.toHaveBeenCalled()
|
|
83
|
+
expect(services.announce).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('ArrowDown does nothing when at last suggestion', () => {
|
|
87
|
+
const e = { key: 'ArrowDown', preventDefault: jest.fn() }
|
|
88
|
+
|
|
89
|
+
handlers.handleInputKeyDown(e, {
|
|
90
|
+
suggestions: [{ text: 'A' }, { text: 'B' }],
|
|
91
|
+
selectedIndex: 1 // last index
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
95
|
+
expect(dispatch).not.toHaveBeenCalled()
|
|
96
|
+
expect(services.announce).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ---------- ArrowUp ----------
|
|
100
|
+
|
|
101
|
+
test('ArrowUp moves selection up and resets to -1', () => {
|
|
102
|
+
const e = { key: 'ArrowUp', preventDefault: jest.fn() }
|
|
103
|
+
|
|
104
|
+
handlers.handleInputKeyDown(e, {
|
|
105
|
+
suggestions: [{ text: 'A' }, { text: 'B' }],
|
|
106
|
+
selectedIndex: 0
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
110
|
+
expect(services.announce).toHaveBeenCalledWith('2 suggestions available')
|
|
111
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
|
|
112
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_KEYBOARD_FOCUS_WITHIN', payload: true })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('ArrowUp moves selection up normally when selectedIndex > 0', () => {
|
|
116
|
+
const e = { key: 'ArrowUp', preventDefault: jest.fn() }
|
|
117
|
+
|
|
118
|
+
handlers.handleInputKeyDown(e, {
|
|
119
|
+
suggestions: [{ text: 'A' }, { text: 'B' }, { text: 'C' }],
|
|
120
|
+
selectedIndex: 2
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
124
|
+
expect(services.announce).toHaveBeenCalledWith('B. 2 of 3 is highlighted')
|
|
125
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: 1 })
|
|
126
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_KEYBOARD_FOCUS_WITHIN', payload: false })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('ArrowUp does nothing when no suggestions', () => {
|
|
130
|
+
const e = { key: 'ArrowUp', preventDefault: jest.fn() }
|
|
131
|
+
|
|
132
|
+
handlers.handleInputKeyDown(e, { suggestions: [], selectedIndex: 0 })
|
|
133
|
+
|
|
134
|
+
expect(e.preventDefault).not.toHaveBeenCalled()
|
|
135
|
+
expect(dispatch).not.toHaveBeenCalled()
|
|
136
|
+
expect(services.announce).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ---------- Escape & default ----------
|
|
140
|
+
|
|
141
|
+
test('Escape hides suggestions and clears selection', () => {
|
|
142
|
+
const e = { key: 'Escape', preventDefault: jest.fn() }
|
|
143
|
+
|
|
144
|
+
handlers.handleInputKeyDown(e, {
|
|
145
|
+
suggestions: [{ text: 'A' }],
|
|
146
|
+
selectedIndex: 0
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(e.preventDefault).toHaveBeenCalled()
|
|
150
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'HIDE_SUGGESTIONS' })
|
|
151
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('Other keys do nothing', () => {
|
|
155
|
+
const e = { key: 'Enter', preventDefault: jest.fn() }
|
|
156
|
+
|
|
157
|
+
handlers.handleInputKeyDown(e, {
|
|
158
|
+
suggestions: [{ text: 'A' }],
|
|
159
|
+
selectedIndex: 0
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
expect(e.preventDefault).not.toHaveBeenCalled()
|
|
163
|
+
expect(dispatch).not.toHaveBeenCalled()
|
|
164
|
+
expect(services.announce).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// /plugins/search/index.test.js
|
|
2
|
+
|
|
3
|
+
// Mock SCSS import so Jest can run without parsing errors
|
|
4
|
+
jest.mock('./search.scss', () => {})
|
|
5
|
+
|
|
6
|
+
import createPlugin from './index'
|
|
7
|
+
|
|
8
|
+
describe('createPlugin', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
jest.resetModules()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns default plugin structure with showMarker and id', async () => {
|
|
14
|
+
const plugin = createPlugin()
|
|
15
|
+
expect(plugin.showMarker).toBe(true)
|
|
16
|
+
expect(plugin.id).toBe('search')
|
|
17
|
+
expect(typeof plugin.load).toBe('function')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('overrides manifest when isExpanded is true', () => {
|
|
21
|
+
const plugin = createPlugin({ isExpanded: true })
|
|
22
|
+
expect(plugin.isExpanded).toBe(true)
|
|
23
|
+
expect(plugin.manifest).toEqual({
|
|
24
|
+
controls: [{ id: 'search', mobile: { slot: 'banner' } }]
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('spreads custom options correctly', () => {
|
|
29
|
+
const custom = { foo: 'bar', isExpanded: false }
|
|
30
|
+
const plugin = createPlugin(custom)
|
|
31
|
+
expect(plugin.foo).toBe('bar')
|
|
32
|
+
expect(plugin.showMarker).toBe(true)
|
|
33
|
+
expect(plugin.id).toBe('search')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('load function dynamically imports the manifest and returns it', async () => {
|
|
37
|
+
// Mock the dynamic import for './manifest.js'
|
|
38
|
+
const manifestMock = { data: 'test-manifest' }
|
|
39
|
+
jest.mock('./manifest.js', () => ({
|
|
40
|
+
manifest: manifestMock
|
|
41
|
+
}), { virtual: true })
|
|
42
|
+
|
|
43
|
+
const plugin = createPlugin()
|
|
44
|
+
const result = await plugin.load()
|
|
45
|
+
expect(result).toEqual(manifestMock)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// /plugins/search/reducer.test.js
|
|
2
|
+
|
|
3
|
+
import { initialState, actions } from './reducer'
|
|
4
|
+
|
|
5
|
+
describe('search state actions', () => {
|
|
6
|
+
it('TOGGLE_EXPANDED sets isExpanded and areSuggestionsVisible', () => {
|
|
7
|
+
const state = { ...initialState }
|
|
8
|
+
const newState = actions.TOGGLE_EXPANDED(state, true)
|
|
9
|
+
expect(newState.isExpanded).toBe(true)
|
|
10
|
+
expect(newState.areSuggestionsVisible).toBe(true)
|
|
11
|
+
|
|
12
|
+
const collapsed = actions.TOGGLE_EXPANDED(state, false)
|
|
13
|
+
expect(collapsed.isExpanded).toBe(false)
|
|
14
|
+
expect(collapsed.areSuggestionsVisible).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('SET_KEYBOARD_FOCUS_WITHIN sets focus and shows suggestions', () => {
|
|
18
|
+
const state = { ...initialState }
|
|
19
|
+
const newState = actions.SET_KEYBOARD_FOCUS_WITHIN(state, true)
|
|
20
|
+
expect(newState.hasKeyboardFocusWithin).toBe(true)
|
|
21
|
+
expect(newState.areSuggestionsVisible).toBe(true)
|
|
22
|
+
|
|
23
|
+
const removedFocus = actions.SET_KEYBOARD_FOCUS_WITHIN(state, false)
|
|
24
|
+
expect(removedFocus.hasKeyboardFocusWithin).toBe(false)
|
|
25
|
+
expect(removedFocus.areSuggestionsVisible).toBe(true) // always true
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('INPUT_BLUR removes focus and updates areSuggestionsVisible correctly', () => {
|
|
29
|
+
const state = { ...initialState, areSuggestionsVisible: true, hasKeyboardFocusWithin: true }
|
|
30
|
+
|
|
31
|
+
// Non-keyboard blur
|
|
32
|
+
const newStateMouse = actions.INPUT_BLUR(state, 'mouse')
|
|
33
|
+
expect(newStateMouse.hasKeyboardFocusWithin).toBe(false)
|
|
34
|
+
expect(newStateMouse.areSuggestionsVisible).toBe(true) // still true
|
|
35
|
+
expect(newStateMouse.selectedIndex).toBe(-1)
|
|
36
|
+
|
|
37
|
+
// Keyboard blur hides suggestions
|
|
38
|
+
const newStateKeyboard = actions.INPUT_BLUR(state, 'keyboard')
|
|
39
|
+
expect(newStateKeyboard.hasKeyboardFocusWithin).toBe(false)
|
|
40
|
+
expect(newStateKeyboard.areSuggestionsVisible).toBe(false) // now false
|
|
41
|
+
expect(newStateKeyboard.selectedIndex).toBe(-1)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('SET_VALUE updates the input value', () => {
|
|
45
|
+
const state = { ...initialState }
|
|
46
|
+
const newState = actions.SET_VALUE(state, 'test')
|
|
47
|
+
expect(newState.value).toBe('test')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('UPDATE_SUGGESTIONS updates the suggestions array', () => {
|
|
51
|
+
const state = { ...initialState }
|
|
52
|
+
const suggestions = [{ id: 1 }, { id: 2 }]
|
|
53
|
+
const newState = actions.UPDATE_SUGGESTIONS(state, suggestions)
|
|
54
|
+
expect(newState.suggestions).toEqual(suggestions)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('SHOW_SUGGESTIONS sets areSuggestionsVisible to true', () => {
|
|
58
|
+
const state = { ...initialState, areSuggestionsVisible: false }
|
|
59
|
+
const newState = actions.SHOW_SUGGESTIONS(state)
|
|
60
|
+
expect(newState.areSuggestionsVisible).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('HIDE_SUGGESTIONS sets areSuggestionsVisible to false', () => {
|
|
64
|
+
const state = { ...initialState, areSuggestionsVisible: true }
|
|
65
|
+
const newState = actions.HIDE_SUGGESTIONS(state)
|
|
66
|
+
expect(newState.areSuggestionsVisible).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('SET_SELECTED updates selectedIndex and visibility', () => {
|
|
70
|
+
const state = { ...initialState, areSuggestionsVisible: false }
|
|
71
|
+
|
|
72
|
+
const selected = actions.SET_SELECTED(state, 1)
|
|
73
|
+
expect(selected.selectedIndex).toBe(1)
|
|
74
|
+
expect(selected.areSuggestionsVisible).toBe(true)
|
|
75
|
+
|
|
76
|
+
const deselected = actions.SET_SELECTED(state, -1)
|
|
77
|
+
expect(deselected.selectedIndex).toBe(-1)
|
|
78
|
+
expect(deselected.areSuggestionsVisible).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { parseOsNamesResults, point } from './parseOsNamesResults.js'
|
|
5
|
+
import OsGridRef from 'geodesy/osgridref.js'
|
|
6
|
+
|
|
7
|
+
// Mock OsGridRef so we can control toLatLon outputs
|
|
8
|
+
jest.mock('geodesy/osgridref.js', () => {
|
|
9
|
+
return jest.fn().mockImplementation((x, y) => ({
|
|
10
|
+
x,
|
|
11
|
+
y,
|
|
12
|
+
toLatLon: () => ({ lat: y / 1e5, lon: x / 1e5 }) // deterministic lat/lon
|
|
13
|
+
}))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('osNamesUtils', () => {
|
|
17
|
+
const sampleEntry = {
|
|
18
|
+
GAZETTEER_ENTRY: {
|
|
19
|
+
ID: 1,
|
|
20
|
+
NAME1: 'London',
|
|
21
|
+
COUNTY_UNITARY: 'Greater London',
|
|
22
|
+
DISTRICT_BOROUGH: 'Camden',
|
|
23
|
+
POSTCODE_DISTRICT: 'WC1',
|
|
24
|
+
LOCAL_TYPE: 'Town',
|
|
25
|
+
MBR_XMIN: 1000,
|
|
26
|
+
MBR_YMIN: 2000,
|
|
27
|
+
MBR_XMAX: 3000,
|
|
28
|
+
MBR_YMAX: 4000,
|
|
29
|
+
GEOMETRY_X: 1500,
|
|
30
|
+
GEOMETRY_Y: 2500
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('returns empty array for null/invalid/error results', () => {
|
|
35
|
+
expect(parseOsNamesResults(null, 'x', 'EPSG:27700')).toEqual([])
|
|
36
|
+
expect(parseOsNamesResults({ error: true }, 'x', 'EPSG:27700')).toEqual([])
|
|
37
|
+
expect(parseOsNamesResults({ header: { totalresults: 0 } }, 'x', 'EPSG:27700')).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('removes tenuous results when query does not match', () => {
|
|
41
|
+
const results = [
|
|
42
|
+
{ GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'Bristol', ID: 2 } }
|
|
43
|
+
]
|
|
44
|
+
const json = { results }
|
|
45
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
46
|
+
expect(output).toHaveLength(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('removes duplicate IDs', () => {
|
|
50
|
+
const dup = { ...sampleEntry, GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
|
|
51
|
+
const json = { results: [sampleEntry, dup] }
|
|
52
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
53
|
+
expect(output).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('limits results to MAX_RESULTS', () => {
|
|
57
|
+
const manyResults = Array.from({ length: 10 }, (_, i) => ({
|
|
58
|
+
GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, ID: i }
|
|
59
|
+
}))
|
|
60
|
+
const json = { results: manyResults }
|
|
61
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
62
|
+
expect(output).toHaveLength(8)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('bounds returns raw OSGB values for EPSG:27700', () => {
|
|
66
|
+
const json = { results: [sampleEntry] }
|
|
67
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
68
|
+
expect(output[0].bounds).toEqual([
|
|
69
|
+
sampleEntry.GAZETTEER_ENTRY.MBR_XMIN,
|
|
70
|
+
sampleEntry.GAZETTEER_ENTRY.MBR_YMIN,
|
|
71
|
+
sampleEntry.GAZETTEER_ENTRY.MBR_XMAX,
|
|
72
|
+
sampleEntry.GAZETTEER_ENTRY.MBR_YMAX
|
|
73
|
+
])
|
|
74
|
+
expect(output[0].point).toEqual([
|
|
75
|
+
sampleEntry.GAZETTEER_ENTRY.GEOMETRY_X,
|
|
76
|
+
sampleEntry.GAZETTEER_ENTRY.GEOMETRY_Y
|
|
77
|
+
])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('bounds converts to WGS84 for EPSG:4326', () => {
|
|
81
|
+
const json = { results: [sampleEntry] }
|
|
82
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:4326')
|
|
83
|
+
const expectedBounds = [
|
|
84
|
+
Math.round(1000 / 1e5 * 1e6) / 1e6,
|
|
85
|
+
Math.round(2000 / 1e5 * 1e6) / 1e6,
|
|
86
|
+
Math.round(3000 / 1e5 * 1e6) / 1e6,
|
|
87
|
+
Math.round(4000 / 1e5 * 1e6) / 1e6
|
|
88
|
+
]
|
|
89
|
+
expect(output[0].bounds).toEqual(expectedBounds)
|
|
90
|
+
const expectedPoint = [
|
|
91
|
+
Math.round(1500 / 1e5 * 1e6) / 1e6,
|
|
92
|
+
Math.round(2500 / 1e5 * 1e6) / 1e6
|
|
93
|
+
]
|
|
94
|
+
expect(output[0].point).toEqual(expectedPoint)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('label generates marked text', () => {
|
|
98
|
+
const json = { results: [sampleEntry] }
|
|
99
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
100
|
+
expect(output[0].text).toContain('London')
|
|
101
|
+
expect(output[0].marked).toContain('<mark>')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('handles MBR_XMIN null by using buffered GEOMETRY_X/Y', () => {
|
|
105
|
+
const entry = {
|
|
106
|
+
GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, MBR_XMIN: null, MBR_YMIN: null }
|
|
107
|
+
}
|
|
108
|
+
const json = { results: [entry] }
|
|
109
|
+
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
110
|
+
expect(output[0].bounds).toEqual([
|
|
111
|
+
entry.GAZETTEER_ENTRY.GEOMETRY_X - 500,
|
|
112
|
+
entry.GAZETTEER_ENTRY.GEOMETRY_Y - 500,
|
|
113
|
+
entry.GAZETTEER_ENTRY.GEOMETRY_X + 500,
|
|
114
|
+
entry.GAZETTEER_ENTRY.GEOMETRY_Y + 500
|
|
115
|
+
])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('label falls back to DISTRICT_BOROUGH when COUNTY_UNITARY is absent', () => {
|
|
119
|
+
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, COUNTY_UNITARY: null } }
|
|
120
|
+
const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
|
|
121
|
+
expect(output[0].text).toContain(sampleEntry.GAZETTEER_ENTRY.DISTRICT_BOROUGH)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('label omits qualifier for City type', () => {
|
|
125
|
+
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, LOCAL_TYPE: 'City' } }
|
|
126
|
+
const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
|
|
127
|
+
expect(output[0].text).toBe('London')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('throws error for unsupported CRS', () => {
|
|
131
|
+
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
|
|
132
|
+
const json = { results: [entry] }
|
|
133
|
+
expect(() => parseOsNamesResults(json, 'London', 'EPSG:9999')).toThrow('Unsupported CRS')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('point function throws error for unsupported CRS', () => {
|
|
137
|
+
const coords = { GEOMETRY_X: 1500, GEOMETRY_Y: 2500 }
|
|
138
|
+
expect(() => point('EPSG:9999', coords)).toThrow('Unsupported CRS: EPSG:9999')
|
|
139
|
+
})
|
|
140
|
+
})
|