diff --git a/Gemfile.lock b/Gemfile.lock
index 03fbf59a7..fc91f9df1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -103,10 +103,10 @@ GEM
aes_key_wrap (1.1.0)
ast (2.4.2)
aws-eventstream (1.3.0)
- aws-partitions (1.1041.0)
+ aws-partitions (1.1043.0)
aws-record (2.13.2)
aws-sdk-dynamodb (~> 1, >= 1.85.0)
- aws-sdk-core (3.216.0)
+ aws-sdk-core (3.217.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -171,7 +171,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
- brakeman (6.2.2)
+ brakeman (7.0.0)
racc
builder (3.3.0)
bullet (8.0.0)
@@ -225,7 +225,7 @@ GEM
docile (1.4.1)
dotenv (3.1.7)
drb (2.2.1)
- dumb_delegator (1.0.0)
+ dumb_delegator (1.1.0)
erubi (1.13.1)
excon (1.2.3)
factory_bot (6.5.0)
@@ -319,7 +319,7 @@ GEM
kaminari-core (1.2.2)
kramdown (2.5.1)
rexml (>= 3.3.9)
- language_server-protocol (3.17.0.3)
+ language_server-protocol (3.17.0.4)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
@@ -421,7 +421,7 @@ GEM
date
stringio
public_suffix (6.0.1)
- puma (6.5.0)
+ puma (6.6.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.8)
@@ -520,7 +520,7 @@ GEM
rspec-support (3.13.2)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
- rubocop (1.70.0)
+ rubocop (1.71.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -530,9 +530,9 @@ GEM
rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.37.0)
+ rubocop-ast (1.38.0)
parser (>= 3.3.1.0)
- rubocop-rails (2.29.0)
+ rubocop-rails (2.29.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
diff --git a/app/controllers/admin/cx_collection_details_controller.rb b/app/controllers/admin/cx_collection_details_controller.rb
index 4a907e2bc..7ab54b0fe 100644
--- a/app/controllers/admin/cx_collection_details_controller.rb
+++ b/app/controllers/admin/cx_collection_details_controller.rb
@@ -74,41 +74,44 @@ def export_csv
# Handle a large-ish csv upload (5+ MB) to S3
def upload_csv
file = params[:file] # Assuming the file comes from a form field named 'file'
-
file_extension = File.extname(file.original_filename).downcase
-
@valid_file_extension = (file_extension == ".csv")
- # check the file to ensure it is valid
- csv_file = CSV.parse(file.read, headers: true)
- begin
- @valid_file_headers = csv_file.headers.sort == [
- "external_id",
- "question_1",
- "positive_effectiveness",
- "positive_ease",
- "positive_efficiency",
- "positive_transparency",
- "positive_humanity",
- "positive_employee",
- "positive_other",
- "negative_effectiveness",
- "negative_ease",
- "negative_efficiency",
- "negative_transparency",
- "negative_humanity",
- "negative_employee",
- "negative_other",
- "question_4"
- ].sort
- rescue CSV::MalformedCSVError => e
- flash[:alert] = "There was an error processing the CSV file: #{e.message}"
- @valid_file_headers = false
- rescue
- @valid_file_headers = false
+ @valid_file_encoding = false
+ file_contents = file.read.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
+ @valid_file_encoding = file_contents.encoding.name == "UTF-8" && file_contents.valid_encoding?
+
+ if @valid_file_encoding
+ csv_file = CSV.parse(file_contents, headers: true)
+ begin
+ @valid_file_headers = csv_file.headers.sort == [
+ "external_id",
+ "question_1",
+ "positive_effectiveness",
+ "positive_ease",
+ "positive_efficiency",
+ "positive_transparency",
+ "positive_humanity",
+ "positive_employee",
+ "positive_other",
+ "negative_effectiveness",
+ "negative_ease",
+ "negative_efficiency",
+ "negative_transparency",
+ "negative_humanity",
+ "negative_employee",
+ "negative_other",
+ "question_4"
+ ].sort
+ rescue CSV::MalformedCSVError => e
+ flash[:alert] = "There was an error processing the CSV file: #{e.message}"
+ @valid_file_headers = false
+ rescue
+ @valid_file_headers = false
+ end
end
- if @valid_file_extension && @valid_file_headers
+ if @valid_file_encoding && @valid_file_extension && @valid_file_headers
key = "cx_data_collections/cx-upload-#{timestamp_string}-#{file.original_filename}"
obj = s3_bucket.object(key)
# Upload the file
@@ -124,13 +127,14 @@ def upload_csv
Event.log_event(Event.names[:cx_collection_detail_upload_created], @cxdu.class.to_s, @cxdu.id, "CX Collection Detail Upload #{@cxdu.id} created at #{DateTime.now}", current_user.id)
flash[:notice] = "A .csv file with #{csv_file.size} rows was uploaded successfully. Please see your uploaded file in the table below, then return to the CX Data Collection."
+ elsif !@valid_file_encoding
+ flash[:alert] = "The uploaded file must be encoded as UTF-8"
elsif !@valid_file_extension
flash[:notice] = "File has a file extension of #{file_extension}, but it should be .csv."
elsif !@valid_file_headers
flash[:alert] = "CSV headers do not match. Headers received were: #{csv_file.headers.to_s}"
end
- # render :upload
redirect_to upload_admin_cx_collection_detail_path(@cx_collection_detail)
end
diff --git a/app/controllers/admin/forms_controller.rb b/app/controllers/admin/forms_controller.rb
index 446cd0a2a..708cc939a 100644
--- a/app/controllers/admin/forms_controller.rb
+++ b/app/controllers/admin/forms_controller.rb
@@ -184,8 +184,8 @@ def show
end
def export
- start_date = params[:start_date] ? Date.parse(params[:start_date]).to_date : Time.zone.now.beginning_of_quarter
- end_date = params[:end_date] ? Date.parse(params[:end_date]).to_date : Time.zone.now.end_of_quarter
+ start_date = params[:start_date] ? Date.parse(params[:start_date]).to_date.beginning_of_day : Time.zone.now.beginning_of_quarter
+ end_date = params[:end_date] ? Date.parse(params[:end_date]).to_date.end_of_day : Time.zone.now.end_of_quarter
count = Form.find_by_short_uuid(@form.short_uuid).non_flagged_submissions(start_date:, end_date:).count
if count > MAX_ROWS_TO_EXPORT
@@ -593,6 +593,7 @@ def form_admin_options_params
:service_id,
:service_stage_id,
:enforce_new_submission_validations,
+ :legacy_link_feature_flag,
)
end
diff --git a/app/controllers/admin/offerings_controller.rb b/app/controllers/admin/offerings_controller.rb
deleted file mode 100644
index 437da2f0b..000000000
--- a/app/controllers/admin/offerings_controller.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- class OfferingsController < AdminController
- before_action :set_offering, only: %i[show edit update destroy add_offering_persona remove_offering_persona]
-
- before_action :set_offering_persona_options, only: %i[
- new
- create
- edit
- update
- ]
-
- def index
- @offerings = Offering.order(:name)
- end
-
- def show; end
-
- def new
- @offering = Offering.new
- end
-
- def edit; end
-
- def create
- @offering = Offering.new(offering_params)
-
- if @offering.save
- redirect_to admin_offering_path(@offering), notice: 'Offering was successfully created.'
- else
- render :new
- end
- end
-
- def update
- if @offering.update(offering_params)
- redirect_to admin_offering_path(@offering), notice: 'Offering was successfully updated.'
- else
- render :edit
- end
- end
-
- def destroy
- @offering.destroy
- redirect_to admin_offerings_url, notice: 'Offering was successfully destroyed.'
- end
-
- def add_offering_persona
- @offering.persona_list.add(params[:persona_id])
- @offering.save
- set_offering_persona_options
- end
-
- def remove_offering_persona
- @offering.persona_list.remove(params[:persona_id])
- @offering.save
- set_offering_persona_options
- end
-
- private
-
- def set_offering
- @offering = Offering.find(params[:id])
- end
-
- def offering_params
- params.require(:offering).permit(:name, :service_id)
- end
-
- def set_offering_persona_options
- @offering_persona_options = Persona.order(:name)
- @offering_persona_options -= @offering.offering_personas if @offering_persona_options && @offering
- end
- end
-end
diff --git a/app/helpers/admin/offerings_helper.rb b/app/helpers/admin/offerings_helper.rb
deleted file mode 100644
index 74969c5c1..000000000
--- a/app/helpers/admin/offerings_helper.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- module OfferingsHelper
- end
-end
diff --git a/app/models/form.rb b/app/models/form.rb
index 3d28e3d4d..fac749083 100644
--- a/app/models/form.rb
+++ b/app/models/form.rb
@@ -227,6 +227,7 @@ def duplicate!(new_user:)
new_form.organization = new_user.organization
new_form.template = false
new_form.enforce_new_submission_validations = true
+ new_form.legacy_link_feature_flag = false
new_form.save!
# Manually remove the Form Section created with create_first_form_section
diff --git a/app/models/offering.rb b/app/models/offering.rb
deleted file mode 100644
index e939ed708..000000000
--- a/app/models/offering.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-class Offering < ApplicationRecord
- belongs_to :service, optional: true
-
- acts_as_taggable_on :personas
-
- has_paper_trail
-
- def offering_personas
- Persona.where(id: persona_list)
- end
-end
diff --git a/app/views/admin/forms/_admin_options.html.erb b/app/views/admin/forms/_admin_options.html.erb
index 0aaddfa11..21b224fdf 100644
--- a/app/views/admin/forms/_admin_options.html.erb
+++ b/app/views/admin/forms/_admin_options.html.erb
@@ -66,6 +66,15 @@
<% end %>
+
diff --git a/app/views/admin/offerings/_form.html.erb b/app/views/admin/offerings/_form.html.erb
deleted file mode 100644
index cd8d70c43..000000000
--- a/app/views/admin/offerings/_form.html.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<%= form_with(model: offering, url: (offering.persisted? ? admin_offering_path(offering) : admin_offerings_path), local: true) do |form| %>
- <%- if offering.errors.any? %>
-
-
<%= pluralize(offering.errors.count, "error") %> prohibited this offering from being saved:
-
-
- <% offering.errors.full_messages.each do |message| %>
- - <%= message %>
- <% end %>
-
-
- <% end %>
-
-
- <%= form.label :name, class: "usa-label" %>
- <%= form.text_field :name, class: "usa-input" %>
-
-
- <%= form.label :service_id, "Service", class: "usa-label" %>
- <%= form.select :service_id, options_for_select(Service.all.includes(:organization).order("organizations.name", "services.name").map { |s| ["#{s.organization.name} - #{s.name}", s.id] }, offering.try(:service_id) ), { prompt: "Which Service?" }, { class: "usa-select" } %>
-
-
- <%- if offering.persisted? %>
-
-
-
- <%= render 'admin/offerings/offering_personas', offering: offering, offering_persona_options: offering_persona_options %>
-
-
-
- <% end %>
-
-
- <%= form.submit class: "usa-button" %>
-
-<% end %>
diff --git a/app/views/admin/offerings/_offering_personas.html.erb b/app/views/admin/offerings/_offering_personas.html.erb
deleted file mode 100644
index 47f7a55ac..000000000
--- a/app/views/admin/offerings/_offering_personas.html.erb
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
- Personas
-
-
- Optionally, this offering may be associated with one or more
- <%= link_to "Personas", admin_personas_path %>.
-
-
-
- <% offering.offering_personas.each do |persona| %>
- -
-
- <%= persona.name %>
-
-
-
-
-
- Delete
-
-
-
-
- <% end %>
-
-
-
-
-
-<%= select_tag :offering_persona_id, options_for_select(offering_persona_options.map { |p| [p.name, p.id] }), prompt: "Choose a Persona", class: "usa-select add-offering-persona" %>
-
-
diff --git a/app/views/admin/offerings/add_offering_persona.js.erb b/app/views/admin/offerings/add_offering_persona.js.erb
deleted file mode 100644
index ab8618143..000000000
--- a/app/views/admin/offerings/add_offering_persona.js.erb
+++ /dev/null
@@ -1 +0,0 @@
-$(".offering-personas-div").html("<%= escape_javascript render(:partial => 'admin/offerings/offering_personas', :locals => { :offering => @offering }) %>");
diff --git a/app/views/admin/offerings/edit.html.erb b/app/views/admin/offerings/edit.html.erb
deleted file mode 100644
index 5c205006e..000000000
--- a/app/views/admin/offerings/edit.html.erb
+++ /dev/null
@@ -1,18 +0,0 @@
-<% content_for :navigation_title do %>
- Editing Offering
-<% end %>
-
- <%= link_to admin_offering_path(@offering) do %>
-
- Back to Offering
- <% end %>
-
-
-<%= render 'form', offering: @offering, offering_persona_options: @offering_persona_options %>
-
-
- <%= link_to admin_offering_path(@offering), method: :delete, data: { confirm: 'Are you sure?' }, class: "usa-button usa-button--secondary" do %>
-
- Delete
- <% end %>
-
diff --git a/app/views/admin/offerings/index.html.erb b/app/views/admin/offerings/index.html.erb
deleted file mode 100644
index b3ba5934c..000000000
--- a/app/views/admin/offerings/index.html.erb
+++ /dev/null
@@ -1,25 +0,0 @@
-<% content_for :navigation_title do %>
- Offerings
- <%= link_to new_admin_offering_path, class: "usa-button usa-button-inverted float-right" do %>
-
- New Offering
- <% end %>
-<% end %>
-
-
-
-
- | Name |
- Service |
-
-
-
-
- <% @offerings.each do |offering| %>
-
- | <%= link_to offering.name, admin_offering_path(offering) %> |
- <%= offering.service.name if offering.service.present? %> |
-
- <% end %>
-
-
diff --git a/app/views/admin/offerings/new.html.erb b/app/views/admin/offerings/new.html.erb
deleted file mode 100644
index 504ebd588..000000000
--- a/app/views/admin/offerings/new.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-<% content_for :navigation_title do %>
- New Offering
-<% end %>
-
- <%= link_to admin_offerings_path do %>
-
- Back to Offerings
- <% end %>
-
-
-<%= render 'form', offering: @offering, offering_persona_options: @offering_persona_options %>
diff --git a/app/views/admin/offerings/remove_offering_persona.js.erb b/app/views/admin/offerings/remove_offering_persona.js.erb
deleted file mode 100644
index ab8618143..000000000
--- a/app/views/admin/offerings/remove_offering_persona.js.erb
+++ /dev/null
@@ -1 +0,0 @@
-$(".offering-personas-div").html("<%= escape_javascript render(:partial => 'admin/offerings/offering_personas', :locals => { :offering => @offering }) %>");
diff --git a/app/views/admin/offerings/show.html.erb b/app/views/admin/offerings/show.html.erb
deleted file mode 100644
index 82dc6b4ba..000000000
--- a/app/views/admin/offerings/show.html.erb
+++ /dev/null
@@ -1,35 +0,0 @@
-<% content_for :navigation_title do %>
- Viewing Offering: <%= @offering.name %>
- <%= link_to edit_admin_offering_path(@offering), class: "usa-button usa-button-inverted float-right" do %>
-
- Edit
- <% end %>
-<% end %>
-
- <%= link_to admin_offerings_path do %>
-
- Back to Offerings
- <% end %>
-
-
-
- Name
-
- <%= @offering.name %>
-
-
-
- Service
-
- <%- if @offering.service %>
- <%= link_to @offering.service.name, admin_service_path(@offering.service) if @offering.service %>
- <% else %>
-
-
-
- This Offering is not currently associated with a Service.
-
-
-
- <% end %>
-
diff --git a/app/views/components/widget/_fba.js.erb b/app/views/components/widget/_fba.js.erb
index 0840da004..ebefad59d 100644
--- a/app/views/components/widget/_fba.js.erb
+++ b/app/views/components/widget/_fba.js.erb
@@ -369,12 +369,18 @@ function FBAform(d, N) {
this.landmarkElement.setAttribute('role', 'complementary');
// Add the fixed, floating tab button
- this.buttonEl = d.createElement('a');
+ <%# FEATURE FLAG %>
+ if(this.options.legacyLink) {
+ this.buttonEl = d.createElement('a');
+ this.buttonEl.setAttribute('href', 'javascript:void(0)');
+ } else {
+ this.buttonEl = d.createElement('button');
+ }
this.buttonEl.setAttribute('id', 'fba-button');
this.buttonEl.setAttribute('data-id', this.options.formId);
this.buttonEl.setAttribute('class', 'fba-button fixed-tab-button usa-button');
this.buttonEl.setAttribute('name', 'fba-button');
- this.buttonEl.setAttribute('href', 'javascript:void(0)');
+ this.buttonEl.setAttribute('aria-label', this.options.modalButtonText);
this.buttonEl.setAttribute('aria-haspopup', 'dialog');
this.buttonEl.setAttribute('aria-controls', this.modalId());
this.buttonEl.setAttribute('data-open-modal', '');
@@ -639,6 +645,7 @@ function FBAform(d, N) {
// Specify the options for your form
const touchpointFormOptions<%= form.short_uuid %> = {
+ 'legacyLink': <%= form.legacy_link_feature_flag? %>,
'formId': "<%= form.short_uuid %>",
'modalButtonText': "<%= form.modal_button_text %>",
'elementSelector': "<%= form.element_selector %>",
diff --git a/db/migrate/20250130195535_add_forms_legacy_link_feature_flag.rb b/db/migrate/20250130195535_add_forms_legacy_link_feature_flag.rb
new file mode 100644
index 000000000..1997e40f7
--- /dev/null
+++ b/db/migrate/20250130195535_add_forms_legacy_link_feature_flag.rb
@@ -0,0 +1,7 @@
+class AddFormsLegacyLinkFeatureFlag < ActiveRecord::Migration[7.2]
+ def change
+ add_column :forms, :legacy_link_feature_flag, :boolean, default: false, comment: "when true, render fba-button as an A, otherwise render as BUTTON"
+
+ Form.update_all(legacy_link_feature_flag: false)
+ end
+end
diff --git a/db/migrate/20250206005216_rm_offerings.rb b/db/migrate/20250206005216_rm_offerings.rb
new file mode 100644
index 000000000..06f186878
--- /dev/null
+++ b/db/migrate/20250206005216_rm_offerings.rb
@@ -0,0 +1,5 @@
+class RmOfferings < ActiveRecord::Migration[7.2]
+ def change
+ drop_table :offerings
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a8ac7d849..d067abcda 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_01_17_004643) do
+ActiveRecord::Schema[7.2].define(version: 2025_02_06_005216) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -311,6 +311,7 @@
t.string "short_uuid", limit: 8
t.boolean "enforce_new_submission_validations", default: true
t.integer "service_stage_id"
+ t.boolean "legacy_link_feature_flag", default: false, comment: "when true, render fba-button as an A, otherwise render as BUTTON"
t.index ["legacy_touchpoint_id"], name: "index_forms_on_legacy_touchpoint_id"
t.index ["legacy_touchpoint_uuid"], name: "index_forms_on_legacy_touchpoint_uuid"
t.index ["organization_id"], name: "index_forms_on_organization_id"
@@ -351,13 +352,6 @@
t.datetime "updated_at", null: false
end
- create_table "offerings", force: :cascade do |t|
- t.string "name"
- t.integer "service_id"
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- end
-
create_table "omb_cx_reporting_collections", comment: "A detailed record belonging to a Collection; a quarterly CX Data Collection", force: :cascade do |t|
t.integer "collection_id"
t.string "service_provided"
diff --git a/spec/controllers/admin/cx_collection_details_controller_spec.rb b/spec/controllers/admin/cx_collection_details_controller_spec.rb
new file mode 100644
index 000000000..657fe50ca
--- /dev/null
+++ b/spec/controllers/admin/cx_collection_details_controller_spec.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Admin::CxCollectionDetailsController, type: :controller do
+ let(:organization) { FactoryBot.create(:organization) }
+ let(:another_organization) { FactoryBot.create(:organization) }
+ let(:a_third_organization) { FactoryBot.create(:organization) }
+ let(:admin) { FactoryBot.create(:user, :admin, organization:) }
+ let(:user) { FactoryBot.create(:user, organization: another_organization) }
+ let(:user_3) { FactoryBot.create(:user, organization: a_third_organization) }
+ let(:service) { FactoryBot.create(:service, organization:, service_owner_id: user.id) }
+ let(:service_provider) { FactoryBot.create(:service_provider, organization:) }
+ let(:cx_collection) { FactoryBot.create(:cx_collection, organization: another_organization, service: service) }
+ let(:cx_collection_detail) { FactoryBot.create(:cx_collection_detail, :with_cx_collection_detail_upload, cx_collection: cx_collection, service: service, transaction_point: :post_service_journey, channel: Service.channels.sample) }
+
+ let(:valid_session) { {} }
+
+ let(:valid_attributes) do
+ FactoryBot.build(:cx_collection, organization:, user: admin, service_provider:).attributes
+ end
+
+ let(:invalid_attributes) do
+ {
+ name: 'Only',
+ organization_id: nil,
+ user_id: nil,
+ }
+ end
+
+ context 'as a User' do
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET /show' do
+ it 'renders a successful response' do
+ get :index, params: {}, session: valid_session
+ expect(response).to be_successful
+ end
+ end
+ end
+
+ context 'as admin' do
+ before do
+ sign_in(admin)
+ end
+
+ describe 'GET #export_csv' do
+ let!(:cx_collection_detail2) { FactoryBot.create(:cx_collection_detail, :with_cx_collection_detail_upload, cx_collection: cx_collection, service: service, transaction_point: :post_service_journey, channel: Service.channels.sample) }
+
+ it 'renders a successful response' do
+ get :export_csv, params: { id: cx_collection_detail.cx_collection_id }, session: valid_session
+ expect(response).to be_successful
+
+ csv = CSV.parse(response.body, headers: true)
+ expect(csv.headers).to eq(
+ [
+ "id",
+ "organization_id",
+ "organization_abbreviation",
+ "organization_name",
+ "cx_collection_id",
+ "cx_collection_fiscal_year",
+ "cx_collection_quarter",
+ "cx_collection_name",
+ "cx_collection_service_provider_id",
+ "cx_collection_service_provider_name",
+ "cx_collection_service_provider_slug",
+ "service_id",
+ "service_name",
+ "transaction_point",
+ "channel",
+ "service_stage_id",
+ "service_stage_name",
+ "service_stage_position",
+ "service_stage_count",
+ "volume_of_customers",
+ "volume_of_customers_provided_survey_opportunity",
+ "volume_of_respondents",
+ "omb_control_number",
+ "survey_type",
+ "survey_title",
+ "trust_question_text",
+ "created_at",
+ "updated_at",
+ ]
+ )
+ expect(csv.size).to eq(2)
+ end
+ end
+
+ let(:user) { create(:user) }
+ let(:cx_collection_detail) { FactoryBot.create(:cx_collection_detail, :with_cx_collection_detail_upload, cx_collection: cx_collection, service: service, transaction_point: :post_service_journey, channel: Service.channels.sample) }
+ let(:valid_csv) do
+ <<~CSV
+ external_id,question_1,positive_effectiveness,positive_ease,positive_efficiency,positive_transparency,positive_humanity,positive_employee,positive_other,negative_effectiveness,negative_ease,negative_efficiency,negative_transparency,negative_humanity,negative_employee,negative_other,question_4
+ 123,Yes,1,1,1,1,1,1,1,0,0,0,0,0,0,0,No
+ CSV
+ end
+
+ let(:invalid_headers_csv) do
+ <<~CSV
+ wrong_header_1,wrong_header_2
+ value1,value2
+ CSV
+ end
+
+ before do
+ allow(controller).to receive(:current_user).and_return(user)
+ allow(controller).to receive(:s3_bucket).and_return(double('S3Bucket', object: double('S3Object', upload_file: true, size: 1000, key: 's3-key')))
+ end
+
+ describe 'POST #upload_csv' do
+ context 'with a valid CSV file' do
+ it 'uploads successfully and creates a CxCollectionDetailUpload' do
+ file = File.open("spec/fixtures/sample_cx_responses_upload.csv")
+ post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/csv'), id: cx_collection_detail.id }
+ expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail))
+ expect(flash[:notice]).to match(/A .csv file with \d+ rows was uploaded successfully/)
+ end
+ end
+
+ context 'with a valid CSV BOM byte order marked file' do
+ it 'uploads successfully and creates a CxCollectionDetailUpload ' do
+ file = File.open("spec/fixtures/sample_cx_responses_upload_with_bom.csv")
+ post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/csv'), id: cx_collection_detail.id }
+ expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail))
+ expect(flash[:notice]).to match(/A .csv file with \d+ rows was uploaded successfully/)
+ end
+ end
+
+ context 'with an invalid file extension' do
+ it 'shows an alert message' do
+ file = Tempfile.new(['invalid', '.txt'])
+ file.write(valid_csv)
+ file.rewind
+
+ post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/plain'), id: cx_collection_detail.id }
+
+ expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail))
+ expect(flash[:notice]).to match(/File has a file extension of .txt, but it should be .csv./)
+ ensure
+ file.close
+ file.unlink
+ end
+ end
+
+ context 'with invalid CSV headers' do
+ it 'shows a header mismatch error' do
+ file = Tempfile.new(['invalid_headers', '.csv'])
+ file.write(invalid_headers_csv)
+ file.rewind
+
+ post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/csv'), id: cx_collection_detail.id }
+
+ expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail))
+ expect(flash[:alert]).to match(/CSV headers do not match/)
+ ensure
+ file.close
+ file.unlink
+ end
+ end
+ end
+
+ end
+end
diff --git a/spec/controllers/admin/forms_controller_spec.rb b/spec/controllers/admin/forms_controller_spec.rb
index 9c1816b9d..68526444b 100644
--- a/spec/controllers/admin/forms_controller_spec.rb
+++ b/spec/controllers/admin/forms_controller_spec.rb
@@ -30,7 +30,7 @@
# Form. As you add validations to Form, be sure to
# adjust the attributes here as well.
let(:organization) { FactoryBot.create(:organization) }
- let!(:form) { FactoryBot.create(:form, organization: organization) }
+ let!(:form) { FactoryBot.create(:form, :two_question_open_ended_form, organization:) }
let(:admin) { FactoryBot.create(:user, :admin, organization: organization) }
let!(:user_role) { FactoryBot.create(:user_role, user: admin, form: form, role: UserRole::Role::FormManager) }
@@ -134,7 +134,6 @@
context 'with valid params' do
before do
- form.questions.create!(text: "Question one", question_type: :text_field, form_section: form.form_sections.first, answer_field: :answer_01, position: 1)
form.created_at = Time.now - 2.weeks
20.times { FactoryBot.create(:submission, form: form) }
form.save!
@@ -208,4 +207,78 @@
expect(response).to redirect_to(admin_forms_url)
end
end
+
+ describe 'GET #export' do
+ let(:start_date) { '2024-01-01' }
+ let(:end_date) { '2024-01-28' }
+ let!(:submission1) { FactoryBot.create(:submission, form:, created_at: '2024-01-01 08:00:00') }
+ let!(:submission2) { FactoryBot.create(:submission, form:, created_at: '2024-01-15 12:00:00') }
+ let!(:submission3) { FactoryBot.create(:submission, form:, created_at: '2024-01-28 23:59:59') }
+ let!(:out_of_range) { FactoryBot.create(:submission, form:, created_at: '2024-01-29 00:00:01') }
+
+ context 'when response count is within the small download limit' do
+ it 'sends a CSV file' do
+ get :export, params: { id: form.short_uuid, start_date: start_date, end_date: end_date }
+ expect(response.content_type).to include('text/csv')
+ expect(response.status).to eq(200)
+ expect(response.body).to include("ID,UUID,Test Text Field,Test Open Area,Location Code,User Agent,Status,Archived,Flagged,Page,Query string,Hostname,Referrer,Created At,IP Address,Tags")
+ expect(response.body).to include(submission1.created_at.to_s)
+ expect(response.body).to include(submission2.created_at.to_s)
+ expect(response.body).to include(submission3.created_at.to_s)
+ expect(response.body).to_not include(out_of_range.created_at.to_s)
+ end
+ end
+
+ context 'when response count exceeds small download limit but is within async job range' do
+ before do
+ allow(Form).to receive(:find_by_short_uuid).with(form.short_uuid).and_return(form)
+ allow(form).to receive(:non_flagged_submissions).and_return(double(count: 1_500))
+ allow(ExportJob).to receive(:perform_later)
+ end
+
+ it 'queues an async export job and redirects' do
+ get :export, params: { id: form.short_uuid, start_date: start_date, end_date: end_date }
+
+ expect(ExportJob).to have_received(:perform_later).with(admin.email, form.short_uuid, "2024-01-01 00:00:00 UTC", "2024-01-28 23:59:59 UTC")
+ expect(response).to redirect_to(responses_admin_form_path(form))
+ expect(flash[:success]).to eq(UserMailer::ASYNC_JOB_MESSAGE)
+ end
+ end
+
+ context 'when response count exceeds the maximum allowed export' do
+ before do
+ allow(Form).to receive(:find_by_short_uuid).with(form.short_uuid).and_return(form)
+ allow(form).to receive(:non_flagged_submissions).and_return(double(count: described_class::MAX_ROWS_TO_EXPORT + 1))
+ end
+
+ it 'returns a bad request error' do
+ get :export, params: { id: form.short_uuid, start_date: start_date, end_date: end_date }
+
+ expect(response).to have_http_status(:bad_request)
+ expect(response.body).to include("Your response set contains")
+ end
+ end
+
+ context 'when no date parameters are provided' do
+ let(:default_start) { Time.zone.now.beginning_of_quarter }
+ let(:default_end) { Time.zone.now.end_of_quarter }
+ let!(:submission1) { FactoryBot.create(:submission, form:, created_at: default_start + 1.day) }
+ let!(:submission2) { FactoryBot.create(:submission, form:, created_at: default_start + 2.days) }
+ let!(:submission3) { FactoryBot.create(:submission, form:, created_at: default_start + 3.days) }
+ let!(:out_of_range1) { FactoryBot.create(:submission, form:, created_at: default_start - 1.day) }
+ let!(:out_of_range2) { FactoryBot.create(:submission, form:, created_at: default_end + 1.day) }
+
+ it 'uses the default quarter range' do
+ get :export, params: { id: form.short_uuid }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(submission1.created_at.to_s)
+ expect(response.body).to include(submission2.created_at.to_s)
+ expect(response.body).to include(submission3.created_at.to_s)
+ expect(response.body).to_not include(out_of_range1.created_at.to_s)
+ expect(response.body).to_not include(out_of_range2.created_at.to_s)
+ end
+ end
+ end
+
end
diff --git a/spec/features/admin/offerings_spec.rb b/spec/features/admin/offerings_spec.rb
deleted file mode 100644
index a938b7bba..000000000
--- a/spec/features/admin/offerings_spec.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-feature 'Offerings', js: true do
- let(:organization) { FactoryBot.create(:organization) }
- let(:admin) { FactoryBot.create(:user, :admin, organization:) }
- let(:user) { FactoryBot.create(:user, organization:) }
-
- context 'as Admin' do
- before do
- login_as admin
- end
-
- describe '#index' do
- before do
- visit admin_offerings_path
- end
-
- it 'load the Offerings#index page' do
- expect(page).to have_content('Offerings')
- expect(page).to have_link('New Offering')
- expect(page.current_path).to eq(admin_offerings_path)
- end
- end
- end
-end
diff --git a/spec/features/embedded_touchpoints_spec.rb b/spec/features/embedded_touchpoints_spec.rb
index a08b49edd..c582f4e23 100644
--- a/spec/features/embedded_touchpoints_spec.rb
+++ b/spec/features/embedded_touchpoints_spec.rb
@@ -6,8 +6,9 @@
let(:organization) { FactoryBot.create(:organization) }
let!(:user) { FactoryBot.create(:user, :admin, organization:) }
+ [true, false].each do |legacy_link_feature_flag_setting|
context "as Admin" do
- let!(:form) { FactoryBot.create(:form, :kitchen_sink, organization:) }
+ let!(:form) { FactoryBot.create(:form, :kitchen_sink, organization:, legacy_link_feature_flag: legacy_link_feature_flag_setting) }
describe '/forms/:id/example' do
before do
@@ -96,4 +97,5 @@
end
end
end
+ end # legacy_link_feature_flag_setting
end
diff --git a/spec/fixtures/sample_cx_responses_upload_with_bom.csv b/spec/fixtures/sample_cx_responses_upload_with_bom.csv
new file mode 100644
index 000000000..936128488
--- /dev/null
+++ b/spec/fixtures/sample_cx_responses_upload_with_bom.csv
@@ -0,0 +1,5 @@
+external_id,question_1,positive_effectiveness,positive_ease,positive_efficiency,positive_transparency,positive_humanity,positive_employee,positive_other,negative_effectiveness,negative_ease,negative_efficiency,negative_transparency,negative_humanity,negative_employee,negative_other,question_4
+100001,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,"alright"
+100002,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,""
+100003,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,Excellent listening
+100004,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,"Very intuitive experience, easy!"
diff --git a/spec/models/form_spec.rb b/spec/models/form_spec.rb
index 01e8b36bd..fb434493e 100644
--- a/spec/models/form_spec.rb
+++ b/spec/models/form_spec.rb
@@ -172,6 +172,24 @@
end
end
+ describe '#non_flagged_submissions' do
+ let!(:submission1) { FactoryBot.create(:submission, form:, created_at: '2024-01-01 08:00:00') }
+ let!(:submission2) { FactoryBot.create(:submission, form:, created_at: '2024-01-15 12:00:00') }
+ let!(:submission3) { FactoryBot.create(:submission, form:, created_at: '2024-01-28 23:59:59') }
+ let!(:out_of_range) { FactoryBot.create(:submission, form:, created_at: '2024-01-29 00:00:01') }
+
+ before do
+ start_date = Date.parse('2024-01-01').beginning_of_day
+ end_date = Date.parse('2024-01-28').end_of_day
+ @results = form.non_flagged_submissions(start_date:, end_date:)
+ end
+
+ it 'includes submissions up to and including the end_date' do
+ expect(@results).to include(submission1, submission2, submission3)
+ expect(@results).not_to include(out_of_range)
+ end
+ end
+
describe '#to_a11_header_csv' do
it 'returns Submission fields' do
csv = form.to_a11_header_csv(start_date: Time.zone.now.to_date, end_date: 3.months.from_now.to_date)