diff --git a/Gemfile b/Gemfile index 0a3c079e87..b9951a825a 100644 --- a/Gemfile +++ b/Gemfile @@ -95,6 +95,9 @@ gem "uri-idna" # For converting HTML to Markdown gem "reverse_markdown" +# For reading translations from a spreadsheet +gem "roo" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[mri windows], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index 2674306eb3..2afb725ac1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -630,6 +630,12 @@ GEM reverse_markdown (3.0.2) nokogiri rexml (3.4.4) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -814,6 +820,7 @@ DEPENDENCIES rails (~> 8.1.3) rails-controller-testing reverse_markdown + roo rspec-rails (>= 3.9.0) rubocop-govuk selenium-webdriver @@ -1047,6 +1054,7 @@ CHECKSUMS request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 diff --git a/app/controllers/forms/welsh_translation_controller.rb b/app/controllers/forms/welsh_translation_controller.rb index f85d152e3c..4fe733b794 100644 --- a/app/controllers/forms/welsh_translation_controller.rb +++ b/app/controllers/forms/welsh_translation_controller.rb @@ -70,6 +70,28 @@ def download disposition: "attachment; filename=#{form_content_service.filename}" end + def show_upload + authorize current_form, :can_edit_form? + render :show_upload, locals: { current_form:, welsh_translation_upload_input: WelshTranslationUploadInput.new } + end + + def upload + authorize current_form, :can_edit_form? + + welsh_translation_upload_input = WelshTranslationUploadInput.new(**welsh_translation_upload_params) + + data = welsh_translation_upload_input.read_file + unless data + return render :show_upload, status: :unprocessable_entity, locals: { current_form:, welsh_translation_upload_input: } + end + + @welsh_translation_input = WelshTranslationInput.new(form: form_with_pages_and_conditions).assign_form_values + @welsh_translation_input.assign_from_spreadsheet(data) + @table_presenter = Forms::TranslationTablePresenter.new + + render :new + end + private def preview_html @@ -96,5 +118,9 @@ def delete_welsh_translation_params def form_with_pages_and_conditions Form.includes(pages: [:routing_conditions]).find(current_form.id) end + + def welsh_translation_upload_params + params.fetch(:forms_welsh_translation_upload_input, ActionController::Parameters.new).permit(:file) + end end end diff --git a/app/input_objects/forms/welsh_condition_translation_input.rb b/app/input_objects/forms/welsh_condition_translation_input.rb index c7459b29c8..8b4fe3e0b1 100644 --- a/app/input_objects/forms/welsh_condition_translation_input.rb +++ b/app/input_objects/forms/welsh_condition_translation_input.rb @@ -1,6 +1,7 @@ class Forms::WelshConditionTranslationInput < BaseInput include ActionView::Helpers::FormTagHelper include ActiveModel::Attributes + include WelshTranslationContentId attr_accessor :condition @@ -39,8 +40,17 @@ def assign_condition_values self end + def assign_from_spreadsheet(data) + %i[exit_page_heading exit_page_markdown].each do |attr| + spreadsheet_id = condition_content_id(condition.id, attr) + send(:"#{attr}_cy=", data[spreadsheet_id]) if data.key?(spreadsheet_id) + end + + self + end + def form_field_id(attribute) - field_id(:forms_welsh_condition_translation_input, condition.id, :condition_translations, attribute) + condition_content_id(condition.id, attribute) end def condition_has_exit_page? diff --git a/app/input_objects/forms/welsh_page_translation_input.rb b/app/input_objects/forms/welsh_page_translation_input.rb index 20d90a655b..b6890ce252 100644 --- a/app/input_objects/forms/welsh_page_translation_input.rb +++ b/app/input_objects/forms/welsh_page_translation_input.rb @@ -2,6 +2,7 @@ class Forms::WelshPageTranslationInput < BaseInput include TextInputHelper include ActionView::Helpers::FormTagHelper include ActiveModel::Attributes + include WelshTranslationContentId attr_accessor :condition_translations, :selection_options_cy attr_reader :page @@ -82,6 +83,24 @@ def assign_page_values self end + def assign_from_spreadsheet(data) + %i[question_text hint_text page_heading guidance_markdown none_of_the_above_question].each do |attr| + spreadsheet_id = page_content_id(page.id, attr) + send(:"#{attr}_cy=", data[spreadsheet_id]) if data.key?(spreadsheet_id) + end + + selection_options_cy&.each_with_index do |option, index| + spreadsheet_id = page_content_id(page.id, "option_#{index}") + option.name_cy = data[spreadsheet_id] if data.key?(spreadsheet_id) + end + + condition_translations.each do |condition_translation| + condition_translation.assign_from_spreadsheet(data) + end + + self + end + def condition_translations_attributes=(attributes) submitted_condition_ids = attributes.values.map { |attrs| attrs["id"] }.compact @@ -108,11 +127,11 @@ def selection_options_cy_attributes=(attributes) next unless english_selection_option Forms::WelshSelectionOptionTranslationInput.new(**selection_option_attrs.symbolize_keys, page:, selection_option: english_selection_option) - }.compact + }.compact.sort_by(&:id) end def form_field_id(attribute) - field_id(:forms_welsh_page_translation_input, page.id, :page_translations, attribute) + page_content_id(page.id, attribute) end def page_has_hint_text? @@ -241,6 +260,10 @@ def page_has_selection_options? page.answer_type == "selection" end + def selection_options_exceed_visibility_threshold? + page.answer_settings&.selection_options&.size.to_i > 30 + end + # We need to normalize the Welsh answer settings to match the English ones. # The only answer settings that need translating are the selection options # We ensure that welsh answer settings are correct by copying the English diff --git a/app/input_objects/forms/welsh_selection_option_translation_input.rb b/app/input_objects/forms/welsh_selection_option_translation_input.rb index e94025ac3d..86be545c3d 100644 --- a/app/input_objects/forms/welsh_selection_option_translation_input.rb +++ b/app/input_objects/forms/welsh_selection_option_translation_input.rb @@ -3,6 +3,7 @@ class Forms::WelshSelectionOptionTranslationInput < BaseInput include ActionView::Helpers::FormTagHelper include ActiveModel::Attributes + SELECTION_OPTIONS_VISIBLE_INPUT_THRESHOLD = 30 NAME_MAX_LENGTH = 250 attr_accessor :selection_option, :page @@ -26,6 +27,14 @@ def assign_selection_option_values self end + def assign_selection_option_values_from_csv_values(csv_values) + return self unless selection_option + + spreadsheet_id = page_content_id(page.id, "option_#{index}") + self.name_cy = csv_values[spreadsheet_id] if csv_values.key?(spreadsheet_id) + self + end + def as_selection_option { name: name_cy, value: selection_option.value } end @@ -38,6 +47,12 @@ def selection_number id + 1 end + def show_name_input? + return true unless selection_options_exceed_visibility_threshold? + + name_cy.blank? || errors[:name_cy].present? + end + def question_number page.position end @@ -46,7 +61,11 @@ def all_fields_empty? name_cy.blank? end -private + private + + def selection_options_exceed_visibility_threshold? + page&.answer_settings&.selection_options&.size.to_i > SELECTION_OPTIONS_VISIBLE_INPUT_THRESHOLD + end def name_present? if name_cy.blank? diff --git a/app/input_objects/forms/welsh_translation_input.rb b/app/input_objects/forms/welsh_translation_input.rb index 22b6e4d9f1..7c3b2efa9c 100644 --- a/app/input_objects/forms/welsh_translation_input.rb +++ b/app/input_objects/forms/welsh_translation_input.rb @@ -122,6 +122,19 @@ def assign_form_values self end + def assign_from_spreadsheet(data) + WelshSpreadsheetImportService::FORM_FIELD_IDS.each do |field_id| + attr = :"#{field_id}_cy" + send(:"#{attr}=", data[field_id]) if data.key?(field_id) + end + + page_translations.each do |page_translation| + page_translation.assign_from_spreadsheet(data) + end + + self + end + def blanked? all_fields_empty? && page_translations.all?(&:blanked?) end diff --git a/app/input_objects/forms/welsh_translation_upload_input.rb b/app/input_objects/forms/welsh_translation_upload_input.rb new file mode 100644 index 0000000000..6a8338f1e7 --- /dev/null +++ b/app/input_objects/forms/welsh_translation_upload_input.rb @@ -0,0 +1,27 @@ +class Forms::WelshTranslationUploadInput < BaseInput + attr_accessor :file + + validates :file, presence: true + validate :validate_file_type + + FILE_TYPES = %w[ + text/csv + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + application/vnd.oasis.opendocument.spreadsheet + ].freeze + + def read_file + return false if invalid? + + WelshSpreadsheetImportService.new(file).read + rescue CSV::MalformedCSVError, WelshSpreadsheetImportService::InvalidHeadersError + errors.add(:file, :invalid_csv) + false + end + + def validate_file_type + if file.present? && FILE_TYPES.exclude?(file.content_type) + errors.add(:file, :disallowed_type) + end + end +end diff --git a/app/lib/welsh_translation_content_id.rb b/app/lib/welsh_translation_content_id.rb new file mode 100644 index 0000000000..df53c79383 --- /dev/null +++ b/app/lib/welsh_translation_content_id.rb @@ -0,0 +1,9 @@ +module WelshTranslationContentId + def page_content_id(page_id, attribute) + "page_#{page_id}_#{attribute}" + end + + def condition_content_id(condition_id, attribute) + "condition_#{condition_id}_#{attribute}" + end +end diff --git a/app/services/welsh_csv_service.rb b/app/services/welsh_csv_service.rb index 73032eec9e..2c728fc42f 100644 --- a/app/services/welsh_csv_service.rb +++ b/app/services/welsh_csv_service.rb @@ -1,4 +1,6 @@ class WelshCsvService + include WelshTranslationContentId + MAX_FILENAME_LENGTH = 80 FILENAME_SEPARATOR = "_".freeze @@ -29,11 +31,11 @@ def filename private def add_header(csv) - csv << ["", "English content", "Welsh content"] + csv << [WelshSpreadsheetImportService::ID_COLUMN, "Content", "English content", "Welsh content"] end def add_form_name(csv) - csv << ["Form name", form.name, form.name_cy] + csv << ["name", "Form name", form.name, form.name_cy] end def add_page_content(csv) @@ -48,16 +50,16 @@ def add_page_content(csv) end def add_question_content(csv, page) - csv << ["#{question_name(page)} - question text", page.question_text, page.question_text_cy] + csv << [page_content_id(page.id, :question_text), "#{question_name(page)} - question text", page.question_text, page.question_text_cy] if page.hint_text.present? - csv << ["#{question_name(page)} - hint text", page.hint_text, page.hint_text_cy] + csv << [page_content_id(page.id, :hint_text), "#{question_name(page)} - hint text", page.hint_text, page.hint_text_cy] end end def add_selection_options(csv, page) page.answer_settings.selection_options.each_with_index do |option, index| welsh_option_name = page.answer_settings_cy&.selection_options&.dig(index)&.name || "" - csv << ["#{question_name(page)} - option #{index + 1}", option.name, welsh_option_name] + csv << [page_content_id(page.id, "option_#{index}"), "#{question_name(page)} - option #{index + 1}", option.name, welsh_option_name] end end @@ -66,7 +68,8 @@ def add_none_of_above_question(csv, page) welsh_question = page.answer_settings_cy&.none_of_the_above_question&.question_text || "" csv << [ - "#{question_name(page)} - question or label if ‘None of the above’ is selected", + page_content_id(page.id, :none_of_the_above_question), + "#{question_name(page)} - question or label if 'None of the above' is selected", english_question, welsh_question, ] @@ -80,21 +83,21 @@ def has_none_of_the_above?(page) def add_routing_conditions(csv, page) page.routing_conditions.each do |condition| if condition.is_exit_page? - csv << ["#{question_name(page)} - exit page heading", condition.exit_page_heading, condition.exit_page_heading_cy] - csv << ["#{question_name(page)} - exit page content", condition.exit_page_markdown, condition.exit_page_markdown_cy] + csv << [condition_content_id(condition.id, :exit_page_heading), "#{question_name(page)} - exit page heading", condition.exit_page_heading, condition.exit_page_heading_cy] + csv << [condition_content_id(condition.id, :exit_page_markdown), "#{question_name(page)} - exit page content", condition.exit_page_markdown, condition.exit_page_markdown_cy] end end end def add_page_heading(csv, page) if page.page_heading.present? - csv << ["#{question_name(page)} - page heading", page.page_heading, page.page_heading_cy] + csv << [page_content_id(page.id, :page_heading), "#{question_name(page)} - page heading", page.page_heading, page.page_heading_cy] end end def add_guidance_text(csv, page) if page.guidance_markdown.present? - csv << ["#{question_name(page)} - guidance text", page.guidance_markdown, page.guidance_markdown_cy] + csv << [page_content_id(page.id, :guidance_markdown), "#{question_name(page)} - guidance text", page.guidance_markdown, page.guidance_markdown_cy] end end @@ -103,24 +106,24 @@ def question_name(page) end def add_form_metadata(csv) - add_field_if_present(csv, "Declaration", form.declaration_text, form.declaration_text_cy) - add_field_if_present(csv, "Information about what happens next", form.what_happens_next_markdown, form.what_happens_next_markdown_cy) - add_field_if_present(csv, "GOV⁠.⁠UK Pay payment link", form.payment_url, form.payment_url_cy) - add_field_if_present(csv, "Link to privacy information for this form", form.privacy_policy_url, form.privacy_policy_url_cy) + add_field_if_present(csv, "declaration_markdown", "Declaration", form.declaration_text, form.declaration_text_cy) + add_field_if_present(csv, "what_happens_next_markdown", "Information about what happens next", form.what_happens_next_markdown, form.what_happens_next_markdown_cy) + add_field_if_present(csv, "payment_url", "GOV\u2060.\u2060UK Pay payment link", form.payment_url, form.payment_url_cy) + add_field_if_present(csv, "privacy_policy_url", "Link to privacy information for this form", form.privacy_policy_url, form.privacy_policy_url_cy) add_support_details(csv) end def add_support_details(csv) - add_field_if_present(csv, "Contact details for support - email address", form.support_email, form.support_email_cy) - add_field_if_present(csv, "Contact details for support - phone number and opening times", form.support_phone, form.support_phone_cy) - add_field_if_present(csv, "Contact details for support - online contact link", form.support_url, form.support_url_cy) - add_field_if_present(csv, "Contact details for support - online contact link text", form.support_url_text, form.support_url_text_cy) + add_field_if_present(csv, "support_email", "Contact details for support - email address", form.support_email, form.support_email_cy) + add_field_if_present(csv, "support_phone", "Contact details for support - phone number and opening times", form.support_phone, form.support_phone_cy) + add_field_if_present(csv, "support_url", "Contact details for support - online contact link", form.support_url, form.support_url_cy) + add_field_if_present(csv, "support_url_text", "Contact details for support - online contact link text", form.support_url_text, form.support_url_text_cy) end - def add_field_if_present(csv, label, english_value, welsh_value) + def add_field_if_present(csv, id, label, english_value, welsh_value) if english_value.present? - csv << [label, english_value, welsh_value || ""] + csv << [id, label, english_value, welsh_value || ""] end end end diff --git a/app/services/welsh_spreadsheet_import_service.rb b/app/services/welsh_spreadsheet_import_service.rb new file mode 100644 index 0000000000..8d4899eda1 --- /dev/null +++ b/app/services/welsh_spreadsheet_import_service.rb @@ -0,0 +1,77 @@ +class WelshSpreadsheetImportService + class InvalidHeadersError < StandardError; end + class InvalidFileTypeError < StandardError; end + + FORM_FIELD_IDS = %w[ + name + declaration_markdown + what_happens_next_markdown + payment_url + privacy_policy_url + support_email + support_phone + support_url + support_url_text + ].freeze + + ID_COLUMN = "Identifier (do not change)".freeze + WELSH_CONTENT_COLUMN = "Welsh content".freeze + + attr_reader :file + + def initialize(file) + @file = file + end + + def read + case @file.content_type + when "text/csv" + read_csv + when "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + read_xlsx + when "application/vnd.oasis.opendocument.spreadsheet" + read_ods + else + raise InvalidFileTypeError + end + end + + def read_csv + csv_data = file.read.force_encoding("UTF-8") + rows = CSV.parse(csv_data, headers: true) + raise InvalidHeadersError unless rows.headers.include?(ID_COLUMN) && rows.headers.include?(WELSH_CONTENT_COLUMN) + + rows.each_with_object({}) do |row, values| + id = row[ID_COLUMN] + next if id.nil? + + values[id] = row[WELSH_CONTENT_COLUMN].to_s + end + end + + def read_xlsx + xlsx = Roo::Excelx.new(file.path) + headers = xlsx.row(1).map(&:to_s) + raise InvalidHeadersError unless headers[0] == ID_COLUMN && headers[3] == WELSH_CONTENT_COLUMN + + xlsx.each_row_streaming(offset: 1).with_object({}) do |row, values| + id = row[0]&.value + next if id.blank? + + values[id] = row[3]&.value.to_s + end + end + + def read_ods + ods = Roo::OpenOffice.new(file.path) + rows = ods.sheet(0).parse(headers: true) + raise InvalidHeadersError unless rows[0].include?(ID_COLUMN) && rows[0].include?(WELSH_CONTENT_COLUMN) + + rows.drop(1).each_with_object({}) do |row, values| + id = row[ID_COLUMN] + next if id.nil? + + values[id] = row[WELSH_CONTENT_COLUMN].to_s + end + end +end diff --git a/app/views/forms/welsh_translation/new.html.erb b/app/views/forms/welsh_translation/new.html.erb index c4e72dfd1a..2b72fb128f 100644 --- a/app/views/forms/welsh_translation/new.html.erb +++ b/app/views/forms/welsh_translation/new.html.erb @@ -15,6 +15,7 @@
<%= govuk_button_link_to t(".csv_download"), welsh_translation_download_path(@welsh_translation_input.form, format: :csv), secondary: true, data: { "track-link": true } %> + <%= govuk_link_to t(".csv_upload"), welsh_translation_show_upload_path(@welsh_translation_input.form), secondary: true %> <%= render PreviewLinkComponent::View.new(@welsh_translation_input.form.pages, preview_link(@welsh_translation_input.form, locale: :cy), t(".preview_link_text")) %>
@@ -123,6 +124,12 @@ <% if page_form.object.page_has_selection_options? %> <%= render ScrollingWrapperComponent::View.new(aria_label: t("forms.welsh_translation.new.section_headings.selection_options", question_number: page_form.object.page.position)) do %> <%= page_form.govuk_fieldset legend: nil, described_by: ["selection_options_cy_table_caption"] do %> + <% if page_form.object.selection_options_exceed_visibility_threshold? %> +

+ You have added a list of options that is very large, exceeds 30 options. To add translations for this list you need to download a copy of your form and upload it with all of the translations for each option. We will only update translations added to the upload file, meaning if you have already added a translation you can leave it in the downloaded file or leave that cell blank and this will not change your current translation. +

+ <% end %> + <% hidden_selection_option_fields = [] %> <%= govuk_table(classes: @table_presenter.two_column_classes) do |table| %> <%= table.with_caption(size: 's', text: t("forms.welsh_translation.new.section_headings.selection_options", question_number: page_form.object.page.position), @@ -131,22 +138,29 @@ <%= table.with_body do |body| %> <% page_form.fields_for :selection_options_cy, page_form.object.selection_options_cy do |selection_options_form| %> - <%= body.with_row do |row| %> - <%= row.with_cell(text: page_form.object.page.answer_settings.selection_options[selection_options_form.index].name) %> - <%= row.with_cell do %> - <%= selection_options_form.hidden_field :id %> - <%= selection_options_form.govuk_text_field :name_cy, - id: selection_options_form.object.form_field_id(:name_cy), - label: { - text: t("helpers.label.forms_welsh_page_translation_input.selection_options_cy_html", question_number: page_form.object.page.position, option_number: selection_options_form.index + 1), for: selection_options_form.object.form_field_id(:name_cy), - lang: "cy" - } - %> + <% if selection_options_form.object.show_name_input? %> + <%= body.with_row do |row| %> + <%= row.with_cell(text: page_form.object.page.answer_settings.selection_options[selection_options_form.index].name) %> + <%= row.with_cell do %> + <%= selection_options_form.hidden_field :id %> + <%= selection_options_form.govuk_text_field :name_cy, + id: selection_options_form.object.form_field_id(:name_cy), + label: { + text: t("helpers.label.forms_welsh_page_translation_input.selection_options_cy_html", question_number: page_form.object.page.position, option_number: selection_options_form.index + 1), for: selection_options_form.object.form_field_id(:name_cy), + lang: "cy" + } + %> + <% end %> <% end %> + <% else %> + <% hidden_selection_option_fields << selection_options_form.hidden_field(:id) %> + <% hidden_selection_option_fields << selection_options_form.hidden_field(:name_cy) %> <% end %> <% end %> <% end %> <% end %> + + <%= safe_join(hidden_selection_option_fields) %> <% end %> <% end %> <% end %> diff --git a/app/views/forms/welsh_translation/show_upload.html.erb b/app/views/forms/welsh_translation/show_upload.html.erb new file mode 100644 index 0000000000..1056262757 --- /dev/null +++ b/app/views/forms/welsh_translation/show_upload.html.erb @@ -0,0 +1,21 @@ +<% set_page_title(t(".title")) %> +<% content_for :back_link, govuk_back_link_to(welsh_translation_path(current_form)) %> + +
+
+ <%= form_with(model: welsh_translation_upload_input, url: welsh_translation_upload_path(current_form), method: 'POST') do |f| %> + <% if welsh_translation_upload_input&.errors.any? %> + <%= f.govuk_error_summary %> + <% end %> + + <%= f.govuk_file_field :file, + label: { tag: 'h1', size: 'l' }, + accept: Forms::WelshTranslationUploadInput::FILE_TYPES.join(", "), + javascript: true + %> + + <%= f.govuk_submit t(".submit") %> + <% end %> +
+
+ diff --git a/config/locales/en.yml b/config/locales/en.yml index 82a4ee3b41..4837e5cf25 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -650,6 +650,7 @@ en: condition: heading: Question %{question_number}’s exit page csv_download: Download content as CSV + csv_upload: Upload translations delete_welsh_version: Delete Welsh version english_header: English content exit_page_heading: Exit page heading @@ -682,6 +683,9 @@ en: support_url: Online contact link support_url_text: Text to describe the contact link welsh_header: Welsh content + show_upload: + submit: Upload translations + title: Upload Welsh translations from CSV group_forms: edit: body_html: "

We’ll send an email to members of the current group to let them know the form has moved and they may no longer have access to it.

\n" diff --git a/config/locales/input_objects/welsh_translation_upload.yml b/config/locales/input_objects/welsh_translation_upload.yml new file mode 100644 index 0000000000..1fbf202eae --- /dev/null +++ b/config/locales/input_objects/welsh_translation_upload.yml @@ -0,0 +1,18 @@ +--- +en: + activemodel: + errors: + models: + forms/welsh_translation_upload_input: + attributes: + file: + blank: Choose a file + disallowed_type: The selected file must be a CSV, XLSX, or ODS + invalid_csv: The spreadsheet is not in the correct format. Download the content as a CSV file, fill in the translations, and upload it again. + helpers: + hint: + forms_welsh_translation_upload_input: + file: Supported file types are CSV, XLSX, and ODS + label: + forms_welsh_translation_upload_input: + file: Upload a spreadsheet with your Welsh translations diff --git a/config/routes.rb b/config/routes.rb index 28d0d5024c..537a2b09c3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ delete "/welsh-translation/delete" => "forms/welsh_translation#destroy", as: :welsh_translation_destroy post "/welsh-translation-preview" => "forms/welsh_translation#render_preview", as: :welsh_translation_render_preview get "/welsh-translation-download" => "forms/welsh_translation#download", as: :welsh_translation_download + get "/welsh-translation-upload" => "forms/welsh_translation#show_upload", as: :welsh_translation_show_upload + post "/welsh-translation-upload" => "forms/welsh_translation#upload", as: :welsh_translation_upload get "/submission-attachments" => "forms/submission_attachments#new", as: :submission_attachments post "/submission-attachments" => "forms/submission_attachments#create", as: :submission_attachments_create get "/batch-submissions" => "forms/batch_submissions#new", as: :batch_submissions diff --git a/spec/input_objects/forms/welsh_condition_translation_input_spec.rb b/spec/input_objects/forms/welsh_condition_translation_input_spec.rb index 3b91a11fad..27bebdc51d 100644 --- a/spec/input_objects/forms/welsh_condition_translation_input_spec.rb +++ b/spec/input_objects/forms/welsh_condition_translation_input_spec.rb @@ -218,8 +218,8 @@ def create_condition(attributes = {}) end it "returns the custom ID for each attribute" do - expect(welsh_condition_translation_input.form_field_id(:exit_page_markdown_cy)).to eq "forms_welsh_condition_translation_input_#{condition.id}_condition_translations_exit_page_markdown_cy" - expect(welsh_condition_translation_input.form_field_id(:exit_page_heading_cy)).to eq "forms_welsh_condition_translation_input_#{condition.id}_condition_translations_exit_page_heading_cy" + expect(welsh_condition_translation_input.form_field_id(:exit_page_markdown_cy)).to eq "condition_#{condition.id}_exit_page_markdown_cy" + expect(welsh_condition_translation_input.form_field_id(:exit_page_heading_cy)).to eq "condition_#{condition.id}_exit_page_heading_cy" end end diff --git a/spec/input_objects/forms/welsh_page_translation_input_spec.rb b/spec/input_objects/forms/welsh_page_translation_input_spec.rb index eaf59b06d5..90957d347a 100644 --- a/spec/input_objects/forms/welsh_page_translation_input_spec.rb +++ b/spec/input_objects/forms/welsh_page_translation_input_spec.rb @@ -460,6 +460,22 @@ def create_page(attributes = {}) expect(page.reload.answer_settings_cy.selection_options.second.name).to eq("welsh option 2") expect(page.reload.answer_settings_cy.selection_options.second.value).to eq("Option 2") end + + context "when the submitted selection options are not in index order" do + let(:new_input_data) do + super().merge({ selection_options_cy_attributes: { + "0" => { "id" => "1", "name_cy" => "welsh option 2" }, + "1" => { "id" => "0", "name_cy" => "welsh option 1" }, + } }) + end + + it "saves the selection options in the original English order" do + welsh_page_translation_input.submit + + expect(page.reload.answer_settings_cy.selection_options.map(&:name)).to eq(["welsh option 1", "welsh option 2"]) + expect(page.reload.answer_settings_cy.selection_options.map(&:value)).to eq(["Option 1", "Option 2"]) + end + end end context "when the page has a selection question with none of the above" do diff --git a/spec/input_objects/forms/welsh_selection_option_translation_input_spec.rb b/spec/input_objects/forms/welsh_selection_option_translation_input_spec.rb index 13234a1f59..cba87e34c4 100644 --- a/spec/input_objects/forms/welsh_selection_option_translation_input_spec.rb +++ b/spec/input_objects/forms/welsh_selection_option_translation_input_spec.rb @@ -107,4 +107,43 @@ end end end + + describe "#show_name_input?" do + context "when the page has 30 or fewer selection options" do + it "shows the input regardless of whether the name has a value" do + expect(welsh_selection_option_translation_input.show_name_input?).to be true + end + end + + context "when the page has more than 30 selection options" do + let(:page) { build(:page, :selection_with_autocomplete, id: 747) } + let(:selection_option_cy) { DataStruct.new(name: "Option 1", value: page.answer_settings.selection_options.first["value"]) } + + context "when the selection option already has a Welsh value" do + let(:new_input_data) { super().merge(name_cy: "Welsh option") } + + it "hides the input" do + expect(welsh_selection_option_translation_input.show_name_input?).to be false + end + end + + context "when the selection option does not have a Welsh value" do + let(:new_input_data) { super().merge(name_cy: "") } + + it "shows the input" do + expect(welsh_selection_option_translation_input.show_name_input?).to be true + end + end + + context "when the selection option has validation errors" do + let(:new_input_data) { super().merge(name_cy: "a" * 251) } + + it "shows the input" do + welsh_selection_option_translation_input.validate + + expect(welsh_selection_option_translation_input.show_name_input?).to be true + end + end + end + end end diff --git a/spec/requests/forms/welsh_translation_controller_spec.rb b/spec/requests/forms/welsh_translation_controller_spec.rb index 8afcec163b..d904e59d9d 100644 --- a/spec/requests/forms/welsh_translation_controller_spec.rb +++ b/spec/requests/forms/welsh_translation_controller_spec.rb @@ -293,8 +293,66 @@ it "returns a CSV with a header row and and content" do csv = CSV.parse(response.body) - expect(csv.first).to eq(["", "English content", "Welsh content"]) - expect(csv.second).to eq(["Form name", "A form with Welsh", "Welsh A form with Welsh"]) + expect(csv.first).to eq(["Identifier (do not change)", "Content", "English content", "Welsh content"]) + expect(csv.second).to eq(["name", "Form name", "A form with Welsh", "Welsh A form with Welsh"]) + end + end + + describe "#upload" do + let(:form) { create(:form, :ready_for_routing, welsh_completed: false) } + + let(:csv_data) do + CSV.generate do |csv| + csv << [WelshSpreadsheetImportService::ID_COLUMN, "Content", "English content", "Welsh content"] + csv << ["name", "Form name", form.name, "Fy Ffurflen"] + end + end + + let(:file) do + file = Tempfile.new(["translations", ".csv"]) + file.write(csv_data) + file.rewind + Rack::Test::UploadedFile.new(file.path, "text/csv", original_filename: "translations.csv") + end + + context "when a valid CSV is uploaded" do + before do + post welsh_translation_upload_path(id), params: { forms_welsh_translation_upload_input: { file: } } + end + + it "renders the new template" do + expect(response).to have_http_status(:ok) + expect(response).to render_template(:new) + end + + it "pre-populates form name from CSV" do + expect(response.body).to include("Fy Ffurflen") + end + end + + context "when no file is provided" do + before do + post welsh_translation_upload_path(id), params: { forms_welsh_translation_upload_input: { file: nil } } + end + + it "renders the upload file page with an error" do + expect(response).to have_http_status(:unprocessable_content) + expect(response).to render_template(:show_upload) + expect(response.body).to include(I18n.t("activemodel.errors.models.forms/welsh_translation_upload_input.attributes.file.blank")) + expect(flash).to be_empty + end + end + + context "when the user is not authorized" do + let(:current_user) { build :user } + + before do + post welsh_translation_upload_path(id), params: { file: } + end + + it "returns 403" do + expect(response).to have_http_status(:forbidden) + end end end end diff --git a/spec/services/welsh_csv_service_spec.rb b/spec/services/welsh_csv_service_spec.rb index 17eb6e4473..5a4b769733 100644 --- a/spec/services/welsh_csv_service_spec.rb +++ b/spec/services/welsh_csv_service_spec.rb @@ -5,15 +5,12 @@ let(:form) { build :form } it "contains the header row" do - expect(csv_rows(form)[0]).to contain_exactly( - "", - "English content", - "Welsh content", - ) + expect(csv_rows(form)[0]).to eq(["Identifier (do not change)", "Content", "English content", "Welsh content"]) end it "contains the form name" do expect(csv_rows(form)).to include([ + "name", "Form name", form.name, form.name_cy, @@ -25,6 +22,7 @@ it "contains the declaration" do expect(csv_rows(form)).to include([ + "declaration_markdown", "Declaration", "Declaration text", "Welsh Declaration text", @@ -37,6 +35,7 @@ it "contains the what happens next" do expect(csv_rows(form)).to include([ + "what_happens_next_markdown", "Information about what happens next", "What happens next text", "Welsh What happens next text", @@ -49,7 +48,8 @@ it "contains the payment URL" do expect(csv_rows(form)).to include([ - "GOV⁠.⁠UK Pay payment link", + "payment_url", + "GOV\u2060.\u2060UK Pay payment link", "https://www.gov.uk/payment", "https://www.gov.uk/payment_cy", ]) @@ -62,6 +62,7 @@ it "contains the support email" do expect(csv_rows(form)).to include([ + "support_email", "Contact details for support - email address", "support@example.gov.uk", "support@example.gov.uk", @@ -74,6 +75,7 @@ it "contains the support phone" do expect(csv_rows(form)).to include([ + "support_phone", "Contact details for support - phone number and opening times", "English support phone", "Welsh support phone", @@ -86,6 +88,7 @@ it "contains the support URL" do expect(csv_rows(form)).to include([ + "support_url", "Contact details for support - online contact link", "https://www.gov.uk/support", "https://www.gov.uk/support_cy", @@ -94,6 +97,7 @@ it "contains the support URL text" do expect(csv_rows(form)).to include([ + "support_url_text", "Contact details for support - online contact link text", "Text to describe the contact link", "Welsh Text to describe the contact link", @@ -116,6 +120,7 @@ it "contains the question text" do expect(csv_rows(form)).to include([ + "page_#{page.id}_question_text", "Question 1 - question text", "Question text", "Welsh question text", @@ -129,6 +134,7 @@ it "contains the hint text" do expect(csv_rows(form)).to include([ + "page_#{page.id}_hint_text", "Question 1 - hint text", "Hint text", "Welsh hint text", @@ -154,10 +160,12 @@ it "contains the options" do expect(csv_rows(form)).to include([ + "page_#{page.id}_option_0", "Question 1 - option 1", "Yes", "Ydy", ], [ + "page_#{page.id}_option_1", "Question 1 - option 2", "No", "Nac ydy", @@ -177,10 +185,12 @@ it "contains the guidance" do expect(csv_rows(form)).to include([ + "page_#{page.id}_guidance_markdown", "Question 1 - guidance text", "Markdown", "Welsh markdown", ], [ + "page_#{page.id}_page_heading", "Question 1 - page heading", "Page heading", "Welsh page heading", @@ -201,7 +211,8 @@ it "contains the none of the above question" do expect(csv_rows(form)).to include([ - "Question 1 - question or label if ‘None of the above’ is selected", + "page_#{page.id}_none_of_the_above_question", + "Question 1 - question or label if 'None of the above' is selected", "None of the above question?", "Welsh None of the above question?", ]) @@ -215,6 +226,7 @@ it "contains the exit page heading" do expect(csv_rows(form)).to include([ + "condition_#{condition.id}_exit_page_heading", "Question 1 - exit page heading", "Exit page heading", "Welsh exit page heading", @@ -261,25 +273,27 @@ it "returns a CSV with a header row and the expected rows" do csv = csv_rows(form) - expected_csv = [["", "English content", "Welsh content"], - ["Form name", "A form", "Welsh A form"], - ["Question 1 - question text", "None of the above question?", "Welsh None of the above question?"], - ["Question 1 - option 1", "Option 1", "Option 1"], - ["Question 1 - option 2", "Option 2", "Option 2"], - ["Question 1 - question or label if ‘None of the above’ is selected", "None of the above question?", "Welsh None of the above question?"], - ["Question 1 - exit page heading", "Exit page heading", "Welsh exit page heading"], - ["Question 1 - exit page content", "Exit page markdown", "Welsh exit page markdown"], - ["Question 2 - page heading", "Page heading", "Welsh Page heading"], - ["Question 2 - guidance text", "This is the guidance.", "Welsh This is the guidance."], - ["Question 2 - question text", "What?", "Welsh What?"], - ["Declaration", "Declaration text", ""], - ["Information about what happens next", "English what happens next", "Welsh what happens next"], - ["GOV⁠.⁠UK Pay payment link", "https://www.gov.uk/payment", "https://www.gov.uk/payment_cy"], - ["Link to privacy information for this form", "https://www.gov.uk/privacy", ""], - ["Contact details for support - email address", "support@example.gov.uk", "support@example.gov.uk"], - ["Contact details for support - phone number and opening times", "English support phone", "Welsh support phone"], - ["Contact details for support - online contact link", "https://www.gov.uk/support", "https://www.gov.uk/support_cy"], - ["Contact details for support - online contact link text", "Support URL text", "Welsh Support URL text"]] + expected_csv = [ + ["Identifier (do not change)", "Content", "English content", "Welsh content"], + ["name", "Form name", "A form", "Welsh A form"], + ["page_#{page.id}_question_text", "Question 1 - question text", "None of the above question?", "Welsh None of the above question?"], + ["page_#{page.id}_option_0", "Question 1 - option 1", "Option 1", "Option 1"], + ["page_#{page.id}_option_1", "Question 1 - option 2", "Option 2", "Option 2"], + ["page_#{page.id}_none_of_the_above_question", "Question 1 - question or label if 'None of the above' is selected", "None of the above question?", "Welsh None of the above question?"], + ["condition_#{condition.id}_exit_page_heading", "Question 1 - exit page heading", "Exit page heading", "Welsh exit page heading"], + ["condition_#{condition.id}_exit_page_markdown", "Question 1 - exit page content", "Exit page markdown", "Welsh exit page markdown"], + ["page_#{another_page.id}_page_heading", "Question 2 - page heading", "Page heading", "Welsh Page heading"], + ["page_#{another_page.id}_guidance_markdown", "Question 2 - guidance text", "This is the guidance.", "Welsh This is the guidance."], + ["page_#{another_page.id}_question_text", "Question 2 - question text", "What?", "Welsh What?"], + ["declaration_markdown", "Declaration", "Declaration text", ""], + ["what_happens_next_markdown", "Information about what happens next", "English what happens next", "Welsh what happens next"], + ["payment_url", "GOV\u2060.\u2060UK Pay payment link", "https://www.gov.uk/payment", "https://www.gov.uk/payment_cy"], + ["privacy_policy_url", "Link to privacy information for this form", "https://www.gov.uk/privacy", ""], + ["support_email", "Contact details for support - email address", "support@example.gov.uk", "support@example.gov.uk"], + ["support_phone", "Contact details for support - phone number and opening times", "English support phone", "Welsh support phone"], + ["support_url", "Contact details for support - online contact link", "https://www.gov.uk/support", "https://www.gov.uk/support_cy"], + ["support_url_text", "Contact details for support - online contact link text", "Support URL text", "Welsh Support URL text"], + ] expect(csv).to eq(expected_csv) end end diff --git a/spec/views/forms/welsh_translation/new.html.erb_spec.rb b/spec/views/forms/welsh_translation/new.html.erb_spec.rb index 235a3dea69..0e9d28f0be 100644 --- a/spec/views/forms/welsh_translation/new.html.erb_spec.rb +++ b/spec/views/forms/welsh_translation/new.html.erb_spec.rb @@ -232,7 +232,7 @@ def build_form(attributes = {}) let(:welsh_translation_input) { Forms::WelshTranslationInput.new(form:, page_translations: []).assign_form_values } it "does not render any page translation content" do - expect(rendered).not_to have_field(id: "forms_welsh_page_translation_input_#{page.id}_page_translations_question_text_cy", type: "text") + expect(rendered).not_to have_field(id: "page_#{page.id}_question_text_cy", type: "text") end it "renders message for no pages" do @@ -242,8 +242,8 @@ def build_form(attributes = {}) context "when the form has pages" do it "has a field for each page's Welsh question text" do - expect(rendered).to have_field("Enter Welsh question text for question #{page.position}", type: "text", id: "forms_welsh_page_translation_input_#{page.id}_page_translations_question_text_cy") - expect(rendered).to have_field("Enter Welsh question text for question #{another_page.position}", type: "text", id: "forms_welsh_page_translation_input_#{another_page.id}_page_translations_question_text_cy") + expect(rendered).to have_field("Enter Welsh question text for question #{page.position}", type: "text", id: "page_#{page.id}_question_text_cy") + expect(rendered).to have_field("Enter Welsh question text for question #{another_page.position}", type: "text", id: "page_#{another_page.id}_question_text_cy") end context "when a page has hint text" do @@ -252,7 +252,7 @@ def build_form(attributes = {}) it "shows the English text and Welsh field for pages with English hint text" do expect(rendered).to have_css("td", text: page.hint_text) - expect(rendered).to have_field("Enter Welsh hint text for question #{page.position}", type: "textarea", id: "forms_welsh_page_translation_input_#{page.id}_page_translations_hint_text_cy") + expect(rendered).to have_field("Enter Welsh hint text for question #{page.position}", type: "textarea", id: "page_#{page.id}_hint_text_cy") end it "does not show the Welsh field for pages without English hint text" do @@ -266,9 +266,9 @@ def build_form(attributes = {}) it "shows the English text and Welsh fields for pages with English page heading and guidance markdown" do expect(rendered).to have_css("td", text: another_page.page_heading) - expect(rendered).to have_field("Enter Welsh page heading for question #{another_page.position}", id: "forms_welsh_page_translation_input_#{another_page.id}_page_translations_page_heading_cy") + expect(rendered).to have_field("Enter Welsh page heading for question #{another_page.position}", id: "page_#{another_page.id}_page_heading_cy") expect(rendered).to have_css("td", text: another_page.guidance_markdown) - expect(rendered).to have_field("Enter Welsh guidance text for question #{another_page.position}", id: "forms_welsh_page_translation_input_#{another_page.id}_page_translations_guidance_markdown_cy") + expect(rendered).to have_field("Enter Welsh guidance text for question #{another_page.position}", id: "page_#{another_page.id}_guidance_markdown_cy") end it "does not show the Welsh field for pages without English page heading and guidance markdown" do @@ -291,6 +291,33 @@ def build_form(attributes = {}) expect(rendered).to have_css("td", text: page.answer_settings.selection_options.second["name"]) expect(rendered).to have_field("Enter Welsh option 2") end + + it "does not show the large list guidance text" do + expect(rendered).not_to have_text("You have added a list of options that is very large, exceeds 30 options.") + end + + context "when the page has more than 30 selection options" do + let(:page) { create :page, :selection_with_autocomplete } + + let(:welsh_translation_input) do + super().tap do |input| + selection_options_input = input.page_translations.find { |page_translation| page_translation.page == page } + + selection_options_input.selection_options_cy.each_with_index do |selection_option, index| + selection_option.name_cy = index < 2 ? "" : "Welsh option #{index + 1}" + end + end + end + + it "renders inputs only for blank selection options and preserves the rest with hidden fields" do + visible_selection_option_fields = "input[type='text'][id^='forms_welsh_selection_option_translation_input_#{page.id}_selection_options_cy_']" + hidden_selection_option_fields = "input[type='hidden'][name*='selection_options_cy'][name$='[name_cy]']" + + expect(rendered).to have_text("You have added a list of options that is very large, exceeds 30 options. To add translations for this list you need to download a copy of your form and upload it with all of the translations for each option. We will only update translations added to the upload file, meaning if you have already added a translation you can leave it in the downloaded file or leave that cell blank and this will not change your current translation.") + expect(rendered).to have_css(visible_selection_option_fields, count: 2) + expect(rendered).to have_css(hidden_selection_option_fields, count: 29, visible: :all) + end + end end context "when a page has a selection question with none of the above" do @@ -368,7 +395,7 @@ def build_form(attributes = {}) it "links the error summary to the invalid field" do error_message = I18n.t("activemodel.errors.models.forms/welsh_page_translation_input.attributes.question_text_cy.blank", question_number: page.position) - expect(rendered).to have_link(error_message, href: "#forms_welsh_page_translation_input_#{page.id}_page_translations_question_text_cy") + expect(rendered).to have_link(error_message, href: "#page_#{page.id}_question_text_cy") end it "adds an inline error message to the invalid field" do @@ -396,7 +423,7 @@ def build_form(attributes = {}) it "links the error summary to the invalid field" do error_message = I18n.t("activemodel.errors.models.forms/welsh_condition_translation_input.attributes.exit_page_heading_cy.blank", question_number: page.position) - expect(rendered).to have_link(error_message, href: "#forms_welsh_condition_translation_input_#{condition.id}_condition_translations_exit_page_heading_cy") + expect(rendered).to have_link(error_message, href: "#condition_#{condition.id}_exit_page_heading_cy") end it "adds an inline error message to the invalid field" do