@defra/interactive-map 0.0.10-alpha → 0.0.12-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/README.md +1 -1
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/button-definition.md +21 -3
- package/docs/api/panel-definition.md +10 -12
- package/docs/api.md +80 -7
- package/docs/demo.mdx +70 -0
- package/docs/index.md +0 -4
- package/docs/plugins/plugin-context.md +3 -3
- package/docs/plugins/plugin-descriptor.md +37 -0
- package/docs/plugins/plugin-manifest.md +1 -1
- package/docusaurus.config.cjs +55 -25
- package/package.json +18 -9
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/src/manifest.js +3 -3
- 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/events.js +4 -14
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +3 -3
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +7 -7
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +28 -6
- package/plugins/interact/src/InteractInit.test.js +19 -5
- package/plugins/interact/src/events.js +17 -15
- package/plugins/interact/src/events.test.js +25 -16
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/esm/index.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +9 -3
- package/plugins/search/src/Search.test.jsx +26 -6
- package/plugins/search/src/components/Form/Form.jsx +35 -7
- package/plugins/search/src/components/Form/Form.module.scss +27 -0
- package/plugins/search/src/components/Form/Form.test.jsx +99 -2
- package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
- package/plugins/search/src/datasets.js +15 -11
- package/plugins/search/src/datasets.test.js +17 -2
- package/plugins/search/src/events/fetchSuggestions.js +1 -1
- package/plugins/search/src/index.js +1 -1
- package/plugins/search/src/index.test.js +4 -4
- package/plugins/search/src/reducer.js +9 -4
- package/plugins/search/src/reducer.test.js +12 -7
- package/plugins/search/src/search.scss +5 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
- package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/appEvents.js +8 -2
- package/providers/beta/esri/src/esriProvider.js +25 -17
- package/providers/beta/esri/src/mapEvents.js +41 -4
- package/providers/beta/esri/src/utils/coords.js +34 -1
- package/providers/beta/esri/src/utils/coords.test.js +126 -0
- package/providers/beta/esri/src/utils/spatial.js +47 -1
- package/providers/beta/esri/src/utils/spatial.test.js +55 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/appEvents.js +10 -1
- package/providers/maplibre/src/appEvents.test.js +13 -4
- package/providers/maplibre/src/index.js +5 -13
- package/providers/maplibre/src/index.test.js +34 -15
- package/providers/maplibre/src/mapEvents.js +9 -1
- package/providers/maplibre/src/maplibreProvider.js +25 -15
- package/providers/maplibre/src/maplibreProvider.test.js +28 -2
- package/providers/maplibre/src/utils/spatial.js +51 -0
- package/providers/maplibre/src/utils/spatial.test.js +47 -0
- package/src/App/components/Actions/Actions.module.scss +5 -4
- package/src/App/components/MapButton/MapButton.jsx +4 -16
- package/src/App/components/MapButton/MapButton.module.scss +12 -12
- package/src/App/components/MapButton/MapButton.test.jsx +0 -9
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +14 -15
- package/src/App/components/Viewport/MapController.jsx +6 -1
- package/src/App/hooks/useLayoutMeasurements.js +1 -1
- package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
- package/src/App/hooks/useMapProviderOverrides.js +21 -1
- package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
- package/src/App/hooks/useMarkersAPI.js +5 -3
- package/src/App/hooks/useModalPanelBehaviour.js +19 -2
- package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
- package/src/App/hooks/useVisibleGeometry.js +100 -0
- package/src/App/hooks/useVisibleGeometry.test.js +331 -0
- package/src/App/layout/Layout.jsx +5 -5
- package/src/App/layout/layout.module.scss +2 -4
- package/src/App/registry/panelRegistry.js +1 -10
- package/src/App/registry/panelRegistry.test.js +6 -11
- package/src/App/renderer/HtmlElementHost.jsx +12 -3
- package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
- package/src/App/renderer/mapButtons.js +128 -28
- package/src/App/renderer/mapButtons.test.js +119 -19
- package/src/App/renderer/pluginWrapper.js +3 -2
- package/src/App/renderer/slots.js +1 -1
- package/src/App/store/AppProvider.jsx +1 -0
- package/src/App/store/MapProvider.jsx +18 -5
- package/src/App/store/MapProvider.test.jsx +56 -1
- package/src/App/store/appActionsMap.js +17 -9
- package/src/App/store/appActionsMap.test.js +33 -7
- package/src/App/store/appDispatchMiddleware.js +19 -0
- package/src/App/store/appDispatchMiddleware.test.js +56 -0
- package/src/App/store/mapActionsMap.js +4 -7
- package/src/InteractiveMap/InteractiveMap.js +18 -0
- package/src/InteractiveMap/InteractiveMap.test.js +12 -0
- package/src/config/appConfig.js +17 -15
- package/src/config/events.js +41 -4
- package/src/config/getInitialOpenPanels.js +2 -2
- package/src/config/getInitialOpenPanels.test.js +7 -7
- package/src/types.js +22 -11
- package/src/utils/getValueForStyle.js +1 -1
|
@@ -24,11 +24,14 @@ describe('Form', () => {
|
|
|
24
24
|
isExpanded: false,
|
|
25
25
|
value: '',
|
|
26
26
|
suggestionsVisible: false,
|
|
27
|
+
areSuggestionsVisible: false,
|
|
28
|
+
hasFetchedSuggestions: false,
|
|
29
|
+
suggestions: [],
|
|
27
30
|
selectedIndex: -1,
|
|
28
31
|
hasKeyboardFocusWithin: false,
|
|
29
32
|
},
|
|
30
33
|
pluginConfig: {
|
|
31
|
-
|
|
34
|
+
expanded: false,
|
|
32
35
|
width: '400px',
|
|
33
36
|
},
|
|
34
37
|
appState: {
|
|
@@ -44,12 +47,21 @@ describe('Form', () => {
|
|
|
44
47
|
handleInputKeyDown: jest.fn(),
|
|
45
48
|
handleSuggestionClick: jest.fn(),
|
|
46
49
|
},
|
|
50
|
+
services: {
|
|
51
|
+
announce: jest.fn(),
|
|
52
|
+
},
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
beforeEach(() => {
|
|
50
56
|
jest.clearAllMocks()
|
|
51
57
|
})
|
|
52
58
|
|
|
59
|
+
it('renders without error when suggestions is undefined (uses default empty array)', () => {
|
|
60
|
+
const { suggestions: _omit, ...pluginStateWithoutSuggestions } = baseProps.pluginState
|
|
61
|
+
render(<Form {...baseProps} pluginState={pluginStateWithoutSuggestions} />)
|
|
62
|
+
expect(screen.getByRole('search')).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
53
65
|
it('renders the form element with correct role, ID, and base classes', () => {
|
|
54
66
|
render(<Form {...baseProps} />)
|
|
55
67
|
const form = screen.getByRole('search')
|
|
@@ -62,7 +74,7 @@ describe('Form', () => {
|
|
|
62
74
|
render(
|
|
63
75
|
<Form
|
|
64
76
|
{...baseProps}
|
|
65
|
-
pluginConfig={{ ...baseProps.pluginConfig,
|
|
77
|
+
pluginConfig={{ ...baseProps.pluginConfig, expanded: true }}
|
|
66
78
|
/>
|
|
67
79
|
)
|
|
68
80
|
const form = screen.getByRole('search')
|
|
@@ -155,4 +167,89 @@ describe('Form', () => {
|
|
|
155
167
|
baseProps.appState
|
|
156
168
|
)
|
|
157
169
|
})
|
|
170
|
+
|
|
171
|
+
describe('status element and announce', () => {
|
|
172
|
+
// Helper: pluginState representing a completed fetch (hasFetchedSuggestions: true)
|
|
173
|
+
const searchedState = { areSuggestionsVisible: true, hasFetchedSuggestions: true }
|
|
174
|
+
|
|
175
|
+
it('hides the status element when suggestions are not visible', () => {
|
|
176
|
+
const { container } = render(<Form {...baseProps} />)
|
|
177
|
+
expect(container.querySelector('.im-c-search__status')).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('shows the status element when a search returned no results', () => {
|
|
181
|
+
render(
|
|
182
|
+
<Form
|
|
183
|
+
{...baseProps}
|
|
184
|
+
pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [] }}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
expect(screen.getByText('No results available')).toBeInTheDocument()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('hides the status element when there are results', () => {
|
|
191
|
+
const { container } = render(
|
|
192
|
+
<Form
|
|
193
|
+
{...baseProps}
|
|
194
|
+
pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [{ text: 'London' }] }}
|
|
195
|
+
/>
|
|
196
|
+
)
|
|
197
|
+
expect(container.querySelector('.im-c-search__status')).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('hides the status element when the fetch has not yet completed', () => {
|
|
201
|
+
const { container } = render(
|
|
202
|
+
<Form
|
|
203
|
+
{...baseProps}
|
|
204
|
+
pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: true, hasFetchedSuggestions: false, suggestions: [] }}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
expect(container.querySelector('.im-c-search__status')).toBeNull()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('announces "No results available" when a search returned no results', () => {
|
|
211
|
+
render(
|
|
212
|
+
<Form
|
|
213
|
+
{...baseProps}
|
|
214
|
+
pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [] }}
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
217
|
+
expect(baseProps.services.announce).toHaveBeenCalledWith('No results available')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('announces result count when suggestions are visible and populated', () => {
|
|
221
|
+
render(
|
|
222
|
+
<Form
|
|
223
|
+
{...baseProps}
|
|
224
|
+
pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [{ text: 'A' }, { text: 'B' }] }}
|
|
225
|
+
/>
|
|
226
|
+
)
|
|
227
|
+
expect(baseProps.services.announce).toHaveBeenCalledWith('2 results available')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('uses singular "result" for a single result', () => {
|
|
231
|
+
render(
|
|
232
|
+
<Form
|
|
233
|
+
{...baseProps}
|
|
234
|
+
pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [{ text: 'A' }] }}
|
|
235
|
+
/>
|
|
236
|
+
)
|
|
237
|
+
expect(baseProps.services.announce).toHaveBeenCalledWith('1 result available')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('does not announce when suggestions are not visible', () => {
|
|
241
|
+
render(<Form {...baseProps} />)
|
|
242
|
+
expect(baseProps.services.announce).not.toHaveBeenCalled()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('does not announce when the fetch has not yet completed', () => {
|
|
246
|
+
render(
|
|
247
|
+
<Form
|
|
248
|
+
{...baseProps}
|
|
249
|
+
pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: true, hasFetchedSuggestions: false, suggestions: [] }}
|
|
250
|
+
/>
|
|
251
|
+
)
|
|
252
|
+
expect(baseProps.services.announce).not.toHaveBeenCalled()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
158
255
|
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/plugins/search/SubmitButton.jsx
|
|
2
|
+
export const SubmitButton = ({ defaultExpanded, submitIcon }) => {
|
|
3
|
+
return (
|
|
4
|
+
<button
|
|
5
|
+
aria-label="Search"
|
|
6
|
+
className="im-c-map-button im-c-search-submit-button"
|
|
7
|
+
type="submit"
|
|
8
|
+
style={defaultExpanded ? undefined : { display: 'none' }}
|
|
9
|
+
>
|
|
10
|
+
{submitIcon && (
|
|
11
|
+
<svg
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
width="24"
|
|
14
|
+
height="24"
|
|
15
|
+
viewBox="0 0 24 24"
|
|
16
|
+
fill="none"
|
|
17
|
+
stroke="currentColor"
|
|
18
|
+
strokeWidth="2"
|
|
19
|
+
strokeLinecap="round"
|
|
20
|
+
strokeLinejoin="round"
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
focusable="false"
|
|
23
|
+
dangerouslySetInnerHTML={{ __html: submitIcon }}
|
|
24
|
+
/>
|
|
25
|
+
)}
|
|
26
|
+
</button>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/plugins/search/components/SubmitButton/SubmitButton.test.jsx
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import { SubmitButton } from './SubmitButton'
|
|
5
|
+
|
|
6
|
+
describe('SubmitButton', () => {
|
|
7
|
+
it('renders a submit button with the correct aria-label', () => {
|
|
8
|
+
render(<SubmitButton defaultExpanded />)
|
|
9
|
+
const button = screen.getByRole('button', { name: 'Search' })
|
|
10
|
+
expect(button).toBeInTheDocument()
|
|
11
|
+
expect(button).toHaveAttribute('type', 'submit')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('hides the button when defaultExpanded is false', () => {
|
|
15
|
+
const { container } = render(<SubmitButton defaultExpanded={false} />)
|
|
16
|
+
expect(container.querySelector('button')).toHaveStyle({ display: 'none' })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('shows the button when defaultExpanded is true', () => {
|
|
20
|
+
render(<SubmitButton defaultExpanded />)
|
|
21
|
+
expect(screen.getByRole('button', { name: 'Search' })).not.toHaveStyle({ display: 'none' })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders an svg when submitIcon is provided', () => {
|
|
25
|
+
const { container } = render(<SubmitButton defaultExpanded submitIcon="<path d='M1 1'/>" />)
|
|
26
|
+
expect(container.querySelector('svg')).toBeTruthy()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not render an svg when submitIcon is not provided', () => {
|
|
30
|
+
const { container } = render(<SubmitButton defaultExpanded />)
|
|
31
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// src/plugins/search/datasets.js
|
|
2
2
|
import { parseOsNamesResults } from './utils/parseOsNamesResults.js'
|
|
3
3
|
|
|
4
|
-
export function createDatasets({ customDatasets = [], osNamesURL, crs }) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
export function createDatasets({ customDatasets = [], osNamesURL, crs, regions = ['england', 'scotland', 'wales'] }) {
|
|
5
|
+
|
|
6
|
+
if (!osNamesURL) {
|
|
7
|
+
return customDatasets
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const defaultDatasets = [{
|
|
11
|
+
name: 'osNames',
|
|
12
|
+
urlTemplate: osNamesURL,
|
|
13
|
+
parseResults: (json, query) => parseOsNamesResults(json, query, regions, crs),
|
|
14
|
+
includeRegex: /[a-zA-Z0-9]/,
|
|
15
|
+
excludeRegex: /^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i // NOSONAR - complexity unavoidable for gridref/coordinate matching
|
|
16
|
+
}]
|
|
17
|
+
|
|
18
|
+
return [...defaultDatasets, ...customDatasets]
|
|
15
19
|
}
|
|
@@ -11,6 +11,21 @@ describe('createDatasets', () => {
|
|
|
11
11
|
jest.clearAllMocks()
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
+
it('returns only customDatasets if osNamesURL is not provided', () => {
|
|
15
|
+
const custom = [
|
|
16
|
+
{ name: 'custom1', urlTemplate: 'https://custom.com' },
|
|
17
|
+
{ name: 'custom2', urlTemplate: 'https://custom2.com' }
|
|
18
|
+
]
|
|
19
|
+
const datasets = createDatasets({ customDatasets: custom, crs })
|
|
20
|
+
expect(datasets).toHaveLength(2)
|
|
21
|
+
expect(datasets).toEqual(custom)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns empty array if neither osNamesURL nor customDatasets are provided', () => {
|
|
25
|
+
const datasets = createDatasets({ crs })
|
|
26
|
+
expect(datasets).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
14
29
|
it('returns default dataset with correct properties', () => {
|
|
15
30
|
const datasets = createDatasets({ osNamesURL, crs })
|
|
16
31
|
expect(datasets).toHaveLength(1)
|
|
@@ -18,7 +33,7 @@ describe('createDatasets', () => {
|
|
|
18
33
|
|
|
19
34
|
expect(ds.name).toBe('osNames')
|
|
20
35
|
expect(ds.urlTemplate).toBe(osNamesURL)
|
|
21
|
-
expect(ds.includeRegex).toEqual(
|
|
36
|
+
expect(ds.includeRegex).toEqual(/[a-zA-Z0-9]/)
|
|
22
37
|
expect(ds.excludeRegex).toEqual(
|
|
23
38
|
/^(?:[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
39
|
)
|
|
@@ -39,7 +54,7 @@ describe('createDatasets', () => {
|
|
|
39
54
|
const query = 'test query'
|
|
40
55
|
|
|
41
56
|
const result = datasets[0].parseResults(json, query)
|
|
42
|
-
expect(parseMock).toHaveBeenCalledWith(json, query, crs)
|
|
57
|
+
expect(parseMock).toHaveBeenCalledWith(json, query, ['england', 'scotland', 'wales'], crs)
|
|
43
58
|
expect(result).toBe('parsed')
|
|
44
59
|
parseMock.mockRestore()
|
|
45
60
|
})
|
|
@@ -42,7 +42,7 @@ export const fetchSuggestions = async (value, datasets, dispatch, transformReque
|
|
|
42
42
|
const sanitisedValue = sanitiseQuery(value)
|
|
43
43
|
|
|
44
44
|
const activeDatasets = datasets.filter(ds => {
|
|
45
|
-
const include = ds.includeRegex ? ds.includeRegex.test(
|
|
45
|
+
const include = ds.includeRegex ? ds.includeRegex.test(value) : true
|
|
46
46
|
const exclude = ds.excludeRegex ? ds.excludeRegex.test(sanitisedValue) : false
|
|
47
47
|
return include && !exclude
|
|
48
48
|
})
|
|
@@ -3,7 +3,7 @@ import './search.scss'
|
|
|
3
3
|
|
|
4
4
|
export default function createPlugin (options = {}) {
|
|
5
5
|
// If search is open then we need to overirde the mobile slot
|
|
6
|
-
if (options.
|
|
6
|
+
if (options.expanded) {
|
|
7
7
|
options.manifest = {controls: [{ id: 'search', mobile: { slot: 'banner' }}]}
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -17,16 +17,16 @@ describe('createPlugin', () => {
|
|
|
17
17
|
expect(typeof plugin.load).toBe('function')
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it('overrides manifest when
|
|
21
|
-
const plugin = createPlugin({
|
|
22
|
-
expect(plugin.
|
|
20
|
+
it('overrides manifest when expanded is true', () => {
|
|
21
|
+
const plugin = createPlugin({ expanded: true })
|
|
22
|
+
expect(plugin.expanded).toBe(true)
|
|
23
23
|
expect(plugin.manifest).toEqual({
|
|
24
24
|
controls: [{ id: 'search', mobile: { slot: 'banner' } }]
|
|
25
25
|
})
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
it('spreads custom options correctly', () => {
|
|
29
|
-
const custom = { foo: 'bar',
|
|
29
|
+
const custom = { foo: 'bar', expanded: false }
|
|
30
30
|
const plugin = createPlugin(custom)
|
|
31
31
|
expect(plugin.foo).toBe('bar')
|
|
32
32
|
expect(plugin.showMarker).toBe(true)
|
|
@@ -4,6 +4,7 @@ const initialState = {
|
|
|
4
4
|
value: '',
|
|
5
5
|
suggestions: [],
|
|
6
6
|
areSuggestionsVisible: false,
|
|
7
|
+
hasFetchedSuggestions: false,
|
|
7
8
|
selectedIndex: -1
|
|
8
9
|
}
|
|
9
10
|
|
|
@@ -12,7 +13,8 @@ const toggleExpanded = (state, payload) => {
|
|
|
12
13
|
return {
|
|
13
14
|
...state,
|
|
14
15
|
isExpanded: payload,
|
|
15
|
-
areSuggestionsVisible: payload
|
|
16
|
+
areSuggestionsVisible: payload,
|
|
17
|
+
hasFetchedSuggestions: false
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -43,21 +45,24 @@ const setValue = (state, payload) => {
|
|
|
43
45
|
const updateSuggestions = (state, payload) => {
|
|
44
46
|
return {
|
|
45
47
|
...state,
|
|
46
|
-
suggestions: payload
|
|
48
|
+
suggestions: payload,
|
|
49
|
+
hasFetchedSuggestions: true
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const showSuggestions = (state) => {
|
|
51
54
|
return {
|
|
52
55
|
...state,
|
|
53
|
-
areSuggestionsVisible: true
|
|
56
|
+
areSuggestionsVisible: true,
|
|
57
|
+
hasFetchedSuggestions: false
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const hideSuggestions = (state) => {
|
|
58
62
|
return {
|
|
59
63
|
...state,
|
|
60
|
-
areSuggestionsVisible: false
|
|
64
|
+
areSuggestionsVisible: false,
|
|
65
|
+
hasFetchedSuggestions: false
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
import { initialState, actions } from './reducer'
|
|
4
4
|
|
|
5
5
|
describe('search state actions', () => {
|
|
6
|
-
it('TOGGLE_EXPANDED sets isExpanded and areSuggestionsVisible', () => {
|
|
7
|
-
const state = { ...initialState }
|
|
6
|
+
it('TOGGLE_EXPANDED sets isExpanded and areSuggestionsVisible, and resets hasFetchedSuggestions', () => {
|
|
7
|
+
const state = { ...initialState, hasFetchedSuggestions: true }
|
|
8
8
|
const newState = actions.TOGGLE_EXPANDED(state, true)
|
|
9
9
|
expect(newState.isExpanded).toBe(true)
|
|
10
10
|
expect(newState.areSuggestionsVisible).toBe(true)
|
|
11
|
+
expect(newState.hasFetchedSuggestions).toBe(false)
|
|
11
12
|
|
|
12
13
|
const collapsed = actions.TOGGLE_EXPANDED(state, false)
|
|
13
14
|
expect(collapsed.isExpanded).toBe(false)
|
|
14
15
|
expect(collapsed.areSuggestionsVisible).toBe(false)
|
|
16
|
+
expect(collapsed.hasFetchedSuggestions).toBe(false)
|
|
15
17
|
})
|
|
16
18
|
|
|
17
19
|
it('SET_KEYBOARD_FOCUS_WITHIN sets focus and shows suggestions', () => {
|
|
@@ -47,23 +49,26 @@ describe('search state actions', () => {
|
|
|
47
49
|
expect(newState.value).toBe('test')
|
|
48
50
|
})
|
|
49
51
|
|
|
50
|
-
it('UPDATE_SUGGESTIONS updates the suggestions array', () => {
|
|
52
|
+
it('UPDATE_SUGGESTIONS updates the suggestions array and sets hasFetchedSuggestions', () => {
|
|
51
53
|
const state = { ...initialState }
|
|
52
54
|
const suggestions = [{ id: 1 }, { id: 2 }]
|
|
53
55
|
const newState = actions.UPDATE_SUGGESTIONS(state, suggestions)
|
|
54
56
|
expect(newState.suggestions).toEqual(suggestions)
|
|
57
|
+
expect(newState.hasFetchedSuggestions).toBe(true)
|
|
55
58
|
})
|
|
56
59
|
|
|
57
|
-
it('SHOW_SUGGESTIONS sets areSuggestionsVisible to true', () => {
|
|
58
|
-
const state = { ...initialState, areSuggestionsVisible: false }
|
|
60
|
+
it('SHOW_SUGGESTIONS sets areSuggestionsVisible to true and resets hasFetchedSuggestions', () => {
|
|
61
|
+
const state = { ...initialState, areSuggestionsVisible: false, hasFetchedSuggestions: true }
|
|
59
62
|
const newState = actions.SHOW_SUGGESTIONS(state)
|
|
60
63
|
expect(newState.areSuggestionsVisible).toBe(true)
|
|
64
|
+
expect(newState.hasFetchedSuggestions).toBe(false)
|
|
61
65
|
})
|
|
62
66
|
|
|
63
|
-
it('HIDE_SUGGESTIONS sets areSuggestionsVisible to false', () => {
|
|
64
|
-
const state = { ...initialState, areSuggestionsVisible: true }
|
|
67
|
+
it('HIDE_SUGGESTIONS sets areSuggestionsVisible to false and resets hasFetchedSuggestions', () => {
|
|
68
|
+
const state = { ...initialState, areSuggestionsVisible: true, hasFetchedSuggestions: true }
|
|
65
69
|
const newState = actions.HIDE_SUGGESTIONS(state)
|
|
66
70
|
expect(newState.areSuggestionsVisible).toBe(false)
|
|
71
|
+
expect(newState.hasFetchedSuggestions).toBe(false)
|
|
67
72
|
})
|
|
68
73
|
|
|
69
74
|
it('SET_SELECTED updates selectedIndex and visibility', () => {
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
@use '../../../src/scss/tools/index' as tools;
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
// Components, using 'CSS in Component' pattern
|
|
5
4
|
@use './components/Form/Form.module';
|
|
6
5
|
@use './components/CloseButton/CloseButton.module';
|
|
6
|
+
@use './components/SubmitButton/SubmitButton.module';
|
|
7
7
|
@use './components/Suggestions/Suggestions.module';
|
|
8
8
|
|
|
9
|
+
:root {
|
|
10
|
+
--search-drop-shadow: drop-shadow(0 -2px 20px rgba(0,0,0,.1)) drop-shadow(0 0 12px rgba(0,0,0,.1));
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
// ===================================================
|
|
10
14
|
// Component: Search
|
|
11
15
|
// ===================================================
|
|
@@ -10,12 +10,27 @@ const isPostcode = (value) => {
|
|
|
10
10
|
return regex.test(value)
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
const removeRegions = (results, regions) => results.filter(r => {
|
|
14
|
+
const country = r?.GAZETTEER_ENTRY?.COUNTRY?.toLowerCase()
|
|
15
|
+
return country && regions.includes(country)
|
|
16
|
+
})
|
|
17
|
+
|
|
13
18
|
const removeDuplicates = (results) =>
|
|
14
19
|
Array.from(new Map(results.map(r => [r.GAZETTEER_ENTRY.ID, r])).values())
|
|
15
20
|
|
|
21
|
+
const transformGazetteerResult = (result) => {
|
|
22
|
+
const gazetterEntry = result.GAZETTEER_ENTRY
|
|
23
|
+
// Sometimes the NAME1 value can be welsh, so we should use NAME2 instead
|
|
24
|
+
const name = gazetterEntry.NAME2_LANG === 'eng' ? gazetterEntry.NAME2 : gazetterEntry.NAME1
|
|
25
|
+
// Force NAME1 to be the english value
|
|
26
|
+
result.GAZETTEER_ENTRY.NAME1 = name
|
|
27
|
+
result.GAZETTEER_ENTRY.NAME1_LANG = 'eng'
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
const removeTenuousResults = (results, query) => {
|
|
17
32
|
const words = query.toLowerCase().replace(/,/g, '').split(' ')
|
|
18
|
-
return results.filter(l => words.some(w => l.GAZETTEER_ENTRY.NAME1.toLowerCase().includes(w) || isPostcode(query)))
|
|
33
|
+
return results.map(transformGazetteerResult).filter(l => words.some(w => l.GAZETTEER_ENTRY.NAME1.toLowerCase().includes(w) || isPostcode(query)))
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
const markString = (string, find) => {
|
|
@@ -86,13 +101,14 @@ const label = (query, { NAME1, COUNTY_UNITARY, DISTRICT_BOROUGH, POSTCODE_DISTRI
|
|
|
86
101
|
}
|
|
87
102
|
}
|
|
88
103
|
|
|
89
|
-
const parseOsNamesResults = (json, query, crs) => {
|
|
104
|
+
const parseOsNamesResults = (json, query, regions, crs) => {
|
|
90
105
|
if (!json || json.error || json.header?.totalresults === 0) {
|
|
91
106
|
return []
|
|
92
107
|
}
|
|
93
108
|
let results = json.results
|
|
94
109
|
results = removeTenuousResults(results, query)
|
|
95
110
|
results = removeDuplicates(results)
|
|
111
|
+
results = removeRegions(results, regions)
|
|
96
112
|
results = results.slice(0, MAX_RESULTS)
|
|
97
113
|
|
|
98
114
|
return results.map(l => ({
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* @jest-environment jsdom
|
|
3
3
|
*/
|
|
4
4
|
import { parseOsNamesResults, point } from './parseOsNamesResults.js'
|
|
5
|
-
import OsGridRef from 'geodesy/osgridref.js'
|
|
6
5
|
|
|
7
6
|
// Mock OsGridRef so we can control toLatLon outputs
|
|
8
7
|
jest.mock('geodesy/osgridref.js', () => {
|
|
@@ -14,6 +13,8 @@ jest.mock('geodesy/osgridref.js', () => {
|
|
|
14
13
|
})
|
|
15
14
|
|
|
16
15
|
describe('osNamesUtils', () => {
|
|
16
|
+
const regions = ['england', 'scotland', 'wales']
|
|
17
|
+
|
|
17
18
|
const sampleEntry = {
|
|
18
19
|
GAZETTEER_ENTRY: {
|
|
19
20
|
ID: 1,
|
|
@@ -22,6 +23,7 @@ describe('osNamesUtils', () => {
|
|
|
22
23
|
DISTRICT_BOROUGH: 'Camden',
|
|
23
24
|
POSTCODE_DISTRICT: 'WC1',
|
|
24
25
|
LOCAL_TYPE: 'Town',
|
|
26
|
+
COUNTRY: 'England',
|
|
25
27
|
MBR_XMIN: 1000,
|
|
26
28
|
MBR_YMIN: 2000,
|
|
27
29
|
MBR_XMAX: 3000,
|
|
@@ -32,9 +34,9 @@ describe('osNamesUtils', () => {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
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([])
|
|
37
|
+
expect(parseOsNamesResults(null, 'x', regions, 'EPSG:27700')).toEqual([])
|
|
38
|
+
expect(parseOsNamesResults({ error: true }, 'x', regions, 'EPSG:27700')).toEqual([])
|
|
39
|
+
expect(parseOsNamesResults({ header: { totalresults: 0 } }, 'x', regions, 'EPSG:27700')).toEqual([])
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
test('removes tenuous results when query does not match', () => {
|
|
@@ -42,14 +44,14 @@ describe('osNamesUtils', () => {
|
|
|
42
44
|
{ GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'Bristol', ID: 2 } }
|
|
43
45
|
]
|
|
44
46
|
const json = { results }
|
|
45
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
47
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
|
|
46
48
|
expect(output).toHaveLength(0)
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
test('removes duplicate IDs', () => {
|
|
50
52
|
const dup = { ...sampleEntry, GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
|
|
51
53
|
const json = { results: [sampleEntry, dup] }
|
|
52
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
54
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
|
|
53
55
|
expect(output).toHaveLength(1)
|
|
54
56
|
})
|
|
55
57
|
|
|
@@ -58,13 +60,13 @@ describe('osNamesUtils', () => {
|
|
|
58
60
|
GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, ID: i }
|
|
59
61
|
}))
|
|
60
62
|
const json = { results: manyResults }
|
|
61
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
63
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
|
|
62
64
|
expect(output).toHaveLength(8)
|
|
63
65
|
})
|
|
64
66
|
|
|
65
67
|
test('bounds returns raw OSGB values for EPSG:27700', () => {
|
|
66
68
|
const json = { results: [sampleEntry] }
|
|
67
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
69
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
|
|
68
70
|
expect(output[0].bounds).toEqual([
|
|
69
71
|
sampleEntry.GAZETTEER_ENTRY.MBR_XMIN,
|
|
70
72
|
sampleEntry.GAZETTEER_ENTRY.MBR_YMIN,
|
|
@@ -79,7 +81,7 @@ describe('osNamesUtils', () => {
|
|
|
79
81
|
|
|
80
82
|
test('bounds converts to WGS84 for EPSG:4326', () => {
|
|
81
83
|
const json = { results: [sampleEntry] }
|
|
82
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:4326')
|
|
84
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:4326')
|
|
83
85
|
const expectedBounds = [
|
|
84
86
|
Math.round(1000 / 1e5 * 1e6) / 1e6,
|
|
85
87
|
Math.round(2000 / 1e5 * 1e6) / 1e6,
|
|
@@ -96,7 +98,7 @@ describe('osNamesUtils', () => {
|
|
|
96
98
|
|
|
97
99
|
test('label generates marked text', () => {
|
|
98
100
|
const json = { results: [sampleEntry] }
|
|
99
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
101
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
|
|
100
102
|
expect(output[0].text).toContain('London')
|
|
101
103
|
expect(output[0].marked).toContain('<mark>')
|
|
102
104
|
})
|
|
@@ -106,7 +108,7 @@ describe('osNamesUtils', () => {
|
|
|
106
108
|
GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, MBR_XMIN: null, MBR_YMIN: null }
|
|
107
109
|
}
|
|
108
110
|
const json = { results: [entry] }
|
|
109
|
-
const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
|
|
111
|
+
const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
|
|
110
112
|
expect(output[0].bounds).toEqual([
|
|
111
113
|
entry.GAZETTEER_ENTRY.GEOMETRY_X - 500,
|
|
112
114
|
entry.GAZETTEER_ENTRY.GEOMETRY_Y - 500,
|
|
@@ -117,24 +119,40 @@ describe('osNamesUtils', () => {
|
|
|
117
119
|
|
|
118
120
|
test('label falls back to DISTRICT_BOROUGH when COUNTY_UNITARY is absent', () => {
|
|
119
121
|
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, COUNTY_UNITARY: null } }
|
|
120
|
-
const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
|
|
122
|
+
const output = parseOsNamesResults({ results: [entry] }, 'London', regions, 'EPSG:27700')
|
|
121
123
|
expect(output[0].text).toContain(sampleEntry.GAZETTEER_ENTRY.DISTRICT_BOROUGH)
|
|
122
124
|
})
|
|
123
125
|
|
|
124
126
|
test('label omits qualifier for City type', () => {
|
|
125
127
|
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, LOCAL_TYPE: 'City' } }
|
|
126
|
-
const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
|
|
128
|
+
const output = parseOsNamesResults({ results: [entry] }, 'London', regions, 'EPSG:27700')
|
|
127
129
|
expect(output[0].text).toBe('London')
|
|
128
130
|
})
|
|
129
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Cover transformGazetteerResult branches
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
test('uses NAME2 when NAME2_LANG is eng', () => {
|
|
137
|
+
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'Caerdydd', NAME2: 'Cardiff', NAME2_LANG: 'eng' }}
|
|
138
|
+
const output = parseOsNamesResults({ results: [entry] }, 'Cardiff', regions, 'EPSG:27700')
|
|
139
|
+
expect(output[0].text).toContain('Cardiff')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('falls back to NAME1 when NAME2_LANG is not eng', () => {
|
|
143
|
+
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'London', NAME2: 'Londres', NAME2_LANG: 'fra' }}
|
|
144
|
+
const output = parseOsNamesResults({ results: [entry] }, 'London', regions, 'EPSG:27700')
|
|
145
|
+
expect(output[0].text).toContain('London')
|
|
146
|
+
})
|
|
147
|
+
|
|
130
148
|
test('throws error for unsupported CRS', () => {
|
|
131
149
|
const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
|
|
132
150
|
const json = { results: [entry] }
|
|
133
|
-
expect(() => parseOsNamesResults(json, 'London', 'EPSG:9999')).toThrow('Unsupported CRS')
|
|
151
|
+
expect(() => parseOsNamesResults(json, 'London', regions, 'EPSG:9999')).toThrow('Unsupported CRS')
|
|
134
152
|
})
|
|
135
153
|
|
|
136
154
|
test('point function throws error for unsupported CRS', () => {
|
|
137
155
|
const coords = { GEOMETRY_X: 1500, GEOMETRY_Y: 2500 }
|
|
138
156
|
expect(() => point('EPSG:9999', coords)).toThrow('Unsupported CRS: EPSG:9999')
|
|
139
157
|
})
|
|
140
|
-
})
|
|
158
|
+
})
|