diff --git a/.gitignore b/.gitignore index 479bd373e2..3b0c9c07b0 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ tests/dist # These files are autogenerated by the deploy GitHub Action public/sha public/ref + +# Redis +dump.rdb diff --git a/.prettierignore b/.prettierignore index 8c29ae5ed9..2829dce319 100644 --- a/.prettierignore +++ b/.prettierignore @@ -39,5 +39,5 @@ terraform/.terraform *.tfstate *.tfstate.backup scratchpad -spec/fixtures/files/fhir/immunisation-create.json -spec/fixtures/files/fhir/immunisation-update.json +spec/fixtures/files/fhir/immunisation_create.json +spec/fixtures/files/fhir/immunisation_update.json diff --git a/Gemfile b/Gemfile index db382080b1..96ceffda29 100644 --- a/Gemfile +++ b/Gemfile @@ -122,4 +122,5 @@ group :test do gem "shoulda-matchers" gem "simplecov", require: false gem "webmock" + gem "rack_session_access" end diff --git a/Gemfile.lock b/Gemfile.lock index f72c1e4d47..f8350ddaf2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1156.0) + aws-partitions (1.1159.0) aws-sdk-accessanalyzer (1.78.0) aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) @@ -137,7 +137,7 @@ GEM aws-sdk-kms (1.112.0) aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) - aws-sdk-rds (1.292.0) + aws-sdk-rds (1.293.0) aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.199.0) @@ -456,7 +456,7 @@ GEM public_suffix (6.0.2) puma (7.0.2) nio4r (~> 2.0) - pundit (2.5.0) + pundit (2.5.1) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) @@ -477,6 +477,9 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) + rack_session_access (0.2.0) + builder (>= 2.0.0) + rack (>= 1.0.0) rackup (2.2.1) rack (>= 3) rails (8.0.2.1) @@ -820,6 +823,7 @@ DEPENDENCIES pry-rails puma pundit + rack_session_access rails (~> 8.0.2) rails_semantic_logger rainbow diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 80b10eefeb..3fe4b5c9e7 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,6 +12,7 @@ import { import { Autocomplete } from "./components/autocomplete.js"; import { UpgradedRadios as Radios } from "./components/radios.js"; +import { Sticky } from "./components/sticky.js"; // Configure Turbo Turbo.session.drive = false; @@ -41,6 +42,10 @@ function initialiseComponents() { createAll(Autocomplete); } + if (!isInitialised("app-sticky")) { + createAll(Sticky); + } + if (!isInitialised("nhsuk-button")) { createAll(Button, { preventDoubleClick: true }); } diff --git a/app/assets/javascripts/components/autocomplete.spec.js b/app/assets/javascripts/components/autocomplete.spec.js index a74ba6a9ef..5791a9adc4 100644 --- a/app/assets/javascripts/components/autocomplete.spec.js +++ b/app/assets/javascripts/components/autocomplete.spec.js @@ -72,7 +72,6 @@ describe("Autocomplete", () => { // Check that matching options are shown const visibleOptions = listbox.querySelectorAll("li"); - console.log(visibleOptions[0].innerHTML.trim()); expect(visibleOptions.length).toEqual(1); // Option display hint text diff --git a/app/assets/javascripts/components/sticky.js b/app/assets/javascripts/components/sticky.js new file mode 100644 index 0000000000..ea1aed4de4 --- /dev/null +++ b/app/assets/javascripts/components/sticky.js @@ -0,0 +1,63 @@ +import { Component } from "nhsuk-frontend"; + +/** + * Sticky component + */ +export class Sticky extends Component { + /** + * @param {Element | null} $root - HTML element to use for component + */ + constructor($root) { + super($root); + + this.stickyElement = $root; + this.stickyElementStyle = null; + this.stickyElementTop = 0; + + this.determineStickyState = this.determineStickyState.bind(this); + this.throttledStickyState = this.throttle(this.determineStickyState, 100); + + this.stickyElementStyle = window.getComputedStyle($root); + this.stickyElementTop = parseInt(this.stickyElementStyle.top, 10); + + window.addEventListener("scroll", this.throttledStickyState); + + this.determineStickyState(); + } + + /** + * Name for the component used when initialising using data-module attributes + */ + static moduleName = "app-sticky"; + + /** + * Determine element’s sticky state + */ + determineStickyState() { + const currentTop = this.stickyElement.getBoundingClientRect().top; + + this.stickyElement.dataset.stuck = String( + currentTop <= this.stickyElementTop, + ); + } + + /** + * Throttle + * + * @param {Function} callback - Function to throttle + * @param {number} limit - Minimum time interval (in milliseconds) + * @returns {Function} Throttled function + */ + throttle(callback, limit) { + let inThrottle; + return function () { + const args = arguments; + const context = this; + if (!inThrottle) { + callback.apply(context, args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; + } +} diff --git a/app/assets/javascripts/components/sticky.spec.js b/app/assets/javascripts/components/sticky.spec.js new file mode 100644 index 0000000000..42bb765885 --- /dev/null +++ b/app/assets/javascripts/components/sticky.spec.js @@ -0,0 +1,194 @@ +import { Sticky } from "./sticky.js"; + +describe("Sticky Component", () => { + let mockElement; + let stickyInstance; + let scrollEventListener; + + beforeEach(() => { + document.body.classList.add("nhsuk-frontend-supported"); + + // Create a mock DOM element + document.body.innerHTML = `
`; + mockElement = document.getElementById("mock-element"); + + // Mock getBoundingClientRect + Object.defineProperty(mockElement, "getBoundingClientRect", { + value: jest.fn(() => ({ top: 0 })), + configurable: true, + }); + + // Mock window.getComputedStyle + Object.defineProperty(window, "getComputedStyle", { + value: jest.fn(() => ({ + top: "20px", + })), + writable: true, + }); + + // Mock window.addEventListener and capture scroll listener + const originalAddEventListener = window.addEventListener; + window.addEventListener = jest.fn((event, listener) => { + if (event === "scroll") { + scrollEventListener = listener; + } + originalAddEventListener.call(window, event, listener); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe("Initialization", () => { + test("should initialize with correct properties", () => { + stickyInstance = new Sticky(mockElement); + + expect(stickyInstance.stickyElement).toBe(mockElement); + expect(stickyInstance.stickyElementTop).toBe(20); + expect(window.getComputedStyle).toHaveBeenCalledWith(mockElement); + expect(window.addEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function), + ); + }); + + test("should have correct moduleName", () => { + expect(Sticky.moduleName).toBe("app-sticky"); + }); + + test("should call determineStickyState on initialization", () => { + const spy = jest.spyOn(Sticky.prototype, "determineStickyState"); + stickyInstance = new Sticky(mockElement); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe("determineStickyState method", () => { + beforeEach(() => { + stickyInstance = new Sticky(mockElement); + }); + + test("should set data-stuck to `true` when element is at or above threshold", () => { + // Element is at the top (currentTop = 0, threshold = 20) + mockElement.getBoundingClientRect.mockReturnValue({ top: 0 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + + test("should set data-stuck to `true` when element above threshold", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 10 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + + test("should set data-stuck to `true` when element at threshold", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 20 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + + test("should set data-stuck to `false` when element below threshold", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 30 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("false"); + }); + }); + + describe("Scroll behavior", () => { + beforeEach(() => { + jest.useFakeTimers(); + + // Clear any existing event listeners + window.removeEventListener = jest.fn(); + + stickyInstance = new Sticky(mockElement); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("should respond to scroll events", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 10 }); + + // Make sure we have the listener + expect(scrollEventListener).toBeDefined(); + + // Trigger scroll event + scrollEventListener(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + }); + + describe("Throttle functionality", () => { + beforeEach(() => { + jest.useFakeTimers(); + stickyInstance = new Sticky(mockElement); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("should throttle function calls", () => { + const mockCallback = jest.fn(); + const throttledCallback = stickyInstance.throttle(mockCallback, 100); + + // Call multiple times rapidly + throttledCallback(); + throttledCallback(); + throttledCallback(); + + // Should only be called once + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Fast forward past throttle limit + jest.advanceTimersByTime(100); + + // Now should allow another call + throttledCallback(); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + test("should preserve context and arguments in throttled function", () => { + const mockCallback = jest.fn(); + const throttledCallback = stickyInstance.throttle(mockCallback, 100); + + throttledCallback("arg1", "arg2"); + + expect(mockCallback).toHaveBeenCalledWith("arg1", "arg2"); + }); + }); + + describe("Integration with different CSS top values", () => { + test("should handle different top values correctly", () => { + // Mock different computed style top value + window.getComputedStyle.mockReturnValue({ top: "50px" }); + + stickyInstance = new Sticky(mockElement); + + expect(stickyInstance.stickyElementTop).toBe(50); + + // Test with element above new threshold + mockElement.getBoundingClientRect.mockReturnValue({ top: 30 }); + stickyInstance.determineStickyState(); + expect(mockElement.dataset.stuck).toBe("true"); + + // Test with element below new threshold + mockElement.getBoundingClientRect.mockReturnValue({ top: 60 }); + stickyInstance.determineStickyState(); + expect(mockElement.dataset.stuck).toBe("false"); + }); + }); +}); diff --git a/app/assets/stylesheets/components/_button.scss b/app/assets/stylesheets/components/_button.scss index a906fe7bd5..a9a470d8fb 100644 --- a/app/assets/stylesheets/components/_button.scss +++ b/app/assets/stylesheets/components/_button.scss @@ -7,11 +7,24 @@ $_secondary-warning-button-hover-colour: color.change( $alpha: 0.1 ); +.button_to { + display: contents; +} + .nhsuk-button { - .button_to &, - .nhsuk-table & { + .button_to & { margin-bottom: 0; } + + .nhsuk-button-group .button_to & { + $horizontal-gap: nhsuk-spacing(4); + $vertical-gap: nhsuk-spacing(3); + margin-bottom: $vertical-gap + $_button-shadow-size; + + @include nhsuk-media-query($from: tablet) { + margin-right: $horizontal-gap; + } + } } .app-button--secondary-warning { diff --git a/app/assets/stylesheets/components/_card.scss b/app/assets/stylesheets/components/_card.scss index a87915fe12..07d842e7b4 100644 --- a/app/assets/stylesheets/components/_card.scss +++ b/app/assets/stylesheets/components/_card.scss @@ -81,10 +81,6 @@ .app-card--compact { @include nhsuk-responsive-margin(3, "bottom"); - .nhsuk-button-group { - margin-top: nhsuk-spacing(-4); - } - .nhsuk-card__heading { @include nhsuk-responsive-margin(1, "bottom"); } diff --git a/app/assets/stylesheets/components/_index.scss b/app/assets/stylesheets/components/_index.scss index 00c6c0760f..551882b7b2 100644 --- a/app/assets/stylesheets/components/_index.scss +++ b/app/assets/stylesheets/components/_index.scss @@ -12,6 +12,7 @@ @forward "highlight"; @forward "search-input"; @forward "secondary-navigation"; +@forward "sticky-navigation"; @forward "status"; @forward "summary-list"; @forward "tables"; diff --git a/app/assets/stylesheets/components/_secondary-navigation.scss b/app/assets/stylesheets/components/_secondary-navigation.scss index 8f5b3c960c..9fca8c5248 100644 --- a/app/assets/stylesheets/components/_secondary-navigation.scss +++ b/app/assets/stylesheets/components/_secondary-navigation.scss @@ -13,13 +13,6 @@ } } -.app-secondary-navigation--sticky { - background-color: nhsuk-colour("grey-5"); - position: sticky; - top: 0; - z-index: 99; -} - .app-secondary-navigation__link { display: block; padding: nhsuk-spacing(2) $nhsuk-gutter-half; diff --git a/app/assets/stylesheets/components/_sticky-navigation.scss b/app/assets/stylesheets/components/_sticky-navigation.scss new file mode 100644 index 0000000000..655d934842 --- /dev/null +++ b/app/assets/stylesheets/components/_sticky-navigation.scss @@ -0,0 +1,38 @@ +@use "sass:color"; +@use "../vendor/nhsuk-frontend" as *; + +.app-sticky-navigation { + background: nhsuk-colour("grey-5"); + border-bottom: 1px solid $nhsuk-border-colour; + + @include nhsuk-responsive-margin(5, "bottom"); + + .app-secondary-navigation { + margin: 0; + } + + .app-secondary-navigation__list { + box-shadow: none; + + @include nhsuk-media-query($until: tablet) { + margin-left: #{$nhsuk-gutter-half * -1}; + } + } + + &[data-app-sticky-init] { + left: 0; + margin-left: -50vw; + margin-right: -50vw; + position: sticky; + right: 0; + top: 0; + width: 100vw; + z-index: 999; + } + + &[data-stuck="true"] { + border-bottom-color: nhsuk-colour("grey-3"); + box-shadow: 0 $nhsuk-border-width 0 + color.scale(nhsuk-colour("grey-3"), $alpha: -50%); + } +} diff --git a/app/assets/stylesheets/core/objects/_grid.scss b/app/assets/stylesheets/core/objects/_grid.scss index 866e834a10..3d32458a2e 100644 --- a/app/assets/stylesheets/core/objects/_grid.scss +++ b/app/assets/stylesheets/core/objects/_grid.scss @@ -1,5 +1,16 @@ @use "../../vendor/nhsuk-frontend" as *; +// Sticky columns +[class^="app-grid-column"], +[class^="nhsuk-grid-column"] { + &[data-app-sticky-init] { + @include nhsuk-media-query($from: desktop) { + position: sticky; + top: 0; + } + } +} + // Patient session and record .app-grid-column-patient-session { @include nhsuk-grid-column(three-quarters, $float: right, $at: large-desktop); @@ -7,6 +18,12 @@ .app-grid-column-patient-record { @include nhsuk-grid-column(one-quarter, $float: right, $at: large-desktop); + + &[data-app-sticky-init] { + @include nhsuk-media-query($from: desktop) { + top: #{nhsuk-spacing(9) + nhsuk-spacing(3)}; + } + } } // Search filters and results @@ -23,18 +40,3 @@ .app-grid-column-results > .nhsuk-warning-callout { margin-top: nhsuk-spacing(3); } - -// Sticky column -.app-grid-column--sticky { - @include nhsuk-media-query($from: desktop) { - position: sticky; - top: 0; - } -} - -.app-grid-column--sticky-below-secondary-navigation { - @include nhsuk-media-query($from: desktop) { - position: sticky; - top: #{nhsuk-spacing(9) + nhsuk-spacing(3)}; - } -} diff --git a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_details.scss b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_details.scss new file mode 100644 index 0000000000..dee1d3e6cc --- /dev/null +++ b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_details.scss @@ -0,0 +1,16 @@ +@use "sass:color"; +@use "../core" as *; + +.nhsuk-expander { + .nhsuk-details__summary { + &[data-app-sticky-init] { + position: sticky; + top: 0; + } + + &[data-stuck="true"] { + border-bottom-color: nhsuk-colour("grey-3"); + box-shadow: 0 $nhsuk-border-width 0 color.scale(nhsuk-colour("grey-3"), $alpha: -50%); + } + } +} diff --git a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_summary-list.scss b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_summary-list.scss index 1d3a18e713..1f7a35e159 100644 --- a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_summary-list.scss +++ b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_summary-list.scss @@ -12,3 +12,8 @@ .nhsuk-summary-list__row:last-of-type { border: none; } + +// Reduce space between summary list and button group +.nhsuk-summary-list:has(+ .nhsuk-button-group) { + @include nhsuk-responsive-margin(4, "bottom"); +} \ No newline at end of file diff --git a/app/components/app_activity_log_component.rb b/app/components/app_activity_log_component.rb index 5ff76eb414..4e6fceca31 100644 --- a/app/components/app_activity_log_component.rb +++ b/app/components/app_activity_log_component.rb @@ -25,6 +25,9 @@ def initialize(team:, patient: nil, patient_session: nil) @patient_sessions = patient_session ? [patient_session] : patient.patient_sessions + @attendance_records = + (patient || patient_session).attendance_records.includes(:location) + @consents = @patient.consents.includes( :consent_form, @@ -52,9 +55,6 @@ def initialize(team:, patient: nil, patient_session: nil) @pre_screenings = (patient || patient_session).pre_screenings.includes(:performed_by) - @session_attendances = - (patient || patient_session).session_attendances.includes(:location) - @triages = @patient.triages.includes(:performed_by) @vaccination_records = @@ -78,7 +78,7 @@ def initialize(team:, patient: nil, patient_session: nil) :patient_sessions, :patient_specific_directions, :pre_screenings, - :session_attendances, + :attendance_records, :triages, :vaccination_records @@ -382,19 +382,19 @@ def vaccination_events end def attendance_events - session_attendances.map do |session_attendance| + attendance_records.map do |attendance_record| title = ( - if session_attendance.attending? + if attendance_record.attending? "Attended session" else "Absent from session" end ) - title += " at #{session_attendance.location.name}" + title += " at #{attendance_record.location.name}" - { title:, at: session_attendance.created_at } + { title:, at: attendance_record.created_at } end end diff --git a/app/components/app_import_errors_component.rb b/app/components/app_import_errors_component.rb index 65087846ad..1e75878b20 100644 --- a/app/components/app_import_errors_component.rb +++ b/app/components/app_import_errors_component.rb @@ -5,39 +5,42 @@ class AppImportErrorsComponent < ViewComponent::Base

- Records could not be imported + <%= @title %>

<%= content %> -
- <% @errors.each do |error| %> -

- <% if error.attribute == :csv %> - CSV - <% else %> - <%= error.attribute.to_s.humanize %> - <% end %> -

+ <% if @errors.present? %> +
+ <% @errors.each do |error| %> +

+ <% if error.attribute == :csv %> + CSV + <% else %> + <%= error.attribute.to_s.humanize %> + <% end %> +

-
    +
      <% if error.type.is_a?(Array) %> <% error.type.each do |type| %>
    • <%= sanitize type %>
    • <% end %> <% else %>
    • <%= sanitize error.type %>
    • - <% end %> -
    - <% end %> -
+ <% end %> + + <% end %> +
+ <% end %>
ERB - def initialize(errors) + def initialize(errors: nil, title: "Records could not be imported") @errors = errors + @title = title end - def render? = @errors.present? + def render? = @errors.present? || content.present? end diff --git a/app/components/app_import_pds_unmatched_summary_component.rb b/app/components/app_import_pds_unmatched_summary_component.rb new file mode 100644 index 0000000000..0238ffcbd5 --- /dev/null +++ b/app/components/app_import_pds_unmatched_summary_component.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class AppImportPDSUnmatchedSummaryComponent < ViewComponent::Base + def initialize(changesets:) + @changesets = changesets + end + + def call + helpers.govuk_table( + html_attributes: { + class: "nhsuk-table-responsive" + } + ) do |table| + table.with_head do |head| + head.with_row do |row| + row.with_cell(text: "First name") + row.with_cell(text: "Last name") + row.with_cell(text: "Date of birth") + row.with_cell(text: "Postcode") + end + end + + table.with_body do |body| + @changesets.each do |changeset| + body.with_row do |row| + row.with_cell do + content_tag( + :span, + "First name", + class: "nhsuk-table-responsive__heading" + ) + changeset.child_attributes["given_name"]&.to_s + end + + row.with_cell do + content_tag( + :span, + "Last name", + class: "nhsuk-table-responsive__heading" + ) + changeset.child_attributes["family_name"]&.to_s + end + + row.with_cell do + content_tag( + :span, + "Date of birth", + class: "nhsuk-table-responsive__heading" + ) + + changeset.child_attributes["date_of_birth"]&.to_date&.to_fs( + :long + ) + end + + row.with_cell do + content_tag( + :span, + "Postcode", + class: "nhsuk-table-responsive__heading" + ) + changeset.child_attributes["address_postcode"]&.to_s + end + end + end + end + end + end +end diff --git a/app/components/app_import_status_component.rb b/app/components/app_import_status_component.rb index db45b75640..619ea48ff7 100644 --- a/app/components/app_import_status_component.rb +++ b/app/components/app_import_status_component.rb @@ -14,7 +14,8 @@ def status_text { "pending_import" => "Processing", "rows_are_invalid" => "Invalid", - "processed" => "Completed" + "processed" => "Completed", + "low_pds_match_rate" => "Failed" }.fetch(@import.status) end @@ -22,7 +23,8 @@ def status_color { "pending_import" => "blue", "rows_are_invalid" => "red", - "processed" => "green" + "processed" => "green", + "low_pds_match_rate" => "red" }.fetch(@import.status) end end diff --git a/app/components/app_patient_session_record_component.rb b/app/components/app_patient_session_record_component.rb index cac36d9b4c..02a2918de2 100644 --- a/app/components/app_patient_session_record_component.rb +++ b/app/components/app_patient_session_record_component.rb @@ -16,11 +16,15 @@ def initialize(patient_session, programme:, current_user:, vaccinate_form:) end def render? - patient.consent_given_and_safe_to_vaccinate?(programme:, academic_year:) && + session.today? && + patient.consent_given_and_safe_to_vaccinate?( + programme:, + academic_year: + ) && ( patient_session.registration_status&.attending? || patient_session.registration_status&.completed? || - (!session.requires_registration? && session.today?) + !session.requires_registration? ) end diff --git a/app/components/app_patient_session_search_result_card_component.rb b/app/components/app_patient_session_search_result_card_component.rb index bda6a3e972..1f0380e053 100644 --- a/app/components/app_patient_session_search_result_card_component.rb +++ b/app/components/app_patient_session_search_result_card_component.rb @@ -2,10 +2,15 @@ class AppPatientSessionSearchResultCardComponent < ViewComponent::Base erb_template <<-ERB - <%= render AppCardComponent.new(heading_level: 4, compact: true) do |card| %> - <% card.with_heading { link_to(patient.full_name_with_known_as, patient_path) } %> + <% card_link = @context != :register ? patient_path : nil %> + <%= render AppCardComponent.new(link_to: card_link, heading_level: 4, compact: true) do |card| %> + <% if card_link.nil? %> + <% card.with_heading { link_to(patient.full_name_with_known_as, patient_path) } %> + <% else %> + <% card.with_heading { patient.full_name_with_known_as } %> + <% end %> - <%= govuk_summary_list do |summary_list| + <%= govuk_summary_list(actions: false) do |summary_list| summary_list.with_row do |row| row.with_key { "Date of birth" } row.with_value { patient_date_of_birth(patient) } @@ -95,13 +100,16 @@ def initialize(patient_session, context:, programmes: []) delegate :academic_year, to: :session def can_register_attendance? - session_attendance = - SessionAttendance.new( + attendance_record = + AttendanceRecord.new( patient:, - session_date: SessionDate.new(session:, value: Date.current) + location: session.location, + date: Date.current ) - policy(session_attendance).new? + attendance_record.session = session + + policy(attendance_record).new? end def patient_path @@ -126,8 +134,12 @@ def action_required return if next_activities.empty? - tag.ul(class: "nhsuk-list nhsuk-list--bullet") do - safe_join(next_activities.map { tag.li(it) }) + if next_activities.size == 1 + next_activities.first + else + tag.ul(class: "nhsuk-list nhsuk-list--bullet") do + safe_join(next_activities.map { tag.li(it) }) + end end end diff --git a/app/components/app_patient_vaccination_table_component.html.erb b/app/components/app_patient_vaccination_table_component.html.erb index 2f71400a54..949ec9746c 100644 --- a/app/components/app_patient_vaccination_table_component.html.erb +++ b/app/components/app_patient_vaccination_table_component.html.erb @@ -7,6 +7,7 @@ <% row.with_cell(text: "Vaccination date") %> <% row.with_cell(text: "Location") %> <% row.with_cell(text: "Programme") if show_programme %> + <% row.with_cell(text: "Source") %> <% row.with_cell(text: "Outcome") %> <% end %> <% end %> @@ -39,6 +40,11 @@ <% end %> <% end %> + <% row.with_cell do %> + Source + <%= vaccination_record.human_enum_name(:source) %> + <% end %> + <% row.with_cell do %> Outcome <%= helpers.vaccination_record_status_tag(vaccination_record) %> diff --git a/app/components/app_secondary_navigation_component.html.erb b/app/components/app_secondary_navigation_component.html.erb index 6001b04a14..c1f2ac7c12 100644 --- a/app/components/app_secondary_navigation_component.html.erb +++ b/app/components/app_secondary_navigation_component.html.erb @@ -1,4 +1,5 @@ -