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 %} +
+

{{ readingOpinionBanner.text }}

+ + Review opinion for {{ readingOpinionBanner.participantName }} + +
+{% 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 %}
+ {# Toast message for previously read case #} + {% include "_includes/reading/opinion-banner.njk" %}
{% if progress.hasPreviousUserReadable %} {# Link to event base URL - route handles redirect to existing-read or opinion #} diff --git a/app/views/_includes/summary-lists/medical-info-summary.njk b/app/views/_includes/summary-lists/medical-info-summary.njk index 5e6937d1..bbd2408f 100644 --- a/app/views/_includes/summary-lists/medical-info-summary.njk +++ b/app/views/_includes/summary-lists/medical-info-summary.njk @@ -5,12 +5,12 @@ Parameters: - allowEdits: boolean - whether to show action links (default: true) + - showOnlyPopulated: boolean - only show rows with data (default: false) - contextUrl: string - base URL for action links (required if allowEdits) - currentUrl: string - current page URL for referrer tracking #} -{% set allowEdits = allowEdits if allowEdits is defined else true %} - +{# Medical history summary #} {# Count medical history items #} {% set medicalHistoryCount = event.medicalInformation.medicalHistory | countMedicalHistoryItems %} @@ -35,6 +35,7 @@ {# Build referrer chain for add journey: confirmation -> review #} {% set medicalHistoryAddReferrerChain = currentUrl | appendReferrer(contextUrl + "/confirm-information/medical-history") %} +{# Symptoms summary #} {# Count symptoms #} {% set symptomsCount = event.medicalInformation.symptoms | length %} @@ -59,6 +60,7 @@ {# Build referrer chain for add journey: confirmation -> review #} {% set symptomsAddReferrerChain = currentUrl | appendReferrer(contextUrl + "/confirm-information/symptoms") %} +{# Breast features summary #} {# Count breast features #} {% set rawBreastFeatures = event.medicalInformation.breastFeaturesRaw | parseJsonString %} {% set breastFeatures = event.medicalInformation.breastFeatures or rawBreastFeatures %} @@ -82,12 +84,14 @@ {% endif %} {% endset %} +{# Previous mammograms summary #} {# Count additional mammograms #} {% set additionalMammogramsCount = event.previousMammograms | length %} {# Build referrer chain for add journey: confirmation -> review #} {% set mammogramsAddReferrerChain = currentUrl | appendReferrer(contextUrl + "/confirm-information/previous-mammograms") %} +{# Other relevant information summary #} {# Get other relevant information summaries #} {% set otherRelevantInfoSummaries = event.medicalInformation | summariseOtherRelevantInformation %} {% set otherRelevantInfoCount = otherRelevantInfoSummaries | length %} @@ -108,92 +112,128 @@ {% endset %} {# Build summary list content #} -{{ summaryList({ - rows: [ - { - key: { - text: "Previous mammograms" - }, - value: { - text: "No additional mammograms added" if additionalMammogramsCount == 0 else (additionalMammogramsCount + " additional " + ("mammogram" | pluralise(additionalMammogramsCount)) + " added") - }, - actions: { - items: [ - { - href: ("./previous-mammograms/add" | urlWithReferrer(mammogramsAddReferrerChain) if additionalMammogramsCount == 0 else "./confirm-information/previous-mammograms" | urlWithReferrer(currentUrl)), - text: "Add a mammogram" if additionalMammogramsCount == 0 else "View or change", - visuallyHiddenText: "previous mammograms" - } - ] - } if allowEdits +{% set rows = [] %} + +{% if not showOnlyPopulated or additionalMammogramsCount > 0 %} + {% set rows = rows | push({ + key: { + text: "Previous mammograms" + }, + value: { + text: "No additional mammograms added" if additionalMammogramsCount == 0 else (additionalMammogramsCount + " additional " + ("mammogram" | pluralise(additionalMammogramsCount)) + " added") + }, + actions: { + items: [ + { + href: ("./previous-mammograms/add" | urlWithReferrer(mammogramsAddReferrerChain) if additionalMammogramsCount == 0 else "./confirm-information/previous-mammograms" | urlWithReferrer(currentUrl)), + text: "Add a mammogram" if additionalMammogramsCount == 0 else "View or change", + visuallyHiddenText: "previous mammograms" + } + ] + } if allowEdits + }) %} +{% endif %} + +{% if not showOnlyPopulated or medicalHistoryCount > 0 %} + {% set rows = rows | push({ + key: { + text: "Medical history" + }, + value: { + html: medicalHistoryHtml }, - { - key: { - text: "Medical history" - }, - value: { - html: medicalHistoryHtml - }, - actions: { - items: [ - { - href: ("./medical-information/medical-history/type" | urlWithReferrer(medicalHistoryAddReferrerChain) if medicalHistoryCount == 0 else "./confirm-information/medical-history" | urlWithReferrer(currentUrl)), - text: "Add medical history" if medicalHistoryCount == 0 else "View or change", - visuallyHiddenText: "medical history" - } - ] - } if allowEdits + actions: { + items: [ + { + href: ("./medical-information/medical-history/type" | urlWithReferrer(medicalHistoryAddReferrerChain) if medicalHistoryCount == 0 else "./confirm-information/medical-history" | urlWithReferrer(currentUrl)), + text: "Add medical history" if medicalHistoryCount == 0 else "View or change", + visuallyHiddenText: "medical history" + } + ] + } if allowEdits + }) %} +{% endif %} + +{% if not showOnlyPopulated or symptomsCount > 0 %} + {% set rows = rows | push({ + key: { + text: "Symptoms" }, - { - key: { - text: "Symptoms" - }, - value: { - html: symptomsHtml - }, - actions: { - items: [ - { - href: ("./medical-information/symptoms/add" | urlWithReferrer(symptomsAddReferrerChain) if symptomsCount == 0 else "./confirm-information/symptoms" | urlWithReferrer(currentUrl)), - text: "Add symptoms" if symptomsCount == 0 else "View or change", - visuallyHiddenText: "symptoms" - } - ] - } if allowEdits + value: { + html: symptomsHtml }, - { - key: { - text: "Breast features" - }, - value: { - html: breastFeaturesHtml - }, - actions: { - items: [ - { - href: contextUrl + "/medical-information/record-breast-features" | urlWithReferrer(currentUrl), - text: "Add breast features" if breastFeaturesCount == 0 else "View or change", - visuallyHiddenText: "breast features" - } - ] - } if allowEdits + actions: { + items: [ + { + href: ("./medical-information/symptoms/add" | urlWithReferrer(symptomsAddReferrerChain) if symptomsCount == 0 else "./confirm-information/symptoms" | urlWithReferrer(currentUrl)), + text: "Add symptoms" if symptomsCount == 0 else "View or change", + visuallyHiddenText: "symptoms" + } + ] + } if allowEdits + }) %} +{% endif %} + +{% if not showOnlyPopulated or breastFeaturesCount > 0 %} + {% set rows = rows | push({ + key: { + text: "Breast features" }, - { - key: { - text: "Other relevant information" - }, - value: { - html: otherRelevantInfoHtml - }, - actions: { - items: [ - { - href: ("./confirm-information/other-relevant-information") | urlWithReferrer(currentUrl), - text: "View or change", - visuallyHiddenText: "other relevant information" - } - ] - } if allowEdits - } - ] -}) }} + value: { + html: breastFeaturesHtml + }, + actions: { + items: [ + { + href: contextUrl + "/medical-information/record-breast-features" | urlWithReferrer(currentUrl), + text: "Add breast features" if breastFeaturesCount == 0 else "View or change", + visuallyHiddenText: "breast features" + } + ] + } if allowEdits + }) %} +{% endif %} + +{% if not showOnlyPopulated or otherRelevantInfoCount > 0 %} + {% set rows = rows | push({ + key: { + text: "Other relevant information" + }, + value: { + html: otherRelevantInfoHtml + }, + actions: { + items: [ + { + href: ("./confirm-information/other-relevant-information") | urlWithReferrer(currentUrl), + text: "View or change", + visuallyHiddenText: "other relevant information" + } + ] + } if allowEdits + }) %} +{% endif %} + +{# Final summary list rows #} +{# Rebuild rows so we can add a no-border class to the final row #} +{% set rowsForSummaryList = [] %} +{% for row in rows %} + {% if loop.last %} + {% set rowsForSummaryList = rowsForSummaryList | push({ + key: row.key, + value: row.value, + actions: row.actions, + classes: "nhsuk-summary-list__row--no-border" + }) %} + {% else %} + {% set rowsForSummaryList = rowsForSummaryList | push(row) %} + {% endif %} +{% endfor %} + +{% if rowsForSummaryList | length == 0 %} +

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 @@

{{ pageHeading }}

{# Medical summary #} + {# Only show rows that have data #} + {% set showOnlyPopulated = true %} + {# No edits allowed in image reading #} {% set allowEdits = false %} {% set medicalSummaryHtml %} {% include "_includes/summary-lists/medical-info-summary.njk" %} {% endset %} {{ card({ - classes: "nhsuk-u-margin-top-8", heading: "Medical summary", headingLevel: "2", feature: true, diff --git a/app/views/reading/workflow/opinion.html b/app/views/reading/workflow/opinion.html index 94a2dd83..9b0637fd 100644 --- a/app/views/reading/workflow/opinion.html +++ b/app/views/reading/workflow/opinion.html @@ -9,6 +9,7 @@ {% block pageContent %} + {# Get existing read data for this user (saved read) #} {% set existingRead = event | getReadForUser %} {% set existingResult = existingRead.result %} @@ -101,7 +102,9 @@

{# Opinion form #} {% set questionText = "What is your opinion of these images?" %} -
+ {# Opinion form data attributes are used to manage the first-load lockout #} + {# Todo: do we need a form here? is the parent layout's form not enough? #} + {# Hidden field to track previous result for change detection #} @@ -210,17 +213,17 @@

{{ questionText }}

-{# Medical summary #} - {#
#} - + {# Medical summary #} + {# Only show rows that have data #} + {% set showOnlyPopulated = true %} + {# No edits allowed in image reading #} {% set allowEdits = false %} + {% set medicalSummaryHtml %} - {% include "_includes/summary-lists/medical-info-summary.njk" %} {% endset %} {{ card({ - classes: "nhsuk-u-margin-top-8", heading: "Medical summary", headingLevel: "2", feature: true, diff --git a/app/views/reading/workflow/recall-for-assessment-details.html b/app/views/reading/workflow/recall-for-assessment-details.html index 3cca0b1d..3f927d89 100644 --- a/app/views/reading/workflow/recall-for-assessment-details.html +++ b/app/views/reading/workflow/recall-for-assessment-details.html @@ -165,7 +165,7 @@

{{ button({ - text: "Confirm opinion and continue" + text: "Continue" }) }}
diff --git a/app/views/reading/workflow/technical-recall.html b/app/views/reading/workflow/technical-recall.html index c1f4e547..ab18c9c6 100644 --- a/app/views/reading/workflow/technical-recall.html +++ b/app/views/reading/workflow/technical-recall.html @@ -11,7 +11,7 @@ {% set pageHeading = "Technical recall" %} -{% set gridColumn = "nhsuk-grid-column-two-thirds" %} +{% set gridColumn = "nhsuk-grid-column-full" %} {# POST to route handler that cleans up unselected views before redirecting to review #} {% set formAction = './technical-recall-answer' %} @@ -53,7 +53,8 @@

{% endfor %} {# Build checkbox items with conditional fields for each view #} - {% set checkboxItems = [] %} + {% set rightCheckboxItems = [] %} + {% set leftCheckboxItems = [] %} {% set currentTechRecall = data.imageReadingTemp.technicalRecall.views | default({}) %} {% for viewCode in views %} @@ -81,28 +82,52 @@

}) }} {% endset %} - {% set checkboxItems = checkboxItems | push({ + {% set checkboxItem = { value: viewCode, text: viewCode, checked: viewData.reason, conditional: { html: conditionalHtml } - }) %} + } %} + + {% if viewCode | first == "R" %} + {% set rightCheckboxItems = rightCheckboxItems | push(checkboxItem) %} + {% else %} + {% set leftCheckboxItems = leftCheckboxItems | push(checkboxItem) %} + {% endif %} {% endfor %} - {{ checkboxes({ - idPrefix: "technicalRecall-selectedViews", - name: "imageReadingTemp[technicalRecall][selectedViews]", - fieldset: { - legend: { - text: "Which views need to be retaken?", - classes: "nhsuk-fieldset__legend--m", - isPageHeading: false - } - }, - items: checkboxItems - }) }} + {% call fieldset({ + legend: { + text: "Which views need to be retaken?", + classes: "nhsuk-fieldset__legend--m", + isPageHeading: false + } + }) %} + +
+
+

Right breast

+ + {{ checkboxes({ + idPrefix: "technicalRecall-selectedViews-right", + name: "imageReadingTemp[technicalRecall][selectedViews]", + items: rightCheckboxItems + }) }} +
+
+

Left breast

+ + {{ checkboxes({ + idPrefix: "technicalRecall-selectedViews-left", + name: "imageReadingTemp[technicalRecall][selectedViews]", + items: leftCheckboxItems + }) }} +
+
+ + {% endcall %} {{ button({ text: "Continue"