diff --git a/Gemfile b/Gemfile index 90ee5eed2..12d0bbdff 100644 --- a/Gemfile +++ b/Gemfile @@ -55,8 +55,7 @@ gem "redis" # Receive exceptions and configure alerts gem "sentry-rails" gem "sentry-ruby" -# Pagination -gem "pagy" +# Possessives in strings gem "possessive" group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index fec988e50..d7648b37c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,8 +93,8 @@ GEM public_suffix (>= 2.0.2, < 5.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.598.0) - aws-sdk-core (3.131.1) + aws-partitions (1.601.0) + aws-sdk-core (3.131.2) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) @@ -153,7 +153,7 @@ GEM dotenv (= 2.7.6) railties (>= 3.2) encryptor (3.0.0) - erb_lint (0.1.1) + erb_lint (0.1.3) activesupport better_html (~> 1.0.7) html_tokenizer @@ -173,28 +173,30 @@ GEM ffi (1.15.5) globalid (1.0.0) activesupport (>= 5.0) - govuk-components (3.0.6) + govuk-components (3.1.2) + actionpack (>= 6.1) activemodel (>= 6.1) - html-attributes-utils (~> 0.9.0) + html-attributes-utils (~> 0.9, >= 0.9.2) + pagy (~> 5.10.1) railties (>= 6.1) - view_component (~> 2.49.1) - govuk_design_system_formbuilder (3.0.3) + view_component (~> 2.56.2) + govuk_design_system_formbuilder (3.1.0) actionview (>= 6.1) activemodel (>= 6.1) activesupport (>= 6.1) - html-attributes-utils (~> 0.9.0) + html-attributes-utils (~> 0.9, >= 0.9.2) govuk_markdown (1.0.0) activesupport redcarpet hashdiff (1.0.1) - html-attributes-utils (0.9.0) + html-attributes-utils (0.9.2) activesupport (>= 6.1.4.4) html_tokenizer (0.0.7) i18n (1.10.0) concurrent-ruby (~> 1.0) iniparse (1.5.0) jmespath (1.6.1) - jsbundling-rails (1.0.2) + jsbundling-rails (1.0.3) railties (>= 6.0.0) json-schema (3.0.0) addressable (>= 2.8) @@ -211,7 +213,7 @@ GEM matrix (0.4.2) method_source (1.0.0) mini_mime (1.1.2) - minitest (5.15.0) + minitest (5.16.1) msgpack (1.5.2) net-imap (0.2.3) digest @@ -252,7 +254,7 @@ GEM parallel (1.22.1) parser (3.1.2.0) ast (~> 2.4.1) - pg (1.3.5) + pg (1.4.1) possessive (1.0.1) postcodes_io (0.4.0) excon (~> 0.39) @@ -276,8 +278,8 @@ GEM rack (>= 1.0, < 3) rack-mini-profiler (2.3.4) rack (>= 1.2.0) - rack-test (1.1.0) - rack (>= 1.0, < 3) + rack-test (2.0.1) + rack (>= 1.3) rails (7.0.3) actioncable (= 7.0.3) actionmailbox (= 7.0.3) @@ -311,7 +313,7 @@ GEM rb-inotify (0.10.1) ffi (~> 1.0) redcarpet (3.5.1) - redis (4.6.0) + redis (4.7.0) regexp_parser (2.5.0) request_store (1.5.1) rack (>= 1.4) @@ -371,7 +373,7 @@ GEM ruby-progressbar (1.11.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) - selenium-webdriver (4.2.1) + selenium-webdriver (4.3.0) childprocess (>= 0.5, < 5.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) @@ -399,9 +401,9 @@ GEM timeout (0.3.0) tzinfo (2.0.4) concurrent-ruby (~> 1.0) - uk_postcode (2.1.7) - unicode-display_width (2.1.0) - view_component (2.49.1) + uk_postcode (2.1.8) + unicode-display_width (2.2.0) + view_component (2.56.2) activesupport (>= 5.0.0, < 8.0) method_source (~> 1.0) warden (1.2.9) @@ -450,7 +452,6 @@ DEPENDENCIES listen (~> 3.3) notifications-ruby-client overcommit (>= 0.37.0) - pagy paper_trail paper_trail-globalid pg (~> 1.1) diff --git a/README.md b/README.md index 7da58bf7c..03940f627 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,37 @@ # Submit social housing lettings and sales data (CORE) -[![Production CI/CD Pipeline](https://github.com/communitiesuk/mhclg-data-collection-beta/actions/workflows/production_pipeline.yml/badge.svg)](https://github.com/communitiesuk/mhclg-data-collection-beta/actions/workflows/production_pipeline.yml) -[![Staging CI/CD Pipeline](https://github.com/communitiesuk/mhclg-data-collection-beta/actions/workflows/staging_pipeline.yml/badge.svg)](https://github.com/communitiesuk/mhclg-data-collection-beta/actions/workflows/staging_pipeline.yml) +[![Production CI/CD Pipeline](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/production_pipeline.yml/badge.svg)](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/production_pipeline.yml) +[![Staging CI/CD Pipeline](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/staging_pipeline.yml/badge.svg)](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/staging_pipeline.yml) -Codebase for the Ruby on Rails app that handles the submission of lettings and sales of social housing data in England. +Ruby on Rails app that handles the submission of lettings and sales of social housing data in England. Currently in private beta. -## API documentation - -API documentation can be found here: . This is driven by [OpenAPI docs](docs/api/DLUHC-CORE-Data.v1.json) - -## Required Setup - -Pre-requisites: - -- Ruby -- Rails -- Postgres - -### Quick start - -1. Copy the `.env.example` to `.env` and replace the database credentials with your local postgres user credentials. - -2. Install the dependencies:\ - `bundle install` - -3. Create the database:\ - `rake db:create` - -4. Run the database migrations:\ - `rake db:migrate` - -5. Seed the database if required:\ -`rake db:seed` - -6. Seed the database with rent ranges if required (~7000 rows per year):\ -`rake "data_import:rent_ranges[,]"` - - For 2021-2022 ranges run:\ - `rake "data_import:rent_ranges[2021,config/rent_range_data/2021.csv]"` - -7. Install the frontend depenencies:\ - `yarn install` - -8. Start the dev servers using foreman:\ - `./bin/dev` - - Or start them individually:\ - - a. Rails:\ - `bundle exec rails s` - - b. JS (for hot reloading):\ - `yarn build --mode=development --watch` - -If you're not modifying front end assets you can bundle them as a one off task:\ - `yarn build --mode=development` - -Development mode will target the latest versions of Chrome, Firefox and Safari for transpilation while production mode will target older browsers. - -The Rails server will start on . - -Running the test suite (front end assets need to be built or server needs to be running):\ - `bundle exec rspec` - -### Using Docker - -1. Build the image:\ -`docker-compose build` - -2. Run the database migrations:\ -`docker-compose run --rm app /bin/bash -c 'rake db:migrate'` - -3. Seed the database if required:\ -`docker-compose run --rm app /bin/bash -c 'rake db:seed'` - -4. To be able to debug with Pry run the app using:\ -`docker-compose run --service-ports app` - -If this is not needed you can run `docker-compose up` as normal - -The Rails server will start on . - -## Infrastructure - -This application is running on [GOV.UK PaaS](https://www.cloud.service.gov.uk/). To deploy you need to: - -1. Contact your organisation manager to get an account in `dluhc-core` organization and in the relevant spaces (staging/production). - -2. [Install the Cloud Foundry CLI](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html) - -3. Login:\ -`cf login -a api.london.cloud.service.gov.uk -u ` +## Domain documentation -4. Set your deployment target (staging/production):\ -`cf target -o dluhc-core -s ` +- [Service overview](docs/service_overview.md) +- [Organisations](docs/organisations.md) +- [Users and roles](docs/users.md) +- [Supported housing schemes](docs/schemes.md) -5. Deploy:\ -`cf push dluhc-core --strategy rolling`. This will use the [manifest file](staging_manifest.yml) +## Technical Documentation -Once the app is deployed: +- [Developer setup](docs/developer_setup.md) +- [Frontend](docs/frontend.md) +- [Testing strategy](docs/testing.md) +- [Form Builder](docs/form_builder.md) +- [Form Runner](docs/form_runner.md) +- [Infrastructure](docs/infrastructure.md) +- [Monitoring](docs/monitoring.md) +- [Exporting to CDS](docs/exports) +- [Architecture decision records](docs/adr) -1. Get a Rails console:\ -`cf ssh dluhc-core-staging -t -c "/tmp/lifecycle/launcher /home/vcap/app 'rails console' ''"` - -2. Check logs:\ -`cf logs dluhc-core-staging --recent` - -### Troubleshooting deployments - -A failed Github deployment action will occasionally leave a Cloud Foundry deployment in a broken state. As a result all subsequent Github deployment actions will also fail with the message `Cannot update this process while a deployment is in flight`. - -` -cf cancel-deployment dluhc-core -` - -You'd then need to check the logs and fix the issue that caused the initial deployment to fail. - -## CI/CD - -When a commit is made to `main` the following GitHub action jobs are triggered: - -1. **Test**: RSpec runs our test suite -2. **Deploy**: If the Test stage passes, this job will deploy the app to our GOV.UK PaaS account using the Cloud Foundry CLI - -When a pull request is opened to `main` only the Test stage runs. - -## Frontend - -### GOV.UK Design System components - -This service follows the guidance and recommendations from the [GOV.UK Design System](https://design-system.service.gov.uk). This is achieved using the following libraries: - -- **GOV.UK Frontend** – CSS and JavaScript for all Design System components\ - [Documentation](https://frontend.design-system.service.gov.uk) · - [GitHub](https://github.com/alphagov/govuk-frontend) - -- **GOV.UK Components** – Rails view components for non-form related Design System components\ - [Documentation](https://govuk-components.netlify.app) · - [Github](https://github.com/DFE-Digital/govuk-components) · - [RubyDoc](https://www.rubydoc.info/gems/govuk-components) - -- **GOV.UK FormBuilder** – Rails form builder for form related Design System components\ - [Documentation](https://govuk-form-builder.netlify.app) · - [GitHub](https://github.com/DFE-Digital/govuk-formbuilder) · - [RubyDoc](https://www.rubydoc.info/gems/govuk_design_system_formbuilder) - -### Service-specific components - -Service-specific components are built using the [ViewComponent](https://viewcomponent.org) framework, and can be found in `app/components`. - -Components use HTML class names that follow the BEM methodology. We use the `app-*` prefix to prevent collisions with components provided by the Design System (which uses `govuk-*`). See [Extending and modifying components in production](https://design-system.service.gov.uk/get-started/extending-and-modifying-components/). - -Stylesheets are written using [Sass](https://sass-lang.com) (and the SCSS syntax), using the mixins and helpers provided by [govuk-frontend](https://frontend.design-system.service.gov.uk/sass-api-reference/). - -Separate stylesheets are used for each component, with filenames that match the component’s namespace. - -Like the components provided by the Design System, components are progressively enhanced. We use [Stimulus](https://stimulus.hotwired.dev) to add any client-side JavaScript enhancements. - -## Single log submission form configuration - -The form for this is driven by a JSON file in `/config/forms/{start_year}_{end_year}.json` - -The JSON should follow the structure: - -```jsonc -{ - "form_type": "lettings" / "sales", - "start_year": Integer, // i.e. 2020 - "end_year": Integer, // i.e. 2021 - "sections": { - "[snake_case_section_name_string]": { - "label": String, - "description": String, - "subsections": { - "[snake_case_subsection_name_string]": { - "label": String, - "pages": { - "[snake_case_page_name_string]": { - "header": String, - "description": String, - "questions": { - "[snake_case_question_name_string]": { - "header": String, - "hint_text": String, - "check_answer_label": String, - "type": "text" / "numeric" / "radio" / "checkbox" / "date", - "min": Integer, // numeric only - "max": Integer, // numeric only - "step": Integer, // numeric only - "width": 2 / 3 / 4 / 5 / 10 / 20, // text and numeric only - "prefix": String, // numeric only - "suffix": String, //numeric only - "answer_options": { // checkbox and radio only - "0": String, - "1": String - }, - "conditional_for": { - "[snake_case_question_to_enable_1_name_string]": ["condition-that-enables"], - "[snake_case_question_to_enable_2_name_string]": ["condition-that-enables"] - }, - "inferred_answers": { "field_that_gets_inferred_from_current_field": { "is_that_field_inferred": true } }, - "inferred_check_answers_value": { - "condition": { "field_name_for_inferred_check_answers_condition": "field_value_for_inferred_check_answers_condition" }, - "value": "Inferred value that gets displayed if condition is met" - } - } - }, - "depends_on": [{ "question_key": "answer_value_required_for_this_page_to_be_shown" }] - } - } - } - } - } - } -} -``` - -Assumptions made by the format: - -- All forms have at least 1 section -- All sections have at least 1 subsection -- All subsections have at least 1 page -- All pages have at least 1 question -- The ActiveRecord case log model has a field for each question name (must match). In the case of checkbox questions it must have one field for every answer option (again names must match). -- Text not required by a page/question such as a header or hint text should be passed as an empty string -- For conditionally shown questions, conditions that have been implemented and can be used are: - - Radio question answer option selected matches one of conditional e.g. ["answer-options-1-string", "answer-option-3-string"] - - Numeric question value matches condition e.g. [">2"], ["<7"] or ["== 6"] -- When the top level question is a radio button and the conditional question is a numeric, text or date field then the conditional question is shown inline -- When the conditional question is a radio, checkbox or select field it should be displayed on it's own page and "depends_on" should be used rather than "conditional_for" - - Page routing: - - - Form navigation works by stepping sequentially through every page defined in the JSON form definition for the given subsection. For every page it checks if it has "depends_on" conditions. If it does, it evaluates them to determine whether that page should be show or not. - - - In this way we can build up whole branches by having: - - ```jsonc - "page_1": { "questions": { "question_1: "answer_options": ["A", "B"] } }, - "page_2": { "questions": { "question_2: "answer_options": ["C", "D"] }, "depends_on": [{ "question_1": "A" }] }, - "page_3": { "questions": { "question_3: "answer_options": ["E", "F"] }, "depends_on": [{ "question_1": "A" }] }, - "page_4": { "questions": { "question_4: "answer_options": ["G", "H"] }, "depends_on": [{ "question_1": "B" }] }, - ``` - -### JSON form validation against Schema +## API documentation -To validate the form JSON against the schema you can run:\ - `rake form_definition:validate["config/forms/2021_2022.json"]` +API documentation can be found here: . This is driven by [OpenAPI docs](docs/api/DLUHC-CORE-Data.v1.json) -n.b. You may have to escape square brackets in zsh\ - `rake form_definition:validate\["config/forms/2021_2022.json"\]` +## System architecture -This will validate the given form definition against the schema in `config/forms/schema/generic.json`. +![View of system architecture](docs/images/architecture.png) -You can also run:\ - `rake form_definition:validate_all` +## User interface -This will validate all forms in directories = `["config/forms", "spec/fixtures/forms"]` +![View of the logs list](docs/images/service.png) diff --git a/app/controllers/schemes_controller.rb b/app/controllers/schemes_controller.rb index e97a08e4b..4dd657534 100644 --- a/app/controllers/schemes_controller.rb +++ b/app/controllers/schemes_controller.rb @@ -7,6 +7,7 @@ class SchemesController < ApplicationController before_action :authenticate_scope! def index + flash[:notice] = "#{Scheme.find(params[:scheme_id].to_i).service_name} has been created." if params[:scheme_id] redirect_to schemes_organisation_path(current_user.organisation) unless current_user.support? all_schemes = Scheme.all @@ -25,20 +26,120 @@ class SchemesController < ApplicationController @total_count = @scheme.locations.size end + def new + @scheme = Scheme.new + end + + def create + @scheme = Scheme.new(scheme_params) + if @scheme.save + render "schemes/primary_client_group" + else + @scheme.errors.add(:organisation_id, message: @scheme.errors[:organisation]) + @scheme.errors.delete(:organisation) + render :new, status: :unprocessable_entity + end + end + + def update + check_answers = params[:scheme][:check_answers] + page = params[:scheme][:page] + + if @scheme.update(scheme_params) + if check_answers + if confirm_secondary_page? page + redirect_to scheme_secondary_client_group_path(@scheme, check_answers: "true") + else + @scheme.update!(secondary_client_group: nil) if @scheme.has_other_client_group == "No" + redirect_to scheme_check_answers_path(@scheme) + end + else + redirect_to next_page_path params[:scheme][:page] + end + else + render request.current_url, status: :unprocessable_entity + end + end + + def primary_client_group + render "schemes/primary_client_group" + end + + def confirm_secondary_client_group + render "schemes/confirm_secondary" + end + + def secondary_client_group + render "schemes/secondary_client_group" + end + + def support + render "schemes/support" + end + + def details + render "schemes/details" + end + + def check_answers + render "schemes/check_answers" + end + private + def confirm_secondary_page?(page) + page == "confirm-secondary" && @scheme.has_other_client_group == "Yes" + end + + def next_page_path(page) + case page + when "primary-client-group" + scheme_confirm_secondary_client_group_path(@scheme) + when "confirm-secondary" + @scheme.has_other_client_group == "Yes" ? scheme_secondary_client_group_path(@scheme) : scheme_support_path(@scheme) + when "secondary-client-group" + scheme_support_path(@scheme) + when "support" + scheme_check_answers_path(@scheme) + when "details" + scheme_primary_client_group_path(@scheme) + end + end + + def scheme_params + required_params = params.require(:scheme).permit(:service_name, + :sensitive, + :organisation_id, + :stock_owning_organisation_id, + :scheme_type, + :registered_under_care_act, + :total_units, + :id, + :has_other_client_group, + :primary_client_group, + :secondary_client_group, + :support_type, + :intended_stay) + + required_params[:sensitive] = required_params[:sensitive].to_i if required_params[:sensitive] + if current_user.data_coordinator? + required_params[:organisation_id] = current_user.organisation_id + end + required_params + end + def search_term params["search"] end def find_resource - @scheme = Scheme.find_by(id: params[:id]) + @scheme = Scheme.find_by(id: params[:id]) || Scheme.find_by(id: params[:scheme_id]) end def authenticate_scope! head :unauthorized and return unless current_user.data_coordinator? || current_user.support? - if %w[show locations].include?(action_name) && !((current_user.organisation == @scheme.organisation) || current_user.support?) + if %w[show locations primary_client_group confirm_secondary_client_group secondary_client_group support details check_answers].include?(action_name) && !((current_user.organisation == @scheme.organisation) || current_user.support?) render_not_found and return end end diff --git a/app/frontend/application.js b/app/frontend/application.js index 45d4b960d..ff4bae81a 100644 --- a/app/frontend/application.js +++ b/app/frontend/application.js @@ -12,12 +12,12 @@ import 'custom-event-polyfill' import 'intersection-observer' // -import GOVUKFrontend from 'govuk-frontend' -import GOVUKPrototypeComponents from 'govuk-prototype-components' +import { initAll as GOVUKFrontend } from 'govuk-frontend' +import { initAll as GOVUKPrototypeComponents } from 'govuk-prototype-components' import './styles/application.scss' import './controllers' require.context('govuk-frontend/govuk/assets') -GOVUKFrontend.initAll() -GOVUKPrototypeComponents.initAll() +GOVUKFrontend() +GOVUKPrototypeComponents() diff --git a/app/frontend/controllers/govukfrontend_controller.js b/app/frontend/controllers/govukfrontend_controller.js index 88edee293..0b435a6b3 100644 --- a/app/frontend/controllers/govukfrontend_controller.js +++ b/app/frontend/controllers/govukfrontend_controller.js @@ -1,10 +1,10 @@ -import GOVUKFrontend from 'govuk-frontend' -import GOVUKPrototypeComponents from 'govuk-prototype-components' +import { initAll as GOVUKFrontend } from 'govuk-frontend' +import { initAll as GOVUKPrototypeComponents } from 'govuk-prototype-components' import { Controller } from '@hotwired/stimulus' export default class extends Controller { connect () { - GOVUKFrontend.initAll() - GOVUKPrototypeComponents.initAll() + GOVUKFrontend() + GOVUKPrototypeComponents() } } diff --git a/app/frontend/styles/_pagination.scss b/app/frontend/styles/_pagination.scss deleted file mode 100644 index 8ac7bd546..000000000 --- a/app/frontend/styles/_pagination.scss +++ /dev/null @@ -1,134 +0,0 @@ -// https://github.com/alphagov/govuk-frontend/blob/add-pagination-prototype/src/govuk/components/pagination/_index.scss -.app-pagination { - border-top: 1px solid $govuk-border-colour; - margin-top: govuk-spacing(2); - padding-top: govuk-spacing(2); - text-align: center; - - @include govuk-media-query($from: tablet) { - // Hide whitespace between elements - font-size: 0; - - // Trick to remove the need for floats - text-align: justify; - - &:after { - content: " "; - display: inline-block; - width: 100%; - } - } -} - -.app-pagination__list { - margin: 0 govuk-spacing(-3); - padding: 0; - list-style: none; - - @include govuk-media-query($from: tablet) { - display: inline-block; - margin-bottom: 0; - } -} - -.app-pagination__results { - @include govuk-font(19); - margin-top: 0; - margin-bottom: govuk-spacing(4); - padding: govuk-spacing(1) 0; - - @include govuk-media-query($from: tablet) { - display: inline-block; - } -} - -.app-pagination__item { - @include govuk-font(19); - display: inline-block; - margin-bottom: govuk-spacing(4); - - // Hide items on small screens - @include govuk-media-query($until: tablet) { - display: none; - } -} - -// Only show previous, next, first, last and current items on mobile -.app-pagination__item--current, -.app-pagination__item--divider, -.app-pagination__item--prev, -.app-pagination__item--next, -.app-pagination__item:nth-child(2), -.app-pagination__item:nth-last-child(2) { - @include govuk-media-query($until: tablet) { - display: inline-block; - } -} - -.app-pagination__item--current, -.app-pagination__item--divider { - box-sizing: border-box; - font-weight: bold; - min-width: govuk-spacing(8); - min-height: govuk-spacing(4); - padding: govuk-spacing(2); - text-align: center; -} - -.app-pagination__item--divider { - margin: 0 govuk-spacing(-4); - padding-right: 0; - padding-left: 0; - color: $govuk-secondary-text-colour; - pointer-events: none; -} - -.app-pagination__link { - @include govuk-link-common; - @include govuk-link-style-no-underline; - box-sizing: border-box; - color: $govuk-link-colour; - display: block; - min-width: govuk-spacing(8); - min-height: govuk-spacing(4); - padding: govuk-spacing(2); - text-align: center; - - .app-pagination__link-label { - @include govuk-font($size: 16, $weight: "regular"); - display: block; - padding-left: 32px; - text-decoration: underline; - } - - &:hover .app-pagination__link-label { - @include govuk-link-hover-decoration; - } - - &:focus { - box-shadow: 0 0 $govuk-focus-colour, 0 4px $govuk-focus-text-colour; - text-decoration: underline; - } - - &:hover:not(:focus) { - background-color: govuk-colour("light-grey", $legacy: "grey-4"); - } -} - -.app-pagination__icon { - fill: currentcolor; -} - -.app-pagination__item--prev .app-pagination__link, -.app-pagination__item--next .app-pagination__link { - padding: govuk-spacing(2) govuk-spacing(3); - font-weight: bold; -} - -.app-pagination__item--prev .app-pagination__icon { - margin-right: govuk-spacing(2); -} - -.app-pagination__item--next .app-pagination__icon { - margin-left: govuk-spacing(2); -} diff --git a/app/frontend/styles/application.scss b/app/frontend/styles/application.scss index 487f2d893..dd4dc3b97 100644 --- a/app/frontend/styles/application.scss +++ b/app/frontend/styles/application.scss @@ -36,7 +36,6 @@ $govuk-breakpoints: ( @import "tag"; @import "task-list"; @import "template"; -@import "pagination"; @import "panel"; @import "primary-navigation"; @import "search"; diff --git a/app/helpers/details_table_helper.rb b/app/helpers/details_table_helper.rb index b436cab38..c39bf75d7 100644 --- a/app/helpers/details_table_helper.rb +++ b/app/helpers/details_table_helper.rb @@ -4,7 +4,7 @@ module DetailsTableHelper list = attribute[:value].map { |value| "
  • #{value}
  • " }.join simple_format(list, { class: "govuk-list govuk-list--bullet" }, wrapper_tag: "ul") else - value = attribute[:value].is_a?(Array) ? attribute[:value].first : attribute[:value] + value = attribute[:value].is_a?(Array) ? attribute[:value].first : attribute[:value] || "None" simple_format(value.to_s, { class: "govuk-body" }, wrapper_tag: "p") end diff --git a/app/helpers/tab_nav_helper.rb b/app/helpers/tab_nav_helper.rb index bb163ddd8..f316d5ab2 100644 --- a/app/helpers/tab_nav_helper.rb +++ b/app/helpers/tab_nav_helper.rb @@ -7,8 +7,8 @@ module TabNavHelper end def scheme_cell(scheme) - link_text = scheme.service_name.presence - [govuk_link_to(link_text, scheme), "Scheme #{scheme.primary_client_group_display}"].join("\n") + link_text = scheme.service_name + [govuk_link_to(link_text, scheme), "Scheme #{scheme.primary_client_group}"].join("\n") end def org_cell(user) diff --git a/app/models/organisation.rb b/app/models/organisation.rb index 92102aee9..65fce7e51 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -4,7 +4,8 @@ class Organisation < ApplicationRecord has_many :managed_case_logs, class_name: "CaseLog", foreign_key: "managing_organisation_id" has_many :data_protection_confirmations has_many :organisation_rent_periods - has_many :schemes + has_many :owned_schemes, class_name: "Scheme", foreign_key: "stock_owning_organisation_id" + has_many :managed_schemes, class_name: "Scheme" scope :search_by_name, ->(name) { where("name ILIKE ?", "%#{name}%") } scope :search_by, ->(param) { search_by_name(param) } diff --git a/app/models/scheme.rb b/app/models/scheme.rb index 625ea28f1..f93c08778 100644 --- a/app/models/scheme.rb +++ b/app/models/scheme.rb @@ -1,109 +1,145 @@ class Scheme < ApplicationRecord belongs_to :organisation + belongs_to :stock_owning_organisation, optional: true, class_name: "Organisation" has_many :locations has_many :case_logs - scope :search_by_code, ->(code) { where("code ILIKE ?", "%#{code}%") } + scope :filter_by_id, ->(id) { where(id: (id.start_with?("S") ? id[1..] : id)) } scope :search_by_service_name, ->(name) { where("service_name ILIKE ?", "%#{name}%") } - scope :search_by, ->(param) { search_by_code(param).or(search_by_service_name(param)) } + scope :search_by_postcode, ->(postcode) { joins(:locations).where("locations.postcode ILIKE ?", "%#{postcode.delete(' ')}%") } + scope :search_by, ->(param) { search_by_postcode(param).or(search_by_service_name(param)).or(filter_by_id(param)).distinct } - SCHEME_TYPE = { - 0 => "Missings", - 4 => "Foyer", - 5 => "Direct Access Hostel", - 6 => "Other Supported Housing", - 7 => "Housing for older people", + SENSITIVE = { + No: 0, + Yes: 1, }.freeze - PRIMARY_CLIENT_GROUP = { - "O" => "Homeless families with support needs", - "H" => "Offenders & people at risk of offending", - "M" => "Older people with support needs", - "L" => "People at risk of domestic violence", - "A" => "People with a physical or sensory disability", - "G" => "People with alcohol problems", - "F" => "People with drug problems", - "B" => "People with HIV or AIDS", - "D" => "People with learning disabilities", - "E" => "People with mental health problems", - "I" => "Refugees (permanent)", - "S" => "Rough sleepers", - "N" => "Single homeless people with support needs", - "R" => "Teenage parents", - "Q" => "Young people at risk", - "P" => "Young people leaving care", - "X" => "Missing", + enum sensitive: SENSITIVE, _suffix: true + + REGISTERED_UNDER_CARE_ACT = { + "No": 0, + "Yes – registered care home providing nursing care": 1, + "Yes – registered care home providing personal care": 2, + "Yes – part registered as a care home": 3, }.freeze + enum registered_under_care_act: REGISTERED_UNDER_CARE_ACT + + SCHEME_TYPE = { + "Missing": 0, + "Foyer": 4, + "Direct Access Hostel": 5, + "Other Supported Housing": 6, + "Housing for older people": 7, + }.freeze + + enum scheme_type: SCHEME_TYPE, _suffix: true + SUPPORT_TYPE = { - 0 => "Missing", - 1 => "Resettlement Support", - 2 => "Low levels of support", - 3 => "Medium levels of support", - 4 => "High levels of care and support", - 5 => "Nursing care services to a care home", - 6 => "Floating Support", + "Missing": 0, + "Resettlement support": 1, + "Low levels of support": 2, + "Medium levels of support": 3, + "High levels of care and support": 4, + "Nursing care services to a care home": 5, + "Floating Support": 6, }.freeze - INTENDED_STAY = { - "M" => "Medium stay", - "P" => "Permanent", - "S" => "Short Stay", - "V" => "Very short stay", - "X" => "Missing", + enum support_type: SUPPORT_TYPE, _suffix: true + + PRIMARY_CLIENT_GROUP = { + "Homeless families with support needs": "O", + "Offenders & people at risk of offending": "H", + "Older people with support needs": "M", + "People at risk of domestic violence": "L", + "People with a physical or sensory disability": "A", + "People with alcohol problems": "G", + "People with drug problems": "F", + "People with HIV or AIDS": "B", + "People with learning disabilities": "D", + "People with mental health problems": "E", + "Refugees (permanent)": "I", + "Rough sleepers": "S", + "Single homeless people with support needs": "N", + "Teenage parents": "R", + "Young people at risk": "Q", + "Young people leaving care": "P", + "Missing": "X", }.freeze - REGISTERED_UNDER_CARE_ACT = { - 0 => "No", - 1 => "Yes – part registered as a care home", + enum primary_client_group: PRIMARY_CLIENT_GROUP, _suffix: true + enum secondary_client_group: PRIMARY_CLIENT_GROUP, _suffix: true + + INTENDED_STAY = { + "Medium stay": "M", + "Permanent": "P", + "Short stay": "S", + "Very short stay": "V", + "Missing": "X", }.freeze - SENSITIVE = { - 0 => "No", - 1 => "Yes", + HAS_OTHER_CLIENT_GROUP = { + No: 0, + Yes: 1, }.freeze - def display_attributes + enum intended_stay: INTENDED_STAY, _suffix: true + enum has_other_client_group: HAS_OTHER_CLIENT_GROUP, _suffix: true + + def id_to_display + "S#{id}" + end + + def check_details_attributes [ - { name: "Service code", value: code }, + { name: "Service code", value: id_to_display }, { name: "Name", value: service_name }, - { name: "Confidential information", value: sensitive_display }, + { name: "Confidential information", value: sensitive }, + { name: "Housing stock owned by", value: stock_owning_organisation&.name }, { name: "Managed by", value: organisation.name }, - { name: "Type of scheme", value: scheme_type_display }, - { name: "Registered under Care Standards Act 2000", value: registered_under_care_act_display }, - { name: "Total number of units", value: total_units }, - { name: "Primary client group", value: primary_client_group_display }, - { name: "Secondary client group", value: secondary_client_group_display }, - { name: "Level of support given", value: support_type_display }, - { name: "Intended length of stay", value: intended_stay_display }, + { name: "Type of scheme", value: scheme_type }, + { name: "Registered under Care Standards Act 2000", value: registered_under_care_act }, ] end - def scheme_type_display - SCHEME_TYPE[scheme_type] - end - - def sensitive_display - SENSITIVE[sensitive] - end - - def registered_under_care_act_display - REGISTERED_UNDER_CARE_ACT[registered_under_care_act] + def check_primary_client_attributes + [ + { name: "Primary client group", value: primary_client_group }, + ] end - def primary_client_group_display - PRIMARY_CLIENT_GROUP[primary_client_group] + def check_secondary_client_confirmation_attributes + [ + { name: "Has another client group", value: has_other_client_group }, + ] end - def secondary_client_group_display - PRIMARY_CLIENT_GROUP[secondary_client_group] + def check_secondary_client_attributes + [ + { name: "Secondary client group", value: secondary_client_group }, + ] end - def support_type_display - SUPPORT_TYPE[support_type] + def check_support_attributes + [ + { name: "Level of support given", value: support_type }, + { name: "Intended length of stay", value: intended_stay }, + ] end - def intended_stay_display - INTENDED_STAY[intended_stay] + def display_attributes + [ + { name: "Service code", value: id_to_display }, + { name: "Name", value: service_name }, + { name: "Confidential information", value: sensitive }, + { name: "Housing stock owned by", value: stock_owning_organisation&.name }, + { name: "Managed by", value: organisation.name }, + { name: "Type of scheme", value: scheme_type }, + { name: "Registered under Care Standards Act 2000", value: registered_under_care_act }, + { name: "Primary client group", value: primary_client_group }, + { name: "Secondary client group", value: secondary_client_group }, + { name: "Level of support given", value: support_type }, + { name: "Intended length of stay", value: intended_stay }, + ] end end diff --git a/app/models/validations/financial_validations.rb b/app/models/validations/financial_validations.rb index c286e23ca..a5ae18c18 100644 --- a/app/models/validations/financial_validations.rb +++ b/app/models/validations/financial_validations.rb @@ -107,11 +107,13 @@ module Validations::FinancialValidations period = record.form.get_question("period", record).label_from_value(record.period).downcase if record.chcharge.blank? record.errors.add :chcharge, I18n.t("validations.financial.carehome.not_provided", period:) - elsif !weekly_value_in_range(record, "chcharge", 0, 1000) + elsif !weekly_value_in_range(record, "chcharge", 10, 1000) max_chcharge = record.weekly_to_value_per_period(1000) + min_chcharge = record.weekly_to_value_per_period(10) max_chcharge = [record.form.get_question("chcharge", record).prefix, max_chcharge].join("") if record.form.get_question("chcharge", record).present? - record.errors.add :period, I18n.t("validations.financial.carehome.over_1000", period:, min_chcharge: "£0", max_chcharge:) - record.errors.add :chcharge, I18n.t("validations.financial.carehome.over_1000", period:, min_chcharge: "£0", max_chcharge:) + min_chcharge = [record.form.get_question("chcharge", record).prefix, min_chcharge].join("") if record.form.get_question("chcharge", record).present? + record.errors.add :period, I18n.t("validations.financial.carehome.out_of_range", period:, min_chcharge:, max_chcharge:) + record.errors.add :chcharge, I18n.t("validations.financial.carehome.out_of_range", period:, min_chcharge:, max_chcharge:) end end end diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 972090066..49a6e689c 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -22,7 +22,7 @@

    Email

    • - <%= govuk_mail_to("submitcoredata@levellingup.gov.uk", class: "govuk-footer__link") %> + <%= govuk_mail_to("dluhc.digital-services@levellingup.gov.uk", subject: "CORE:", class: "govuk-footer__link") %>
    • We aim to respond within 2 working days
    diff --git a/app/views/organisations/schemes.html.erb b/app/views/organisations/schemes.html.erb index 364a26c0b..4c59601e6 100644 --- a/app/views/organisations/schemes.html.erb +++ b/app/views/organisations/schemes.html.erb @@ -11,9 +11,11 @@ ) %> <% end %> +<%= govuk_button_link_to "Create a new supported housing scheme", new_scheme_path, html: { method: :post } %> +

    Supported housing schemes

    -<%= render SearchComponent.new(current_user:, search_label: "Search by scheme name or code", value: @searched) %> +<%= render SearchComponent.new(current_user:, search_label: "Search by scheme name, code or postcode", value: @searched) %>
    diff --git a/app/views/pagy/_nav.html.erb b/app/views/pagy/_nav.html.erb index f33e47ea0..af32163ed 100644 --- a/app/views/pagy/_nav.html.erb +++ b/app/views/pagy/_nav.html.erb @@ -1,41 +1,6 @@ -<% link = pagy_link_proc(pagy) -%> +<%= govuk_pagination(pagy:) %> <% if pagy.pages > 1 %> - +

    + Showing <%= pagy.from %> to <%= pagy.to %> of <%= pagy.count %> <%= item_name %> +

    <% end %> diff --git a/app/views/schemes/_scheme_list.html.erb b/app/views/schemes/_scheme_list.html.erb index a5b2ab06b..8b5c6c85c 100644 --- a/app/views/schemes/_scheme_list.html.erb +++ b/app/views/schemes/_scheme_list.html.erb @@ -22,7 +22,7 @@ <% @schemes.each do |scheme| %> <%= table.body do |body| %> <%= body.row do |row| %> - <% row.cell(text: scheme.code) %> + <% row.cell(text: scheme.id_to_display) %> <% row.cell(text: simple_format(scheme_cell(scheme), { class: "govuk-!-font-weight-bold" }, wrapper_tag: "div")) %> <% row.cell(text: scheme.organisation.name) %> <% row.cell(text: scheme.created_at.to_formatted_s(:govuk_date)) %> diff --git a/app/views/schemes/check_answers.html.erb b/app/views/schemes/check_answers.html.erb new file mode 100644 index 000000000..78fff8674 --- /dev/null +++ b/app/views/schemes/check_answers.html.erb @@ -0,0 +1,65 @@ +<% content_for :title, "Check your answers before creating this scheme" %> + +<%= render partial: "organisations/headings", locals: { main: "Check your changes before updating this scheme", sub: @scheme.service_name } %> + +<%= govuk_tabs(title: "Check your answers before creating this scheme") do |component| %> + <% component.tab(label: "Scheme") do %> + <%= govuk_summary_list do |summary_list| %> + <% @scheme.check_details_attributes.each do |attr| %> + <% next if current_user.data_coordinator? && attr[:name] == ("Managed by") %> + <%= summary_list.row do |row| %> + <% row.key { attr[:name].to_s } %> + <% row.value { details_html(attr) } %> + <% row.action( + text: "Change", + href: scheme_details_path(scheme_id: @scheme.id, check_answers: true), + ) %> + <% end %> + <% end %> + <% @scheme.check_primary_client_attributes.each do |attr| %> + <%= summary_list.row do |row| %> + <% row.key { attr[:name].to_s } %> + <% row.value { details_html(attr) } %> + <% row.action( + text: "Change", + href: scheme_primary_client_group_path(scheme_id: @scheme.id, check_answers: true), + ) %> + <% end %> + <% end %> + <% @scheme.check_secondary_client_confirmation_attributes.each do |attr| %> + <%= summary_list.row do |row| %> + <% row.key { attr[:name].to_s } %> + <% row.value { details_html(attr) } %> + <% row.action( + text: "Change", + href: scheme_confirm_secondary_client_group_path(scheme_id: @scheme.id, check_answers: true), + ) %> + <% end %> + <% end %> + <% if @scheme.has_other_client_group == "Yes" %> + <% @scheme.check_secondary_client_attributes.each do |attr| %> + <%= summary_list.row do |row| %> + <% row.key { attr[:name].to_s } %> + <% row.value { details_html(attr) } %> + <% row.action( + text: "Change", + href: scheme_secondary_client_group_path(scheme_id: @scheme.id, check_answers: true), + ) %> + <% end %> + <% end %> + <% end %> + <% @scheme.check_support_attributes.each do |attr| %> + <%= summary_list.row do |row| %> + <% row.key { attr[:name].to_s } %> + <% row.value { details_html(attr) } %> + <% row.action( + text: "Change", + href: scheme_support_path(scheme_id: @scheme.id, check_answers: true), + ) %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> + +<%= govuk_button_link_to "Create scheme", schemes_path(scheme_id: @scheme.id), html: { method: :get } %> diff --git a/app/views/schemes/confirm_secondary.html.erb b/app/views/schemes/confirm_secondary.html.erb new file mode 100644 index 000000000..b511c5a01 --- /dev/null +++ b/app/views/schemes/confirm_secondary.html.erb @@ -0,0 +1,32 @@ +<% content_for :title, "Does this scheme provide for another client group?" %> + +<% content_for :before_content do %> + <%= govuk_back_link( + text: "Back", + href: request.query_parameters["check_answers"] ? "/schemes/#{@scheme.id}/check-answers" : "/schemes/#{@scheme.id}/primary-client-group", + ) %> +<% end %> + +<%= render partial: "organisations/headings", locals: { main: "Does this scheme provide for another client group?", sub: @scheme.service_name } %> + +<%= form_for(@scheme, method: :patch) do |f| %> +
    +
    + + <% selection = [OpenStruct.new(id: "Yes", name: "Yes"), OpenStruct.new(id: "No", name: "No")] %> + + <%= f.govuk_collection_radio_buttons :has_other_client_group, + selection, + :id, + :name, + legend: nil %> + + <%= f.hidden_field :page, value: "confirm-secondary" %> + <% if request.query_parameters["check_answers"] == "true" %> + <%= f.hidden_field :check_answers, value: "true" %> + <% end %> + + <%= f.govuk_submit "Save and continue" %> +
    +
    +<% end %> diff --git a/app/views/schemes/details.html.erb b/app/views/schemes/details.html.erb new file mode 100644 index 000000000..73ef511aa --- /dev/null +++ b/app/views/schemes/details.html.erb @@ -0,0 +1,76 @@ +<% content_for :title, "Create a new supported housing scheme" %> + + <% content_for :before_content do %> + <%= govuk_back_link( + text: "Back", + href: :back, + ) %> + <% end %> + + <%= render partial: "organisations/headings", locals: { main: "Create a new supported housing scheme", sub: nil } %> + + <%= form_for(@scheme, method: :patch) do |f| %> +
    +
    + <%= f.govuk_error_summary %> + + <%= f.govuk_text_field :service_name, + label: { text: "Scheme name", size: "m" }, + hint: { text: "This is how you’ll refer to this supported housing scheme within your organisation. For example, the name could relate to the address or location. You’ll be able to see the client group when selecting it." } %> + + <%= f.govuk_check_box :sensitive, + 1, + 0, + checked: @scheme.sensitive?, + multiple: false, + label: { text: "This scheme contains confidential information" } %> + + <% null_option = [OpenStruct.new(id: "", name: "Select an option")] %> + <% organisations = Organisation.all.map { |org| OpenStruct.new(id: org.id, name: org.name) } %> + <% stock_org_answer_options = null_option + organisations %> + + <%= f.govuk_collection_select :stock_owning_organisation_id, + stock_org_answer_options, + :id, + :name, + label: { text: "Which organisation owns the housing stock for this scheme?", size: "m" }, + "data-controller": %w[accessible-autocomplete conditional-filter] %> + + <% if current_user.support? %> + <%= f.govuk_collection_select :organisation_id, + organisations, + :id, + :name, + label: { text: "Which organisation manages this scheme?", size: "m" }, + options: { required: true }, + "data-controller": %w[accessible-autocomplete conditional-filter] %> + <% end %> + + <% if current_user.data_coordinator? %> + <%= f.hidden_field :organisation_id, value: current_user.organisation.id %> + <% end %> + + <% scheme_types_selection = Scheme.scheme_types.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + + <%= f.govuk_collection_radio_buttons :scheme_type, + scheme_types_selection, + :id, + :name, + legend: { text: "What is this type of scheme?", size: "m" } %> + + <% care_acts_selection = Scheme.registered_under_care_acts.keys.reverse.map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + + <%= f.govuk_collection_radio_buttons :registered_under_care_act, + care_acts_selection, + :id, + :name, + legend: { text: "Is this scheme registered under the Care Standards Act 2000?", size: "m" } %> + + <%= f.hidden_field :page, value: "details" %> + <% if request.query_parameters["check_answers"] %> + <%= f.hidden_field :check_answers, value: "true" %> + <% end %> + <%= f.govuk_submit "Save and continue" %> +
    +
    + <% end %> diff --git a/app/views/schemes/index.html.erb b/app/views/schemes/index.html.erb index 50affc921..190d29d3d 100644 --- a/app/views/schemes/index.html.erb +++ b/app/views/schemes/index.html.erb @@ -7,7 +7,9 @@

    Supported housing schemes

    -<%= render SearchComponent.new(current_user:, search_label: "Search by scheme name or code", value: @searched) %> +<%= govuk_button_link_to "Create a new supported housing scheme", new_scheme_path, html: { method: :post } %> + +<%= render SearchComponent.new(current_user:, search_label: "Search by scheme name, code or postcode", value: @searched) %>
    diff --git a/app/views/schemes/new.html.erb b/app/views/schemes/new.html.erb new file mode 100644 index 000000000..194f61c3e --- /dev/null +++ b/app/views/schemes/new.html.erb @@ -0,0 +1,72 @@ +<% content_for :title, "Create a new supported housing scheme" %> + +<% content_for :before_content do %> + <%= govuk_back_link( + text: "Back", + href: "javascript:history.go(-1);", + ) %> +<% end %> + +<%= form_for(@scheme, as: :scheme, method: :post) do |f| %> +
    +
    + <%= f.govuk_error_summary %> + +

    + <%= content_for(:title) %> +

    + + <%= f.govuk_text_field :service_name, + label: { text: "Scheme name", size: "m" }, + hint: { text: "This is how you refer to this supported housing scheme within your organisation. For example, the name could relate to the address or location. You’ll be able to see the client group when selecting it." } %> + + <%= f.govuk_check_box :sensitive, + "Yes", + checked: @scheme.sensitive?, + multiple: false, + label: { text: "This scheme contains confidential information" } %> + + <% null_option = [OpenStruct.new(id: "", name: "Select an option")] %> + <% organisations = Organisation.all.map { |org| OpenStruct.new(id: org.id, name: org.name) } %> + <% answer_options = null_option + organisations %> + + <%= f.govuk_collection_select :stock_owning_organisation_id, + answer_options, + :id, + :name, + label: { text: "Which organisation owns the housing stock for this scheme?", size: "m" }, + "data-controller": %w[accessible-autocomplete conditional-filter] %> + + <% if current_user.support? %> + <%= f.govuk_collection_select :organisation_id, + answer_options, + :id, + :name, + label: { text: "Which organisation manages this scheme?", size: "m" }, + "data-controller": %w[accessible-autocomplete conditional-filter] %> + <% end %> + + <% if current_user.data_coordinator? %> + <%= f.hidden_field :organisation_id, value: current_user.organisation.id %> + <% end %> + + <% scheme_types_selection = Scheme.scheme_types.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + + <%= f.govuk_collection_radio_buttons :scheme_type, + scheme_types_selection, + :id, + :name, + legend: { text: "What is this type of scheme?", size: "m" } %> + + <% care_acts_selection = Scheme.registered_under_care_acts.keys.reverse.map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + + <%= f.govuk_collection_radio_buttons :registered_under_care_act, + care_acts_selection, + :id, + :name, + legend: { text: "Is this scheme registered under the Care Standards Act 2000?", size: "m" } %> + + <%= f.govuk_submit "Save and continue" %> +
    +
    +<% end %> diff --git a/app/views/schemes/primary_client_group.html.erb b/app/views/schemes/primary_client_group.html.erb new file mode 100644 index 000000000..af3ea80e5 --- /dev/null +++ b/app/views/schemes/primary_client_group.html.erb @@ -0,0 +1,35 @@ +<% content_for :title, "What client group is this scheme intended for?" %> + +<% content_for :before_content do %> + <%= govuk_back_link( + text: "Back", + href: request.query_parameters["check_answers"] ? "/schemes/#{@scheme.id}/check-answers" : "/schemes/#{@scheme.id}/details", + ) %> +<% end %> + +<%= render partial: "organisations/headings", locals: { main: "What client group is this scheme intended for?", sub: @scheme.service_name } %> + +<%= form_for(@scheme, method: :patch) do |f| %> +
    +
    + <%= f.govuk_error_summary %> + + + + + <% primary_client_group_selection = Scheme.primary_client_groups.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + <%= f.govuk_collection_radio_buttons :primary_client_group, + primary_client_group_selection, + :id, + :name, + legend: nil %> + + <%= f.hidden_field :page, value: "primary-client-group" %> + <% if request.query_parameters["check_answers"] == "true" %> + <%= f.hidden_field :check_answers, value: "true" %> + <% end %> + + <%= f.govuk_submit "Save and continue" %> +
    +
    +<% end %> diff --git a/app/views/schemes/secondary_client_group.html.erb b/app/views/schemes/secondary_client_group.html.erb new file mode 100644 index 000000000..f785fb887 --- /dev/null +++ b/app/views/schemes/secondary_client_group.html.erb @@ -0,0 +1,35 @@ +<% content_for :title, "What is the other client group?" %> + +<% content_for :before_content do %> + <%= govuk_back_link( + text: "Back", + href: request.query_parameters["check_answers"] ? "/schemes/#{@scheme.id}/check-answers" : "/schemes/#{@scheme.id}/confirm-secondary-client-group", + ) %> +<% end %> + +<%= render partial: "organisations/headings", locals: { main: "What is the other client group?", sub: @scheme.service_name } %> + +<%= form_for(@scheme, method: :patch) do |f| %> +
    +
    + <%= f.govuk_error_summary %> + + + + + <% secondary_client_group_selection = Scheme.secondary_client_groups.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + <%= f.govuk_collection_radio_buttons :secondary_client_group, + secondary_client_group_selection, + :id, + :name, + legend: nil %> + + <%= f.hidden_field :page, value: "secondary-client-group" %> + <% if request.query_parameters["check_answers"] == "true" %> + <%= f.hidden_field :check_answers, value: "true" %> + <% end %> + + <%= f.govuk_submit "Save and continue" %> +
    +
    +<% end %> diff --git a/app/views/schemes/support.html.erb b/app/views/schemes/support.html.erb new file mode 100644 index 000000000..317b1d4f1 --- /dev/null +++ b/app/views/schemes/support.html.erb @@ -0,0 +1,39 @@ +<% content_for :title, "What support does this scheme provide?" %> + +<% content_for :before_content do %> + <%= govuk_back_link( + text: "Back", + href: request.query_parameters["check_answers"] ? "/schemes/#{@scheme.id}/check-answers" : "/schemes/#{@scheme.id}/secondary-client-group", + ) %> +<% end %> + +<%= render partial: "organisations/headings", locals: { main: "What support does this scheme provide?", sub: @scheme.service_name } %> + +<%= form_for(@scheme, method: :patch) do |f| %> +
    +
    + <%= f.govuk_error_summary %> + + + + + <% support_type_selection = Scheme.support_types.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + <%= f.govuk_collection_radio_buttons :support_type, + support_type_selection, + :id, + :name, + legend: { text: "Level of support given", size: "m" } %> + + <% intended_stay_selection = Scheme.intended_stays.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize) } %> + <%= f.govuk_collection_radio_buttons :intended_stay, + intended_stay_selection, + :id, + :name, + legend: { text: "Intended length of stay", size: "m" } %> + + <%= f.hidden_field :page, value: "support" %> + + <%= f.govuk_submit "Save and continue" %> +
    +
    +<% end %> diff --git a/app/views/start/index.html.erb b/app/views/start/index.html.erb index 72a6619e4..7a6d0897f 100644 --- a/app/views/start/index.html.erb +++ b/app/views/start/index.html.erb @@ -19,7 +19,7 @@

    Before you start

    Use your account details to sign in.

    If you need to set up a new account, speak to your organisation’s CORE data coordinator. If you don’t know who that is, <%= govuk_link_to("contact the helpdesk", "https://digital.dclg.gov.uk/jira/servicedesk/customer/portal/4/group/21") %>.

    -

    You can <%= govuk_link_to("request an account", "https://digital.dclg.gov.uk/jira/servicedesk/customer/portal/4/group/21") %> if your organisation doesn’t have one.

    +

    You can <%= govuk_mail_to("dluhc.digital-services@levellingup.gov.uk", "request an account", subject: "CORE: Request a new account") %> if your organisation doesn’t have one.

    diff --git a/config/locales/en.yml b/config/locales/en.yml index 0b76d9abf..deec6bc74 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -38,6 +38,14 @@ en: create_password: "Create a password to finish setting up your account" reset_password: "Reset your password" + activerecord: + errors: + models: + scheme: + attributes: + organisation: + required: "Enter the existing organisation’s name" + validations: organisation: name_missing: "Enter the organisation’s name" @@ -149,7 +157,7 @@ en: rent_period: invalid_for_org: "%{org_name} does not charge rent %{rent_period}" carehome: - over_1000: "Household rent and other charges must be between %{min_chcharge} and %{max_chcharge} if paying %{period}" + out_of_range: "Household rent and other charges must be between %{min_chcharge} and %{max_chcharge} if paying %{period}" not_provided: "Enter how much rent and other charges the household pays %{period}" household: reasonpref: diff --git a/config/routes.rb b/config/routes.rb index 01efc2623..0cbaa82c2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,7 +35,14 @@ Rails.application.routes.draw do get "edit/password", to: "users#edit_password" end - resources :schemes, only: %i[index show] do + resources :schemes do + get "primary-client-group", to: "schemes#primary_client_group" + get "confirm-secondary-client-group", to: "schemes#confirm_secondary_client_group" + get "secondary-client-group", to: "schemes#secondary_client_group" + get "support", to: "schemes#support" + get "details", to: "schemes#details" + get "check-answers", to: "schemes#check_answers" + member do get "locations", to: "schemes#locations" end diff --git a/db/migrate/20220616130451_add_reference_to_case_log.rb b/db/migrate/20220616130451_add_reference_to_case_log.rb index 52f7bfecb..6fca582a9 100644 --- a/db/migrate/20220616130451_add_reference_to_case_log.rb +++ b/db/migrate/20220616130451_add_reference_to_case_log.rb @@ -1,5 +1,5 @@ class AddReferenceToCaseLog < ActiveRecord::Migration[7.0] def change - add_reference :case_logs, :scheme, foreign_key: true + add_reference :case_logs, :scheme, foreign_key: true, null: true end end diff --git a/db/migrate/20220623132228_add_has_other_client_group_field.rb b/db/migrate/20220623132228_add_has_other_client_group_field.rb new file mode 100644 index 000000000..af04a6af8 --- /dev/null +++ b/db/migrate/20220623132228_add_has_other_client_group_field.rb @@ -0,0 +1,7 @@ +class AddHasOtherClientGroupField < ActiveRecord::Migration[7.0] + def change + change_table :schemes, bulk: true do |t| + t.column :has_other_client_group, :integer + end + end +end diff --git a/db/migrate/20220629100324_remove_code_from_schemes.rb b/db/migrate/20220629100324_remove_code_from_schemes.rb new file mode 100644 index 000000000..be918f62f --- /dev/null +++ b/db/migrate/20220629100324_remove_code_from_schemes.rb @@ -0,0 +1,5 @@ +class RemoveCodeFromSchemes < ActiveRecord::Migration[7.0] + def change + remove_column :schemes, :code, :string + end +end diff --git a/db/migrate/20220629105452_add_stock_owning_organisation_to_schemes.rb b/db/migrate/20220629105452_add_stock_owning_organisation_to_schemes.rb new file mode 100644 index 000000000..1623eb654 --- /dev/null +++ b/db/migrate/20220629105452_add_stock_owning_organisation_to_schemes.rb @@ -0,0 +1,5 @@ +class AddStockOwningOrganisationToSchemes < ActiveRecord::Migration[7.0] + def change + add_reference :schemes, :stock_owning_organisation, foreign_key: { to_table: :organisations } + end +end diff --git a/db/schema.rb b/db/schema.rb index a566333dc..ea1e2e2e3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -299,7 +299,6 @@ ActiveRecord::Schema[7.0].define(version: 2022_06_30_154441) do end create_table "schemes", force: :cascade do |t| - t.string "code" t.string "service_name" t.bigint "organisation_id", null: false t.datetime "created_at", null: false @@ -313,7 +312,10 @@ ActiveRecord::Schema[7.0].define(version: 2022_06_30_154441) do t.integer "support_type" t.string "intended_stay" t.datetime "end_date" + t.integer "has_other_client_group" + t.bigint "stock_owning_organisation_id" t.index ["organisation_id"], name: "index_schemes_on_organisation_id" + t.index ["stock_owning_organisation_id"], name: "index_schemes_on_stock_owning_organisation_id" end create_table "users", force: :cascade do |t| @@ -373,4 +375,5 @@ ActiveRecord::Schema[7.0].define(version: 2022_06_30_154441) do add_foreign_key "case_logs", "schemes" add_foreign_key "locations", "schemes" add_foreign_key "schemes", "organisations" + add_foreign_key "schemes", "organisations", column: "stock_owning_organisation_id" end diff --git a/db/seeds.rb b/db/seeds.rb index fdf9d6172..654f1c777 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -71,7 +71,6 @@ unless Rails.env.test? if Rails.env.development? && Scheme.count.zero? scheme1 = Scheme.create!( - code: "S878", service_name: "Beulahside Care", sensitive: 0, registered_under_care_act: 0, @@ -86,7 +85,6 @@ unless Rails.env.test? ) scheme2 = Scheme.create!( - code: "S312", service_name: "Abdullahview Point", sensitive: 0, registered_under_care_act: 1, @@ -101,7 +99,6 @@ unless Rails.env.test? ) Scheme.create!( - code: "7XYZ", service_name: "Caspermouth Center", sensitive: 1, registered_under_care_act: 1, diff --git a/docs/developer_setup.md b/docs/developer_setup.md new file mode 100644 index 000000000..acc1542a7 --- /dev/null +++ b/docs/developer_setup.md @@ -0,0 +1,195 @@ +# Developing locally on host machine + +The most common way to run a development version of the application is run with local dependencies. + +Dependencies: + +- [Ruby](https://www.ruby-lang.org/en/) +- [Rails](https://rubyonrails.org/) +- [PostgreSQL](https://www.postgresql.org/) +- [NodeJS](https://nodejs.org/en/) +- [Gecko driver](https://github.com/mozilla/geckodriver/releases) [for running Selenium tests] + +We recommend using [RBenv](https://github.com/rbenv/rbenv) to manage Ruby versions. + +1. Install PostgreSQL + + macOS: + + ```bash + brew install postgresql + brew services start postgresql + ``` + + Linux (Debian): + + ```bash + sudo apt install -y postgresql postgresql-contrib libpq-dev + sudo systemctl start postgresql + ``` + +2. Create a Postgres user + + ```bash + sudo su - postgres -c "createuser -s -P" + ``` + +3. Install RBenv & Ruby-build + + macOS: + + ```bash + brew install rbenv + rbenv init + mkdir -p ~/.rbenv/plugins + git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build + ``` + + Linux (Debian): + + ```bash + sudo apt install -y rbenv git + rbenv init + echo 'eval "$(rbenv init -)"' >> ~/.bashrc + mkdir -p ~/.rbenv/plugins + git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build + ``` + +4. Install Ruby and Bundler + + ```bash + rbenv install 3.1.2 + rbenv global 3.1.2 + source ~/.bashrc + gem install bundler + ``` + +5. Install JavaScript dependencies + + macOS: + + ```bash + brew install node + brew install yarn + ``` + + Linux (Debian): + + ```bash + curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - + sudo apt -y install nodejs + mkdir -p ~/.npm-packages + npm config set prefix ~/.npm-packages + echo 'NPM_PACKAGES="~/.npm-packages"' >> ~/.bashrc + echo 'export PATH="$PATH:$NPM_PACKAGES/bin"' >> ~/.bashrc + source ~/.bashrc + npm install --location=global yarn + ``` + +6. Clone the repo + + ```bash + git clone https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data.git + ``` + +## Application setup + +1. Copy the `.env.example` to `.env` and replace the database credentials with your local postgres user credentials. + +2. Install the dependencies: + + ```bash + bundle install && yarn install + ``` + +3. Create the database & run migrations: + + ```bash + bundle exec rake db:create db:migrate + ``` + +4. Seed the database if required: + + ```bash + bundle exec rake db:seed + ``` + +5. Start the dev servers + + a. Using Foreman: + + ```bash + ./bin/dev + ``` + + b. Individually: + + Rails: + + ```bash + bundle exec rails s + ``` + + JavaScript (for hot reloading): + + ```bash + yarn build --mode=development --watch + ``` + + If you’re not modifying front end assets you can bundle them as a one off task: + + ```bash + yarn build --mode=development + ``` + + Development mode will target the latest versions of Chrome, Firefox and Safari for transpilation while production mode will target older browsers. + + The Rails server will start on . + +6. Install Gecko Driver + + Linux (Debian): + + ```bash + wget https://github.com/mozilla/geckodriver/releases/download/v0.31.0/geckodriver-v0.31.0-linux64.tar.gz + tar -xvzf geckodriver-v0.31.0-linux64.tar.gz + rm geckodriver-v0.31.0-linux64.tar.gz + chmod +x geckodriver + sudo mv geckodriver /usr/local/bin/ + ``` + + Running the test suite (front end assets need to be built or server needs to be running): + + ```bash + bundle exec rspec + ``` + +## Using Docker + +1. Build the image: + + ```bash + docker-compose build + ``` + +2. Run the database migrations: + + ```bash + docker-compose run --rm app /bin/bash -c 'rake db:migrate' + ``` + +3. Seed the database if required: + + ```bash + docker-compose run --rm app /bin/bash -c 'rake db:seed' + ``` + +4. To be able to debug with Pry run the app using: + + ```bash + docker-compose run --service-ports app + ``` + +If this is not needed you can run `docker-compose up` as normal + +The Rails server will start on . diff --git a/docs/exports.md b/docs/exports.md new file mode 100644 index 000000000..2b7e1ba25 --- /dev/null +++ b/docs/exports.md @@ -0,0 +1,17 @@ +# Exporting to CDS + +All data collected by the application needs to be exported to the Consolidated Data Store (CDS) which is a data warehouse based on MS SQL running in the DAP (Data Analytics Platform). + +This is done via XML exports saved in an S3 bucket located in the DAP VPC using dedicated credentials shared out of band. The data mapping for this export can be found in `app/services/exports/case_log_export_service.rb` + +Initially the application database field names and field types were chosen to match the existing CDS data as closely as possible to minimise the amount of transformation needed. This has led to a less than optimal data model though and increasingly we should look to transform at the mapping layer where beneficial for our application. + +The export service is triggered nightly using [Gov PaaS tasks](https://docs.cloudfoundry.org/devguide/using-tasks.html). These tasks are triggered from a Github action, as Gov PaaS does not currently support the Cloud Foundry Task Scheduler. + +The S3 bucket is located in the DAP VPC rather than the application VPC as DAP runs in an AWS account directly so access to the S3 bucket can be restricted to only the IPs used by the application. This is not possible the other way around as [Gov PaaS does not support restricting S3 access by IP](https://github.com/alphagov/paas-roadmap/issues/107). + +## Other options previously considered + +- CDC replication using a managed service such as [AWS DMS](https://aws.amazon.com/dms/) + - Would require VPC peering which [Gov PaaS does not currently support](https://github.com/alphagov/paas-roadmap/issues/105) + - Would require CDS to make changes to their ingestion model diff --git a/docs/form_builder.md b/docs/form_builder.md new file mode 100644 index 000000000..556b086d0 --- /dev/null +++ b/docs/form_builder.md @@ -0,0 +1,190 @@ +# Form Builder + +## Background + +Social housing lettings and sales data is collected in annual collection windows that run from 1st April to 1st April. + +During this window the form and questions generally stay constant. The form will generally change by small amounts between each collection window. Typical changes are adding new questions, adding or removing answer options from questions or tweaking question wording for clarity. + +A paper form is produced for guidance and to help data providers collect the data offline, and a bulk upload template is circulated which need to match the online form. + +Data is accepted for a collection window for up to 3 months after it’s finished to allow for late data submission. This means that between April and July two version of the form run simultaneously. + +Other considerations that went into our design are being able to re-use as much of this solution for other data collections, and possibly having the ability to generate the form and/or form changes from a user interface. + +We haven’t used micro-services, preferring to deploy a single application but we have modelled the form itself as configuration in the form of a JSON structure that acts as a sort of DSL/form builder for the form. + +The idea is to decouple the code that creates the required routes, controller methods, views etc to display the form from the actual wording of questions or order of pages such that it becomes possible to make changes to the form with little or no code changes. + +This should also mean that in the future it could be possible to create an interface that can construct the JSON config, which would open up the ability to make form changes to a wider audience. Doing this fully would require generating and running the necessary migrations for data storage, generating the required ActiveRecord methods to validate the data server side, and generating/updating API endpoints and documentation. All of this is likely to be beyond the scope of initial MVP but could be looked at in the future. + +Since initially the JSON config will not create database migrations or ActiveRecord model validations, it will instead assume that these have been correctly created for the config provided. The reasoning for this is the following assumptions: + +- The form will be tweaked regularly (amending questions wording, changing the order of questions or the page a question is displayed on) + +- The actual data collected will change very infrequently. Time series continuity is very important to ADD (Analysis and Data Directorate) so the actual data collected should stay largely consistent i.e. in general we can change the question wording in ways that makes the intent clearer or easier to understand, but not in ways that would make the data provider give a different answer. + +A form parser class will parse this config into ruby objects/methods that can be used as an API by the rest of the application, such that we could change the underlying config if needed (for example swap JSON for YAML or for DataBase objects) without needing to change the rest of the application. We’ll call this the Form Runner part of the application. + +## Setup this log + +The setup this log section is treated slightly differently from the rest of the form. It is more accurately viewed as providing metadata about the form than as being part of the form itself. It also needs to know far more about the application specific context than other parts of the form such as who the current user is, what organisation they’re part of and what role they have etc. + +As a result it’s not modelled as part of the config but rather as code. It still uses the same Form Runner components though. + +## Features the Form Config supports + +- Defining sections, subsections, pages and questions that fit the GOV.UK task list pattern + +- Auto-generated routes – URLs are automatically created from dasherized page names + +- Data persistence requires a database field to exist which matches the name/id for each question (and answer option for checkbox questions) + +- Text, numeric, date, radio, select and checkbox question types + +- Conditional questions (`conditional_for`) – Radio and checkbox questions can support conditional text or numeric questions that show/hide on the same page when the triggering option is selected + +- Routing (`depends_on`) – all pages can specify conditions (attributes of the case log) that determine whether or not they’re shown to the user + + - Methods can be chained (i.e. you can have conditions in the form `{ owning_organisation.provider_type: "local_authority"`) which will call `case_log.owning_organisation.provider_type` and compare the result to the provided value. + + - Numeric questions support math expression depends_on conditions such as `{ age2: ">16" }` + +- By default questions on pages that are not routed to are assumed to be invalid and are cleared. This can be prevented by setting `derived: true` on a question. + +- Questions can be optionally hidden from the check answers page of each section by setting `hidden_in_check_answers: true`. This can also take a condition. + +- Questions can be set as being inferred from other answers. This is similar to derived with the difference being that derived questions can be derived from anything not just other form question answers, and inferred answers are cleared when the answers they depend on change, whereas derived questions aren’t. + +- Soft validation interruption pages can be included + +- For complex HTML guidance partials can be referenced + +## JSON Config + +The form for this is driven by a JSON file in `/config/forms/{start_year}_{end_year}.json` + +The JSON should follow the structure: + +```jsonc +{ + "form_type": "lettings" / "sales", + "start_year": Integer, // i.e. 2020 + "end_year": Integer, // i.e. 2021 + "sections": { + "[snake_case_section_name_string]": { + "label": String, + "description": String, + "subsections": { + "[snake_case_subsection_name_string]": { + "label": String, + "pages": { + "[snake_case_page_name_string]": { + "header": String, + "description": String, + "questions": { + "[snake_case_question_name_string]": { + "header": String, + "hint_text": String, + "check_answer_label": String, + "type": "text" / "numeric" / "radio" / "checkbox" / "date", + "min": Integer, // numeric only + "max": Integer, // numeric only + "step": Integer, // numeric only + "width": 2 / 3 / 4 / 5 / 10 / 20, // text and numeric only + "prefix": String, // numeric only + "suffix": String, //numeric only + "answer_options": { // checkbox and radio only + "0": String, + "1": String + }, + "conditional_for": { + "[snake_case_question_to_enable_1_name_string]": ["condition-that-enables"], + "[snake_case_question_to_enable_2_name_string]": ["condition-that-enables"] + }, + "inferred_answers": { "field_that_gets_inferred_from_current_field": { "is_that_field_inferred": true } }, + "inferred_check_answers_value": { + "condition": { "field_name_for_inferred_check_answers_condition": "field_value_for_inferred_check_answers_condition" }, + "value": "Inferred value that gets displayed if condition is met" + } + } + }, + "depends_on": [{ "question_key": "answer_value_required_for_this_page_to_be_shown" }] + } + } + } + } + } + } +} +``` + +Assumptions made by the format: + +- All forms have at least 1 section + +- All sections have at least 1 subsection + +- All subsections have at least 1 page + +- All pages have at least 1 question + +- The ActiveRecord case log model has a field for each question name (must match). In the case of checkbox questions it must have one field for every answer option (again names must match). + +- Text not required by a page/question such as a header or hint text should be passed as an empty string + +- For conditionally shown questions, conditions that have been implemented and can be used are: + + - Radio question answer option selected matches one of conditional e.g.\ + `["answer-options-1-string", "answer-option-3-string"]` + + - Numeric question value matches condition e.g. [">2"], ["<7"] or ["== 6"] + +- When the top level question is a radio button and the conditional question is a numeric, text or date field then the conditional question is shown inline + +- When the conditional question is a radio, checkbox or select field it should be displayed on it’s own page and "depends_on" should be used rather than "conditional_for" + +### Page routing + +Form navigation works by stepping sequentially through every page defined in the JSON form definition for the given subsection. For every page it checks if it has "depends_on" conditions. If it does, it evaluates them to determine whether that page should be show or not. + +In this way we can build up whole branches by having: + +```jsonc +"page_1": { "questions": { "question_1: "answer_options": ["A", "B"] } }, +"page_2": { "questions": { "question_2: "answer_options": ["C", "D"] }, "depends_on": [{ "question_1": "A" }] }, +"page_3": { "questions": { "question_3: "answer_options": ["E", "F"] }, "depends_on": [{ "question_1": "A" }] }, +"page_4": { "questions": { "question_4: "answer_options": ["G", "H"] }, "depends_on": [{ "question_1": "B" }] }, +``` + +## JSON form validation against Schema + +To validate the form JSON against the schema you can run: + +```bash +rake form_definition:validate["config/forms/2021_2022.json"] +``` + +Note: you may have to escape square brackets in zsh: + +```bash +rake form_definition:validate\["config/forms/2021_2022.json"\] +``` + +This will validate the given form definition against the schema in `config/forms/schema/generic.json`. + +You can also run: + +```bash +rake form_definition:validate_all +``` + +This will validate all forms in directories `["config/forms", "spec/fixtures/forms"]` + +## Improvements that could be made + +- JSON schema definition could be expanded such that we can better automatically validate that a given config is valid and internally consistent + +- Generators could parse a given valid JSON form and generate the required database migrations to ensure all the expected fields exist and are of a compatible type + +- The parsed form could be visualised using something like GraphViz to help manually verify the coded config meets requirements diff --git a/docs/form_runner.md b/docs/form_runner.md new file mode 100644 index 000000000..23173c74b --- /dev/null +++ b/docs/form_runner.md @@ -0,0 +1,21 @@ +# Form Runner + +The Form Runner is composed of: + +Ruby Classes: + +- A singleton form handler that instantiates an instances of each form definition (config file we have) combined with the setup section that is common to all forms. This is created at rails boot time. (`app/models/form_handler.rb`) +- A `Form` class that is the entry point for parsing a form definition and handles most of the associated logic (`app/models/form.rb`) +- `Section`, `Subsection`, `Page` and `Question` classes (`app/models/form/`) +- Setup subsection specific instances (subclasses) of `Section`, `Subsection`, `Pages` and `Questions` (`app/form/setup/`) + +ERB templates: + +- The page view which is the main view for each form page (`app/views/form/page.html.erb`) +- Partials for each question type (radio, checkbox, select, text, numeric, date) (`app/views/form/`) +- Partials for specific question guidance (`app/views/form/guidance`) +- The check answers page which is the view for the answer summary page of each section (`app/views/form/check_answers.html.erb`) + +Routes for each form page are generated by looping over each Page instance in each Form instance held by the form handler and defining a `GET` path. The corresponding controller method is also auto-generated with meta-programming via the same looping in `app/controllers/form_controller.rb` + +All form pages submit to the same controller method (`app/controllers/form_controller.rb#submit_form`) which validates and persists the data, and then redirects to the next form page that identifies as `routed_to` given the current case log state. diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 000000000..d8957429c --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,49 @@ +# Frontend + +## GOV.UK Design System components + +This service follows the guidance and recommendations from the [GOV.UK Design System](https://design-system.service.gov.uk). This is achieved using the following libraries: + +- **GOV.UK Frontend** – CSS and JavaScript for all Design System components\ + [Documentation](https://frontend.design-system.service.gov.uk) · + [GitHub](https://github.com/alphagov/govuk-frontend) + +- **GOV.UK Components** – Rails view components for non-form related Design System components\ + [Documentation](https://govuk-components.netlify.app) · + [Github](https://github.com/DFE-Digital/govuk-components) · + [RubyDoc](https://www.rubydoc.info/gems/govuk-components) + +- **GOV.UK FormBuilder** – Rails form builder for form related Design System components\ + [Documentation](https://govuk-form-builder.netlify.app) · + [GitHub](https://github.com/DFE-Digital/govuk-formbuilder) · + [RubyDoc](https://www.rubydoc.info/gems/govuk_design_system_formbuilder) + +## Service-specific components + +Service-specific components are built using the [ViewComponent](https://viewcomponent.org) framework, and can be found in `app/components`. + +Components use HTML class names that follow the BEM methodology. We use the `app-*` prefix to prevent collisions with components provided by the Design System (which uses `govuk-*`). See [Extending and modifying components in production](https://design-system.service.gov.uk/get-started/extending-and-modifying-components/). + +Stylesheets are written using [Sass](https://sass-lang.com) (and the SCSS syntax), using the mixins and helpers provided by [govuk-frontend](https://frontend.design-system.service.gov.uk/sass-api-reference/). + +Separate stylesheets are used for each component, with filenames that match the component’s namespace. + +Like the components provided by the Design System, components are progressively enhanced. We use [Stimulus](https://stimulus.hotwired.dev) to add any client-side JavaScript enhancements. + +### Stimulus + +For adding custom javascript to the application we use [Stimulus](https://stimulus.hotwired.dev/). + +The general pattern is: + +- Register a controller in `/app/frontend/controllers/index.js`- be sure to use kebab case +- Create that controller in `app/frontend/controllers/` - be sure to use underscore case +- Attach the controller to the html element that should trigger it’s functionality + +### Asset bundling and compilation + +- We use [Webpack](https://webpack.js.org/) via [jsbundling-rails](https://github.com/rails/jsbundling-rails) to bundle JavaScript, CSS and images. The configuration can be found in `webpack.config.js`. +- We use [Propshaft](https://github.com/rails/propshaft) as our asset pipeline to serve the assets bundled/compiled by webpack +- We use [Babel](https://babeljs.io/) to transpile js down to ES5 for Internet Explorer compatibility. The configuration can be found in `babel.config.js` +- We use [browserslist](https://github.com/browserslist/browserslist) to specify the browsers we want to transpile for. The configuration can be found in `package.json` +- We include a number of polyfills to support Internet Explorer. These can be found in `app/frontend/application.js` diff --git a/docs/images/architecture.png b/docs/images/architecture.png new file mode 100644 index 000000000..30e146258 Binary files /dev/null and b/docs/images/architecture.png differ diff --git a/docs/images/organisational_relationships.png b/docs/images/organisational_relationships.png new file mode 100644 index 000000000..18e71df3e Binary files /dev/null and b/docs/images/organisational_relationships.png differ diff --git a/docs/images/service.png b/docs/images/service.png new file mode 100644 index 000000000..9a96a4f3e Binary files /dev/null and b/docs/images/service.png differ diff --git a/docs/images/user_log_permissions.png b/docs/images/user_log_permissions.png new file mode 100644 index 000000000..d0be30840 Binary files /dev/null and b/docs/images/user_log_permissions.png differ diff --git a/docs/index.html b/docs/index.html index 1f0ae16fa..0c25ee1da 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,9 +3,9 @@ - + OpenAPI DLUHC CORE Data Collection -
    +