@defra/interactive-map 0.0.9-alpha → 0.0.11-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.
Files changed (120) hide show
  1. package/DOCS_README.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/css/index.css +1 -1
  4. package/dist/esm/im-core.js +1 -1
  5. package/dist/esm/im-shell.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/button-definition.md +21 -3
  9. package/docs/api/panel-definition.md +10 -12
  10. package/docs/api.md +81 -8
  11. package/docs/architecture/architecture-diagrams.md +1 -3
  12. package/docs/architecture/diagrams-viewer.mdx +12 -0
  13. package/docs/demo.mdx +70 -0
  14. package/docs/govuk-prototype.md +23 -0
  15. package/docs/index.md +19 -0
  16. package/docs/plugins/plugin-context.md +3 -3
  17. package/docs/plugins/plugin-manifest.md +1 -1
  18. package/docusaurus.config.cjs +136 -0
  19. package/mise.toml +2 -0
  20. package/package.json +27 -5
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/beta/datasets/src/manifest.js +3 -3
  24. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  25. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
  26. package/plugins/beta/draw-es/src/events.js +2 -2
  27. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  28. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  29. package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
  30. package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
  31. package/plugins/beta/draw-ml/src/events.js +18 -10
  32. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  33. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  34. package/plugins/beta/map-styles/src/manifest.js +3 -3
  35. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  36. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  37. package/plugins/beta/use-location/src/manifest.js +7 -7
  38. package/plugins/search/dist/css/index.css +1 -1
  39. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  40. package/plugins/search/dist/esm/index.js +1 -1
  41. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  42. package/plugins/search/dist/umd/index.js +1 -1
  43. package/plugins/search/src/Search.jsx +9 -3
  44. package/plugins/search/src/Search.test.jsx +190 -0
  45. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
  46. package/plugins/search/src/components/Form/Form.jsx +35 -7
  47. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  48. package/plugins/search/src/components/Form/Form.test.jsx +255 -0
  49. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
  50. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  51. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  52. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  53. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
  54. package/plugins/search/src/datasets.js +15 -11
  55. package/plugins/search/src/datasets.test.js +61 -0
  56. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  57. package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
  58. package/plugins/search/src/events/formHandlers.test.js +232 -0
  59. package/plugins/search/src/events/index.test.js +118 -0
  60. package/plugins/search/src/events/inputHandlers.test.js +104 -0
  61. package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
  62. package/plugins/search/src/index.js +1 -1
  63. package/plugins/search/src/index.test.js +47 -0
  64. package/plugins/search/src/reducer.js +9 -4
  65. package/plugins/search/src/reducer.test.js +85 -0
  66. package/plugins/search/src/search.scss +5 -1
  67. package/plugins/search/src/utils/parseOsNamesResults.js +20 -3
  68. package/plugins/search/src/utils/parseOsNamesResults.test.js +158 -0
  69. package/plugins/search/src/utils/updateMap.test.js +52 -0
  70. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  71. package/providers/beta/esri/src/appEvents.js +8 -2
  72. package/providers/beta/esri/src/esriProvider.js +6 -14
  73. package/providers/beta/esri/src/mapEvents.js +7 -1
  74. package/providers/beta/esri/src/utils/coords.js +33 -1
  75. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  76. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  77. package/providers/maplibre/dist/esm/index.js +1 -1
  78. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/umd/index.js +1 -1
  80. package/providers/maplibre/src/appEvents.js +10 -1
  81. package/providers/maplibre/src/appEvents.test.js +13 -4
  82. package/providers/maplibre/src/index.js +5 -13
  83. package/providers/maplibre/src/index.test.js +34 -15
  84. package/providers/maplibre/src/mapEvents.js +9 -1
  85. package/providers/maplibre/src/maplibreProvider.js +14 -15
  86. package/providers/maplibre/src/maplibreProvider.test.js +14 -1
  87. package/providers/maplibre/src/utils/spatial.js +11 -0
  88. package/providers/maplibre/src/utils/spatial.test.js +12 -0
  89. package/src/App/components/Actions/Actions.module.scss +5 -4
  90. package/src/App/components/MapButton/MapButton.jsx +4 -16
  91. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  92. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  93. package/src/App/components/Panel/Panel.jsx +6 -6
  94. package/src/App/components/Panel/Panel.test.jsx +14 -15
  95. package/src/App/components/Viewport/MapController.jsx +2 -1
  96. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  97. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  98. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  99. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  100. package/src/App/layout/Layout.jsx +4 -4
  101. package/src/App/layout/layout.module.scss +1 -0
  102. package/src/App/registry/panelRegistry.js +1 -10
  103. package/src/App/registry/panelRegistry.test.js +6 -11
  104. package/src/App/renderer/HtmlElementHost.jsx +11 -3
  105. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  106. package/src/App/renderer/mapButtons.js +128 -28
  107. package/src/App/renderer/mapButtons.test.js +119 -19
  108. package/src/App/store/MapProvider.jsx +18 -5
  109. package/src/App/store/MapProvider.test.jsx +56 -1
  110. package/src/App/store/appActionsMap.js +17 -9
  111. package/src/App/store/appActionsMap.test.js +33 -7
  112. package/src/App/store/mapActionsMap.js +4 -7
  113. package/src/InteractiveMap/InteractiveMap.js +18 -0
  114. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  115. package/src/config/appConfig.js +17 -15
  116. package/src/config/events.js +41 -4
  117. package/src/config/getInitialOpenPanels.js +2 -2
  118. package/src/config/getInitialOpenPanels.test.js +7 -7
  119. package/src/types.js +13 -11
  120. package/src/utils/getValueForStyle.js +1 -1
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { fetchSuggestions, sanitiseQuery } from './fetchSuggestions.js'
5
+
6
+ describe('fetchSuggestions', () => {
7
+ const dispatch = jest.fn()
8
+
9
+ beforeEach(() => {
10
+ dispatch.mockClear()
11
+ global.fetch = jest.fn()
12
+ jest.spyOn(console, 'error').mockImplementation(() => {})
13
+ })
14
+
15
+ afterEach(() => {
16
+ jest.restoreAllMocks()
17
+ })
18
+
19
+ test('sanitiseQuery strips invalid chars and trims', () => {
20
+ expect(sanitiseQuery(' he!!llo@ ')).toBe('hello')
21
+ })
22
+
23
+ test('fetches results, applies parsing, and dispatches', async () => {
24
+ global.fetch.mockResolvedValueOnce({
25
+ ok: true,
26
+ json: async () => ({ items: ['a', 'b'] })
27
+ })
28
+
29
+ const datasets = [
30
+ {
31
+ urlTemplate: '/api?q={query}',
32
+ parseResults: (json) => json.items
33
+ }
34
+ ]
35
+
36
+ const result = await fetchSuggestions('test', datasets, dispatch)
37
+
38
+ expect(fetch).toHaveBeenCalledWith('/api?q=test', { method: 'GET' })
39
+ expect(result.results).toEqual(['a', 'b'])
40
+ expect(dispatch).toHaveBeenCalledWith({
41
+ type: 'UPDATE_SUGGESTIONS',
42
+ payload: ['a', 'b']
43
+ })
44
+ })
45
+
46
+ test('respects includeRegex and excludeRegex', async () => {
47
+ const datasets = [
48
+ {
49
+ includeRegex: /^ok/,
50
+ excludeRegex: /bad/,
51
+ urlTemplate: '/x?q={query}',
52
+ parseResults: () => ['x']
53
+ }
54
+ ]
55
+
56
+ const result = await fetchSuggestions('bad', datasets, dispatch)
57
+
58
+ expect(fetch).not.toHaveBeenCalled()
59
+ expect(result.results).toEqual([])
60
+ })
61
+
62
+ test('uses buildRequest when provided', async () => {
63
+ global.fetch.mockResolvedValueOnce({
64
+ ok: true,
65
+ json: async () => ({})
66
+ })
67
+
68
+ const datasets = [
69
+ {
70
+ buildRequest: (query) => ({
71
+ url: `/custom/${query}`,
72
+ options: { method: 'POST' }
73
+ }),
74
+ parseResults: () => ['y']
75
+ }
76
+ ]
77
+
78
+ const result = await fetchSuggestions('abc', datasets, dispatch)
79
+
80
+ expect(fetch).toHaveBeenCalledWith('/custom/abc', { method: 'POST' })
81
+ expect(result.results).toEqual(['y'])
82
+ })
83
+
84
+ test('uses transformRequest when provided', async () => {
85
+ global.fetch.mockResolvedValueOnce({
86
+ ok: true,
87
+ json: async () => ({})
88
+ })
89
+
90
+ const transformRequest = (req) => ({
91
+ ...req,
92
+ options: { method: 'PUT' }
93
+ })
94
+
95
+ const datasets = [
96
+ {
97
+ urlTemplate: '/t?q={query}',
98
+ parseResults: () => ['z']
99
+ }
100
+ ]
101
+
102
+ await fetchSuggestions('x', datasets, dispatch, transformRequest)
103
+
104
+ expect(fetch).toHaveBeenCalledWith('/t?q=x', { method: 'PUT' })
105
+ })
106
+
107
+ test('handles fetch HTTP error', async () => {
108
+ global.fetch.mockResolvedValueOnce({ ok: false, status: 500 })
109
+
110
+ const datasets = [
111
+ {
112
+ label: 'test-ds',
113
+ urlTemplate: '/fail?q={query}',
114
+ parseResults: () => ['nope']
115
+ }
116
+ ]
117
+
118
+ const result = await fetchSuggestions('err', datasets, dispatch)
119
+
120
+ expect(result.results).toEqual([])
121
+ expect(console.error).toHaveBeenCalled()
122
+ })
123
+
124
+ test('uses fallback dataset label on fetch HTTP error', async () => {
125
+ global.fetch.mockResolvedValueOnce({
126
+ ok: false,
127
+ status: 404
128
+ })
129
+
130
+ const datasets = [
131
+ {
132
+ // no label on purpose
133
+ urlTemplate: '/missing?q={query}',
134
+ parseResults: () => []
135
+ }
136
+ ]
137
+
138
+ await fetchSuggestions('oops', datasets, dispatch)
139
+
140
+ expect(console.error).toHaveBeenCalledWith(
141
+ 'Fetch error for dataset: 404'
142
+ )
143
+ })
144
+
145
+ test('handles network error', async () => {
146
+ global.fetch.mockRejectedValueOnce(new Error('network'))
147
+
148
+ const datasets = [
149
+ {
150
+ urlTemplate: '/net?q={query}',
151
+ parseResults: () => ['nope']
152
+ }
153
+ ]
154
+
155
+ const result = await fetchSuggestions('err', datasets, dispatch)
156
+
157
+ expect(result.results).toEqual([])
158
+ expect(console.error).toHaveBeenCalled()
159
+ })
160
+
161
+ test('stops processing when exclusive dataset returns results', async () => {
162
+ global.fetch
163
+ .mockResolvedValueOnce({
164
+ ok: true,
165
+ json: async () => ({})
166
+ })
167
+ .mockResolvedValueOnce({
168
+ ok: true,
169
+ json: async () => ({})
170
+ })
171
+
172
+ const datasets = [
173
+ {
174
+ exclusive: true,
175
+ urlTemplate: '/first?q={query}',
176
+ parseResults: () => ['first']
177
+ },
178
+ {
179
+ urlTemplate: '/second?q={query}',
180
+ parseResults: () => ['second']
181
+ }
182
+ ]
183
+
184
+ const result = await fetchSuggestions('go', datasets, dispatch)
185
+
186
+ expect(result.results).toEqual(['first'])
187
+ expect(fetch).toHaveBeenCalledTimes(1)
188
+ })
189
+
190
+ test('buildRequest can call default request builder', async () => {
191
+ global.fetch.mockResolvedValueOnce({
192
+ ok: true,
193
+ json: async () => ({})
194
+ })
195
+
196
+ const datasets = [
197
+ {
198
+ buildRequest: (query, getDefault) => {
199
+ // 👇 THIS is the missing function call
200
+ return getDefault()
201
+ },
202
+ urlTemplate: '/default?q={query}',
203
+ parseResults: () => ['ok']
204
+ }
205
+ ]
206
+
207
+ const result = await fetchSuggestions('hi', datasets, dispatch)
208
+
209
+ expect(fetch).toHaveBeenCalledWith('/default?q=hi', { method: 'GET' })
210
+ expect(result.results).toEqual(['ok'])
211
+ })
212
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { createFormHandlers } from './formHandlers.js'
5
+ import { fetchSuggestions } from './fetchSuggestions.js'
6
+ import { updateMap } from '../utils/updateMap.js'
7
+ import { DEFAULTS } from '../defaults.js'
8
+
9
+ jest.mock('./fetchSuggestions.js')
10
+ jest.mock('../utils/updateMap.js')
11
+
12
+ describe('createFormHandlers', () => {
13
+ let dispatch
14
+ let services
15
+ let viewportRef
16
+ let markers
17
+ let handlers
18
+
19
+ beforeEach(() => {
20
+ dispatch = jest.fn()
21
+
22
+ services = {
23
+ eventBus: { emit: jest.fn() }
24
+ }
25
+
26
+ viewportRef = {
27
+ current: { focus: jest.fn() }
28
+ }
29
+
30
+ markers = {
31
+ remove: jest.fn()
32
+ }
33
+
34
+ handlers = createFormHandlers({
35
+ dispatch,
36
+ services,
37
+ viewportRef,
38
+ mapProvider: 'map',
39
+ markers,
40
+ datasets: [],
41
+ transformRequest: jest.fn(),
42
+ showMarker: true,
43
+ markerColor: 'red'
44
+ })
45
+
46
+ jest.clearAllMocks()
47
+ })
48
+
49
+ test('handleOpenClick dispatches and emits', () => {
50
+ handlers.handleOpenClick()
51
+
52
+ expect(dispatch).toHaveBeenCalledWith({
53
+ type: 'TOGGLE_EXPANDED',
54
+ payload: true
55
+ })
56
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:open')
57
+ })
58
+
59
+ test('handleCloseClick resets state and focuses button', () => {
60
+ jest.useFakeTimers()
61
+ const buttonRef = { current: { focus: jest.fn() } }
62
+
63
+ handlers.handleCloseClick(null, buttonRef)
64
+
65
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_EXPANDED', payload: false })
66
+ expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_SUGGESTIONS', payload: [] })
67
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_VALUE', payload: '' })
68
+ expect(markers.remove).toHaveBeenCalledWith('search')
69
+
70
+ jest.runAllTimers()
71
+ expect(buttonRef.current.focus).toHaveBeenCalled()
72
+
73
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:clear')
74
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
75
+
76
+ jest.useRealTimers()
77
+ })
78
+
79
+ test('handleSubmit uses selected suggestion when selectedIndex >= 0', async () => {
80
+ const suggestion = {
81
+ text: 'Paris',
82
+ bounds: 'b',
83
+ point: 'p'
84
+ }
85
+
86
+ await handlers.handleSubmit(
87
+ { preventDefault: jest.fn() },
88
+ {},
89
+ {
90
+ suggestions: [suggestion],
91
+ selectedIndex: 0,
92
+ value: 'x'
93
+ }
94
+ )
95
+
96
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
97
+ expect(dispatch).toHaveBeenCalledWith({ type: 'HIDE_SUGGESTIONS' })
98
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_VALUE', payload: 'Paris' })
99
+
100
+ expect(updateMap).toHaveBeenCalledWith(
101
+ expect.objectContaining({ bounds: 'b', point: 'p' })
102
+ )
103
+
104
+ expect(services.eventBus.emit).toHaveBeenCalledWith(
105
+ 'search:match',
106
+ expect.objectContaining({ query: 'Paris' })
107
+ )
108
+ })
109
+
110
+ test('handleSubmit returns early for short input', async () => {
111
+ await handlers.handleSubmit(
112
+ { preventDefault: jest.fn() },
113
+ {},
114
+ {
115
+ suggestions: [],
116
+ selectedIndex: -1,
117
+ value: 'a'.repeat(DEFAULTS.minSearchLength - 1)
118
+ }
119
+ )
120
+
121
+ expect(fetchSuggestions).not.toHaveBeenCalled()
122
+ expect(updateMap).not.toHaveBeenCalled()
123
+ })
124
+
125
+ test('handleSubmit fetches suggestions and updates map (keyboard)', async () => {
126
+ fetchSuggestions.mockResolvedValueOnce({
127
+ sanitisedValue: 'rome',
128
+ results: [
129
+ { text: 'Rome', bounds: 'b', point: 'p' }
130
+ ]
131
+ })
132
+
133
+ await handlers.handleSubmit(
134
+ { preventDefault: jest.fn() },
135
+ { interfaceType: 'keyboard' },
136
+ {
137
+ suggestions: [],
138
+ selectedIndex: -1,
139
+ value: 'rome'
140
+ }
141
+ )
142
+
143
+ expect(fetchSuggestions).toHaveBeenCalled()
144
+ expect(viewportRef.current.focus).toHaveBeenCalled()
145
+ expect(updateMap).toHaveBeenCalled()
146
+ expect(services.eventBus.emit).toHaveBeenCalledWith(
147
+ 'search:match',
148
+ expect.objectContaining({ query: 'rome' })
149
+ )
150
+ })
151
+
152
+ test('handleSubmit mobile closes search', async () => {
153
+ fetchSuggestions.mockResolvedValueOnce({
154
+ sanitisedValue: 'berlin',
155
+ results: [
156
+ { text: 'Berlin', bounds: 'b', point: 'p' }
157
+ ]
158
+ })
159
+
160
+ await handlers.handleSubmit(
161
+ { preventDefault: jest.fn() },
162
+ { breakpoint: 'mobile' },
163
+ {
164
+ suggestions: [],
165
+ selectedIndex: -1,
166
+ value: 'berlin'
167
+ }
168
+ )
169
+
170
+ expect(dispatch).toHaveBeenCalledWith({
171
+ type: 'TOGGLE_EXPANDED',
172
+ payload: false
173
+ })
174
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
175
+ })
176
+
177
+ test('handleSubmit does nothing when no suggestions are returned', async () => {
178
+ fetchSuggestions.mockResolvedValueOnce({
179
+ sanitisedValue: 'none',
180
+ results: []
181
+ })
182
+
183
+ await handlers.handleSubmit(
184
+ { preventDefault: jest.fn() },
185
+ {},
186
+ {
187
+ suggestions: [],
188
+ selectedIndex: -1,
189
+ value: 'none'
190
+ }
191
+ )
192
+
193
+ // Fetch happens
194
+ expect(fetchSuggestions).toHaveBeenCalled()
195
+
196
+ // But nothing downstream runs
197
+ expect(updateMap).not.toHaveBeenCalled()
198
+ expect(services.eventBus.emit).not.toHaveBeenCalledWith(
199
+ 'search:match',
200
+ expect.anything()
201
+ )
202
+ })
203
+
204
+ test('does not refetch when value matches lastFetchedValue', async () => {
205
+ fetchSuggestions.mockResolvedValueOnce({
206
+ sanitisedValue: 'same',
207
+ results: [{ text: 'Same', bounds: 'b', point: 'p' }]
208
+ })
209
+
210
+ await handlers.handleSubmit(
211
+ { preventDefault: jest.fn() },
212
+ {},
213
+ {
214
+ suggestions: [],
215
+ selectedIndex: -1,
216
+ value: 'same'
217
+ }
218
+ )
219
+
220
+ await handlers.handleSubmit(
221
+ { preventDefault: jest.fn() },
222
+ {},
223
+ {
224
+ suggestions: [{ text: 'Same', bounds: 'b', point: 'p' }],
225
+ selectedIndex: -1,
226
+ value: 'same'
227
+ }
228
+ )
229
+
230
+ expect(fetchSuggestions).toHaveBeenCalledTimes(1)
231
+ })
232
+ })
@@ -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
+ })