From c30fbdb867f0c16637fb14efc0f1fd32535751fd Mon Sep 17 00:00:00 2001 From: Manny Dinssa <44172848+Dinssa@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:47:59 +0000 Subject: [PATCH] Prototype --- app/controllers/address_options_controller.rb | 33 +++++ .../address_autocomplete_controller.js | 115 ++++++++++++++++++ app/frontend/controllers/index.js | 3 + app/helpers/address_options_helper.rb | 2 + .../derived_variables/sales_log_variables.rb | 8 ++ app/models/form/sales/pages/address_search.rb | 24 ++++ .../form/sales/questions/address_search.rb | 32 +++++ .../form/sales/questions/uprn_confirmation.rb | 2 +- .../sales/subsections/property_information.rb | 1 + app/models/log.rb | 36 +++--- app/services/address_client.rb | 34 ++++-- .../_address_autocomplete_question.html.erb | 23 ++++ app/views/form/_select_question.html.erb | 21 ++-- .../2024/sales/property_information.en.yml | 6 + .../2025/sales/property_information.en.yml | 6 + config/routes.rb | 2 + ...145927_add_address_search_to_sales_logs.rb | 5 + db/schema.rb | 7 +- .../lettings/questions/uprn_selection_spec.rb | 2 +- .../sales/questions/uprn_selection_spec.rb | 2 +- spec/requests/address_options_spec.rb | 7 ++ 21 files changed, 331 insertions(+), 40 deletions(-) create mode 100644 app/controllers/address_options_controller.rb create mode 100644 app/frontend/controllers/address_autocomplete_controller.js create mode 100644 app/helpers/address_options_helper.rb create mode 100644 app/models/form/sales/pages/address_search.rb create mode 100644 app/models/form/sales/questions/address_search.rb create mode 100644 app/views/form/_address_autocomplete_question.html.erb create mode 100644 db/migrate/20241204145927_add_address_search_to_sales_logs.rb create mode 100644 spec/requests/address_options_spec.rb diff --git a/app/controllers/address_options_controller.rb b/app/controllers/address_options_controller.rb new file mode 100644 index 000000000..d1f2bc385 --- /dev/null +++ b/app/controllers/address_options_controller.rb @@ -0,0 +1,33 @@ +class AddressOptionsController < ApplicationController + def index + query = params[:query] + service = AddressClient.new(address: query) + service.call + + if service.error.present? + render json: { error: service.error }, status: :unprocessable_entity + else + render json: service.result.map { |result| { address: result["ADDRESS"], uprn: result["UPRN"] } } + end + end + + def current + log_id = params[:log_id] + sales_log = SalesLog.find_by(id: log_id) + uprn = sales_log&.address_search + + if uprn.present? + service = AddressClient.new(uprn: uprn) + service.call + + if service.error.present? + render json: { error: service.error }, status: :unprocessable_entity + else + address = service.result.find { |result| result["UPRN"] == uprn }&.dig("ADDRESS") + render json: { stored_value: { uprn:, address: } } + end + else + render json: { stored_value: nil } + end + end +end diff --git a/app/frontend/controllers/address_autocomplete_controller.js b/app/frontend/controllers/address_autocomplete_controller.js new file mode 100644 index 000000000..95d5060c0 --- /dev/null +++ b/app/frontend/controllers/address_autocomplete_controller.js @@ -0,0 +1,115 @@ +import {Controller} from '@hotwired/stimulus' +import accessibleAutocomplete from 'accessible-autocomplete' +import 'accessible-autocomplete/dist/accessible-autocomplete.min.css' +import {searchableName} from "../modules/search"; + +const options = [] + +const fetchOptions = async (query) => { + const response = await fetch(`/address_options?query=${query}`) + const data = await response.json() + console.log(data) + return data +} + +const fetchAndPopulateSearchResults = async (query, populateResults, populateOptions, selectEl) => { + if (/\S/.test(query)) { + const results = await fetchOptions(query) + console.log(results) // address and uprn keys returned per result + populateOptions(results, selectEl) + populateResults(Object.values(results).map((o) => o.address)) + // populateResults(results) + } +} + +const populateOptions = (results, selectEl) => { + selectEl.innerHTML = '' + + results.forEach((result) => { + const option = document.createElement('option') + option.value = result.uprn + option.innerHTML = result.address + option.setAttribute('address', result.address) + selectEl.appendChild(option) + options.push(option) + }) +} + +// const populateOptions = (results, selectEl) => { +// selectEl.innerHTML = '' +// +// Object.keys(results).forEach((key) => { +// const option = document.createElement('option') +// option.value = key +// option.innerHTML = results[key].value +// if (results[key].hint) { option.setAttribute('data-hint', results[key].hint) } +// option.setAttribute('text', searchableName(results[key])) +// selectEl.appendChild(option) +// options.push(option) +// }) +// } + +export default class extends Controller { + connect () { + const selectEl = this.element + + const currentValue = this.getCurrentValue() + console.log(selectEl) + + if (currentValue && currentValue.stored_value) { + console.log(currentValue) + const option = document.createElement('option') + option.value = currentValue.stored_value.uprn + option.innerHTML = currentValue.stored_value.address + option.selected = true + selectEl.appendChild(option) + } + + accessibleAutocomplete.enhanceSelectElement({ + defaultValue: '', + selectElement: selectEl, + minLength: 1, + source: (query, populateResults) => { + fetchAndPopulateSearchResults(query, populateResults, populateOptions, selectEl) + }, + autoselect: true, + showNoOptionsFound: true, + placeholder: currentValue?.stored_value?.address || 'Start typing to search', + templates: { suggestion: (value) => value }, + onConfirm: (val) => { + const selectedResult = Array.from(selectEl.options).find(option => option.address === val) + + if (selectedResult) { + selectedResult.selected = true + } + } + }) + } + + fetchOptions(query, populateResults) { + fetch(`/address_options?query=${query}`) + .then(response => response.json()) + .then(data => { + console.log(data) + const results = data.map(result => result.uprn) + populateResults(results.slice(0, 10)) + }) + } + + async getCurrentValue() { + const currentPageUrl = window.location.href; + console.log(currentPageUrl); + const match = currentPageUrl.match(/sales-logs\/(\d+)\/address-search/); + const id = match ? match[1] : null; + + if (id) { + const response = await fetch(`/address_options/current?log_id=${id}`); + const data = await response.json(); + console.log(data); + return data; + } + + return null; + } + +} diff --git a/app/frontend/controllers/index.js b/app/frontend/controllers/index.js index 944e32e2d..bf09582fe 100644 --- a/app/frontend/controllers/index.js +++ b/app/frontend/controllers/index.js @@ -19,6 +19,8 @@ import FilterLayoutController from './filter_layout_controller.js' import TabsController from './tabs_controller.js' +import AddressAutocompleteController from './address_autocomplete_controller.js' + application.register('accessible-autocomplete', AccessibleAutocompleteController) application.register('conditional-filter', ConditionalFilterController) application.register('conditional-question', ConditionalQuestionController) @@ -27,3 +29,4 @@ application.register('numeric-question', NumericQuestionController) application.register('filter-layout', FilterLayoutController) application.register('search', SearchController) application.register('tabs', TabsController) +application.register('address-autocomplete', AddressAutocompleteController) diff --git a/app/helpers/address_options_helper.rb b/app/helpers/address_options_helper.rb new file mode 100644 index 000000000..d746d4c02 --- /dev/null +++ b/app/helpers/address_options_helper.rb @@ -0,0 +1,2 @@ +module AddressOptionsHelper +end diff --git a/app/models/derived_variables/sales_log_variables.rb b/app/models/derived_variables/sales_log_variables.rb index 6e12ec488..df151bd01 100644 --- a/app/models/derived_variables/sales_log_variables.rb +++ b/app/models/derived_variables/sales_log_variables.rb @@ -61,6 +61,7 @@ module DerivedVariables::SalesLogVariables self.pcodenk = nil self.postcode_full = nil self.la = nil + self.address_search = nil end end @@ -75,6 +76,13 @@ module DerivedVariables::SalesLogVariables self.pcodenk = nil self.postcode_full = nil self.la = nil + self.address_search = nil + end + + if address_search + self.uprn = self.address_search + self.uprn_known = 1 + self.uprn_confirmed = 1 end if form.start_year_2025_or_later? && is_bedsit? diff --git a/app/models/form/sales/pages/address_search.rb b/app/models/form/sales/pages/address_search.rb new file mode 100644 index 000000000..4db733f97 --- /dev/null +++ b/app/models/form/sales/pages/address_search.rb @@ -0,0 +1,24 @@ +class Form::Sales::Pages::AddressSearch < ::Form::Page + def initialize(id, hsh, subsection) + super + @id = "address_search" + @copy_key = "sales.property_information.address_search" + @depends_on = [ + { "uprn_known" => nil }, + { "uprn_known" => 0 }, + { "uprn_confirmed" => 0 }, + ] + end + + def questions + @questions ||= [ + Form::Sales::Questions::AddressSearch.new(nil, nil, self), + ] + end + + def skip_href(log = nil) + return unless log + + "/#{log.model_name.param_key.dasherize}s/#{log.id}/property-number-of-bedrooms" + end +end diff --git a/app/models/form/sales/questions/address_search.rb b/app/models/form/sales/questions/address_search.rb new file mode 100644 index 000000000..16c4562c2 --- /dev/null +++ b/app/models/form/sales/questions/address_search.rb @@ -0,0 +1,32 @@ +class Form::Sales::Questions::AddressSearch < ::Form::Question + def initialize(id, hsh, page) + super + @id = "address_search" + @copy_key = "sales.property_information.address_search" + @error_label = "Enter search query" + @type = "address_autocomplete" + @plain_label = true + end + + def answer_options(log = nil, _user = nil) + return {} unless ActiveRecord::Base.connected? + return {} unless log&.address_options + + answer_opts = {} + + (0...[log.address_options.count, 10].min).each do |i| + answer_opts[log.address_options[i][:uprn]] = { "value" => log.address_options[i][:address] } + end + + answer_opts["divider"] = { "value" => true } + answer_opts + end + + def displayed_answer_options(log, user = nil) + answer_options(log, user).transform_values { |value| value["value"] } || {} + end + + def hidden_in_check_answers?(log, _current_user = nil) + (log.uprn_known == 1 || log.uprn_confirmed == 1) + end +end diff --git a/app/models/form/sales/questions/uprn_confirmation.rb b/app/models/form/sales/questions/uprn_confirmation.rb index fed35f665..a8e8c974b 100644 --- a/app/models/form/sales/questions/uprn_confirmation.rb +++ b/app/models/form/sales/questions/uprn_confirmation.rb @@ -35,6 +35,6 @@ class Form::Sales::Questions::UprnConfirmation < ::Form::Question end def hidden_in_check_answers?(log, _current_user = nil) - log.uprn_known != 1 || log.uprn_confirmed.present? + log.uprn_known != 1 || log.uprn_confirmed.present? || log.address_search.present? end end diff --git a/app/models/form/sales/subsections/property_information.rb b/app/models/form/sales/subsections/property_information.rb index 28c0ad004..f7cef35a7 100644 --- a/app/models/form/sales/subsections/property_information.rb +++ b/app/models/form/sales/subsections/property_information.rb @@ -26,6 +26,7 @@ class Form::Sales::Subsections::PropertyInformation < ::Form::Subsection [ Form::Sales::Pages::Uprn.new(nil, nil, self), Form::Sales::Pages::UprnConfirmation.new(nil, nil, self), + Form::Sales::Pages::AddressSearch.new(nil, nil, self), Form::Sales::Pages::AddressMatcher.new(nil, nil, self), Form::Sales::Pages::NoAddressFound.new(nil, nil, self), Form::Sales::Pages::UprnSelection.new(nil, nil, self), diff --git a/app/models/log.rb b/app/models/log.rb index 94f0ff58d..8326f7b5b 100644 --- a/app/models/log.rb +++ b/app/models/log.rb @@ -89,7 +89,7 @@ class Log < ApplicationRecord def process_address_change! if uprn_selection.present? || select_best_address_match.present? if select_best_address_match - service = AddressClient.new(address_string) + service = AddressClient.new(address: address_string) service.call return nil if service.result.blank? || service.error.present? @@ -125,27 +125,29 @@ class Log < ApplicationRecord "#{address_line1_input}, #{postcode_full_input}" end - def address_options - return @address_options if @address_options && @last_searched_address_string == address_string + def address_options # TODO: use this method for autocomplete + search_query = address_search.presence || address_string + return @address_options if @address_options && @last_searched_address_string == search_query - if [address_line1_input, postcode_full_input].all?(&:present?) - @last_searched_address_string = address_string + search_query = address_search.presence || address_string + return if search_query.blank? - service = AddressClient.new(address_string) - service.call - if service.result.blank? || service.error.present? - @address_options = [] - return @answer_options - end + @last_searched_address_string = search_query - address_opts = [] - service.result.first(10).each do |result| - presenter = AddressDataPresenter.new(result) - address_opts.append({ address: presenter.address, uprn: presenter.uprn }) - end + service = AddressClient.new(address: address_string) + service.call + if service.result.blank? || service.error.present? + @address_options = [] + return @answer_options + end - @address_options = address_opts + address_opts = [] + service.result.first(10).each do |result| + presenter = AddressDataPresenter.new(result) + address_opts.append({ address: presenter.address, uprn: presenter.uprn }) end + + @address_options = address_opts end def collection_start_year diff --git a/app/services/address_client.rb b/app/services/address_client.rb index 81c8da7ed..ec3cbdb31 100644 --- a/app/services/address_client.rb +++ b/app/services/address_client.rb @@ -1,14 +1,14 @@ -require "net/http" - class AddressClient - attr_reader :address + attr_reader :address, :uprn attr_accessor :error ADDRESS = "api.os.uk".freeze - PATH = "/search/places/v1/find".freeze + PATH_FIND = "/search/places/v1/find".freeze + PATH_UPRN = "/search/places/v1/uprn".freeze - def initialize(address) + def initialize(address: nil, uprn: nil) @address = address + @uprn = uprn end def call @@ -27,7 +27,15 @@ class AddressClient end end -private + def result_by_uprn + if response.is_a?(Net::HTTPSuccess) + @result ||= JSON.parse(response.body)["result"] + else + @result = nil + end + end + + private def http_client client = Net::HTTP.new(ADDRESS, 443) @@ -39,7 +47,7 @@ private end def endpoint_uri - uri = URI(PATH) + uri = URI(PATH_FIND) params = { query: address, key: ENV["OS_DATA_KEY"], @@ -50,7 +58,17 @@ private uri.to_s end + def endpoint_uri_by_uprn + uri = URI(PATH_UPRN) + params = { + uprn: uprn, + key: ENV["OS_DATA_KEY"], + } + uri.query = URI.encode_www_form(params) + uri.to_s + end + def response - @response ||= http_client.request_get(endpoint_uri) + @response ||= http_client.request_get(address ? endpoint_uri : endpoint_uri_by_uprn) end end diff --git a/app/views/form/_address_autocomplete_question.html.erb b/app/views/form/_address_autocomplete_question.html.erb new file mode 100644 index 000000000..b6b8569ee --- /dev/null +++ b/app/views/form/_address_autocomplete_question.html.erb @@ -0,0 +1,23 @@ +<% selected = @log.public_send(question.id) || "" %> +<% answers = question.displayed_answer_options(@log, current_user).map { |key, value| OpenStruct.new(id: key, name: select_option_name(value), resource: value) } %> +<%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %> + +<%= f.govuk_select(question.id.to_sym, + label: legend(question, page_header, conditional), + "data-controller": "address-autocomplete", + caption: caption(caption_text, page_header, conditional), + hint: { text: question.hint_text&.html_safe }) do %> + <% if answers.any? %> + <% answers.each do |answer| %> + + <% end %> + <% else %> + + <% end %> +<% end %> + +<%= render partial: "form/guidance/#{question.bottom_guidance_partial}" if question.bottom_guidance? %> diff --git a/app/views/form/_select_question.html.erb b/app/views/form/_select_question.html.erb index 2dceffd63..cf5833f3e 100644 --- a/app/views/form/_select_question.html.erb +++ b/app/views/form/_select_question.html.erb @@ -1,20 +1,23 @@ - <% selected = @log.public_send(question.id) || "" %> <% answers = question.displayed_answer_options(@log, current_user).map { |key, value| OpenStruct.new(id: key, name: select_option_name(value), resource: value) } %> <%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %> <%= f.govuk_select(question.id.to_sym, - label: legend(question, page_header, conditional), - "data-controller": "accessible-autocomplete", - caption: caption(caption_text, page_header, conditional), - hint: { text: question.hint_text&.html_safe }) do %> + label: legend(question, page_header, conditional), + "data-controller": "accessible-autocomplete", + caption: caption(caption_text, page_header, conditional), + hint: { text: question.hint_text&.html_safe }) do %> + <% if answers.any? %> <% answers.each do |answer| %> + data-synonyms="<%= answer_option_synonyms(answer.resource) %>" + data-append="<%= answer_option_append(answer.resource) %>" + data-hint="<%= answer_option_hint(answer.resource) %>" + <%= question.answer_selected?(@log, answer) ? "selected" : "" %>><%= answer.name || answer.resource %> <% end %> + <% else %> + <% end %> +<% end %> <%= render partial: "form/guidance/#{question.bottom_guidance_partial}" if question.bottom_guidance? %> diff --git a/config/locales/forms/2024/sales/property_information.en.yml b/config/locales/forms/2024/sales/property_information.en.yml index 269f4fdca..ef6f85c8a 100644 --- a/config/locales/forms/2024/sales/property_information.en.yml +++ b/config/locales/forms/2024/sales/property_information.en.yml @@ -60,6 +60,12 @@ en: hint_text: "" question_text: "Postcode" + address_search: + page_header: "Search and find address" + check_answer_label: "Search - Find address" + hint_text: "" + question_text: "Find an address" + la: page_header: "" check_answer_label: "Local authority" diff --git a/config/locales/forms/2025/sales/property_information.en.yml b/config/locales/forms/2025/sales/property_information.en.yml index d658362ea..1ce632a4f 100644 --- a/config/locales/forms/2025/sales/property_information.en.yml +++ b/config/locales/forms/2025/sales/property_information.en.yml @@ -60,6 +60,12 @@ en: hint_text: "" question_text: "Postcode" + address_search: + page_header: "Search and find address" + check_answer_label: "Search - Find address" + hint_text: "" + question_text: "Find an address" + la: page_header: "" check_answer_label: "Local authority" diff --git a/config/routes.rb b/config/routes.rb index 55d58b41b..aff3ec28f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,8 @@ Rails.application.routes.draw do get "/data-sharing-agreement", to: "content#data_sharing_agreement" get "/service-moved", to: "maintenance#service_moved" get "/service-unavailable", to: "maintenance#service_unavailable" + get "/address_options", to: "address_options#index" + get "/address_options/current", to: "address_options#current" get "collection-resources", to: "collection_resources#index" get "/collection-resources/:log_type/:year/:resource_type/download", to: "collection_resources#download_mandatory_collection_resource", as: :download_mandatory_collection_resource diff --git a/db/migrate/20241204145927_add_address_search_to_sales_logs.rb b/db/migrate/20241204145927_add_address_search_to_sales_logs.rb new file mode 100644 index 000000000..363a1b1a0 --- /dev/null +++ b/db/migrate/20241204145927_add_address_search_to_sales_logs.rb @@ -0,0 +1,5 @@ +class AddAddressSearchToSalesLogs < ActiveRecord::Migration[7.0] + def change + add_column :sales_logs, :address_search, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index bd7672c24..5582d953e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -84,7 +84,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_06_142944) do t.datetime "last_accessed" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.check_constraint "log_type::text = ANY (ARRAY['lettings'::character varying, 'sales'::character varying]::text[])", name: "log_type_check" + t.check_constraint "log_type::text = ANY (ARRAY['lettings'::character varying::text, 'sales'::character varying::text])", name: "log_type_check" t.check_constraint "year >= 2000 AND year <= 2099", name: "year_check" end @@ -761,13 +761,14 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_06_142944) do t.integer "partner_under_16_value_check" t.integer "multiple_partners_value_check" t.bigint "created_by_id" - t.integer "has_management_fee" - t.decimal "management_fee", precision: 10, scale: 2 t.integer "firststair" t.integer "numstair" t.decimal "mrentprestaircasing", precision: 10, scale: 2 t.datetime "lasttransaction" t.datetime "initialpurchase" + t.integer "has_management_fee" + t.decimal "management_fee", precision: 10, scale: 2 + t.string "address_search" t.index ["assigned_to_id"], name: "index_sales_logs_on_assigned_to_id" t.index ["bulk_upload_id"], name: "index_sales_logs_on_bulk_upload_id" t.index ["created_by_id"], name: "index_sales_logs_on_created_by_id" diff --git a/spec/models/form/lettings/questions/uprn_selection_spec.rb b/spec/models/form/lettings/questions/uprn_selection_spec.rb index c3edc646e..53fd3816f 100644 --- a/spec/models/form/lettings/questions/uprn_selection_spec.rb +++ b/spec/models/form/lettings/questions/uprn_selection_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Form::Lettings::Questions::UprnSelection, type: :model do let(:question_definition) { nil } let(:page) { instance_double(Form::Page, skip_href: "skip_href") } let(:log) { build(:lettings_log, :in_progress, address_line1_input: "Address line 1", postcode_full_input: "AA1 1AA") } - let(:address_client_instance) { AddressClient.new(log.address_string) } + let(:address_client_instance) { AddressClient.new(address: log.address_string) } before do allow(AddressClient).to receive(:new).and_return(address_client_instance) diff --git a/spec/models/form/sales/questions/uprn_selection_spec.rb b/spec/models/form/sales/questions/uprn_selection_spec.rb index ff1b1a6dd..2ed1117d1 100644 --- a/spec/models/form/sales/questions/uprn_selection_spec.rb +++ b/spec/models/form/sales/questions/uprn_selection_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Form::Sales::Questions::UprnSelection, type: :model do let(:question_definition) { nil } let(:page) { instance_double(Form::Page, skip_href: "skip_href") } let(:log) { build(:sales_log, :in_progress, address_line1_input: "Address line 1", postcode_full_input: "AA1 1AA") } - let(:address_client_instance) { AddressClient.new(log.address_string) } + let(:address_client_instance) { AddressClient.new(address: log.address_string) } before do allow(AddressClient).to receive(:new).and_return(address_client_instance) diff --git a/spec/requests/address_options_spec.rb b/spec/requests/address_options_spec.rb new file mode 100644 index 000000000..3871eeca1 --- /dev/null +++ b/spec/requests/address_options_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "AddressOptions", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end