Browse Source

Merge branch 'main' into CLDC-none-ai-optimisation

CLDC-none-ai-optimisation
Nat Dean-Lewis 5 days ago
parent
commit
4ef2f9191f
  1. 8
      Gemfile
  2. 103
      Gemfile.lock
  3. 4
      app/components/bulk_upload_error_row_component.html.erb
  4. 9
      app/components/bulk_upload_error_row_component.rb
  5. 4
      app/components/bulk_upload_error_summary_table_component.html.erb
  6. 3
      app/components/bulk_upload_error_summary_table_component.rb
  7. 16
      app/components/bulk_upload_summary_component.rb
  8. 6
      app/components/check_answers_summary_list_card_component.html.erb
  9. 11
      app/components/check_answers_summary_list_card_component.rb
  10. 18
      app/components/create_log_actions_component.html.erb
  11. 19
      app/components/create_log_actions_component.rb
  12. 2
      app/components/data_protection_confirmation_banner_component.html.erb
  13. 5
      app/components/data_protection_confirmation_banner_component.rb
  14. 2
      app/components/document_list_component.html.erb
  15. 2
      app/components/document_list_component.rb
  16. 2
      app/components/lettings_log_summary_component.html.erb
  17. 2
      app/components/lettings_log_summary_component.rb
  18. 2
      app/components/missing_stock_owners_banner_component.html.erb
  19. 9
      app/components/missing_stock_owners_banner_component.rb
  20. 2
      app/components/primary_navigation_component.html.erb
  21. 2
      app/components/primary_navigation_component.rb
  22. 2
      app/components/sales_log_summary_component.html.erb
  23. 2
      app/components/sales_log_summary_component.rb
  24. 4
      app/components/search_component.html.erb
  25. 2
      app/components/search_component.rb
  26. 2
      app/components/search_result_caption_component.rb
  27. 4
      app/components/sub_navigation_component.html.erb
  28. 2
      app/components/sub_navigation_component.rb
  29. 2
      app/frontend/styles/_filter.scss
  30. 11
      app/frontend/styles/_header.scss
  31. 2
      app/frontend/styles/_related-navigation.scss
  32. 2
      app/frontend/styles/_tag.scss
  33. 2
      app/frontend/styles/_testing-tools.scss
  34. 12
      app/frontend/styles/application.scss
  35. 13
      app/helpers/application_helper.rb
  36. 30
      app/views/layouts/application.html.erb
  37. 18
      app/views/layouts/rails_admin/_navigation.html.erb
  38. 4
      app/views/users/_user_list.html.erb
  39. 5
      docs/Gemfile
  40. 5
      docs/Gemfile.lock
  41. 2
      docs/adr/index.md
  42. 8
      docs/dev_tasks/index.md
  43. 179
      docs/dev_tasks/new_question.md
  44. 2
      docs/documentation_website.md
  45. 48
      docs/form/page.md
  46. 156
      docs/form/question.md
  47. 20
      docs/setup.md
  48. 4
      package.json
  49. 46
      spec/helpers/application_helper_spec.rb
  50. 54
      spec/requests/users_controller_spec.rb
  51. 4
      webpack.config.js
  52. 1377
      yarn.lock

8
Gemfile

@ -10,7 +10,7 @@ gem "rails", "~> 7.2.2"
# Use postgresql as the database for Active Record
gem "pg", "~> 1.1"
# Use Puma as the app server
gem "puma", "~> 6.4"
gem "puma", "~> 7.2.1"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Bundle and transpile JavaScript [https://github.com/rails/jsbundling-rails]
@ -18,7 +18,7 @@ gem "jsbundling-rails"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", ">= 1.4.4", require: false
# GOV UK frontend components
gem "govuk-components", "~> 5.7"
gem "govuk-components", "~> 6.2"
# GOV UK component form builder DSL
gem "govuk_design_system_formbuilder", "~> 5.7"
# Convert Markdown into GOV.UK frontend-styled HTML
@ -40,7 +40,7 @@ gem "devise_two_factor_authentication"
gem "uk_postcode"
# Get rich data from postcode lookups. Wraps postcodes.io
# Use Ruby objects to build reusable markup. A React inspired evolution of the presenter pattern
gem "view_component", "~> 3.9"
gem "view_component", "~> 4.9"
# Use the AWS S3 SDK as storage mechanism
gem "aws-sdk-s3"
# Track changes to models for auditing or versioning.
@ -67,7 +67,7 @@ gem "faker"
gem "method_source", "~> 1.1"
gem "rails_admin", "~> 3.1"
gem "ruby-openai"
gem "sidekiq"
gem "sidekiq", "~> 7.2.4"
gem "sidekiq-cron"
gem "unread"

103
Gemfile.lock

@ -78,8 +78,8 @@ GEM
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
auto_strip_attributes (2.6.0)
activerecord (>= 4.0)
@ -123,7 +123,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (4.0.1)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.18.3)
msgpack (~> 1.2)
@ -155,18 +155,21 @@ GEM
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
concurrent-ruby (1.3.6)
connection_pool (2.5.3)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
cssbundling-rails (1.4.0)
railties (>= 6.0.0)
csv (3.3.2)
date (3.5.1)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@ -187,7 +190,7 @@ GEM
drb (2.2.3)
dumb_delegator (1.0.0)
encryptor (3.0.0)
erb (6.0.2)
erb (6.0.4)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@ -196,7 +199,7 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
et-orbi (1.2.11)
et-orbi (1.4.0)
tzinfo
event_stream_parser (1.0.0)
excon (0.111.0)
@ -207,24 +210,24 @@ GEM
railties (>= 5.0.0)
faker (3.2.3)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.1.0)
net-http
faraday-net_http (3.4.3)
net-http (~> 0.5)
ffi (1.16.3)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
fugit (1.12.2)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
govuk-components (5.7.0)
govuk-components (6.2.0)
html-attributes-utils (~> 1.0.0, >= 1.0.0)
pagy (>= 6, < 10)
view_component (>= 3.9, < 3.17)
view_component (>= 4.9, < 4.10)
govuk_design_system_formbuilder (5.7.1)
actionview (>= 6.1)
activemodel (>= 6.1)
@ -241,7 +244,7 @@ GEM
ice_nine (0.11.2)
iniparse (1.5.0)
io-console (0.8.2)
irb (1.17.0)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
@ -249,10 +252,10 @@ GEM
jmespath (1.6.2)
jsbundling-rails (1.3.0)
railties (>= 6.0.0)
json (2.19.2)
json (2.19.8)
json-schema (4.1.1)
addressable (>= 2.8)
jwt (2.8.0)
jwt (3.2.0)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@ -290,9 +293,9 @@ GEM
msgpack (1.7.2)
multipart-post (2.4.1)
nested_form (0.3.2)
net-http (0.4.1)
uri
net-imap (0.5.7)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@ -301,23 +304,23 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.19.1-arm64-darwin)
nio4r (2.7.5)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
notifications-ruby-client (6.0.0)
jwt (>= 1.5, < 3)
notifications-ruby-client (6.4.0)
jwt (>= 1.5, < 4)
orm_adapter (0.5.0)
overcommit (0.63.0)
childprocess (>= 0.6.3, < 6)
iniparse (~> 1.4)
rexml (~> 3.2)
pagy (9.3.2)
pagy (9.4.0)
paper_trail (15.2.0)
activerecord (>= 6.1)
request_store (~> 1.4)
@ -350,19 +353,19 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (5.0.4)
puma (6.5.0)
public_suffix (7.0.5)
puma (7.2.1)
nio4r (~> 2.0)
pundit (2.3.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.20)
rack (3.1.21)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
@ -408,7 +411,7 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rake (13.4.2)
randexp (0.1.7)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
@ -419,7 +422,7 @@ GEM
tsort
redcarpet (3.6.0)
redis (4.8.1)
redis-client (0.22.1)
redis-client (0.29.0)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
@ -513,10 +516,11 @@ GEM
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.19.0)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6)
sidekiq (>= 6.5.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@ -530,7 +534,7 @@ GEM
thor (1.4.0)
thread_safe (0.3.6)
timecop (0.9.8)
timeout (0.4.3)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
@ -538,17 +542,18 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uk_postcode (2.1.8)
unicode (0.4.4.5)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
unread (0.14.0)
activerecord (>= 6.1)
uri (1.0.4)
uri (1.1.1)
useragent (0.16.11)
view_component (3.10.0)
activesupport (>= 5.2.0, < 8.0)
concurrent-ruby (~> 1.0)
method_source (~> 1.0)
view_component (4.9.0)
actionview (>= 7.1.0)
activesupport (>= 7.1.0)
concurrent-ruby (~> 1)
virtus (2.0.0)
axiom-types (~> 0.1)
coercible (~> 1.0)
@ -571,7 +576,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.5)
zeitwerk (2.8.2)
PLATFORMS
arm64-darwin
@ -599,7 +604,7 @@ DEPENDENCIES
factory_bot_rails
faker
faraday (>= 2.14.1)
govuk-components (~> 5.7)
govuk-components (~> 6.2)
govuk_design_system_formbuilder (~> 5.7)
govuk_markdown
jsbundling-rails
@ -616,7 +621,7 @@ DEPENDENCIES
possessive
propshaft
pry-byebug
puma (~> 6.4)
puma (~> 7.2.1)
pundit
rack (~> 3.1.20)
rack-attack
@ -634,7 +639,7 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
sidekiq
sidekiq (~> 7.2.4)
sidekiq-cron
simplecov
stimulus-rails
@ -643,7 +648,7 @@ DEPENDENCIES
tzinfo-data
uk_postcode
unread
view_component (~> 3.9)
view_component (~> 4.9)
web-console (>= 4.1.0)
webmock

4
app/components/bulk_upload_error_row_component.html.erb

@ -13,7 +13,7 @@
<% if critical_errors.any? %>
<h2 class="govuk-heading-m">Critical errors</h2>
<p class="govuk-body">These errors must be fixed to complete your logs.</p>
<%= govuk_table(html_attributes: { class: potential_errors.any? ? "" : "no-bottom-border" }) do |table| %>
<%= helpers.govuk_table(html_attributes: { class: potential_errors.any? ? "" : "no-bottom-border" }) do |table| %>
<%= table.with_head do |head| %>
<% head.with_row do |row| %>
<% row.with_cell(header: true, text: "Cell") %>
@ -39,7 +39,7 @@
<% if potential_errors.any? %>
<h2 class="govuk-heading-m">Confirmation needed</h2>
<p class="govuk-body">Potential data discrepancies exist in the following cells.<br><br>Please resolve all critical errors and review the cells with data discrepancies before re-uploading the file. Bulk confirmation of potential discrepancies is accessible only after all critical errors have been resolved.</p>
<%= govuk_table(html_attributes: { class: "no-bottom-border" }) do |table| %>
<%= helpers.govuk_table(html_attributes: { class: "no-bottom-border" }) do |table| %>
<%= table.with_head do |head| %>
<% head.with_row do |row| %>
<% row.with_cell(header: true, text: "Cell") %>

9
app/components/bulk_upload_error_row_component.rb

@ -2,9 +2,8 @@ class BulkUploadErrorRowComponent < ViewComponent::Base
attr_reader :bulk_upload_errors
def initialize(bulk_upload_errors:)
super()
@bulk_upload_errors = bulk_upload_errors
super
end
def row
@ -18,7 +17,7 @@ class BulkUploadErrorRowComponent < ViewComponent::Base
def tenant_code_html
return if tenant_code.blank?
content_tag :span, class: "govuk-!-margin-left-3" do
helpers.content_tag :span, class: "govuk-!-margin-left-3" do
"Tenant code: #{tenant_code}"
end
end
@ -30,7 +29,7 @@ class BulkUploadErrorRowComponent < ViewComponent::Base
def purchaser_code_html
return if purchaser_code.blank?
content_tag :span, class: "govuk-!-margin-left-3" do
helpers.content_tag :span, class: "govuk-!-margin-left-3" do
"Purchaser code: #{purchaser_code}"
end
end
@ -42,7 +41,7 @@ class BulkUploadErrorRowComponent < ViewComponent::Base
def property_ref_html
return if property_ref.blank?
content_tag :span, class: "govuk-!-margin-left-3" do
helpers.content_tag :span, class: "govuk-!-margin-left-3" do
"Property reference: #{property_ref}"
end
end

4
app/components/bulk_upload_error_summary_table_component.html.erb

@ -3,7 +3,7 @@
</p>
<% sorted_errors.each do |error| %>
<%= govuk_table do |table| %>
<%= helpers.govuk_table do |table| %>
<%= table.with_head do |head| %>
<% head.with_row do |row| %>
<% row.with_cell(text: question_for_field(error[0][1].to_sym), header: true) %>
@ -13,7 +13,7 @@
<%= table.with_body do |body| %>
<% body.with_row do |row| %>
<% row.with_cell(text: error[0][2].html_safe) %>
<% row.with_cell(text: pluralize(error[1], "error"), numeric: true) %>
<% row.with_cell(text: helpers.pluralize(error[1], "error"), numeric: true) %>
<% end %>
<% end %>
<% end %>

3
app/components/bulk_upload_error_summary_table_component.rb

@ -6,9 +6,8 @@ class BulkUploadErrorSummaryTableComponent < ViewComponent::Base
delegate :question_for_field, to: :row_parser_class
def initialize(bulk_upload:)
super()
@bulk_upload = bulk_upload
super
end
def sorted_errors

16
app/components/bulk_upload_summary_component.rb

@ -2,9 +2,9 @@ class BulkUploadSummaryComponent < ViewComponent::Base
attr_reader :bulk_upload
def initialize(bulk_upload:)
super()
@bulk_upload = bulk_upload
@bulk_upload_errors = bulk_upload.bulk_upload_errors
super
end
def upload_status
@ -27,9 +27,9 @@ class BulkUploadSummaryComponent < ViewComponent::Base
return if count.nil? || count <= 0
text = count > 1 ? (plural_text || singular_text.pluralize(count)) : singular_text
content_tag(:p, class: "govuk-!-font-size-16 govuk-!-margin-bottom-1") do
concat(content_tag(:strong, count))
concat(" #{text}")
helpers.content_tag(:p, class: "govuk-!-font-size-16 govuk-!-margin-bottom-1") do
helpers.concat(helpers.content_tag(:strong, count))
helpers.concat(" #{text}")
end
end
@ -44,11 +44,11 @@ class BulkUploadSummaryComponent < ViewComponent::Base
end
def download_lettings_file_link(bulk_upload)
govuk_link_to "Download file", download_lettings_bulk_upload_path(bulk_upload), class: "govuk-link govuk-!-margin-right-2"
helpers.govuk_link_to "Download file", download_lettings_bulk_upload_path(bulk_upload), class: "govuk-link govuk-!-margin-right-2"
end
def download_sales_file_link(bulk_upload)
govuk_link_to "Download file", download_sales_bulk_upload_path(bulk_upload), class: "govuk-link govuk-!-margin-right-2"
helpers.govuk_link_to "Download file", download_sales_bulk_upload_path(bulk_upload), class: "govuk-link govuk-!-margin-right-2"
end
def view_error_report_link(bulk_upload)
@ -61,12 +61,12 @@ class BulkUploadSummaryComponent < ViewComponent::Base
"bulk_upload_#{bulk_upload.log_type}_result_path"
end
govuk_link_to "View error report", send(path, bulk_upload), class: "govuk-link"
helpers.govuk_link_to "View error report", helpers.send(path, bulk_upload), class: "govuk-link"
end
def view_logs_link(bulk_upload)
return unless bulk_upload.status.to_s == "logs_uploaded_with_errors"
govuk_link_to "View logs with errors", send("#{bulk_upload.log_type}_logs_path", bulk_upload_id: [bulk_upload.id]), class: "govuk-link"
helpers.govuk_link_to "View logs with errors", helpers.send("#{bulk_upload.log_type}_logs_path", bulk_upload_id: [bulk_upload.id]), class: "govuk-link"
end
end

6
app/components/check_answers_summary_list_card_component.html.erb

@ -7,12 +7,12 @@
<% end %>
<div class="govuk-summary-card__content">
<%= govuk_summary_list do |summary_list| %>
<%= helpers.govuk_summary_list do |summary_list| %>
<% applicable_questions.each do |question| %>
<% summary_list.with_row do |row| %>
<% row.with_key { get_question_label(question) } %>
<% row.with_value do %>
<%= simple_format(
<%= helpers.simple_format(
get_answer_label(question),
wrapper_tag: "span",
class: "govuk-!-margin-right-4",
@ -21,7 +21,7 @@
<% extra_value = question.get_extra_check_answer_value(log) %>
<% if extra_value && question.answer_label(log).present? %>
<%= simple_format(
<%= helpers.simple_format(
extra_value,
wrapper_tag: "span",
class: "govuk-!-font-weight-regular app-!-colour-muted",

11
app/components/check_answers_summary_list_card_component.rb

@ -2,12 +2,11 @@ class CheckAnswersSummaryListCardComponent < ViewComponent::Base
attr_reader :questions, :log, :user
def initialize(questions:, log:, user:, correcting_hard_validation: false)
super()
@questions = questions
@log = log
@user = user
@correcting_hard_validation = correcting_hard_validation
super
end
def applicable_questions
@ -34,16 +33,16 @@ class CheckAnswersSummaryListCardComponent < ViewComponent::Base
def action_href(question, log)
referrer = question.displayed_as_answered?(log) ? "check_answers" : "check_answers_new_answer"
send("#{log.log_type}_#{question.page.id}_path", log, referrer:)
helpers.send("#{log.log_type}_#{question.page.id}_path", log, referrer:)
end
def correct_validation_action_href(question, log, _related_question_ids, correcting_hard_validation)
return action_href(question, log) unless correcting_hard_validation
if question.displayed_as_answered?(log)
send("#{log.log_type}_confirm_clear_answer_path", log, question_id: question.id)
helpers.send("#{log.log_type}_confirm_clear_answer_path", log, question_id: question.id)
else
send("#{log.log_type}_#{question.page.id}_path", log, referrer: "check_errors", related_question_ids: request.query_parameters["related_question_ids"], original_page_id: request.query_parameters["original_page_id"])
helpers.send("#{log.log_type}_#{question.page.id}_path", log, referrer: "check_errors", related_question_ids: request.query_parameters["related_question_ids"], original_page_id: request.query_parameters["original_page_id"])
end
end
@ -56,7 +55,7 @@ private
"govuk-link govuk-link--no-visited-state"
end
govuk_link_to question.check_answer_prompt, correct_validation_action_href(question, log, nil, @correcting_hard_validation), class: link_class
helpers.govuk_link_to question.check_answer_prompt, correct_validation_action_href(question, log, nil, @correcting_hard_validation), class: link_class
end
def number_of_buyers

18
app/components/create_log_actions_component.html.erb

@ -1,11 +1,11 @@
<div class="govuk-button-group app-filter-toggle <%= "govuk-!-margin-bottom-6" if display_actions? %>">
<% if display_actions? %>
<%= govuk_button_to create_button_copy, create_button_href, class: "govuk-!-margin-right-3" %>
<%= helpers.govuk_button_to create_button_copy, create_button_href, class: "govuk-!-margin-right-3" %>
<% unless user.support? %>
<%= govuk_button_link_to upload_button_copy, upload_button_href, secondary: true %>
<%= helpers.govuk_button_link_to upload_button_copy, upload_button_href, secondary: true %>
<% end %>
<% if user.support? %>
<%= govuk_button_link_to view_uploads_button_copy, view_uploads_button_href, secondary: true %>
<%= helpers.govuk_button_link_to view_uploads_button_copy, view_uploads_button_href, secondary: true %>
<% end %>
<% if FeatureToggle.create_test_logs_enabled? %>
@ -13,42 +13,42 @@
<span class="govuk-tag app-testing-tools__tag">Testing tools</span>
<span class="govuk-body govuk-body-s">These tools can only be seen and used in testing environments.</span>
<div>
<%= govuk_button_link_to create_test_log_href, class: "govuk-button" do %>
<%= helpers.govuk_button_link_to create_test_log_href, class: "govuk-button" do %>
New <%= current_collection_year_label %> test log
<svg class="govuk-button__start-icon" xmlns="http://www.w3.org/2000/svg" width="17.5" height="19" viewBox="0 0 33 40" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z"></path>
</svg>
<% end %>
<% if FeatureToggle.allow_future_form_use? %>
<%= govuk_button_link_to create_next_year_test_log_href, class: "govuk-button" do %>
<%= helpers.govuk_button_link_to create_next_year_test_log_href, class: "govuk-button" do %>
New <%= next_collection_year_label %> test log
<svg class="govuk-button__start-icon" xmlns="http://www.w3.org/2000/svg" width="17.5" height="19" viewBox="0 0 33 40" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z"></path>
</svg>
<% end %>
<% end %>
<%= govuk_button_link_to create_setup_test_log_href, class: "govuk-button" do %>
<%= helpers.govuk_button_link_to create_setup_test_log_href, class: "govuk-button" do %>
New <%= current_collection_year_label %> test log (setup only)
<svg class="govuk-button__start-icon" xmlns="http://www.w3.org/2000/svg" width="17.5" height="19" viewBox="0 0 33 40" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z"></path>
</svg>
<% end %>
<% if FeatureToggle.allow_future_form_use? %>
<%= govuk_button_link_to create_next_year_setup_test_log_href, class: "govuk-button" do %>
<%= helpers.govuk_button_link_to create_next_year_setup_test_log_href, class: "govuk-button" do %>
New <%= next_collection_year_label %> test log (setup only)
<svg class="govuk-button__start-icon" xmlns="http://www.w3.org/2000/svg" width="17.5" height="19" viewBox="0 0 33 40" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z"></path>
</svg>
<% end %>
<% end %>
<%= govuk_button_link_to create_test_bulk_upload_href(2025), class: "govuk-button govuk-button--secondary" do %>
<%= helpers.govuk_button_link_to create_test_bulk_upload_href(2025), class: "govuk-button govuk-button--secondary" do %>
25/26 BU test file
<svg class="govuk-button__start-icon bi bi-download" xmlns="http://www.w3.org/2000/svg" width="18" height="19" fill="currentColor" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z" />
</svg>
<% end %>
<%= govuk_button_link_to create_test_bulk_upload_href(2026), class: "govuk-button govuk-button--secondary" do %>
<%= helpers.govuk_button_link_to create_test_bulk_upload_href(2026), class: "govuk-button govuk-button--secondary" do %>
26/27 BU test file
<svg class="govuk-button__start-icon bi bi-download" xmlns="http://www.w3.org/2000/svg" width="18" height="19" fill="currentColor" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.4">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />

19
app/components/create_log_actions_component.rb

@ -5,11 +5,10 @@ class CreateLogActionsComponent < ViewComponent::Base
attr_reader :bulk_upload, :user, :log_type
def initialize(user:, log_type:, bulk_upload: nil)
super()
@bulk_upload = bulk_upload
@user = user
@log_type = log_type
super
end
def display_actions?
@ -24,7 +23,7 @@ class CreateLogActionsComponent < ViewComponent::Base
end
def create_button_href
send("#{log_type}_logs_path")
helpers.send("#{log_type}_logs_path")
end
def upload_button_copy
@ -32,23 +31,23 @@ class CreateLogActionsComponent < ViewComponent::Base
end
def upload_button_href
send("bulk_upload_#{log_type}_log_path", id: "start")
helpers.send("bulk_upload_#{log_type}_log_path", id: "start")
end
def create_test_log_href
send("create_test_#{log_type}_log_path")
helpers.send("create_test_#{log_type}_log_path")
end
def create_next_year_test_log_href
send("create_next_year_test_#{log_type}_log_path")
helpers.send("create_next_year_test_#{log_type}_log_path")
end
def create_setup_test_log_href
send("create_setup_test_#{log_type}_log_path")
helpers.send("create_setup_test_#{log_type}_log_path")
end
def create_next_year_setup_test_log_href
send("create_next_year_setup_test_#{log_type}_log_path")
helpers.send("create_next_year_setup_test_#{log_type}_log_path")
end
def current_collection_year_label
@ -60,7 +59,7 @@ class CreateLogActionsComponent < ViewComponent::Base
end
def create_test_bulk_upload_href(year)
send("create_#{year}_test_#{log_type}_bulk_upload_path")
helpers.send("create_#{year}_test_#{log_type}_bulk_upload_path")
end
def view_uploads_button_copy
@ -68,6 +67,6 @@ class CreateLogActionsComponent < ViewComponent::Base
end
def view_uploads_button_href
send("bulk_uploads_#{log_type}_logs_path")
helpers.send("bulk_uploads_#{log_type}_logs_path")
end
end

2
app/components/data_protection_confirmation_banner_component.html.erb

@ -1,5 +1,5 @@
<% if display_banner? %>
<%= govuk_notification_banner(title_text: "Important") do %>
<%= helpers.govuk_notification_banner(title_text: "Important") do %>
<p class="govuk-notification-banner__heading govuk-!-width-full" style="max-width: fit-content">
<%= header_text %>
<p>

5
app/components/data_protection_confirmation_banner_component.rb

@ -4,10 +4,9 @@ class DataProtectionConfirmationBannerComponent < ViewComponent::Base
attr_reader :user, :organisation
def initialize(user:, organisation: nil)
super()
@user = user
@organisation = organisation
super
end
def display_banner?
@ -32,7 +31,7 @@ class DataProtectionConfirmationBannerComponent < ViewComponent::Base
def banner_text
if show_no_dpo_message? || user.is_dpo? || !org_or_user_org.holds_own_stock?
govuk_link_to(
helpers.govuk_link_to(
link_text,
link_href,
class: "govuk-notification-banner__link govuk-!-font-weight-bold",

2
app/components/document_list_component.html.erb

@ -5,7 +5,7 @@
<% items.each do |item| %>
<div class="app-document-list__item">
<dt class="app-document-list__item-title">
<%= govuk_link_to item[:name], item[:href] %>
<%= helpers.govuk_link_to item[:name], item[:href] %>
</dt>
<% if item[:description] %>
<dd class="app-document-list__item-description"><%= item[:description] %></dd>

2
app/components/document_list_component.rb

@ -2,8 +2,8 @@ class DocumentListComponent < ViewComponent::Base
attr_reader :items, :label
def initialize(items:, label:)
super()
@items = items
@label = label
super
end
end

2
app/components/lettings_log_summary_component.html.erb

@ -3,7 +3,7 @@
<div class="govuk-grid-column-two-thirds">
<header class="app-log-summary__header">
<h2 class="app-log-summary__title">
<%= govuk_link_to lettings_log_path(log) do %>
<%= helpers.govuk_link_to lettings_log_path(log) do %>
Log <%= log.id %>
<% end %>
</h2>

2
app/components/lettings_log_summary_component.rb

@ -2,9 +2,9 @@ class LettingsLogSummaryComponent < ViewComponent::Base
attr_reader :current_user, :log
def initialize(current_user:, log:)
super()
@current_user = current_user
@log = log
super
end
def log_status

2
app/components/missing_stock_owners_banner_component.html.erb

@ -1,5 +1,5 @@
<% if display_banner? %>
<%= govuk_notification_banner(title_text: "Important") do %>
<%= helpers.govuk_notification_banner(title_text: "Important") do %>
<p class="govuk-notification-banner__heading govuk-!-width-full" style="max-width: fit-content">
<%= header_text %>
<p>

9
app/components/missing_stock_owners_banner_component.rb

@ -4,10 +4,9 @@ class MissingStockOwnersBannerComponent < ViewComponent::Base
attr_reader :user, :organisation
def initialize(user:, organisation: nil)
super()
@user = user
@organisation = organisation || user.organisation
super
end
def display_banner?
@ -36,7 +35,7 @@ class MissingStockOwnersBannerComponent < ViewComponent::Base
private
def add_stock_owner_link
govuk_link_to(
helpers.govuk_link_to(
"add a stock owner",
stock_owners_add_organisation_path(id: organisation.id),
class: "govuk-notification-banner__link govuk-!-font-weight-bold",
@ -44,7 +43,7 @@ private
end
def contact_helpdesk_link
govuk_link_to(
helpers.govuk_link_to(
"contact the helpdesk",
GlobalConstants::HELPDESK_URL,
class: "govuk-notification-banner__link govuk-!-font-weight-bold",
@ -52,7 +51,7 @@ private
end
def users_link
govuk_link_to(
helpers.govuk_link_to(
"users page",
users_path,
class: "govuk-notification-banner__link govuk-!-font-weight-bold",

2
app/components/primary_navigation_component.html.erb

@ -1,4 +1,4 @@
<%= govuk_service_navigation(navigation_id: "primary-navigation", classes: "app-service-navigation") do |sn|
<%= helpers.govuk_service_navigation(navigation_id: "primary-navigation", classes: "app-service-navigation") do |sn|
items.each do |item|
sn.with_navigation_item(text: item[:text], href: item[:href], classes: "", current: item[:current])
end

2
app/components/primary_navigation_component.rb

@ -2,8 +2,8 @@ class PrimaryNavigationComponent < ViewComponent::Base
attr_reader :items
def initialize(items:)
super()
@items = items
super
end
def highlighted_item?(item, _path)

2
app/components/sales_log_summary_component.html.erb

@ -3,7 +3,7 @@
<div class="govuk-grid-column-two-thirds">
<header class="app-log-summary__header">
<h2 class="app-log-summary__title">
<%= govuk_link_to sales_log_path(log) do %>
<%= helpers.govuk_link_to sales_log_path(log) do %>
Log <%= log.id %>
<% end %>
</h2>

2
app/components/sales_log_summary_component.rb

@ -2,9 +2,9 @@ class SalesLogSummaryComponent < ViewComponent::Base
attr_reader :current_user, :log
def initialize(current_user:, log:)
super()
@current_user = current_user
@log = log
super
end
def log_status

4
app/components/search_component.html.erb

@ -1,4 +1,4 @@
<%= form_with url: path(current_user), method: "get", local: true do |f| %>
<%= helpers.form_with url: path(current_user), method: "get", local: true do |f| %>
<div class="app-search govuk-!-margin-bottom-4">
<%= f.govuk_text_field :search,
form_group: {
@ -11,6 +11,6 @@
class: "app-search__input" %>
<%= f.govuk_submit "Search", class: "app-search__button" %>
<%= govuk_button_link_to "Clear search", path(current_user), secondary: true, class: "app-search__button" %>
<%= helpers.govuk_button_link_to "Clear search", path(current_user), secondary: true, class: "app-search__button" %>
</div>
<% end %>

2
app/components/search_component.rb

@ -2,10 +2,10 @@ class SearchComponent < ViewComponent::Base
attr_reader :current_user, :search_label, :value
def initialize(current_user:, search_label:, value: nil)
super()
@current_user = current_user
@search_label = search_label
@value = value
super
end
def path(current_user)

2
app/components/search_result_caption_component.rb

@ -2,12 +2,12 @@ class SearchResultCaptionComponent < ViewComponent::Base
attr_reader :searched, :count, :item_label, :total_count, :item, :filters_count
def initialize(searched:, count:, item_label:, total_count:, item:, filters_count:)
super()
@searched = searched
@count = count
@item_label = item_label
@total_count = total_count
@item = item
@filters_count = filters_count
super
end
end

4
app/components/sub_navigation_component.html.erb

@ -3,11 +3,11 @@
<% items.each do |item| %>
<% if item.current %>
<li class="app-sub-navigation__item app-sub-navigation__item--current">
<%= govuk_link_to item[:text], item[:href], class: "app-sub-navigation__link", aria: { current: "page" } %>
<%= helpers.govuk_link_to item[:text], item[:href], class: "app-sub-navigation__link", aria: { current: "page" } %>
</li>
<% else %>
<li class="app-sub-navigation__item">
<%= govuk_link_to item[:text], item[:href], class: "app-sub-navigation__link" %>
<%= helpers.govuk_link_to item[:text], item[:href], class: "app-sub-navigation__link" %>
</li>
<% end %>
<% end %>

2
app/components/sub_navigation_component.rb

@ -2,8 +2,8 @@ class SubNavigationComponent < ViewComponent::Base
attr_reader :items
def initialize(items:)
super()
@items = items
super
end
def highlighted_item?(item, _path)

2
app/frontend/styles/_filter.scss

@ -109,7 +109,7 @@
}
.autocomplete__option__hint {
@include govuk-font(14);
@include govuk-font(16);
word-break: break-all;
}
}

11
app/frontend/styles/_header.scss

@ -1,6 +1,4 @@
.app-header {
border-bottom: govuk-spacing(2) solid $govuk-brand-colour;
.govuk-header__logo {
@include govuk-media-query($from: desktop) {
width: 60%;
@ -21,12 +19,3 @@
}
}
}
.app-header--orange,
.app-header--orange .govuk-header__container {
border-bottom-color: govuk-colour("orange");
}
.app-header__no-border-bottom {
border-bottom: 0;
}

2
app/frontend/styles/_related-navigation.scss

@ -11,7 +11,7 @@
.app-related-navigation__sub-heading {
@include govuk-font(16);
border-top: 1px solid govuk-colour("mid-grey", $legacy: "grey-2");
border-top: 1px solid govuk-colour("mid-grey");
margin: 0;
padding-top: govuk-spacing(3);
}

2
app/frontend/styles/_tag.scss

@ -1,5 +1,5 @@
.app-tag--small {
@include govuk-font(14, $weight: bold);
@include govuk-font(16, $weight: bold);
padding-top: 2px;
padding-right: 6px;
padding-bottom: 2px;

2
app/frontend/styles/_testing-tools.scss

@ -12,7 +12,7 @@
}
.app-testing-tools__tag {
@include govuk-font(14);
@include govuk-font(16);
background-color: #fcd6c3;
margin-top: 0;
margin-bottom: 10px;

12
app/frontend/styles/application.scss

@ -130,3 +130,15 @@ $govuk-breakpoints: (
left: -15px;
position: relative;
}
.app-main-service-navigation {
border-bottom: govuk-spacing(2) solid $govuk-brand-colour;
}
.app-service-navigation--orange {
border-bottom-color: govuk-colour("orange");
}
.app-service-navigation--no-border {
border-bottom: 0;
}

13
app/helpers/application_helper.rb

@ -10,14 +10,11 @@ module ApplicationHelper
end
end
def govuk_header_classes(current_user)
if current_user&.support?
"app-header app-header--orange"
elsif notifications_to_display?
"app-header app-header__no-border-bottom"
else
"app-header"
end
def govuk_service_navigation_classes(current_user)
return "app-service-navigation--orange" if current_user&.support?
return "app-service-navigation--no-border" if notifications_to_display?
""
end
def govuk_phase_banner_tag(current_user)

30
app/views/layouts/application.html.erb

@ -81,20 +81,24 @@
<%= govuk_skip_link %>
<%= govuk_header(
classes: govuk_header_classes(current_user),
classes: "app-header",
homepage_url: root_path,
navigation_classes: "govuk-header__navigation--end",
) do |component|
component.with_product_name(name: t("service_name"))
unless FeatureToggle.service_moved? || FeatureToggle.service_unavailable?
if current_user.nil?
component.with_navigation_item(text: "Sign in", href: user_session_path)
else
component.with_navigation_item(text: "Your account", href: account_path)
component.with_navigation_item(text: "Sign out", href: destroy_user_session_path)
end
end
end %>
) %>
<%= govuk_service_navigation(
service_name: t("service_name"),
service_url: root_path,
classes: "app-main-service-navigation #{govuk_service_navigation_classes(current_user)}",
) do |component| %>
<% unless FeatureToggle.service_moved? || FeatureToggle.service_unavailable? %>
<% if current_user.nil? %>
<%= component.with_navigation_item(text: "Sign in", href: user_session_path) %>
<% else %>
<%= component.with_navigation_item(text: "Your account", href: account_path) %>
<%= component.with_navigation_item(text: "Sign out", href: destroy_user_session_path) %>
<% end %>
<% end %>
<% end %>
<% if notifications_to_display? %>
<%= render "notifications/notification_banner" %>

18
app/views/layouts/rails_admin/_navigation.html.erb

@ -1,9 +1,13 @@
<%= govuk_header(
classes: "app-header app-header--orange",
classes: "app-header",
homepage_url: Rails.application.routes.url_helpers.root_path,
navigation_classes: "govuk-header__navigation--end",
) do |component|
component.with_product_name(name: t("service_name"))
component.with_navigation_item(text: "Your account", href: Rails.application.routes.url_helpers.account_path)
component.with_navigation_item(text: "Sign out", href: Rails.application.routes.url_helpers.destroy_user_session_path)
end %>
) %>
<%= govuk_service_navigation(
service_name: t("service_name"),
service_url: Rails.application.routes.url_helpers.root_path,
classes: "app-main-service-navigation app-service-navigation--orange",
) do |component| %>
<%= component.with_navigation_item(text: "Your account", href: Rails.application.routes.url_helpers.account_path) %>
<%= component.with_navigation_item(text: "Sign out", href: Rails.application.routes.url_helpers.destroy_user_session_path) %>
<% end %>

4
app/views/users/_user_list.html.erb

@ -36,7 +36,7 @@
<% if user.is_data_protection_officer? %>
<%= govuk_tag(
classes: "app-tag--small",
colour: "turquoise",
colour: "teal",
text: "Data protection officer",
) %>
<% else %>
@ -45,7 +45,7 @@
<% if user.is_key_contact? %>
<%= govuk_tag(
classes: "app-tag--small",
colour: "turquoise",
colour: "teal",
text: "Key contact",
) %>
<% else %>

5
docs/Gemfile

@ -7,3 +7,8 @@ end
group :development do
gem "webrick"
end
# used to be in standard library
gem "base64"
gem "bigdecimal"
gem "csv"

5
docs/Gemfile.lock

@ -26,6 +26,7 @@ GEM
commonmarker (0.23.10)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
csv (3.3.5)
dnsruby (1.61.9)
simpleidn (~> 0.1)
drb (2.2.3)
@ -275,10 +276,14 @@ GEM
PLATFORMS
arm64-darwin-21
arm64-darwin-23
arm64-darwin-25
x86_64-darwin-22
x86_64-linux
DEPENDENCIES
base64
bigdecimal
csv
github-pages
webrick

2
docs/adr/index.md

@ -1,6 +1,6 @@
---
has_children: true
nav_order: 14
nav_order: 15
---
# Architecture decisions

8
docs/dev_tasks/index.md

@ -0,0 +1,8 @@
---
has_children: true
nav_order: 14
---
# Common dev tasks
A collection of guides for tasks that may have to be carried out repeatedly.

179
docs/dev_tasks/new_question.md

@ -0,0 +1,179 @@
---
parent: Common dev tasks
nav_order: 1
---
# New Questions
Concerns adding a brand-new question to Lettings Logs or Sales Logs. This question will appear on the website as part of the form and should be handled in Bulk Uploads. It will be exported as either a CSV download for users or XML export for automated ingestion by downstream users.
Guide is up-to-date as of 2026.
## Basic checklist of tasks
### 1. Create a migration to add the new field to the database
This allows the answer to the new question to be saved.
You can create a new empty migration file from the terminal if you are in the root of the project:
```
bin/rails generate migration NameOfMigration
```
The new migration file will be saved in `db/migrate`.
Whilst the specifics will vary, the new migration file should look something like this:
```ruby
class AddSexRegisteredAtBirthToSalesLogs < ActiveRecord::Migration[7.2]
def change
# Add a new column called "name" of type string to the sales_logs table
change_table :sales_logs, bulk: true do |t|
t.column :name, :string
end
end
end
```
See also: [Active record migrations](https://guides.rubyonrails.org/active_record_migrations.html)
### 2. Run the new migration
`bundle exec rake db:migrate`
This will update `schema.rb`. You should not edit `schema.rb` directly.
### 3. Create a new question class
This will define the question that gets rendered on the online form.
Existing question classes can be found in `app/models/form/<log type>/questions/`. Depending on the type of question (checkboxes, radio groups, free-text fields), there will almost certainly be an existing question class that you can refer to as a guide.
For example, if you need to create a new radio form, then you may want to copy `armed_forces.rb`.
Sometimes a question will appear in multiple places in the form, or have multiple similar forms. Historically on CORE we'd make a different question class for each, but now we think it's more maintainable to add a small amount of logic to the question to make it dynamic.
See also: [Question]({% link form/question.md %})
### 4. Create a new page class
This creates the page that your new question will be rendered on.
Existing page classes can be found in `app/models/form/<log type>/pages`.
Usually there is only one question per page, but in some cases there may be multiple. It may not be necessary to create a new page if the new question is being added to an existing one.
See also: [Page]({% link form/page.md %})
### 5. Add new page to an existing subsection
Without this step, your new page will not be inserted into the form!
Subsections can be found in `app/models/form/<log type>/subsections`.
You will want to add your new page to the appropriate place in the list returned by `def pages`.
To make your new page only appear in the forms for the upcoming year, you wrap the page class in parentheses and add a conditional expression to the end, like so:
```ruby
(Form::Sales::Pages::SexRegisteredAtBirth1.new(nil, nil, self) if form.start_year_2026_or_later?),
```
Note: the `@id` attribute of a page is what will be displayed in the url when visiting it. It must be unique within a collection year (i.e. two pages in 25/26 cannot share an ID, but two pages in different collection years can share an ID).
Do not use a `depends_on` block for showing a page on a specific year.
### 6. Update the locale file
The locale files define some of the text for the new question, including hints and the question itself.
Locale files can be found in `config/locales/forms/<year>/<log type>/` and there is one locale file for each form subsection.
Copy the entry for an existing question and substitute in the text for your new one.
The locale config for a question by default is laid out like `en.forms.\<year>.\<form type>.\<subsection>.\<page id>`. We assume 1 question per page. If there is more than one question per page you will need to add these as subsections in the page locale and set up a custom <code>@copy_key</code> property in the constructor.
### 7. Add validations
Add validation methods to the appropriate file. For example `app/models/validations/<subsection>_validations.rb` or `app/models/validations/sales/<subsection>_validations.rb` for sales. Any method in these files with a name starting `validate` will be automatically called. Adding an error to the record (log) will show an error on the frontend and the BU.
An error is added by calling `record.errors.add :<question id>, I18n.t("validations.<validation key>")`. You'll need to add the key where appropriate to the relevant locale file inside `config/locales/validations/...`.
Make sure to add errors to all relevant question IDs that can trigger the error. Note that questions on CORE can be answered in any order and amended at will so you cannot make assumptions on order of questions answered. CORE uses the question ID to show the error to the user on the page. If you don't add errors to all relevant question IDs, the user will not see an error but not be able to submit.
### 8. Include the new field in exports
The fields that get exported in CSVs and XMLs are defined in `app/services/exports/\<log type>_log_export_constants.rb`.
If there is not a set for `YEAR_<year>_EXPORT_FIELDS`, create one. Add your new field to the current year's set.
You may also have to update the `<log type>_log_export_service.rb` to correctly filter the year-specific fields.
### 9. Update the bulk upload parser
This will allow bulk upload files to save the new field to the database.
You can find the relevant file at `app/services/bulk_upload/<log type>/year<year>/row_parser.rb`.
If doing this work during yearly rebuild, add this new field as the last field in the file. It's much easier to have a single task at the end to correct all the field numbers.
You will need to add a new `field_XXX` for the new field. In total, update the following places:
- Add the new field to `QUESTIONS` with the text of the question.
- Add a new attribute alongside the existing ones neat the top of the file:
```ruby
attribute :field_XXX, :type
```
- Add the new field to `field_mapping_for_errors` with the name of the field in the database.
- Add the new field to `attributes_for_log` with the name of the field in the database.
- If the field needs to be case-insensitive, add to `CASE_INSENSITIVE_FIELDS`.
You may also have to add some additional validation rules in this file.
We should try and keep validations in the BU few, and leave to validations on the log which you set up earlier. Only add validation specific to the csv format. For instance, validating the input format where we allow "R".
Validation for ensuring that the value uploaded is one of the permitted options is handled automatically, using the question class as the original source of truth.
Make sure that if a value fails a BU validation, then a valid value is not written to the log in the `attributes_for_log` method. If a BU produces a log that is 'complete', all errors will be ignored. Errors on fields the log considers 'valid' are ignored. So, you must make sure the log is incomplete for the user to see the error report. Normally you'll do this intuitively as invalid fields will mean you won't have a valid value.
You'll also need to update the field count in `app/services/bulk_upload/<log type>/year<year>/csv_parser.rb`, as well as the `cols` method.
### 10. Update unit tests
- Create new test files for any new classes you have created. Update any test files for files that you have edited.
- Update `spec/fixtures/variable_definitions/sales_download_25_26.csv` (for sales/lettings and for the relevant collection year) with the new question's field name and definition.
### 11. Update factory file
In `spec/factories` there are a series of factory files. These generate populated objects for use in tests. We also use them on CORE for the buttons that generate completed logs.
You will need to update the factory file for the relevant log type to include the new field. Don't worry about gating it to be year specific, old year questions will be ignored.
### 12. Update reference example BU file
In `spec/fixtures/files` we keep a sample BU file for a range of years and both log types. This gives us a fixed reference for a valid BU file. Make sure to update this and test that you can upload it locally.
### 13. Update auto generated example BU file
In the `app/helpers_bulk_upload/<log type>_log_to_csv.rb` file we maintain functions that convert a log into a BU file. This is used by tests and by the button on CORE to download an example BU file.
Make sure that this file is updated to include the new field in the relevant `to_<year>_row` method. You don't need to update the field numbers.
If you're adding a new method for this year, copy paste the previous year's row method. This'll avoid some nasty merge conflicts down the line.
Make sure you can download this test file and then upload it again.
### 14. Check the CSV download service
Users are able to download CSV exports of logs. This is handled by `app/services/csv/<log type>_log_csv_service.rb`. It should automatically include the new field, but worth checking locally that you're happy with both the codes and labels download.
### 15. Add test CSV export definitions
In `spec/fixtures/variable_definitions` there are a series of CSV files that define the names of fields. These are normally set in the UI by CORE but to make our tests more authentic we maintain labels for use in the tests.
If making one for the new year, just add a new file in that folder. The CSV export tests will pick it up automatically.
### 16. Check log export service
We export an XML representation of all logs made that day nightly. This is handled by `app/services/exports/<log type>_log_export_service.rb`. It should automatically include the new field, though make sure the spec file for this service passes. You will need to update `apply_cds_transformation` if the column name requested in the export doesn't match the database.

2
docs/documentation_website.md

@ -1,5 +1,5 @@
---
nav_order: 15
nav_order: 16
---
# This documentation website

48
docs/form/page.md

@ -11,32 +11,11 @@ Pages sit below the [`Subsection`](subsection) level of a form definition.
An example page might look something like this:
```
class Form::Sales::Pages::PropertyPostcode < ::Form::Page
class Form::Sales::Pages::SomePage < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = property_postcode
@depends_on = [{ "needstype" => 1 }]
@title_text = {
"translation": "translation1",
"arguments": [
{
"key": "some_general_field",
"label": true,
"i18n_template": "template1"
}
]
}
@informative_text": {
"translation": "translation2",
"arguments": [
{
"key": "some_currency_method",
"label": false,
"i18n_template": "template2",
"currency": true,
}
]
}
@id = "some_page"
@depends_on = [{ "needstype" => 1 }, { "age#{@person_index}" => { "operator" => "<", "operand" => 16 } }]
end
def questions
@ -56,3 +35,24 @@ The description is optional but if provided is used for a paragraph displayed un
It’s worth noting that like subsections a page can also have a `depends_on` which contains the set of conditions that must be met for the section to be accessible to a data provider. If the conditions are not met then the page is not routed to as part of the form flow. The `depends_on` for a page will usually depend on answers given to questions, most likely to be questions in the setup section. In the above example the page is dependent on the answer to the `needstype` question being `1`, which corresponds to picking `General needs` on that question as displayed to the data provider.
Pages can contain one or more [questions](question).
## Useful Properties
<dl>
<dt>id</dt>
<dd>
The name of the field. This should correspond to a column in the database. In the example, the id is 'ppcodenk'.
<br>
Note page IDs must be unique. If not unique, you may run into issues where the next page function will have a stack overflow exception. This is true if using `depends_on` blocks to hide/show pages. You can however reuse page names if using ternary or if conditions to dynamically add pages to a subsection. We only do this for year specific pages on CORE, so page IDs don't have to be unique across all years.
<br>
A potential issue you may see is if you accidentally set the page ID to null, the code to register all the page routes will try to register a nil route, which will set it to the root path for a log. This means you'll get a strange error on the log summary page.</dd>
<dt>depends_on</dt>
<dd>
Pages can have a `depends_on` which contains the set of conditions that must be met for the page to be accessible. It is specified as a hash from properties of a log to conditions.
<br>
A condition can be a single value that must equal the property or a hash of comparisons. Multiple conditions in a single hash work as 'AND' conditions. Multiple hashes work as 'OR' conditions.
<br>
If the conditions are not met then the page is not routed to as part of the form flow. In the above example the page is dependent on picking `General needs` on the `needstype` question, or age being 16+.</dd>
<dt>questions</dt>
<dd>Array of questions to show on the page.</dd>
</dl>

156
docs/form/question.md

@ -6,81 +6,145 @@ nav_order: 4
# Question
_Updated for 2026._
Questions are under the page level of the form definition.
An example question might look something like this:
```
class Form::Sales::Questions::PostcodeKnown < ::Form::Question
```ruby
class Form::Sales::Questions::PreviousPostcodeKnown < ::Form::Question
def initialize(id, hsh, page)
super
@id = postcode_known
@hint_text = ""
@header = "Do you know the property postcode?"
@check_answer_label = "Do you know the property postcode?"
@id = "ppcodenk"
@copy_key = "sales.household_situation.last_accommodation.ppcodenk"
@type = "radio"
@answer_options = {
"1" => { "value" => "Yes" },
"0" => { "value" => "No" }
},
@answer_options = ANSWER_OPTIONS
@conditional_for = {
"postcode_full" => [1]
},
@hidden_in_check_answers = true
"ppostcode_full" => [0],
}
@hidden_in_check_answers = {
"depends_on" => [
{
"ppcodenk" => 0,
},
{
"ppcodenk" => 1,
},
],
}
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@disable_clearing_if_not_routed_or_dynamic_answer_options = true
end
ANSWER_OPTIONS = {
"0" => { "value" => "Yes" },
"1" => { "value" => "No" },
}.freeze
QUESTION_NUMBER_FROM_YEAR = { 2023 => 57, 2024 => 59, 2025 => 57 }.freeze
end
```
In the above example the the question has the id `postcode_known`.
## Useful Properties
The `check_answer_label` contains the text that will be displayed in the label of the table on the check answers page.
<dl>
<dt>id</dt>
<dd>The name of the field. This should correspond to a column in the database. In the example, the id is 'ppcodenk'.</dd>
The header is text that is displayed for the question.
<dt>copy_key</dt>
<dd>This specifies copy from <code>config/locales/forms/...</code> that should be associated with the question. Not normally needed, will be inferred as <code>#{form.type}.#{subsection.copy_key}.#{id}</code>.</dd>
Hint text is optional, but if provided it sits under the header and is normally given to provide the data inputters with guidance when answering the question, for example it might inform them about terms used in the question.
<dt>type</dt>
<dd>Determines what type of question is rendered on the page. In the example, the question is a Radio Form so the <code>app/views/form/_radio_question.html.erb</code> partial will be rendered on the page when this question is displayed to the user</dd>
The type is question type, which is used to determine the view rendered for the question. In the above example the question is a radio type so the `app/views/form/_radio_question.html.erb` partial will be rendered on the page when this question is displayed to the user.
<dt>answer_options</dt>
<dd>Some types of question offer multiple options to pick from, which can be defined here. In the example, there are two options. The option that will be rendered with the label 'Yes' has the underlying value 0. The option with the label 'No' has the underlying value 1.</dd>
The `conditional_for` contains the value needed to be selected by the data inputter in order to display another question that appears on the same page. In the example above the `postcode_full` question depends on the answer to `postcode_known` being selected as `1` or `Yes`, this would then display the `postcode_full` underneath the `Yes` option on the page, allowing the provide the provide the postcode if they have indicated they know it. If the user has JavaScript enabled then this realtime conditional display is handled by the `app/frontend/controllers/conditional_question_controller.js` file.
<dt>conditional_for</dt>
<dd>Allows for additional questions to be rendered on the page if a certain value is chosen for the current question. In the example, if the value of this question is 0 (the 'Yes' option is selected), then the question with id 'ppostcode_full' will be rendered beneath the selected option.<br/>If the user has JavaScript enabled then this realtime conditional display is handled by the <code>app/frontend/controllers/conditional_question_controller.js</code> file.</dd>
the `hidden_in_check_answers` is used to hide a value from displaying on the check answers page. You only need to provide this if you want to set it to true in order to hide the value for some reason e.g. it's one of two questions appearing on a page and the other question is displayed on the check answers page. It's also worth noting that you can declare this as a with a `depends_on` which can be useful for conditionally displaying values on the check answers page. For example:
<dt>hidden_in_check_answers</dt>
<dd>
Allows us to hide the question on the 'check your answers' page. You only need to provide this if you want to set it to true in order to hide the value for some reason e.g. it's one of two questions appearing on a page and the other question is displayed on the check answers page.
<br/>
If <code>depends_on</code> is supplied, then whether this question is hidden can be made conditional on the answers provided to any question. In the example, the question is hidden if 'ppcodenk' (this question) has value 0 or 1. (As these are the only two possible answers, the question will always be hidden.)
</dd>
```
@hidden_in_check_answers = {
"depends_on" => [
{ "age6_known" => 0 },
{ "age6_known" => 1 }
]
}
```
<dt>question_number</dt>
<dd>
Determines which number gets rendered next to the question text on the question page and in the 'check your answers' page.
<br/>
The convention that we use for the question number is that the hash should contain all years explicitly, even if it doesn't change between years. When building a new year's forms we should add the question number for the new year to all questions. See the `add_new_year_to_questions` rake.
</dd>
Would mean the question the above is attached to would be hidden in the check answers page if the value of age6_known is either `0` or `1`.
<dt>disable_clearing_if_not_routed_or_dynamic_answer_options</dt>
<dd>
Questions that are not routed to will be cleared. Setting this to true will prevent this from happening. Normally can be replaced with implementing <code>derived?</code>.
</dd>
The answer the data inputter provides to some questions allows us to infer the values of other questions we might have asked in the form, allowing us to save the data inputters some time. An example of how this might look is as follows:
<dt>check_answers_card_number</dt>
<dd>
Is used only for the household characteristics section as each person gets their own card on the CYA page. If you're looking to add a custom CYA card somewhere else in the form, see <code>check_answers_card_title</code>
</dd>
```
class Form::Sales::Questions::PostcodeFull < ::Form::Question
<dt>check_answers_card_title</dt>
<dd>
If set to non nil, on the CYA this question will be put in a box with this title. If multiple questions set the same <code>check_answers_card_title</code>, they will be grouped.
</dd>
</dl>
Another example shows us some fields that are used when we want to infer the answers to one question based on a user's answers to another question. This can allow the user to have to answer fewer questions, lowering their total number of clicks.
```ruby
class Form::Sales::Questions::PostcodeForFullAddress < ::Form::Question
def initialize(id, hsh, page)
super
@id = postcode_full
@hint_text = ""
@header = "What is the property’s postcode?""
@check_answer_label = "Postcode""
@type = "text"
@width = 5
@id = "postcode_full"
@inferred_check_answers_value = [{
"condition" => {
"pcodenk" => 1,
},
"value" => "Not known",
}]
@inferred_answers = {
"la" => { "is_la_inferred" => true }
"la" => {
"is_la_inferred" => true,
},
}
@inferred_check_answers_value => [{
"condition" => { "postcode_known" => 0 },
"value": "Not known"
}]
# Other fields omitted for brevity
end
end
```
In the above example the width is an optional attribute and can be provided for text type questions to determine the width of the text box on the page when when the question is displayed to a user (this allows you to match the width of the text box on the page to that of the design for a question).
<dl>
<dt>inferred_check_answers_value</dt>
<dd>Determines what gets shown on the 'check your answers' page if we infer the answer to this question. In the example, if the question 'pcodenk' has value 1 (indicating that the postcode is not known), then the answer shown for this question will be 'Not known'.</dd>
<dt>inferred_answers</dt>
<dd>Determines any questions whose answers can be inferred based on the answer to this question. In the example, the 'la' question (Local Authority) can be inferred from the Postcode. We set a property 'is_la_inferred' on the log to record this inferrance.</dd>
</dl>
## Useful methods
<dl>
<dt>derived?</dt>
<dd>This is function that should return true if the question is to be derived, such as where it's answer can be inferred based on another question. Setting this to true will cause the question to not be shown in CYA. The user will still be shown the question. This method is very similar to a depends_on method, but a depends_on block is used to always infer an answer of "". derived? is reliant on other code setting the answer, such as <code>set_derived_fields!</code></dd>
<dt>get_extra_check_answer_value</dt>
<dd>Used for putting extra lines below the main answer in the CYA page. Used on the address search to show the full address below the UPRN</dd>
<dt>label_from_value</dt>
<dd>Used for custom labels that differ from the main site. Normally will use the labels on the page, Useful for instance changing "No, enter xyz" to just "No".</dd>
<dt>skip_question_in_form_flow?</dt>
<dd>Similar to derived, but the user is still able to go back and edit the question later. Will not cause question to be hidden from CYA.</dd>
</dl>
## Question visibility
The above example links to the first example as both of these questions would be on the same page. The `inferred_check_answers_value` is what should be displayed on the check answers page for this question if we infer it. If the value of `postcode_known` was given as `0` (which is a no), as seen in the condition part of `inferred_check_answers_value` then we can infer that the data inputter does not know the postcode and so we would display the value of `Not known` on the check answers page for the postcode.
There are broadly 3 reasons to hide a question. Here's how to handle them.
In the above example the `inferred_answers` refers to a question where we can infer the answer based on the answer of this question. In this case the `la` question can be inferred from the postcode value given by the data inputter as we are able to lookup the local authority based on the postcode given. We then set a property on the lettings log `is_la_inferred` to true to indicate that this is an answer we've inferred.
1. The question should not be asked, answer should be derived as nil and user should not be able to change this. If so, set up a `depends_on` on the page.
2. The question should not be asked, answer should be derived as some value and user should not be able to change this. If so, set up a `depends_on` on the page and set up a `derived?`. Use a method like `set_derived_fields!` to set the answer.
3. The question should not be asked, answer should be derived as some value and user should be able to change this. If so, set up a `skip_question_in_form_flow?` method.

20
docs/setup.md

@ -18,6 +18,14 @@ We recommend using [RBenv](https://github.com/rbenv/rbenv) to manage Ruby versio
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS versions.
## Instructions for Windows users
If you are working on a windows machine, you will want to install the tools on [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) as some of them are not native to windows. The instructions in these docs assume a Debian-based distribution, such as Ubuntu.
_You will see a significant performance degradation if you are running the server on WSL whilst the files are on windows._ Thus, make sure to clone the repository into your WSL instance.
Some windows IDEs, such as [VSCode](https://code.visualstudio.com/docs/remote/wsl) and [RubyMine](jetbrains.com/help/ruby/remote-development-starting-page.html#run_in_wsl_ij) can connect you to WSL, which will allow you to develop as though the files were on the local windows filesystem. Ignore any reccommendations that you might see suggesting you keep the files on windows - our experience is that both tests and page loads are much slower when the files are on windows.
## Pre-setup installation
1. Install PostgreSQL
@ -106,9 +114,17 @@ We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS version
sudo mv geckodriver /usr/local/bin/
```
Also ensure you have firefox installed
Also ensure you have firefox installed
7. Install libyaml-dev if on Linux
Linux (Debian):
```bash
sudo apt install -y libyaml-dev
```
7. Clone the repo
8. Clone the repo
```bash
git clone https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data.git

4
package.json

@ -12,7 +12,7 @@
"@ministryofjustice/frontend": "^3.3.0",
"@stimulus/polyfills": "^2.0.0",
"@webcomponents/webcomponentsjs": "^2.6.0",
"@x-govuk/govuk-prototype-components": "^3.0.9",
"@x-govuk/govuk-prototype-components": "^6.0.0",
"accessible-autocomplete": "^2.0.3",
"babel-loader": "^8.2.3",
"babel-plugin-macros": "^3.1.0",
@ -21,7 +21,7 @@
"css-loader": "^6.7.1",
"custom-event-polyfill": "^1.0.7",
"file-loader": "^6.2.0",
"govuk-frontend": "5.7.1",
"govuk-frontend": "6.0.0",
"html5shiv": "^3.7.3",
"intersection-observer": "^0.12.0",
"jquery": "^3.7.1",

46
spec/helpers/application_helper_spec.rb

@ -8,18 +8,50 @@ RSpec.describe ApplicationHelper do
let(:pagy) { nil }
let(:current_user) { FactoryBot.create(:user) }
describe "govuk_header_classes" do
context "with external user" do
it "shows the standard app header" do
expect(govuk_header_classes(current_user)).to eq("app-header")
describe "govuk_service_navigation_classes" do
context "with non-support user" do
context "when no notifications are displayed" do
before do
allow(helper).to receive(:notifications_to_display?).and_return(false)
end
it "returns empty string for blue border (default)" do
expect(helper.govuk_service_navigation_classes(current_user)).to eq("")
end
end
context "when notifications are displayed" do
before do
allow(helper).to receive(:notifications_to_display?).and_return(true)
end
it "returns no-border class to hide the border (notification banner shows instead)" do
expect(helper.govuk_service_navigation_classes(current_user)).to eq("app-service-navigation--no-border")
end
end
end
context "with internal support user" do
context "with support user" do
let(:current_user) { FactoryBot.create(:user, :support) }
it "shows an orange header" do
expect(govuk_header_classes(current_user)).to eq("app-header app-header--orange")
context "when no notifications are displayed" do
before do
allow(helper).to receive(:notifications_to_display?).and_return(false)
end
it "always returns orange class for orange border" do
expect(helper.govuk_service_navigation_classes(current_user)).to eq("app-service-navigation--orange")
end
end
context "when notifications are displayed" do
before do
allow(helper).to receive(:notifications_to_display?).and_return(true)
end
it "still returns orange class (support users always see orange border)" do
expect(helper.govuk_service_navigation_classes(current_user)).to eq("app-service-navigation--orange")
end
end
end
end

54
spec/requests/users_controller_spec.rb

@ -76,13 +76,39 @@ RSpec.describe UsersController, type: :request do
end
describe "title link" do
it "routes user to the home page" do
sign_in user
get "/", headers:, params: {}
expect(path).to eq("/")
expect(page).to have_content("Welcome back")
expected_link = "<a class=\"govuk-header__link govuk-header__link--homepage\" href=\"/\">"
expect(CGI.unescape_html(response.body)).to include(expected_link)
context "with a non-support user" do
before do
sign_in user
end
it "has GOV.UK header and service navigation both linking to home page" do
get "/", headers:, params: {}
expect(path).to eq("/")
expect(page).to have_content("Welcome back")
govuk_header_link = '<a class="govuk-header__link govuk-header__homepage-link" href="/">'
expect(CGI.unescape_html(response.body)).to include(govuk_header_link)
expect(page).to have_css(".govuk-service-navigation__link[href='/']", text: "Submit social housing lettings and sales data (CORE)")
end
end
context "with a support user" do
let(:support_user) { create(:user, :support) }
before do
sign_in support_user
end
it "has GOV.UK header and service navigation both linking to home page" do
get "/", headers:, params: {}
follow_redirect!
govuk_header_link = '<a class="govuk-header__link govuk-header__homepage-link" href="/">'
expect(CGI.unescape_html(response.body)).to include(govuk_header_link)
expect(page).to have_css(".govuk-service-navigation__link[href='/']", text: "Submit social housing lettings and sales data (CORE)")
end
end
end
@ -2597,18 +2623,4 @@ RSpec.describe UsersController, type: :request do
end
end
end
describe "title link" do
before do
sign_in user
end
it "routes user to the home page" do
get "/", headers:, params: {}
expect(path).to eq("/")
expect(page).to have_content("Welcome back")
expected_link = "<a class=\"govuk-header__link govuk-header__link--homepage\" href=\"/\">"
expect(CGI.unescape_html(response.body)).to include(expected_link)
end
end
end

4
webpack.config.js

@ -40,8 +40,8 @@ module.exports = {
},
resolve: {
alias: {
'govuk-frontend-styles': path.resolve(__dirname, 'node_modules/govuk-frontend/dist/govuk/all.scss'),
'govuk-prototype-styles': path.resolve(__dirname, 'node_modules/@x-govuk/govuk-prototype-components/x-govuk/all.scss'),
'govuk-frontend-styles': path.resolve(__dirname, 'node_modules/govuk-frontend/dist/govuk/index.scss'),
'govuk-prototype-styles': path.resolve(__dirname, 'node_modules/@x-govuk/govuk-prototype-components/dist/govuk-prototype-components.scss'),
'moj-frontend': path.resolve(__dirname, 'node_modules/@ministryofjustice/frontend/moj/all.js')
},
modules: ['node_modules', 'node_modules/govuk-frontend/dist/govuk']

1377
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save