diff --git a/app/assets/javascript/main.js b/app/assets/javascript/main.js index e13ff9a6..bae0b862 100644 --- a/app/assets/javascript/main.js +++ b/app/assets/javascript/main.js @@ -97,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => { if ($resetLink) { $resetLink.addEventListener('click', async (e) => { e.preventDefault() + sessionStorage.clear() try { const response = await fetch('/prototype-admin/reset-session-data', { @@ -118,4 +119,97 @@ document.addEventListener('DOMContentLoaded', () => { } }) } + + // Reading workflow: delay initial opinion controls to prevent premature clicks + + // When first arriving on a case, users should be prevented from giving an opinion for a period of time. On NBSS this is 30 seconds, but for the prototype is set to 5 seconds to avoid being annoying whilst testing. + const opinionForm = document.querySelector('[data-reading-opinion-form]') + if (opinionForm) { + const eventId = opinionForm.dataset.eventId + if (eventId) { + try { + if (opinionForm.dataset.readingOpinionLocked !== 'true') { + opinionForm.classList.remove('app-reading-opinion--locked') + opinionForm.dataset.readingOpinionLocked = 'false' + return + } + + // Key by date + batch + event so resets and new batches re-lock + const batchId = opinionForm.dataset.batchId || 'no-batch' + const todayKey = new Date().toISOString().slice(0, 10) + const unlockKey = `readingOpinionUnlocked:${todayKey}:${batchId}:${eventId}` + if (!sessionStorage.getItem(unlockKey)) { + sessionStorage.setItem(unlockKey, 'true') + opinionForm.classList.add('app-reading-opinion--locked') + opinionForm.dataset.readingOpinionLocked = 'true' + + const controls = Array.from( + opinionForm.querySelectorAll('button, input, select, textarea') + ) + const interactiveControls = controls.filter((control) => { + if ( + control.tagName.toLowerCase() === 'input' && + control.type === 'hidden' + ) { + return false + } + + return true + }) + + const linkControls = Array.from( + opinionForm.querySelectorAll('.app-button-link') + ).filter((control) => control.tagName.toLowerCase() === 'a') + + interactiveControls.forEach((control) => { + control.disabled = true + }) + + linkControls.forEach((control) => { + control.setAttribute('aria-disabled', 'true') + control.dataset.readingOpinionDisabled = 'true' + control.addEventListener('click', (event) => { + if (control.dataset.readingOpinionDisabled === 'true') { + event.preventDefault() + } + }) + }) + + setTimeout(() => { + interactiveControls.forEach((control) => { + control.disabled = false + }) + + linkControls.forEach((control) => { + control.removeAttribute('aria-disabled') + control.dataset.readingOpinionDisabled = 'false' + }) + + opinionForm.classList.remove('app-reading-opinion--locked') + opinionForm.dataset.readingOpinionLocked = 'false' + }, 5000) + } else { + opinionForm.classList.remove('app-reading-opinion--locked') + opinionForm.dataset.readingOpinionLocked = 'false' + } + } catch (error) { + console.error('Error applying opinion delay:', error) + } + } + } + + // Reading workflow: auto-dismiss temporary opinion banner + const opinionBanner = document.querySelector('[data-reading-opinion-banner]') + if (opinionBanner) { + const delayValue = Number(opinionBanner.dataset.autoCloseDelay) + if (!Number.isNaN(delayValue) && delayValue > 0) { + const fadeDurationMs = 200 + setTimeout(() => { + opinionBanner.classList.add('app-reading-opinion-banner--fade-out') + setTimeout(() => { + opinionBanner.remove() + }, fadeDurationMs) + }, delayValue) + } + } }) diff --git a/app/assets/sass/components/_reading.scss b/app/assets/sass/components/_reading.scss index c3ad4d90..660fbc95 100644 --- a/app/assets/sass/components/_reading.scss +++ b/app/assets/sass/components/_reading.scss @@ -11,6 +11,8 @@ padding: 10px; } +// Todo: should this be merged with app-status-bar.scss? +// Todo: otherwise refctor so classes aren't created with & .app-reading-status { background-color: nhsuk-shade(nhsuk-colour("blue"), 40%); color: $nhsuk-reverse-text-colour; @@ -54,3 +56,107 @@ } } } + +// Toast message shown after giving an opinion +.app-reading-opinion-banner { + background-color: nhsuk-colour("blue"); + color: $nhsuk-reverse-text-colour; + display: flex; + gap: nhsuk-spacing(3); + opacity: 1; + transition: opacity 0.2s ease-out; + + // Manually chosen to line up with the 'previous case' link + left: 17px; + top: 10px; + max-width: 720px; + padding: nhsuk-spacing(3) nhsuk-spacing(3); + position: absolute; + z-index: 10; + // Todo: reinstate this in v11 + // border-radius: nhsuk-px-to-rem($nhsuk-button-border-radius); + border-radius: 4px; + filter: drop-shadow(2px 3px 3px nhsuk-tint(nhsuk-colour("black"), 50%)); +} + +@media (prefers-reduced-motion: reduce) { + .app-reading-opinion-banner { + transition: none; + } +} + +.app-reading-opinion-banner--fade-out { + opacity: 0; +} + +.app-reading-opinion-banner__text { + color: $nhsuk-reverse-text-colour; + margin: 0; +} + +.app-reading-opinion-banner__link, +.app-reading-opinion-banner__link:visited, +.app-reading-opinion-banner__link:hover, +.app-reading-opinion-banner__link:focus { + color: $nhsuk-reverse-text-colour; + margin-left: nhsuk-spacing(3); + text-decoration: underline; + transform: translateY(2px); + white-space: nowrap; +} + +// To do: is there a better way of doing inverse links? +.app-reading-opinion-banner__link:focus, +.app-reading-opinion-banner__link:active { + color: $nhsuk-text-colour; +} + +// Reading workflow: styling for locked state +// When first arriving on a case, users should be prevented from giving an opinion for a period of time. On NBSS this is 30 seconds, but for the prototype is set to 5 seconds to avoid being annoying whilst testing. +.js-enabled { + .app-reading-opinion--locked, + .app-reading-opinion[data-reading-opinion-locked="true"] { + cursor: not-allowed; + + * { + cursor: not-allowed; + } + + button, + input[type="button"], + input[type="submit"], + input[type="radio"], + .nhsuk-button, + .nhsuk-radios__label, + .nhsuk-radios__input, + .app-button-link { + cursor: not-allowed; + } + + .nhsuk-button, + .nhsuk-radios__label, + .nhsuk-radios__input, + button, + input[type="button"], + input[type="submit"] { + opacity: 0.6; + pointer-events: none; + } + + .app-button-link, + .app-button-link:hover, + .app-button-link:focus, + .app-button-link[aria-disabled="true"], + .app-button-link[aria-disabled="true"]:hover, + .app-button-link[aria-disabled="true"]:focus, + .app-button-link[disabled], + .app-button-link[disabled]:hover, + .app-button-link[disabled]:focus, + .app-button-link:disabled, + .app-button-link:disabled:hover, + .app-button-link:disabled:focus { + color: nhsuk-tint($nhsuk-link-colour, 50%); + text-decoration: underline; + } + } +} diff --git a/app/config.js b/app/config.js index 31634362..4f7bbc59 100644 --- a/app/config.js +++ b/app/config.js @@ -48,6 +48,7 @@ module.exports = { // Distribution of image set tags (must sum to 1.0) // These are the base weights - they get adjusted based on event context // (symptoms, imperfect images, etc.) + opinionBannerDurationMs: 3000, mammogramTagWeights: { normal: 0.7, abnormal: 0.15, diff --git a/app/routes/reading.js b/app/routes/reading.js index c62f4bab..eea793ac 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -18,6 +18,7 @@ const { skipEventInBatch, getReadingMetadata } = require('../lib/utils/reading') +const { getShortName } = require('../lib/utils/participants') const { camelCase, snakeCase } = require('../lib/utils/strings') const dayjs = require('dayjs') const generateId = require('../lib/utils/id-generator') @@ -319,6 +320,18 @@ module.exports = (router) => { // Update res.locals.data to reflect the change (it was set before this middleware) res.locals.data.imageReadingTemp = data.imageReadingTemp } + + // Pass along opinion banner and remove from session + // Bypassing req.flash as we couldn't get it to work - possibly due to redirect loops + // Not great we're hardcoding these pages. Would be better to have a more general mechanism. + if ( + (req.path.endsWith('/opinion') || + req.path.endsWith('/existing-read')) && + data.readingOpinionBanner + ) { + res.locals.readingOpinionBanner = data.readingOpinionBanner + delete data.readingOpinionBanner + } } // Set up locals for templates @@ -844,6 +857,27 @@ module.exports = (router) => { // Get progress to find next event const progress = getBatchReadingProgress(data, batchId, eventId) + // Store banner message for the next case + // Bypassing req.flash as we couldn't get it to work - possibly due to redirect loops + // Todo: can we get this working with req.flash? + const participant = data.participants.find( + (person) => person.id === event.participantId + ) + const shortName = getShortName(participant) + const resultLabels = { + normal: 'Normal', + technical_recall: 'Technical recall', + recall_for_assessment: 'Recall for assessment' + } + const resultLabel = resultLabels[formData.result] || 'Opinion' + const message = `${resultLabel} opinion recorded for ${shortName}` + + data.readingOpinionBanner = { + text: message, + participantName: `${shortName}`, // This didn't work when used directly - coerced to string instead. + editHref: `/reading/batch/${batchId}/events/${eventId}/existing-read` + } + // Redirect to next event or batch view if (progress.hasNextUserReadable) { res.redirect( diff --git a/app/views/_includes/reading/opinion-banner.njk b/app/views/_includes/reading/opinion-banner.njk new file mode 100644 index 00000000..57b2ebab --- /dev/null +++ b/app/views/_includes/reading/opinion-banner.njk @@ -0,0 +1,11 @@ +{# app/views/_includes/reading/opinion-banner.njk #} +{# Temporary banner shown after recording the previous opinion #} + +{% if readingOpinionBanner %} +
+{% endif %} \ No newline at end of file diff --git a/app/views/_includes/reading/workflow-navigation.njk b/app/views/_includes/reading/workflow-navigation.njk index 88889b8f..fa2515a2 100644 --- a/app/views/_includes/reading/workflow-navigation.njk +++ b/app/views/_includes/reading/workflow-navigation.njk @@ -4,6 +4,8 @@ {# Previous/next case navigation #} {% if progress.hasPreviousUserReadable or progress.hasNextUserReadable %}No medical information recorded.
+{% else %} + {{ summaryList({ + rows: rowsForSummaryList + }) }} +{% endif %} diff --git a/app/views/_includes/summary-lists/medical-information/symptoms/summary.njk b/app/views/_includes/summary-lists/medical-information/symptoms/summary.njk index db702619..a71f7ae1 100644 --- a/app/views/_includes/summary-lists/medical-information/symptoms/summary.njk +++ b/app/views/_includes/summary-lists/medical-information/symptoms/summary.njk @@ -135,7 +135,7 @@ } ] } if allowEdits, - classes: "nhsuk-summary-list--no-border" if loop.last + classes: "nhsuk-summary-list__row--no-border" if loop.last }) %} {% endfor %} diff --git a/app/views/reading/workflow/existing-read.html b/app/views/reading/workflow/existing-read.html index 863452ef..1b9e539a 100644 --- a/app/views/reading/workflow/existing-read.html +++ b/app/views/reading/workflow/existing-read.html @@ -8,8 +8,6 @@ {% block pageContent %} - - {{ participant | getFullName }} @@ -32,13 +30,15 @@