diff --git a/app/controllers/session_dates_controller.rb b/app/controllers/session_dates_controller.rb
index 1c05a0f1ff..2e82cb895f 100644
--- a/app/controllers/session_dates_controller.rb
+++ b/app/controllers/session_dates_controller.rb
@@ -2,41 +2,33 @@
class SessionDatesController < ApplicationController
before_action :set_session
+ before_action :set_draft_session_dates
def show
- @session.session_dates.build if @session.session_dates.empty?
- end
-
- def update
- @session.assign_attributes(remove_invalid_dates(session_params))
- @session.set_notification_dates
+ initialize_draft_from_session
- render :show, status: :unprocessable_content and return if @session.invalid?
-
- @session.save!
-
- # If deleting dates, they don't disappear from `session.dates` until
- # the model has been saved due to how `accepts_nested_attributes_for`
- # works.
- if any_destroyed?
- @session.session_dates.reload
- @session.set_notification_dates
- @session.save!
+ if params[:add_another] ||
+ @draft_session_dates.session_dates_attributes.empty?
+ add_blank_session_date
end
+ end
- StatusUpdaterJob.perform_later(session: @session)
+ def update
+ @draft_session_dates.session_dates_attributes =
+ fetch_draft_session_dates_from_form
- if params.include?(:add_another)
- @session.session_dates.build
+ if params[:delete_date]
+ delete_session_date(form_index_to_attr_index(params[:delete_date]))
+ return render_with_error if @draft_session_dates.errors.any?
+ render :show
+ elsif params[:add_another]
+ add_blank_session_date
render :show
else
- redirect_to(
- if any_destroyed?
- session_dates_path(@session)
- else
- edit_session_path(@session)
- end
- )
+ unless @draft_session_dates.save(context: :continue)
+ return render_with_error
+ end
+ apply_changes_and_redirect
end
end
@@ -46,41 +38,121 @@ def set_session
@session = policy_scope(Session).find_by!(slug: params[:session_slug])
end
- def session_params
- params.expect(
- session: {
- session_dates_attributes: [%i[id value _destroy]]
- }
+ def set_draft_session_dates
+ @draft_session_dates =
+ DraftSessionDates.new(
+ request_session: session,
+ current_user: current_user,
+ session: @session,
+ wizard_step: :dates
+ )
+ end
+
+ def draft_session_dates_params
+ params
+ .fetch(:draft_session_dates, {})
+ .permit(
+ session_dates_attributes: [
+ :id,
+ :value,
+ :_destroy,
+ "value(1i)",
+ "value(2i)",
+ "value(3i)"
+ ]
+ )
+ .merge(wizard_step: :dates)
+ end
+
+ def initialize_draft_from_session
+ attributes = {}
+
+ @session
+ .session_dates
+ .order(:value)
+ .each_with_index do |session_date, index|
+ attributes[index] = {
+ "id" => session_date.id.to_s,
+ "value" => session_date.value
+ }
+ end
+
+ @draft_session_dates.session_dates_attributes = attributes
+ @draft_session_dates.save!(context: :initialize)
+ end
+
+ def add_blank_session_date
+ current_attrs = @draft_session_dates.session_dates_attributes
+ next_index = (current_attrs.keys.map(&:to_i).max || -1) + 1
+ current_attrs[next_index] = { "value" => nil }
+ @draft_session_dates.session_dates_attributes = current_attrs
+ end
+
+ def render_with_error
+ render :show, status: :unprocessable_content
+ end
+
+ def apply_changes_and_redirect
+ @draft_session_dates.write_to!(@session)
+ session.delete(@draft_session_dates.request_session_key)
+ StatusUpdaterJob.perform_later(session: @session)
+ redirect_to edit_session_path(@session)
+ rescue ActiveRecord::RecordInvalid => e
+ @draft_session_dates.errors.add(
+ :base,
+ "Failed to save session dates: #{e.message}"
)
+ render_with_error
end
- def any_destroyed?
- session_params[:session_dates_attributes].values.any? do
- _1[:_destroy].present?
+ def form_index_to_attr_index(form_index)
+ current_attrs = @draft_session_dates.session_dates_attributes
+ corresponding_form_index = -1
+ current_attrs.each do |real_index, attrs|
+ corresponding_form_index += 1 unless attrs["_destroy"] == "true"
+
+ return real_index if corresponding_form_index.to_s == form_index
end
+
+ (
+ form_index.to_i - corresponding_form_index.to_i + current_attrs.length
+ ).to_s
end
- def remove_invalid_dates(obj, key: "session_dates_attributes")
- return obj if obj[key].blank?
-
- obj[key] = obj[key].transform_values do |value|
- if value.key?("value(1i)") && value.key?("value(2i)") &&
- value.key?("value(3i)")
- begin
- Date.new(
- value["value(1i)"].to_i,
- value["value(2i)"].to_i,
- value["value(3i)"].to_i
- )
- value
- rescue StandardError
- {}
- end
- else
- value
- end
+ def fetch_draft_session_dates_from_form
+ current_attrs = @draft_session_dates.session_dates_attributes
+
+ form_params = draft_session_dates_params[:session_dates_attributes] || {}
+
+ merged_attrs = {}
+
+ current_attrs.each do |index, attrs|
+ merged_attrs[index] = attrs if attrs["_destroy"] == "true"
end
- obj
+ form_params.each do |form_index, form_attrs|
+ attr_index = form_index_to_attr_index(form_index)
+ merged_attrs[attr_index] = form_attrs
+ end
+
+ merged_attrs
+ end
+
+ def delete_session_date(index)
+ current_attrs = @draft_session_dates.session_dates_attributes
+
+ if current_attrs[index]
+ if @draft_session_dates.non_destroyed_session_dates_count <= 1
+ @draft_session_dates.errors.add(
+ :base,
+ "You cannot delete the last session date. A session must have at least one date."
+ )
+ return
+ end
+
+ current_attrs[index]["_destroy"] = "true"
+ @draft_session_dates.session_dates_attributes = current_attrs
+ @draft_session_dates.save!(context: :update)
+ end
end
end
diff --git a/app/models/draft_session_dates.rb b/app/models/draft_session_dates.rb
new file mode 100644
index 0000000000..3f5391a6ed
--- /dev/null
+++ b/app/models/draft_session_dates.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+class DraftSessionDates
+ include RequestSessionPersistable
+ include WizardStepConcern
+ include ActiveRecord::AttributeMethods::Serialization
+
+ def request_session_key
+ "draft_session_dates"
+ end
+
+ attribute :session_id, :integer
+ attribute :current_user_id, :integer
+
+ def initialize(current_user:, **attributes)
+ @current_user = current_user
+ super(**attributes)
+ end
+
+ serialize :session_dates_attributes_json, coder: JSON
+
+ def wizard_steps
+ %i[dates]
+ end
+
+ on_wizard_step :dates, exact: true do
+ validate :validate_session_dates, on: %i[update continue]
+ end
+
+ def session
+ return nil if session_id.nil?
+
+ SessionPolicy::Scope.new(@current_user, Session).resolve.find(session_id)
+ end
+
+ def session=(value)
+ self.session_id = value.id
+ end
+
+ def session_dates
+ @session_dates ||= build_session_dates_from_attributes
+ end
+
+ def non_destroyed_session_dates_count
+ session_dates_attributes.count { |_, attrs| attrs["_destroy"] != "true" }
+ end
+
+ def session_dates_attributes
+ @session_dates_attributes ||=
+ JSON.parse(session_dates_attributes_json || "{}")
+ end
+
+ def session_dates_attributes=(attributes)
+ @session_dates_attributes = attributes
+ self.session_dates_attributes_json = attributes
+ @session_dates = nil # Reset cached session dates
+ end
+
+ def write_to!(session)
+ return unless session_id == session.id
+
+ ActiveRecord::Base.transaction do
+ delete_marked_session_dates(session)
+ create_or_update_session_dates(session)
+ update_session_notifications(session)
+ end
+ end
+
+ private
+
+ def delete_marked_session_dates(session)
+ session_dates_attributes.each_value do |attrs|
+ next unless attrs["_destroy"] == "true" && attrs["id"].present?
+
+ session_date = session.session_dates.find_by(id: attrs["id"])
+ session_date&.destroy! unless session_date&.session_attendances&.any?
+ end
+ end
+
+ def create_or_update_session_dates(session)
+ session_dates_attributes.each_value do |attrs|
+ next if attrs["_destroy"] == "true"
+
+ date_value = parse_date_from_attributes(attrs)
+ next unless date_value
+
+ if attrs["id"].present?
+ update_existing_session_date(session, attrs["id"], date_value)
+ else
+ session.session_dates.create!(value: date_value)
+ end
+ end
+ end
+
+ def update_existing_session_date(session, id, date_value)
+ session_date = session.session_dates.find_by(id: id)
+ session_date&.update!(value: date_value)
+ end
+
+ def update_session_notifications(session)
+ session.set_notification_dates
+ session.save!
+ end
+
+ def reset_unused_fields
+ # No unused fields to reset for session dates
+ end
+
+ def build_session_dates_from_attributes
+ dates = []
+
+ if session_dates_attributes.present?
+ session_dates_attributes.each_value do |attrs|
+ next if attrs["_destroy"] == "true"
+
+ if attrs["id"].present?
+ existing_date = session&.session_dates&.find_by(id: attrs["id"])
+ parsed_date = parse_date_from_attributes(attrs)
+ dates << DraftSessionDate.new(
+ id: attrs["id"],
+ value: parsed_date || existing_date.value,
+ session: session,
+ persisted: true
+ )
+ else
+ parsed_date = parse_date_from_attributes(attrs)
+ dates << DraftSessionDate.new(
+ value: parsed_date,
+ session: session,
+ persisted: false
+ )
+ end
+ end
+ end
+
+ dates
+ end
+
+ class DraftSessionDate
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ attribute :id, :integer
+ attribute :value, :date
+
+ attr_accessor :session, :persisted_state
+
+ def initialize(session:, persisted: false, **attributes)
+ @session = session
+ @persisted_state = persisted
+ super(attributes)
+ end
+
+ def persisted?
+ @persisted_state
+ end
+
+ def session_attendances
+ return [] unless persisted? && id
+
+ session&.session_dates&.find_by(id: id)&.session_attendances || []
+ end
+ end
+
+ def parse_date_from_attributes(attrs)
+ return attrs["value"] if attrs["value"].is_a?(Date)
+
+ if attrs["value(1i)"] && attrs["value(2i)"] && attrs["value(3i)"]
+ begin
+ Date.new(
+ attrs["value(1i)"].to_i,
+ attrs["value(2i)"].to_i,
+ attrs["value(3i)"].to_i
+ )
+ rescue StandardError
+ nil
+ end
+ end
+ end
+
+ def validate_session_dates
+ attrs_hash = session_dates_attributes
+
+ accepted_dates = []
+ has_valid_date = false
+
+ attrs_hash.each_value do |attrs|
+ next if attrs["_destroy"] == "true"
+
+ date_value = parse_date_from_attributes(attrs)
+
+ if date_value.nil?
+ if attrs["value(1i)"].present? || attrs["value(2i)"].present? ||
+ attrs["value(3i)"].present? || attrs["value"].present?
+ errors.add(:base, "Enter a valid date")
+ end
+ next
+ end
+
+ has_valid_date = true
+
+ if accepted_dates.include?(date_value)
+ errors.add(:base, "Session dates must be unique")
+ next
+ end
+
+ accepted_dates << date_value
+ end
+
+ errors.add(:base, "Enter a date") unless has_valid_date
+ end
+end
diff --git a/app/views/session_dates/show.html.erb b/app/views/session_dates/show.html.erb
index 5093f3e9f8..bf91c4793e 100644
--- a/app/views/session_dates/show.html.erb
+++ b/app/views/session_dates/show.html.erb
@@ -4,7 +4,7 @@
<%= h1 "When will sessions be held?" %>
-<%= form_with model: @session, url: session_dates_path(@session), method: :put do |f| %>
+<%= form_with model: @draft_session_dates, url: session_dates_path(@session), method: :put do |f| %>
<% content_for(:before_content) { f.govuk_error_summary } %>
@@ -23,14 +23,13 @@
legend: { size: "m", text: "Session date" },
hint: { text: "For example, 27 3 2024" } %>
- <% if date_f.object.persisted? || @session.session_dates.length > 1 %>
- <% button_class = "nhsuk-button app-add-another__delete app-button--secondary-warning app-button--small" %>
-
- <% if date_f.object.new_record? %>
- <%= link_to "Delete", session_dates_path(@session), class: button_class %>
- <% else %>
- <%= f.govuk_submit "Delete", name: "session[session_dates_attributes][#{date_f.index}][_destroy]", value: "true", class: button_class %>
- <% end %>
+
+ <% if date_f.object.persisted? || @draft_session_dates.session_dates.length > 1 %>
+ <%= f.govuk_submit "Delete",
+ name: "delete_date",
+ value: date_f.index,
+ warning: true,
+ class: "app-add-another__delete app-button--small" %>
<% end %>
<% end %>
@@ -42,6 +41,5 @@
<%= f.govuk_submit "Continue" %>
- <%= govuk_link_to "Back", edit_session_path(@session) %>
<% end %>
diff --git a/spec/features/draft_session_dates_spec.rb b/spec/features/draft_session_dates_spec.rb
new file mode 100644
index 0000000000..04e6cded92
--- /dev/null
+++ b/spec/features/draft_session_dates_spec.rb
@@ -0,0 +1,474 @@
+# frozen_string_literal: true
+
+describe "Edit session dates" do
+ around { |example| travel_to(Time.zone.local(2024, 2, 18)) { example.run } }
+
+ scenario "User can modify session dates without immediate saving" do
+ given_i_have_a_session_with_existing_dates
+ when_i_visit_the_session_edit_page
+ then_i_see_the_existing_session_dates
+
+ when_i_click_change_session_dates
+ then_i_see_the_session_dates_page_with_existing_dates
+
+ when_i_modify_the_first_date
+ and_i_click_back
+ then_i_should_be_back_on_session_edit_page
+ and_the_original_dates_should_be_unchanged
+
+ when_i_click_change_session_dates_again
+ then_i_see_the_session_dates_page_with_original_dates
+
+ when_i_modify_the_first_date_to_a_different_value
+ and_i_click_continue
+ then_i_should_be_back_on_session_edit_page
+ and_the_dates_should_be_updated
+ end
+
+ scenario "User can add new session dates" do
+ given_i_have_a_session_without_dates
+ when_i_visit_the_session_edit_page
+ then_i_see_add_session_dates_link
+
+ when_i_click_add_session_dates
+ then_i_see_the_session_dates_page_with_empty_form
+
+ when_i_add_a_new_date
+ and_i_click_add_another_date
+ when_i_add_a_second_date
+ and_i_click_continue
+ then_i_should_be_back_on_session_edit_page
+ and_i_should_see_both_new_dates
+ end
+
+ scenario "User gets validation error for invalid date" do
+ given_i_have_a_session_without_dates
+ when_i_visit_the_session_edit_page
+ and_i_click_add_session_dates
+
+ when_i_add_an_invalid_date
+ and_i_click_continue
+ then_i_should_see_invalid_date_error
+ end
+
+ scenario "User sees read-only display for session dates with attendances" do
+ given_i_have_a_session_with_dates_and_attendances
+ when_i_visit_the_session_edit_page
+ then_i_see_the_existing_session_dates
+
+ when_i_click_change_session_dates
+ then_i_see_the_session_dates_page_with_attendance_restrictions
+ end
+
+ scenario "User can delete session dates" do
+ given_i_have_a_session_with_existing_dates
+ when_i_visit_the_session_edit_page
+ then_i_see_the_existing_session_dates
+
+ when_i_click_change_session_dates
+ then_i_see_the_session_dates_page_with_existing_dates
+
+ when_i_delete_the_first_date
+ and_i_delete_the_new_first_date_too
+ then_i_should_only_see_the_third_date
+
+ and_i_click_continue
+ then_i_should_be_back_on_session_edit_page
+ and_only_the_third_date_should_remain
+ end
+
+ scenario "User can delete a newly added session date in the same session" do
+ given_i_have_a_session_with_existing_dates
+ when_i_visit_the_session_edit_page
+ then_i_see_the_existing_session_dates
+
+ when_i_click_change_session_dates
+ then_i_see_the_session_dates_page_with_existing_dates
+
+ and_i_click_add_another_date
+ when_i_add_a_fourth_date
+ then_i_should_see_four_dates
+
+ when_i_delete_the_newly_added_fourth_date
+ then_i_should_see_original_three_dates_only
+
+ and_i_click_continue
+ then_i_should_be_back_on_session_edit_page
+ and_the_original_dates_should_be_unchanged
+ end
+
+ scenario "User cannot delete the last remaining session date" do
+ given_i_have_a_session_with_one_date
+ when_i_visit_the_session_edit_page
+ then_i_see_the_single_session_date
+
+ when_i_click_change_session_dates
+ then_i_see_the_session_dates_page_with_one_date
+
+ when_i_try_to_delete_the_only_date
+ then_i_should_see_cannot_delete_last_date_error
+ and_the_date_should_still_be_visible
+ end
+
+ def given_i_have_a_session_with_existing_dates
+ @team = create(:team, :with_one_nurse)
+ @session = create(:session, :unscheduled, team: @team)
+ @original_date1 = Date.new(2024, 3, 15)
+ @original_date2 = Date.new(2024, 3, 16)
+ @original_date3 = Date.new(2024, 3, 17)
+ @session.session_dates.create!(value: @original_date1)
+ @session.session_dates.create!(value: @original_date2)
+ @session.session_dates.create!(value: @original_date3)
+
+ sign_in @team.users.first
+ end
+
+ def given_i_have_a_session_without_dates
+ @team = create(:team, :with_one_nurse)
+ @session = create(:session, :unscheduled, team: @team)
+
+ sign_in @team.users.first
+ end
+
+ def given_i_have_a_session_with_one_date
+ @team = create(:team, :with_one_nurse)
+ @session = create(:session, :unscheduled, team: @team)
+ @single_date = Date.new(2024, 3, 15)
+ @session.session_dates.create!(value: @single_date)
+
+ sign_in @team.users.first
+ end
+
+ def when_i_visit_the_session_edit_page
+ visit edit_session_path(@session)
+ end
+
+ def then_i_see_the_existing_session_dates
+ expect(page).to have_content("15 March 2024")
+ expect(page).to have_content("16 March 2024")
+ expect(page).to have_content("17 March 2024")
+ end
+
+ def then_i_see_add_session_dates_link
+ expect(page).to have_link("Add session dates")
+ end
+
+ def when_i_click_change_session_dates
+ click_link "Change session dates"
+ end
+
+ def when_i_click_change_session_dates_again
+ when_i_click_change_session_dates
+ end
+
+ def when_i_click_add_session_dates
+ click_link "Add session dates"
+ end
+
+ alias_method :and_i_click_add_session_dates, :when_i_click_add_session_dates
+
+ def then_i_see_the_session_dates_page_with_existing_dates
+ expect(page).to have_content("When will sessions be held?")
+
+ # Check that existing dates are populated
+ within page.all(".app-add-another__list-item")[0] do
+ expect(page).to have_field("Day", with: "15")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+
+ within page.all(".app-add-another__list-item")[1] do
+ expect(page).to have_field("Day", with: "16")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+
+ within page.all(".app-add-another__list-item")[2] do
+ expect(page).to have_field("Day", with: "17")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+ end
+
+ def then_i_see_the_session_dates_page_with_original_dates
+ then_i_see_the_session_dates_page_with_existing_dates
+ end
+
+ def then_i_see_the_session_dates_page_with_empty_form
+ expect(page).to have_content("When will sessions be held?")
+ # Check that there are empty form fields (they might have nil values)
+ expect(page).to have_field("Day")
+ expect(page).to have_field("Month")
+ expect(page).to have_field("Year")
+ end
+
+ def when_i_modify_the_first_date
+ within page.all(".app-add-another__list-item")[0] do
+ fill_in "Day", with: "20"
+ end
+ end
+
+ def when_i_modify_the_first_date_to_a_different_value
+ within page.all(".app-add-another__list-item")[0] do
+ fill_in "Day", with: "25"
+ end
+ end
+
+ def when_i_add_a_new_date
+ fill_in "Day", with: "15"
+ fill_in "Month", with: "3"
+ fill_in "Year", with: "2024"
+ end
+
+ def when_i_add_a_second_date
+ # Find the second list item (after clicking "Add another date")
+ within page.all(".app-add-another__list-item")[1] do
+ fill_in "Day", with: "16"
+ fill_in "Month", with: "3"
+ fill_in "Year", with: "2024"
+ end
+ end
+
+ def and_i_click_back
+ click_link "Back"
+ end
+
+ def and_i_click_continue
+ click_button "Continue"
+ end
+
+ def and_i_click_add_another_date
+ click_button "Add another date"
+ end
+
+ def then_i_should_be_back_on_session_edit_page
+ expect(page).to have_content("Edit session")
+ expect(page).to have_content(@session.location.name)
+ end
+
+ def and_the_original_dates_should_be_unchanged
+ @session.reload
+ expect(@session.session_dates.map(&:value)).to contain_exactly(
+ @original_date1,
+ @original_date2,
+ @original_date3
+ )
+ expect(page).to have_content("15 March 2024")
+ expect(page).to have_content("16 March 2024")
+ expect(page).to have_content("17 March 2024")
+ end
+
+ def and_the_dates_should_be_updated
+ @session.reload
+ expect(@session.session_dates.map(&:value)).to contain_exactly(
+ Date.new(2024, 3, 25),
+ @original_date2,
+ @original_date3
+ )
+ expect(page).to have_content("25 March 2024")
+ expect(page).to have_content("16 March 2024")
+ expect(page).to have_content("17 March 2024")
+ end
+
+ def and_i_should_see_both_new_dates
+ @session.reload
+ expect(@session.session_dates.count).to eq(2)
+ expect(@session.session_dates.map(&:value)).to contain_exactly(
+ Date.new(2024, 3, 15),
+ Date.new(2024, 3, 16)
+ )
+ expect(page).to have_content("15 March 2024")
+ expect(page).to have_content("16 March 2024")
+ end
+
+ def then_i_should_see_duplicate_date_error
+ expect(page).to have_content("There is a problem")
+ expect(page).to have_content("Session dates must be unique")
+ end
+
+ def when_i_delete_the_first_date
+ within page.all(".app-add-another__list-item")[0] do
+ click_button "Delete"
+ end
+ end
+
+ alias_method :and_i_delete_the_new_first_date_too,
+ :when_i_delete_the_first_date
+
+ def then_i_should_only_see_the_third_date
+ expect(page).to have_content("When will sessions be held?")
+
+ expect(page).to have_field(with: "17")
+ expect(page).not_to have_field(with: "16")
+ expect(page).not_to have_field(with: "15")
+
+ visible_date_groups = page.all(".app-add-another__list-item")
+ expect(visible_date_groups.count).to eq(1)
+ end
+
+ def and_only_the_third_date_should_remain
+ @session.reload
+ expect(@session.session_dates.count).to eq(1)
+ expect(@session.session_dates.first.value).to eq(@original_date3)
+ expect(page).to have_content("17 March 2024")
+ expect(page).not_to have_content("16 March 2024")
+ expect(page).not_to have_content("15 March 2024")
+ end
+
+ def when_i_add_a_fourth_date
+ # Find the fourth list item (after clicking "Add another date")
+ within page.all(".app-add-another__list-item")[3] do
+ fill_in "Day", with: "18"
+ fill_in "Month", with: "3"
+ fill_in "Year", with: "2024"
+ end
+ end
+
+ def then_i_should_see_four_dates
+ expect(page).to have_content("When will sessions be held?")
+
+ visible_date_groups = page.all(".app-add-another__list-item")
+ expect(visible_date_groups.count).to eq(4)
+
+ # Check all four dates are visible
+ expect(page).to have_field(with: "15")
+ expect(page).to have_field(with: "16")
+ expect(page).to have_field(with: "17")
+ expect(page).to have_field(with: "18")
+ end
+
+ def when_i_delete_the_newly_added_fourth_date
+ within page.all(".app-add-another__list-item")[3] do
+ click_button "Delete"
+ end
+ end
+
+ def then_i_should_see_original_three_dates_only
+ expect(page).to have_content("When will sessions be held?")
+
+ visible_date_groups = page.all(".app-add-another__list-item")
+ expect(visible_date_groups.count).to eq(3)
+
+ # Check only original three dates are visible
+ expect(page).to have_field(with: "15")
+ expect(page).to have_field(with: "16")
+ expect(page).to have_field(with: "17")
+ expect(page).not_to have_field(with: "18")
+ end
+
+ def when_i_add_an_invalid_date
+ fill_in "Day", with: "29"
+ fill_in "Month", with: "2"
+ fill_in "Year", with: "2023" # Not a leap year
+ end
+
+ def when_i_add_an_incomplete_date
+ fill_in "Day", with: "15"
+ fill_in "Month", with: ""
+ fill_in "Year", with: "2024"
+ end
+
+ def then_i_should_see_invalid_date_error
+ expect(page).to have_content("There is a problem")
+ expect(page).to have_content("Enter a valid date")
+ end
+
+ def given_i_have_a_session_with_dates_and_attendances
+ @team = create(:team, :with_one_nurse)
+ @session = create(:session, :unscheduled, team: @team)
+ @original_date1 = Date.new(2024, 3, 15)
+ @original_date2 = Date.new(2024, 3, 16)
+ @original_date3 = Date.new(2024, 3, 17)
+
+ # Create session dates
+ @session_date1 = @session.session_dates.create!(value: @original_date1)
+ @session_date2 = @session.session_dates.create!(value: @original_date2)
+ @session_date3 = @session.session_dates.create!(value: @original_date3)
+
+ # Create a patient and patient session
+ @patient = create(:patient, team: @team)
+ @patient_session =
+ create(:patient_session, patient: @patient, session: @session)
+
+ # Create session attendance for the first date (this will prevent changing that date)
+ create(
+ :session_attendance,
+ :present,
+ patient_session: @patient_session,
+ session_date: @session_date1
+ )
+
+ sign_in @team.users.first
+ end
+
+ def then_i_see_the_session_dates_page_with_attendance_restrictions
+ expect(page).to have_content("When will sessions be held?")
+
+ # First date should be read-only (has attendances)
+ within page.all(".app-add-another__list-item")[0] do
+ expect(page).to have_content("15 March 2024")
+ expect(page).to have_content(
+ "Children have attended this session. It cannot be changed."
+ )
+ expect(page).not_to have_field("Day")
+ end
+
+ # Second and third dates should be editable (no attendances)
+ within page.all(".app-add-another__list-item")[1] do
+ expect(page).to have_field("Day", with: "16")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+
+ within page.all(".app-add-another__list-item")[2] do
+ expect(page).to have_field("Day", with: "17")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+ end
+
+ def then_i_should_see_no_dates_error
+ expect(page).to have_content("There is a problem")
+ expect(page).to have_content("Enter a date")
+ end
+
+ def then_i_see_the_single_session_date
+ expect(page).to have_content("15 March 2024")
+ end
+
+ def then_i_see_the_session_dates_page_with_one_date
+ expect(page).to have_content("When will sessions be held?")
+
+ visible_date_groups = page.all(".app-add-another__list-item")
+ expect(visible_date_groups.count).to eq(1)
+
+ within visible_date_groups[0] do
+ expect(page).to have_field("Day", with: "15")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+ end
+
+ def when_i_try_to_delete_the_only_date
+ within page.all(".app-add-another__list-item")[0] do
+ click_button "Delete"
+ end
+ end
+
+ def then_i_should_see_cannot_delete_last_date_error
+ expect(page).to have_content("There is a problem")
+ expect(page).to have_content("You cannot delete the last session date")
+ expect(page).to have_content("A session must have at least one date")
+ end
+
+ def and_the_date_should_still_be_visible
+ visible_date_groups = page.all(".app-add-another__list-item")
+ expect(visible_date_groups.count).to eq(1)
+
+ within visible_date_groups[0] do
+ expect(page).to have_field("Day", with: "15")
+ expect(page).to have_field("Month", with: "3")
+ expect(page).to have_field("Year", with: "2024")
+ end
+ end
+end
diff --git a/spec/models/draft_session_dates_spec.rb b/spec/models/draft_session_dates_spec.rb
new file mode 100644
index 0000000000..f9055c30f8
--- /dev/null
+++ b/spec/models/draft_session_dates_spec.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+describe DraftSessionDates do
+ subject(:draft_session_dates) do
+ described_class.new(
+ request_session: request_session,
+ current_user: current_user,
+ session: session,
+ wizard_step: :dates
+ )
+ end
+
+ let(:programme) { create(:programme, :hpv) }
+ let(:team) { create(:team, :with_one_nurse, programmes: [programme]) }
+ let(:session) { create(:session, team: team, programmes: [programme]) }
+ let(:current_user) { team.users.first }
+ let(:request_session) { {} }
+
+ describe "validations" do
+ context "with valid session dates" do
+ before do
+ draft_session_dates.session_dates_attributes = {
+ "0" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "15"
+ }
+ }
+ end
+
+ it "is valid" do
+ expect(draft_session_dates.valid?(:update)).to be true
+ end
+ end
+
+ context "with invalid date" do
+ before do
+ draft_session_dates.session_dates_attributes = {
+ "0" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "13", # Invalid month
+ "value(3i)" => "15"
+ }
+ }
+ end
+
+ it "is invalid" do
+ expect(draft_session_dates.valid?(:update)).to be false
+ expect(draft_session_dates.errors[:base]).to include(
+ "Enter a valid date"
+ )
+ end
+ end
+
+ context "with duplicate dates" do
+ before do
+ draft_session_dates.session_dates_attributes = {
+ "0" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "15"
+ },
+ "1" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "15"
+ }
+ }
+ end
+
+ it "is invalid" do
+ expect(draft_session_dates.valid?(:update)).to be false
+ expect(draft_session_dates.errors[:base]).to include(
+ "Session dates must be unique"
+ )
+ end
+
+ it "validates uniqueness of dates" do
+ # First validate to trigger the validation
+ draft_session_dates.valid?(:update)
+
+ # Check that the validation was triggered
+ expect(draft_session_dates.errors[:base]).to include(
+ "Session dates must be unique"
+ )
+
+ # Now change one of the dates to make them unique
+ draft_session_dates.session_dates_attributes = {
+ "0" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "15"
+ },
+ "1" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "16" # Changed day from 15 to 16
+ }
+ }
+
+ # Validate again
+ expect(draft_session_dates.valid?(:update)).to be true
+ end
+ end
+
+ context "with no dates" do
+ before { draft_session_dates.session_dates_attributes = {} }
+
+ it "is invalid on update" do
+ expect(draft_session_dates.valid?(:update)).to be false
+ expect(draft_session_dates.errors[:base]).to include("Enter a date")
+ end
+
+ it "is valid on create (allows saving empty draft)" do
+ expect(draft_session_dates.valid?(:create)).to be true
+ end
+ end
+ end
+
+ describe "#write_to!" do
+ let(:target_session) do
+ create(:session, team: team, programmes: [programme])
+ end
+
+ before do
+ draft_session_dates.session = target_session
+ draft_session_dates.session_dates_attributes = {
+ "0" => {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "15"
+ }
+ }
+ end
+
+ it "creates new session dates" do
+ expect { draft_session_dates.write_to!(target_session) }.to change {
+ target_session.session_dates.count
+ }.by(1)
+
+ expect(target_session.session_dates.last.value).to eq(
+ Date.new(2024, 10, 15)
+ )
+ end
+
+ it "calls set_notification_dates on the session" do
+ expect(target_session).to receive(:set_notification_dates)
+ expect(target_session).to receive(:save!)
+
+ draft_session_dates.write_to!(target_session)
+ end
+ end
+
+ describe "#parse_date_from_attributes" do
+ it "parses date from multi-parameter attributes" do
+ attrs = {
+ "value(1i)" => "2024",
+ "value(2i)" => "10",
+ "value(3i)" => "15"
+ }
+
+ result = draft_session_dates.send(:parse_date_from_attributes, attrs)
+ expect(result).to eq(Date.new(2024, 10, 15))
+ end
+
+ it "returns nil for invalid date" do
+ attrs = {
+ "value(1i)" => "2024",
+ "value(2i)" => "13", # Invalid month
+ "value(3i)" => "15"
+ }
+
+ result = draft_session_dates.send(:parse_date_from_attributes, attrs)
+ expect(result).to be_nil
+ end
+
+ it "returns date object as-is" do
+ date = Date.new(2024, 10, 15)
+ attrs = { "value" => date }
+
+ result = draft_session_dates.send(:parse_date_from_attributes, attrs)
+ expect(result).to eq(date)
+ end
+ end
+end