diff --git a/app/assets/images/mobile.svg b/app/assets/images/mobile.svg new file mode 100644 index 0000000..f0a3fa3 --- /dev/null +++ b/app/assets/images/mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/org.svg b/app/assets/images/org.svg new file mode 100644 index 0000000..6fc2313 --- /dev/null +++ b/app/assets/images/org.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/static.svg b/app/assets/images/static.svg new file mode 100644 index 0000000..1971eae --- /dev/null +++ b/app/assets/images/static.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/javascript/map-scripts.js b/app/assets/javascript/map-scripts.js new file mode 100644 index 0000000..4e2b94c --- /dev/null +++ b/app/assets/javascript/map-scripts.js @@ -0,0 +1,47 @@ +function circleIcon({ fill = '#fff', stroke = '#000', strokeWidth = 2, + text = '', textColor = '#000', radius = 14, fillOpacity = 1 } = {}) { + const width = radius * 2; + const pointHeight = radius * 1.2; + const height = width + pointHeight; + const cx = radius; + const cy = radius; + + return L.divIcon({ + html: ` + + + + + + + ${text} + + `, + className: '', + iconSize: [width, height], + iconAnchor: [cx, height], + popupAnchor: [0, -height] + }); +} + +const landmarkIcons = { + orgLocation: circleIcon({ fill: '#fff9c4', text: 'O' }), + static: circleIcon({ fill: '#fff9c4', text: 'S' }), + mobile: circleIcon({ fill: '#ed8b00', text: 'M' }), + default: circleIcon({ fill: '#fff', text: '' }), +}; + +const mapOptions = { + attributionControl: false, + scrollWheelZoom: false +}; + +const map = L.map('app-map', { + maxZoom: 15 +}); diff --git a/app/assets/sass/components/_secondary-navigation.scss b/app/assets/sass/components/_secondary-navigation.scss new file mode 100644 index 0000000..345b6c0 --- /dev/null +++ b/app/assets/sass/components/_secondary-navigation.scss @@ -0,0 +1,96 @@ +@use "nhsuk-frontend/dist/nhsuk/core" as *; + +.app-secondary-navigation { + margin-right: #{$nhsuk-gutter-half * -1}; + margin-left: #{$nhsuk-gutter-half * -1}; + + @include nhsuk-responsive-margin(5, "bottom"); + + @include nhsuk-media-query($from: tablet) { + margin-right: auto; + margin-left: auto; + } +} + +.app-secondary-navigation__link { + display: flex; + align-items: center; + gap: 6px; + padding: nhsuk-spacing(2) $nhsuk-gutter-half; + + &:link { + text-decoration: none; + } + + @include nhsuk-link-style-default; + @include nhsuk-link-style-no-visited-state; + + @include nhsuk-media-query($from: tablet) { + padding: nhsuk-spacing(3) 2px; + } + + &[aria-current] { + color: $nhsuk-text-colour; + box-shadow: inset $nhsuk-border-width 0 nhsuk-colour("blue"); + text-decoration: none; + + @include nhsuk-media-query($from: tablet) { + box-shadow: inset 0 ($nhsuk-border-width * -1) nhsuk-colour("blue"); + } + } + + &:focus { + box-shadow: inset $nhsuk-focus-width 0 $nhsuk-focus-text-colour; + + @include nhsuk-media-query($from: tablet) { + box-shadow: inset 0 ($nhsuk-focus-width * -1) $nhsuk-focus-text-colour; + } + } + + .nhsuk-icon { + width: 1.5rem; + height: 1.5rem; + } +} + +.app-secondary-navigation__link--disabled { + color: $nhsuk-secondary-text-colour; + cursor: not-allowed; + pointer-events: none; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: none; + } +} + +.app-secondary-navigation__current { + font-weight: inherit; +} + +.app-secondary-navigation__list { + display: flex; + + flex-flow: column; + + width: 100%; + margin: 0; + padding: 0; + + list-style: none; + // The list uses box-shadow rather than a border to set a 1px grey line at the + // bottom, so that the current item appears on top of the grey line. + box-shadow: inset 0 -1px 0 $nhsuk-border-colour; + + @include nhsuk-font(19); + + @include nhsuk-media-query($from: tablet) { + flex-flow: row wrap; + gap: nhsuk-spacing(2) nhsuk-spacing(5); + } +} + +.app-secondary-navigation__list-item { + margin-bottom: 0; +} diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss index 697b813..ad807ef 100755 --- a/app/assets/sass/main.scss +++ b/app/assets/sass/main.scss @@ -1,4 +1,14 @@ // Import NHS.UK frontend library -@import "nhsuk-frontend/dist/nhsuk"; +@forward "nhsuk-frontend/dist/nhsuk/nhsuk"; + +// Stolen Manage prototype components :P +// https://github.com/NHSDigital/manage-breast-screening-prototype + +@forward "components/secondary-navigation"; // Add your custom CSS/Sass styles below. +.app-card-editable { + display: flex; + justify-content: space-between; + align-items: baseline; +} diff --git a/app/config.js b/app/config.js index 535ee66..8fd8d1d 100644 --- a/app/config.js +++ b/app/config.js @@ -2,7 +2,7 @@ module.exports = { // Service name - serviceName: 'Service name goes here', + serviceName: 'Cohort to clinic service name', // Port to run the prototype on locally port: 3000 diff --git a/app/filters.js b/app/filters.js index b562ced..25d59a9 100644 --- a/app/filters.js +++ b/app/filters.js @@ -1,44 +1,45 @@ -/** - * @param {Environment} env - */ -module.exports = function (env) { - const filters = {} - - /* ------------------------------------------------------------------ - add your methods to the filters obj below this comment block: - @example: - - filters.sayHi = function(name) { - return 'Hi ' + name + '!' - } - - Which in your templates would be used as: - - {{ 'Paul' | sayHi }} => 'Hi Paul' - - Notice the first argument of your filters method is whatever - gets 'piped' via '|' to the filter. - - Filters can take additional arguments, for example: - - filters.sayHi = function(name,tone) { - return (tone == 'formal' ? 'Greetings' : 'Hi') + ' ' + name + '!' - } - - Which would be used like this: - - {{ 'Joel' | sayHi('formal') }} => 'Greetings Joel!' - {{ 'Gemma' | sayHi }} => 'Hi Gemma!' - - For more on filters and how to write them see the Nunjucks - documentation. - - ------------------------------------------------------------------ */ - - /* keep the following line to return your filters to the app */ - return filters -} - -/** - * @import { Environment } from 'nunjucks' - */ +// app/filters.js + + const fs = require('fs') + const path = require('path') + + module.exports = function (env) { + /* eslint-disable-line func-names,no-unused-vars */ + /** + * Instantiate object used to store the methods registered as a + * 'filter' (of the same name) within nunjucks. You can override + * gov.uk core filters by creating filter methods of the same name. + * + * @type {object} + */ + const filters = {} + + // Get all files from utils directory + const utilsPath = path.join(__dirname, 'lib/utils') + //const filtersPath = path.join(__dirname, 'filters') + + //const folderPaths = [utilsPath, filtersPath] + const folderPaths = [utilsPath] + + try { + folderPaths.forEach((folderPath) => { + const files = fs.readdirSync(folderPath) + + files.forEach((file) => { + if (path.extname(file) === '.js') { + const module = require(path.join(folderPath, file)) + + Object.entries(module).forEach(([name, func]) => { + if (typeof func === 'function') { + filters[name] = func + } + }) + } + }) + }) + } catch (err) { + console.warn('Error loading filters:', err) + } + + return filters + } diff --git a/app/lib/utils/arrays.js b/app/lib/utils/arrays.js new file mode 100644 index 0000000..0a7a70b --- /dev/null +++ b/app/lib/utils/arrays.js @@ -0,0 +1,180 @@ +// app/lib/utils/arrays.js + +const _ = require('lodash') + +/** + * Find an object by ID in an array + * + * @param {Array} array - Array to search + * @param {string} id - ID to find + * @returns {object} Found object or undefined + */ +const findById = (array, id) => { + if (!array || !Array.isArray(array)) return undefined + return array.find((item) => item.id === id) +} + +const push = (array, item) => { + const newArray = [...array] + newArray.push(_.cloneDeep(item)) // clone needed to stop this mutating original + return newArray +} + +/** + * Check if an array includes a value + * + * @param {Array} array - Array to check + * @param {*} value - Value to look for + * @returns {boolean} True if array includes value, false otherwise + */ +const includes = (array, value) => { + if (!array || !Array.isArray(array)) return false + return array.includes(value) +} + +/** + * Find first array item where the specified key matches the value + * + * @param {Array} array - Array to search + * @param {string} key - Object key to match against + * @param {any} value - Value to find + * @returns {any} First matching item or undefined + * @example + * const users = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}] + * find(users, 'name', 'Bob') // Returns {id: 2, name: 'Bob'} + */ +const find = (array, key, value) => { + if (!array || !Array.isArray(array)) return undefined + return array.find((item) => item[key] === value) +} + +/** + * Remove empty items from arrays or strings + * + * @param {Array|string} items - Items to filter + * @returns {Array|string|undefined} Filtered items or undefined if empty + */ +const removeEmpty = (items) => { + if (!items) return + + if (_.isString(items)) { + return items.trim() || undefined + } + + if (_.isArray(items)) { + const filtered = items.filter((item) => { + // Filter out falsy values and empty strings + if (!item || item === '') return false + + // Filter out empty objects + if ( + _.isObject(item) && + !_.isArray(item) && + Object.keys(item).length === 0 + ) + return false + + // Filter out empty arrays + if (_.isArray(item) && item.length === 0) return false + + return true + }) + return filtered.length ? filtered : undefined + } +} + +/** + * Filter array to items where the specified property matches one of the comparison values + * + * @param {Array} array - Array to filter + * @param {string} key - Object property path to match against (supports dot notation) + * @param {*|Array} compare - Value or array of values to match + * @returns {Array} Filtered array containing only matching items + * @example + * where([{type: 'dog'}, {type: 'cat'}], 'type', 'dog') // Returns [{type: 'dog'}] + * where(users, 'address.postcode', ['OX1', 'OX2']) // Returns users with matching postcodes + */ +const where = (array, key, compare) => { + if (!array || !Array.isArray(array)) return [] + + // Force comparison value to array + const compareValues = Array.isArray(compare) ? compare : [compare] + + return array.filter((item) => { + const value = _.get(item, key) + return compareValues.includes(value) + }) +} + +/** + * Filter array to remove items where the specified property matches one of the comparison values + * + * @param {Array} array - Array to filter + * @param {string} key - Object property path to match against (supports dot notation) + * @param {*|Array} compare - Value or array of values to exclude + * @returns {Array} Filtered array with matching items removed + * @example + * removeWhere([{type: 'dog'}, {type: 'cat'}], 'type', 'dog') // Returns [{type: 'cat'}] + * removeWhere(users, 'status', ['inactive', 'suspended']) // Returns only active users + */ +const removeWhere = (array, key, compare) => { + if (!array || !Array.isArray(array)) return [] + + // Force comparison value to array + const compareValues = Array.isArray(compare) ? compare : [compare] + + return array.filter((item) => { + const value = _.get(item, key) + return !compareValues.includes(value) + }) +} + +/** + * Apply a filter to each element in an array + * + * @param {Array} array - Array to map over + * @param {string} filterName - Name of the filter to apply to each element + * @returns {Array} New array with filter applied to each element + */ +const map = function (array, filterName) { + if (!array || !Array.isArray(array)) return [] + + // In Nunjucks filter context, 'this' gives us access to the environment + // and we can access other filters through the environment + const env = this.env + + if (!env || !env.filters || !env.filters[filterName]) { + console.warn(`Filter '${filterName}' not found`) + return array + } + + const filterFunction = env.filters[filterName] + + return array.map((item) => filterFunction.call(this, item)) +} + +/** + * Check if a value is an array + * + * @param {*} value - Value to check + * @returns {boolean} True if value is an array, false otherwise + * @example + * isArray([1, 2, 3]) // Returns true + * isArray('hello') // Returns false + * isArray(null) // Returns false + */ +const isArray = (value) => { + return Array.isArray(value) +} + +module.exports = { + push, + includes, + find, + removeEmpty, + findById, + where, + removeWhere, + map, + isArray +} diff --git a/app/views/_components/secondary-navigation/macro.njk b/app/views/_components/secondary-navigation/macro.njk new file mode 100644 index 0000000..0f1480e --- /dev/null +++ b/app/views/_components/secondary-navigation/macro.njk @@ -0,0 +1,3 @@ +{% macro appSecondaryNavigation(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/app/views/_components/secondary-navigation/template.njk b/app/views/_components/secondary-navigation/template.njk new file mode 100644 index 0000000..0428b4b --- /dev/null +++ b/app/views/_components/secondary-navigation/template.njk @@ -0,0 +1,28 @@ +{%- from "nhsuk/macros/attributes.njk" import nhsukAttributes -%} + diff --git a/app/views/index.html b/app/views/index.html index 2caa7e2..8225aa6 100755 --- a/app/views/index.html +++ b/app/views/index.html @@ -37,16 +37,28 @@

{{ serviceName }}

-

Set the service name for your prototype by editing /app/config.js.

+

Late March 2026

+

Mat note: first rounds of flats based on "easy win" building blocks. Cheap sketches to provoke.

-

- This is the index page for your prototype. You can find it at /app/views/index.html. -

+
    +
  1. + Manage organisation, breast screening units, and locations +
  2. +
  3. + BSO organisation +
  4. +
  5. + Breast screening units +
  6. +
  7. + Locations +
  8. +

- Get started + Getting started