diff --git a/package-lock.json b/package-lock.json index 664a4b720..2c65db7f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.667", + "@defra/forms-model": "^3.0.668", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.22-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -1315,9 +1315,9 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", + "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.28.6", @@ -3513,9 +3513,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.667", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.667.tgz", - "integrity": "sha512-NLAj/qblpSZy3wR0VOfM8QDWqNj7qnfiZwj01cmfJLrpaVzpHJTkjyxO3Ki4JI8xAtk3p3knBWFNv3ayED56QQ==", + "version": "3.0.668", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.668.tgz", + "integrity": "sha512-H0FBwHZu+joIlBXdGOiqDguZMtVT8WDxIFUUw737wJNdFrqj0PxlpVQn/hPHc5Juvt0s19JUZhrNlI0sXoucZw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -11694,9 +11694,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -17596,9 +17596,9 @@ } }, "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -18337,9 +18337,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -20397,9 +20397,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -34474,9 +34474,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz", - "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", + "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", @@ -34974,9 +34974,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 441a71b0f..42a2515be 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.667", + "@defra/forms-model": "^3.0.668", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.22-alpha", "@elastic/ecs-pino-format": "^1.5.0", diff --git a/src/client/javascripts/geospatial-map.js b/src/client/javascripts/geospatial-map.js index 01c805a84..aec354bd3 100644 --- a/src/client/javascripts/geospatial-map.js +++ b/src/client/javascripts/geospatial-map.js @@ -7,6 +7,7 @@ import { getCentroidGridRef, getCoordinateGridRef } from '~/src/client/javascripts/map.js' +import { formatDelimtedList } from '~/src/client/javascripts/utils.js' const helpPanelConfig = { showLabel: true, @@ -28,8 +29,63 @@ const helpPanelConfig = { open: true, dismissible: true, modal: false - }, - html: '

You can add points, shapes or lines to the map.

' + } +} + +/** + * @param {boolean} allowLine + * @param {boolean} allowShape + */ +function getLineOrShapeText(allowLine, allowShape) { + if (allowLine && allowShape) { + return 'a line or shape' + } + if (allowLine) { + return 'a line' + } + if (allowShape) { + return 'a shape' + } + return '' +} + +/** + * @param {boolean} allowPoint + * @param {boolean} allowLine + * @param {boolean} allowShape + */ +function getAllowedTypesPhrase(allowPoint, allowLine, allowShape) { + const items = [] + + if (allowPoint) { + items.push('points') + } + if (allowLine) { + items.push('lines') + } + if (allowShape) { + items.push('shapes') + } + + return formatDelimtedList(items, ',', 'or') +} + +/** + * @param {boolean} allowPoint + * @param {boolean} allowLine + * @param {boolean} allowShape + */ +export function getHelpPanelHtml(allowPoint, allowLine, allowShape) { + const lineOrShapeText = getLineOrShapeText(allowLine, allowShape) + const doneExtra = lineOrShapeText + ? `
  • Double‑click, or select 'Done', when you have finished drawing ${lineOrShapeText}
  • ` + : '' + const allowedTypesText = getAllowedTypesPhrase( + allowPoint, + allowLine, + allowShape + ) + return `

    You can add ${allowedTypesText} to the map.

    ` } const lineFeatureProperties = { @@ -157,7 +213,18 @@ export function processGeospatial(config, geospatial, index) { const { map, interactPlugin } = createMap(mapId, initConfig, config) const featuresManager = getFeaturesManager(geojson) const activeFeatureManager = getActiveFeatureManager() - const uiManager = getUIManager(geojson, map, mapId, listEl, geospatialInput) + const geometryTypes = geospatial.dataset.geometrytypes ?? 'point,line,shape' + const options = { + geometryTypes + } + const uiManager = getUIManager( + geojson, + map, + mapId, + listEl, + geospatialInput, + options + ) /** * @type {Context} @@ -492,16 +559,32 @@ function getValueRenderer(geojson, geospatialInput) { * @param {string} mapId - the ID of the map * @param {HTMLDivElement} listEl - where to render the feature list * @param {HTMLTextAreaElement} geospatialInput - the geospatial textarea + * @param {UIManagerOptions} options - extra options such as allowable geometry types */ -function getUIManager(geojson, map, mapId, listEl, geospatialInput) { +function getUIManager(geojson, map, mapId, listEl, geospatialInput, options) { + /** + * Get a CSV list of geometry types the user can create + * @returns {string[]} + */ + function getAllowableGeometryTypes() { + return options.geometryTypes ? options.geometryTypes.split(',') : [] + } + /** * Toggle the hidden state of the action buttons * @type {ToggleActionButtons} */ function toggleActionButtons(hidden) { - map.toggleButtonState('btnAddPoint', 'hidden', hidden) - map.toggleButtonState('btnAddPolygon', 'hidden', hidden) - map.toggleButtonState('btnAddLine', 'hidden', hidden) + const types = getAllowableGeometryTypes() + if (types.includes('point')) { + map.toggleButtonState('btnAddPoint', 'hidden', hidden) + } + if (types.includes('shape')) { + map.toggleButtonState('btnAddPolygon', 'hidden', hidden) + } + if (types.includes('line')) { + map.toggleButtonState('btnAddLine', 'hidden', hidden) + } } /** @@ -528,7 +611,8 @@ function getUIManager(geojson, map, mapId, listEl, geospatialInput) { renderValue, listEl, toggleActionButtons, - focusDescriptionInput + focusDescriptionInput, + getAllowableGeometryTypes } } @@ -572,7 +656,8 @@ function createContainers(geospatialInput, index) { function onMapReadyFactory(context) { const { map, activeFeatureManager, uiManager, interactPlugin, drawPlugin } = context - const { toggleActionButtons, renderList } = uiManager + const { toggleActionButtons, renderList, getAllowableGeometryTypes } = + uiManager const { resetActiveFeature } = activeFeatureManager /** @@ -581,53 +666,67 @@ function onMapReadyFactory(context) { * @param {MapLibreMap} e.map - the map provider instance */ return function onMapReady(e) { + const types = getAllowableGeometryTypes() + const allowPoint = types.includes('point') + const allowLine = types.includes('line') + const allowShape = types.includes('shape') + // Add info panel - map.addPanel('info', helpPanelConfig) - - map.addButton('btnAddPoint', { - variant: 'tertiary', - label: 'Add point', - iconSvgContent: POINT_SVG, - onClick: () => { - resetActiveFeature() - toggleActionButtons(true) - renderList(true) - interactPlugin.enable() - }, - mobile: { slot: 'actions' }, - tablet: { slot: 'actions' }, - desktop: { slot: 'actions' } + map.addPanel('info', { + ...helpPanelConfig, + html: getHelpPanelHtml(allowPoint, allowLine, allowShape) }) - map.addButton('btnAddPolygon', { - variant: 'tertiary', - label: 'Add shape', - iconSvgContent: POLYGON_SVG, - onClick: () => { - resetActiveFeature() - toggleActionButtons(true) - renderList(true) - drawPlugin.newPolygon(generateID(), polygonFeatureProperties) - }, - mobile: { slot: 'actions' }, - tablet: { slot: 'actions' }, - desktop: { slot: 'actions' } - }) + if (allowPoint) { + map.addButton('btnAddPoint', { + variant: 'tertiary', + label: 'Add point', + iconSvgContent: POINT_SVG, + onClick: () => { + resetActiveFeature() + toggleActionButtons(true) + renderList(true) + interactPlugin.enable() + }, + mobile: { slot: 'actions' }, + tablet: { slot: 'actions' }, + desktop: { slot: 'actions' } + }) + } - map.addButton('btnAddLine', { - variant: 'tertiary', - label: 'Add line', - iconSvgContent: LINE_SVG, - onClick: () => { - resetActiveFeature() - toggleActionButtons(true) - renderList(true) - drawPlugin.newLine(generateID(), lineFeatureProperties) - }, - mobile: { slot: 'actions' }, - tablet: { slot: 'actions' }, - desktop: { slot: 'actions' } - }) + if (allowShape) { + map.addButton('btnAddPolygon', { + variant: 'tertiary', + label: 'Add shape', + iconSvgContent: POLYGON_SVG, + onClick: () => { + resetActiveFeature() + toggleActionButtons(true) + renderList(true) + drawPlugin.newPolygon(generateID(), polygonFeatureProperties) + }, + mobile: { slot: 'actions' }, + tablet: { slot: 'actions' }, + desktop: { slot: 'actions' } + }) + } + + if (allowLine) { + map.addButton('btnAddLine', { + variant: 'tertiary', + label: 'Add line', + iconSvgContent: LINE_SVG, + onClick: () => { + resetActiveFeature() + toggleActionButtons(true) + renderList(true) + drawPlugin.newLine(generateID(), lineFeatureProperties) + }, + mobile: { slot: 'actions' }, + tablet: { slot: 'actions' }, + desktop: { slot: 'actions' } + }) + } // Set the map provider on the context context.mapProvider = e.map @@ -1055,6 +1154,12 @@ function onListElKeydownFactory() { * @returns {void} */ +/** + * Returns the list of geometry types a user can create + * @callback GetAllowableGeometryTypes + * @returns {string[]} + */ + /** * Set focus to the last description input * @callback FocusDescriptionInput @@ -1084,6 +1189,7 @@ function onListElKeydownFactory() { * @property {HTMLDivElement} listEl - the summary list of features * @property {ToggleActionButtons} toggleActionButtons - function that toggles the action buttons * @property {FocusDescriptionInput} focusDescriptionInput - function that sets focus to a description input element + * @property {GetAllowableGeometryTypes} getAllowableGeometryTypes - function that returns the array of geometry types a user can create */ /** @@ -1098,5 +1204,5 @@ function onListElKeydownFactory() { */ /** - * @import { MapLibreMap } from '~/src/client/javascripts/map.js' + * @import { MapLibreMap, UIManagerOptions } from '~/src/client/javascripts/map.js' */ diff --git a/src/client/javascripts/map.js b/src/client/javascripts/map.js index a6852c120..07f69a5d6 100644 --- a/src/client/javascripts/map.js +++ b/src/client/javascripts/map.js @@ -395,6 +395,11 @@ export function centerMap(map, mapProvider, center) { * @property {TileData} data - the tile data config */ +/** + * @typedef {object} UIManagerOptions + * @property {string} [geometryTypes] - the CSV list of geometry types that a user can create + */ + /** * @import { Feature } from '~/src/server/plugins/engine/types.js' */ diff --git a/src/client/javascripts/utils.js b/src/client/javascripts/utils.js new file mode 100644 index 000000000..d7f5314d3 --- /dev/null +++ b/src/client/javascripts/utils.js @@ -0,0 +1,23 @@ +/** + * Builds a text representation of a list in the form 'a, b, c, d or e' + * @param {string[]} items + * @param {string} separator + * @param {string} lastSpearator + */ +export function formatDelimtedList(items, separator, lastSpearator) { + if (items.length === 0) { + return '' + } + + if (items.length === 1) { + return items[0] + } + + if (items.length === 2) { + return `${items[0]} ${lastSpearator} ${items[1]}` + } + + const last = items.pop() + const separatorAndSpace = `${separator} ` + return `${items.join(separatorAndSpace)} ${lastSpearator} ${last}` +} diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index efa8f8215..e43cabd66 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -25,6 +25,15 @@ pages: required: true schema: {} id: b68df7f1-d4f4-4c17-83c8-402f584906c9 + - type: GeospatialField + title: Where do you live? + name: applicantLocation + shortDescription: Your location + hint: '' + options: + required: true + schema: {} + id: e18116e0-7c3e-416a-af42-6f229017c5b1 next: [] id: 622a35ec-3795-418a-81f3-a45746959045 - title: Upload a copy of your passport diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index f629f5010..b7ea33415 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -96,6 +96,7 @@ export class GeospatialField extends FormComponent { return { ...viewModel, country: this.options.countries?.at(0), + geometryTypes: this.options.geometryTypes, value } } diff --git a/src/server/plugins/engine/views/components/geospatialfield.html b/src/server/plugins/engine/views/components/geospatialfield.html index 6548fc57d..36915ec7e 100644 --- a/src/server/plugins/engine/views/components/geospatialfield.html +++ b/src/server/plugins/engine/views/components/geospatialfield.html @@ -1,7 +1,7 @@ {% from "govuk/components/textarea/macro.njk" import govukTextarea %} {% macro GeospatialField(component) %} -
    +
    {{ govukTextarea(component.model) }}
    {% endmacro %} diff --git a/test/client/javascripts/map.test.js b/test/client/javascripts/map.test.js index 8b3d6b733..1e0c1f7d5 100644 --- a/test/client/javascripts/map.test.js +++ b/test/client/javascripts/map.test.js @@ -1,6 +1,7 @@ import { createFeatureHTML, - createFeaturesHTML + createFeaturesHTML, + getHelpPanelHtml } from '~/src/client/javascripts/geospatial-map.js' import { formSubmitFactory, @@ -289,6 +290,44 @@ describe('Maps Client JS', () => { }) }) + describe('getHelpPanelHtml', () => { + it('should handle only point', () => { + expect(getHelpPanelHtml(true, false, false)).toBe( + '

    You can add points to the map.

    ' + ) + }) + it('should handle only line', () => { + expect(getHelpPanelHtml(false, true, false)).toBe( + '

    You can add lines to the map.

    ' + ) + }) + it('should handle only shape', () => { + expect(getHelpPanelHtml(false, false, true)).toBe( + '

    You can add shapes to the map.

    ' + ) + }) + it('should handle point and line', () => { + expect(getHelpPanelHtml(true, true, false)).toBe( + '

    You can add points or lines to the map.

    ' + ) + }) + it('should handle point and shape', () => { + expect(getHelpPanelHtml(true, false, true)).toBe( + '

    You can add points or shapes to the map.

    ' + ) + }) + it('should handle line and shape', () => { + expect(getHelpPanelHtml(false, true, true)).toBe( + '

    You can add lines or shapes to the map.

    ' + ) + }) + it('should handle point, line and shape', () => { + expect(getHelpPanelHtml(true, true, true)).toBe( + '

    You can add points, lines or shapes to the map.

    ' + ) + }) + }) + describe('Easting northing component', () => { beforeEach(() => { document.body.innerHTML = ` diff --git a/test/client/javascripts/utils.test.js b/test/client/javascripts/utils.test.js new file mode 100644 index 000000000..eeeaf71dc --- /dev/null +++ b/test/client/javascripts/utils.test.js @@ -0,0 +1,25 @@ +import { formatDelimtedList } from '~/src/client/javascripts/utils.js' + +describe('utils', () => { + describe('formatDelimitedList', () => { + it('should handle empty list', () => { + expect(formatDelimtedList([], ',', 'or')).toBe('') + }) + + it('should handle one item', () => { + expect(formatDelimtedList(['item1'], ',', 'or')).toBe('item1') + }) + + it('should handle two items', () => { + expect(formatDelimtedList(['item1', 'item2'], ',', 'or')).toBe( + 'item1 or item2' + ) + }) + + it('should handle three items', () => { + expect(formatDelimtedList(['item1', 'item2', 'item3'], ',', 'or')).toBe( + 'item1, item2 or item3' + ) + }) + }) +})