Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
87cd28a
Create TimelineRecords module for patient event history visualisation
murugapl Mar 21, 2025
f071224
Add timeline UI with configurable filters for patient events
murugapl Mar 21, 2025
2abcb60
Update sample_patient function to return nil when no comparison optio…
murugapl Sep 10, 2025
d45fb48
Add ability to show PII related information on the timeline
murugapl Sep 10, 2025
e9251bd
Refactor timeline config
murugapl Sep 30, 2025
461af24
Improve graph_records.rb
alistair-white-horne-tng Apr 15, 2025
5f27ba7
Update functional test
alistair-white-horne-tng Apr 15, 2025
c27461f
Add controller, route and view for the frontend
alistair-white-horne-tng Apr 15, 2025
f3535b9
Create feature test
alistair-white-horne-tng Apr 15, 2025
be8208c
Add toggle for `show_pii` mode in the UI
alistair-white-horne-tng May 29, 2025
ba217da
Adjust display fields for new data structure
samcoy3 Aug 14, 2025
f2488b1
Add support user type to User model
murugapl Jul 29, 2025
1791b40
Limit access to ops tools to support users only
Aug 12, 2025
d938d22
Remove production constraint on ops endpoints
Aug 12, 2025
c6124a6
Add function to authenticate ops users with PII access
samcoy3 Aug 7, 2025
f728014
Restrict PII access to eligible ops users
samcoy3 Aug 7, 2025
b9ffecf
Enforce timeline PII access controls on frontend
samcoy3 Aug 7, 2025
97c901c
Enforce graph PII access controls on frontend
samcoy3 Aug 7, 2025
d2489dd
Add feature tests for support access to tools
samcoy3 Aug 11, 2025
e99ec30
Fix tests after rebase
samcoy3 Sep 25, 2025
3828495
Add access logging for PII mode in timeline and graphs
murugapl May 30, 2025
1fb8181
Add column to access log to store request details
samcoy3 Jul 29, 2025
cd46718
Log request details for PII accesses in timeline
samcoy3 Jul 29, 2025
5582534
Log PII access for graph view
samcoy3 Aug 5, 2025
b57c265
Add unit tests for PII access logging
samcoy3 Aug 15, 2025
bb4be45
Add feature flag for ops tools
alistair-white-horne-tng Oct 1, 2025
73a5d1e
Ensure ops tools feature specs enable the feature flag first
benilovj Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 104 additions & 19 deletions app/components/app_timeline_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,37 @@ class AppTimelineComponent < ViewComponent::Base
<% @items.each do |item| %>
<% next if item.blank? %>

<li class="app-timeline__item <%= 'app-timeline__item--past' if item[:is_past_item] %>">
<% if item[:active] || item[:is_past_item] %>
<svg class="app-timeline__badge" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<circle cx="14" cy="14" r="13" fill="#005EB8"/>
</svg>
<% else %>
<svg class="app-timeline__badge app-timeline__badge--small" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6" fill="white" stroke="#AEB7BD" stroke-width="2"/>
</svg>
<% end %>

<div class="app-timeline__content">
<h3 class="app-timeline__header <%= 'nhsuk-u-font-weight-bold' if item[:active] %>">
<%= item[:heading_text].html_safe %>
<% if item[:type] == :section_header %>
<li>
<h3 class="nhsuk-u-margin-top-2">
<%= content_tag(:strong, item[:date]) %>
</h3>

<% if item[:description].present? %>
<p class="app-timeline__description"><%= item[:description].html_safe %></p>
</li>
<% else %>
<li class="app-timeline__item <%= 'app-timeline__item--past' if item[:is_past_item] %>">
<% if item[:active] || item[:is_past_item] %>
<svg class="app-timeline__badge" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<circle cx="14" cy="14" r="13" fill="#005EB8"/>
</svg>
<% else %>
<svg class="app-timeline__badge app-timeline__badge--small" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6" fill="white" stroke="#AEB7BD" stroke-width="2"/>
</svg>
<% end %>
</div>
</li>

<div class="app-timeline__content">
<h3 class="app-timeline__header <%= 'nhsuk-u-font-weight-bold' if item[:active] %>">
<%= format_heading(item).html_safe %>
</h3>

<% if item[:description].present? || item[:details].present? %>
<div class="app-timeline__description">
<%= format_description(item).html_safe %>
</div>
<% end %>
</div>
</li>
<% end %>
<% end %>
</ul>
ERB
Expand All @@ -38,4 +48,79 @@ def initialize(items)
def render?
@items.present?
end

private

EVENT_COLOUR_MAPPING = {
"CohortImport" => "blue",
"ClassImport" => "purple",
"PatientSession" => "green",
"Consent" => "yellow",
"Triage" => "red",
"VaccinationRecord" => "grey",
"SchoolMove" => "orange",
"SchoolMoveLogEntry" => "pink"
}.freeze

def format_heading(item)
if item[:type] && item[:created_at]
time = format_time(item[:created_at])
event_tag =
govuk_tag(
text: item[:event_type],
colour: tag_colour(item[:event_type])
)
"#{event_tag} at #{time}"
elsif item[:heading_text]
item[:heading_text]
end
end

def format_description(item)
if item[:details].present?
formatted_details = format_event_details(item[:details])
id_info =
item[:id] ? "<p class=\"timeline__byline\">id: #{item[:id]}</p>" : ""
"#{id_info}#{formatted_details}"
elsif item[:description].present?
item[:description]
end
end

def format_time(date_time)
date_time.strftime("%H:%M:%S")
end

def format_event_details(details)
return "" if details.blank?

if details.is_a?(Hash)
formatted =
details.map do |key, value|
if value.is_a?(Hash)
nested =
value.map do |sub_key, sub_value|
nested_value =
sub_value.is_a?(String) ? sub_value : sub_value.inspect
"<div style='margin-left: 1em;'><strong>#{sub_key}:</strong> #{nested_value}</div>"
end
"<div><strong>#{key}:</strong>#{nested.join}</div>"
else
"<div><strong>#{key}:</strong> #{value}</div>"
end
end
formatted.join
else
details.to_s
end
end

def tag_colour(type)
return "light-blue" if type.end_with?("-Audit")
EVENT_COLOUR_MAPPING.fetch(type, "grey")
end

def govuk_tag(text:, colour:)
helpers.govuk_tag(text: text, colour: colour)
end
end
190 changes: 190 additions & 0 deletions app/components/app_timeline_filter_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<%= render AppCardComponent.new(filters: true) do |card| %>
<% card.with_heading { "Customise timeline" } %>
<%= form_with url: url,
method: :get,
data: { module: "autosubmit",
turbo: "true",
turbo_action: "replace" },
builder: GOVUKDesignSystemFormBuilder::FormBuilder do |f| %>

<div class="nhsuk-u-margin-bottom-4">
<% if pii_access_allowed %>
<%= f.govuk_check_box :show_pii, true,
label: { text: "Show PII" },
checked: show_pii,
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% else %>
<%= f.govuk_check_box :show_pii, true,
label: { text: "Show PII (not allowed for this user)" },
checked: false,
"disabled": "true" %>
<% end %>
</div>


<%= f.govuk_fieldset legend: { text: "Events to display:", size: "s" } do %>
<% event_options.keys.each do |value| %>
<%= f.govuk_check_boxes_fieldset :event_names, legend: { hidden: true } do %>
<%= f.govuk_check_box :event_names,
value,
label: { text: value.to_s.humanize },
checked: value.to_s.in?(params[:event_names] || event_options.keys.map(&:to_s)),
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>

<% available_fields = timeline_fields[value.to_sym] || [] %>
<% if available_fields.any? && value.to_s.in?(params[:event_names]) %>
<div class="nhsuk-checkboxes__conditional nhsuk-u-margin-bottom-2">
<% available_fields.each do |field| %>
<%= f.govuk_check_box "detail_config[#{value}]",
field,
small: true,
label: { text: field },
checked: field.to_s.in?(params.dig("detail_config", value) || []),
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% end %>
</div>
<% end %>
<% end %>
<% end %>

<%= f.govuk_check_boxes_fieldset :audit_config, legend: { hidden: true } do %>
<%= f.govuk_check_box :event_names, "audits",
label: { text: "Audits" },
checked: "audits".in?(params[:event_names]),
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% if "audits".in?(params[:event_names]) %>
<div class="nhsuk-checkboxes__conditional nhsuk-u-margin-bottom-2">
<%= f.govuk_check_box "audit_config[include_associated_audits]", true, false,
multiple: false,
label: { text: "include associated audits" },
checked: params.dig(:audit_config, :include_associated_audits) == "true",
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>

<%= f.govuk_check_box "audit_config[include_filtered_audit_changes]", true, false,
multiple: false,
label: { text: "include filtered audit changes" },
checked: params.dig(:audit_config, :include_filtered_audit_changes) == "true",
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
</div>
<% end %>
<% end %>

<%= f.govuk_check_box :event_names, "org_cohort_imports",
label: { text: "Cohort Imports for Team #{teams.join(",")} excluding patient" },
checked: "org_cohort_imports".in?(params[:event_names]),
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>

<% (additional_class_imports).each do |location_id, import_ids| %>
<%= f.govuk_check_box :event_names, "add_class_imports_#{location_id}",
label: { text: "Class Imports for Location-#{location_id} excluding Patient" },
checked: "add_class_imports_#{location_id}".in?(params[:event_names]),
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% end %>


<%= f.govuk_radio_buttons_fieldset :compare_option, legend: { text: "Compare with another patient:", size: "s" } do %>
<%= f.govuk_radio_button :compare_option,
nil,
label: { text: "Do not compare" },
checked: params[:compare_option].blank?,
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>

<% if class_imports.present? %>
<%= f.govuk_radio_button :compare_option,
"class_import",
label: { text: "From a Class Import" },
checked: params[:compare_option] == "class_import",
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" do %>
<% class_imports.each do |import| %>
<%= f.govuk_radio_button :compare_option_class_import,
import,
label: { text: "ClassImport-#{import}" },
checked: params[:compare_option_class_import].to_s == import.to_s,
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% end %>
<% end %>
<% end %>

<% if cohort_imports.present? %>
<%= f.govuk_radio_button :compare_option,
"cohort_import",
label: { text: "From a Cohort Import" },
checked: params[:compare_option] == "cohort_import",
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" do %>
<% cohort_imports.each do |import| %>
<%= f.govuk_radio_button :compare_option_cohort_import,
import,
label: { text: "CohortImport-#{import}" },
checked: params[:compare_option_cohort_import].to_s == import.to_s,
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% end %>
<% end %>
<% end %>

<% if sessions.present? %>
<%= f.govuk_radio_button :compare_option,
"session",
label: { text: "In a Session" },
checked: params[:compare_option] == "session" do %>
<% sessions.each do |session| %>
<%= f.govuk_radio_button :compare_option_session,
session,
label: { text: "Session-#{session}" },
checked: params[:compare_option_session].to_s == session.to_s && params[:compare_option] == "session",
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% end %>
<% end %>
<% end %>

<%= f.govuk_radio_button :compare_option,
"manual_entry",
label: { text: "With a specific Patient ID" },
checked: params[:compare_option] == "manual_entry" do %>
<%= f.govuk_number_field :manual_patient_id,
label: { hidden: true },
width: 10,
"data-autosubmit-target": "field",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<% end %>
<% end %>

<%= helpers.govuk_button_link_to "Reset filters",
reset_url,
class: "govuk-button govuk-button--secondary nhsuk-u-display-block app-button--small",
secondary: true,
"data-autosubmit-target": "reset",
"data-action": "autosubmit#submit",
"data-turbo-permanent": "true" %>
<%= f.govuk_submit "Filter" %>
<% end %>
<% end %>
<% end %>
44 changes: 44 additions & 0 deletions app/components/app_timeline_filter_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

class AppTimelineFilterComponent < ViewComponent::Base
def initialize(
url:,
patient:,
teams:,
event_options:,
timeline_fields:,
additional_class_imports:,
class_imports:,
cohort_imports:,
sessions:,
reset_url:,
show_pii:,
pii_access_allowed:
)
@url = url
@patient = patient
@teams = teams.map(&:id)
@event_options = event_options
@timeline_fields = timeline_fields
@additional_class_imports = additional_class_imports
@class_imports = class_imports
@cohort_imports = cohort_imports
@sessions = sessions
@reset_url = reset_url
@show_pii = show_pii
@pii_access_allowed = pii_access_allowed
end

attr_reader :url,
:reset_url,
:patient,
:teams,
:event_options,
:timeline_fields,
:additional_class_imports,
:class_imports,
:cohort_imports,
:sessions,
:show_pii,
:pii_access_allowed
end
Loading