diff --git a/.erb_lint.yml b/.erb_lint.yml new file mode 100644 index 000000000..a0d32f723 --- /dev/null +++ b/.erb_lint.yml @@ -0,0 +1,37 @@ +--- +glob: "**/*.erb" +exclude: + - '**/vendor/**/*' + - 'test/fixtures/**' +linters: + Rubocop: + enabled: true + exclude: + - "**/vendor/**/*" + - "**/vendor/**/.*" + - "bin/**" + - "db/**/*" + - "config/**/*" + rubocop_config: + inherit_from: + - .rubocop.yml + AllCops: + DisabledByDefault: true + Layout/InitialIndentation: + Enabled: false + Layout/TrailingEmptyLines: + Enabled: false + Layout/TrailingWhitespace: + Enabled: false + Naming/FileName: + Enabled: false + Style/FrozenStringLiteralComment: + Enabled: false + Layout/LineLength: + Enabled: false + Lint/UselessAssignment: + Enabled: false + Layout/FirstHashElementIndentation: + Enabled: false + Rails/SaveBang: + Enabled: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0f4ea1e..32f7a9d21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.gitignore b/.gitignore index bc99023aa..e24f1d4bb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,18 @@ # belong in git's global ignore instead: # `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` +# System files +.DS_Store + # Ignore bundler config. /.bundle # Ignore all environment files. /.env* +# Ignore caches +.cache + # Ignore all logfiles and tempfiles. /log/* /tmp/* @@ -30,8 +36,19 @@ /public/assets +# Ignore built assets +/app/assets/builds/* +!/app/assets/builds/.keep + # Ignore master key for decrypting credentials and more. /config/master.key # Ignore version files other than .ruby-version mise.toml + +# Ignore JS artefacts +node_modules +.yarn + +# test coverage reports +/coverage diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 000000000..b30100864 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,14 @@ +module.exports = { + semi: false, + singleQuote: true, + trailingComma: 'none', + overrides: [ + { + files: '*.scss', + options: { + printWidth: 120, + singleQuote: false + } + } + ] +} diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4a5..f239943c5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,24 @@ -# Omakase Ruby styling for Rails -inherit_gem: { rubocop-rails-omakase: rubocop.yml } +inherit_gem: + rubocop-govuk: + - config/default.yml + - config/rails.yml + - config/rake.yml + - config/rspec.yml -# Overwrite or add rules to create your own house style +inherit_mode: + merge: + - Exclude + +# ************************************************************** +# TRY NOT TO ADD OVERRIDES IN THIS FILE +# +# This repo is configured to follow the RuboCop GOV.UK styleguide. +# Any rules you override here will cause this repo to diverge from +# the way we write code in all other GOV.UK repos. # -# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` -# Layout/SpaceInsideArrayLiteralBrackets: -# Enabled: false +# See https://github.com/alphagov/rubocop-govuk/blob/main/CONTRIBUTING.md +# ************************************************************** + +Rails/SaveBang: + Exclude: + - 'Rakefile' diff --git a/.stylelintrc.yml b/.stylelintrc.yml new file mode 100644 index 000000000..196317cbd --- /dev/null +++ b/.stylelintrc.yml @@ -0,0 +1,2 @@ +extends: stylelint-config-gds/scss +ignoreFiles: diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 000000000..16d44da90 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules + +yarnPath: .yarn/releases/yarn-4.9.2.cjs diff --git a/Gemfile b/Gemfile index 0740dd3d9..79a928f24 100644 --- a/Gemfile +++ b/Gemfile @@ -1,42 +1,60 @@ source "https://rubygems.org" -# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 8.0.2", ">= 8.0.2.1" -# The modern asset pipeline for Rails [https://github.com/rails/propshaft] -gem "propshaft" -# Use postgresql as the database for Active Record +gem "babosa" +gem "bootsnap", require: false +gem "content_block_tools" +gem "dartsass-rails" +gem "friendly_id" +gem "gds-api-adapters" +gem "gds-sso" +gem "govuk_app_config" +gem "govuk_frontend_toolkit" +gem "govuk_publishing_components" +gem "jbuilder" +gem "kaminari" gem "pg", "~> 1.1" -# Use the Puma web server [https://github.com/puma/puma] +gem "plek" gem "puma", ">= 5.0" -# Build JSON APIs with ease [https://github.com/rails/jbuilder] -gem "jbuilder" -# Use Redis adapter to run Action Cable in production -# gem "redis", ">= 4.0.1" - -# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" - -# Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[ windows jruby ] - -# Reduces boot times through caching; required in config/boot.rb -gem "bootsnap", require: false - -# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "rails", "~> 8.0.2", ">= 8.0.2.1" +gem "record_tag_helper", require: false +gem "sprockets-rails" gem "thruster", require: false +gem "transitions", require: ["transitions", "active_record/transitions"] +gem "tzinfo-data", platforms: %i[windows jruby] +gem "view_component" group :development, :test do - # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" - - # Static analysis for security vulnerabilities [https://brakemanscanner.org/] gem "brakeman", require: false + gem "byebug" + gem "debug", platforms: %i[mri windows], require: "debug/prelude" + gem "erb_lint" + gem "govuk_test" + gem "pry-byebug" + gem "pry-rails" + gem "rspec-rails" + gem "rubocop-govuk" +end - # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] - gem "rubocop-rails-omakase", require: false +group :test do + gem "database_cleaner-active_record" + gem "factory_bot" + gem "minitest" + gem "minitest-fail-fast" + gem "minitest-stub-const" + gem "mocha" + gem "simplecov" + gem "webmock", require: false end group :development do - # Use console on exceptions pages [https://github.com/rails/web-console] - gem "web-console" + gem "better_errors" + gem "binding_of_caller" +end + +group :cucumber, :test do + gem "capybara" + gem "capybara-playwright-driver" + gem "cucumber" + gem "cucumber-rails", require: false + gem "launchy" end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..2d4b2e6f4 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,920 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + mail (>= 2.8.0) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) + globalid (>= 0.3.6) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) + timeout (>= 0.4.0) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) + marcel (~> 1.0) + activesupport (8.0.2.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + babosa (2.0.0) + base64 (0.3.0) + benchmark (0.4.1) + better_errors (2.10.1) + erubi (>= 1.0.0) + rack (>= 0.9.0) + rouge (>= 1.0.0) + better_html (2.1.1) + actionview (>= 6.0) + activesupport (>= 6.0) + ast (~> 2.0) + erubi (~> 1.4) + parser (>= 2.4) + smart_properties + bigdecimal (3.2.2) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.1.0) + racc + builder (3.3.0) + byebug (12.0.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + capybara-playwright-driver (0.5.7) + addressable + capybara + playwright-ruby-client (>= 1.16.0) + childprocess (5.1.0) + logger (~> 1.5) + coderay (1.1.3) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + content_block_tools (1.0.3) + actionview (>= 6) + govspeak (= 10.6.1) + crack (1.0.0) + bigdecimal + rexml + crass (1.0.6) + cucumber (9.2.1) + builder (~> 3.2) + cucumber-ci-environment (> 9, < 11) + cucumber-core (> 13, < 14) + cucumber-cucumber-expressions (~> 17.0) + cucumber-gherkin (> 24, < 28) + cucumber-html-formatter (> 20.3, < 22) + cucumber-messages (> 19, < 25) + diff-lcs (~> 1.5) + mini_mime (~> 1.1) + multi_test (~> 1.1) + sys-uname (~> 1.2) + cucumber-ci-environment (10.0.1) + cucumber-core (13.0.3) + cucumber-gherkin (>= 27, < 28) + cucumber-messages (>= 20, < 23) + cucumber-tag-expressions (> 5, < 7) + cucumber-cucumber-expressions (17.1.0) + bigdecimal + cucumber-gherkin (27.0.0) + cucumber-messages (>= 19.1.4, < 23) + cucumber-html-formatter (21.14.0) + cucumber-messages (> 19, < 28) + cucumber-messages (22.0.0) + cucumber-rails (3.1.1) + capybara (>= 3.11, < 4) + cucumber (>= 5, < 10) + railties (>= 5.2, < 9) + cucumber-tag-expressions (6.1.2) + dartsass-rails (0.5.1) + railties (>= 6.0.0) + sass-embedded (~> 1.63) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) + date (3.4.1) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + debug_inspector (1.2.0) + diff-lcs (1.6.2) + docile (1.4.1) + domain_name (0.6.20240107) + drb (2.2.3) + erb (5.0.2) + erb_lint (0.9.0) + activesupport + better_html (>= 2.0.1) + parser (>= 2.7.1.4) + rainbow + rubocop (>= 1) + smart_properties + erubi (1.13.1) + factory_bot (6.5.5) + activesupport (>= 6.1.0) + faraday (2.13.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.1) + net-http (>= 0.5.0) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + friendly_id (5.5.1) + activerecord (>= 4.0.0) + gds-api-adapters (101.0.0) + addressable + link_header + null_logger + plek (>= 1.9.0) + rack (>= 2.2.0) + rest-client (~> 2.0) + gds-sso (21.1.0) + oauth2 (~> 2.0) + omniauth (~> 2.1) + omniauth-oauth2 (~> 1.8) + plek (>= 5) + rack + rails (>= 7) + warden (~> 1.2) + warden-oauth2 (~> 0.0.1) + globalid (1.2.1) + activesupport (>= 6.1) + google-protobuf (4.31.1) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-aarch64-linux-gnu) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-aarch64-linux-musl) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-x86_64-linux-gnu) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-x86_64-linux-musl) + bigdecimal + rake (>= 13) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) + govspeak (10.6.1) + actionview (>= 6, < 8.0.3) + addressable (>= 2.3.8, < 2.8.8) + govuk_publishing_components (>= 43) + htmlentities (~> 4) + i18n (>= 0.7, < 1.14.8) + kramdown (>= 2.3.1, < 2.5.2) + nokogiri (~> 1.12) + rinku (~> 2.0) + sanitize (>= 6, < 8) + govuk_app_config (9.19.1) + logstasher (~> 2.1) + opentelemetry-exporter-otlp (>= 0.25, < 0.31) + opentelemetry-instrumentation-all (>= 0.39.1, < 0.79.0) + opentelemetry-sdk (~> 1.2) + plek (>= 4, < 6) + prometheus_exporter (~> 2.0) + puma (>= 5.6, < 7.0) + rack-proxy (~> 0.7) + sentry-rails (~> 5.3) + sentry-ruby (~> 5.3) + statsd-ruby (~> 1.5) + govuk_frontend_toolkit (9.0.1) + railties (>= 3.1.0) + govuk_personalisation (1.1.0) + plek (>= 1.9.0) + rails (>= 6, < 9) + govuk_publishing_components (60.0.1) + govuk_app_config + govuk_personalisation (>= 0.7.0) + kramdown + ostruct + plek + rails (>= 6) + rouge + sprockets (>= 3) + sprockets-rails + govuk_test (4.1.2) + brakeman (>= 5.0.2) + capybara (>= 3.36) + puma + selenium-webdriver (>= 4.0) + hashdiff (1.2.0) + hashie (5.0.0) + htmlentities (4.3.4) + http-accept (1.7.0) + http-cookie (1.0.8) + domain_name (~> 0.5) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.13.2) + jwt (3.1.2) + base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + kramdown (2.5.1) + rexml (>= 3.3.9) + language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + link_header (0.0.8) + lint_roller (1.1.0) + logger (1.7.0) + logstasher (2.1.5) + activesupport (>= 5.2) + request_store + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.3) + method_source (1.1.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0812) + mini_mime (1.1.5) + minitest (5.25.5) + minitest-fail-fast (0.1.0) + minitest (~> 5) + minitest-stub-const (0.6) + mocha (2.7.1) + ruby2_keywords (>= 0.0.5) + msgpack (1.8.0) + multi_test (1.1.0) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + net-http (0.6.0) + uri + net-imap (0.5.9) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + netrc (0.11.0) + nio4r (2.7.4) + nokogiri (1.18.9-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.9-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-musl) + racc (~> 1.4) + null_logger (0.0.1) + oauth2 (2.0.12) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (>= 1.1.8, < 3) + omniauth (2.1.3) + hashie (>= 3.4.6) + rack (>= 2.2.3) + rack-protection + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + opentelemetry-api (1.5.0) + opentelemetry-common (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.30.0) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-helpers-mysql (0.2.0) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) + opentelemetry-helpers-sql (0.1.1) + opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql-obfuscation (0.3.0) + opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-action_pack (0.12.3) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (~> 0.21) + opentelemetry-instrumentation-action_view (0.9.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_job (0.8.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_model_serializers (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_record (0.9.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_storage (0.1.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (~> 0.7) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-active_support (0.8.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-all (0.78.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) + opentelemetry-instrumentation-aws_lambda (~> 0.3.0) + opentelemetry-instrumentation-aws_sdk (~> 0.8.0) + opentelemetry-instrumentation-bunny (~> 0.22.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) + opentelemetry-instrumentation-dalli (~> 0.27.0) + opentelemetry-instrumentation-delayed_job (~> 0.23.0) + opentelemetry-instrumentation-ethon (~> 0.22.0) + opentelemetry-instrumentation-excon (~> 0.23.0) + opentelemetry-instrumentation-faraday (~> 0.27.0) + opentelemetry-instrumentation-grape (~> 0.3.0) + opentelemetry-instrumentation-graphql (~> 0.29.0) + opentelemetry-instrumentation-grpc (~> 0.2.0) + opentelemetry-instrumentation-gruf (~> 0.3.0) + opentelemetry-instrumentation-http (~> 0.25.0) + opentelemetry-instrumentation-http_client (~> 0.23.0) + opentelemetry-instrumentation-koala (~> 0.21.0) + opentelemetry-instrumentation-lmdb (~> 0.23.0) + opentelemetry-instrumentation-mongo (~> 0.23.0) + opentelemetry-instrumentation-mysql2 (~> 0.29.0) + opentelemetry-instrumentation-net_http (~> 0.23.0) + opentelemetry-instrumentation-pg (~> 0.30.0) + opentelemetry-instrumentation-que (~> 0.9.0) + opentelemetry-instrumentation-racecar (~> 0.4.0) + opentelemetry-instrumentation-rack (~> 0.26.0) + opentelemetry-instrumentation-rails (~> 0.36.0) + opentelemetry-instrumentation-rake (~> 0.3.1) + opentelemetry-instrumentation-rdkafka (~> 0.7.0) + opentelemetry-instrumentation-redis (~> 0.26.1) + opentelemetry-instrumentation-resque (~> 0.6.0) + opentelemetry-instrumentation-restclient (~> 0.23.0) + opentelemetry-instrumentation-ruby_kafka (~> 0.22.0) + opentelemetry-instrumentation-sidekiq (~> 0.26.0) + opentelemetry-instrumentation-sinatra (~> 0.25.0) + opentelemetry-instrumentation-trilogy (~> 0.61.0) + opentelemetry-instrumentation-aws_lambda (0.3.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-aws_sdk (0.8.2) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-base (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) + opentelemetry-registry (~> 0.1) + opentelemetry-instrumentation-bunny (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-concurrent_ruby (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-dalli (0.27.3) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-delayed_job (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-ethon (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-excon (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-faraday (0.27.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-grape (0.3.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (~> 0.21) + opentelemetry-instrumentation-graphql (0.29.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-grpc (0.2.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-gruf (0.3.0) + opentelemetry-api (>= 1.0.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-http (0.25.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-http_client (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-koala (0.21.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-lmdb (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-mongo (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-mysql2 (0.29.1) + opentelemetry-api (~> 1.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-obfuscation + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-net_http (0.23.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-pg (0.30.1) + opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-obfuscation + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-que (0.9.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-racecar (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (0.26.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rails (0.36.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_mailer (~> 0.4.0) + opentelemetry-instrumentation-action_pack (~> 0.12.0) + opentelemetry-instrumentation-action_view (~> 0.9.0) + opentelemetry-instrumentation-active_job (~> 0.8.0) + opentelemetry-instrumentation-active_record (~> 0.9.0) + opentelemetry-instrumentation-active_storage (~> 0.1.0) + opentelemetry-instrumentation-active_support (~> 0.8.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) + opentelemetry-instrumentation-rake (0.3.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rdkafka (0.7.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-redis (0.26.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-resque (0.6.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-restclient (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-ruby_kafka (0.22.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-sidekiq (0.26.1) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-sinatra (0.25.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-rack (~> 0.21) + opentelemetry-instrumentation-trilogy (0.61.1) + opentelemetry-api (~> 1.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-obfuscation + opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-semantic_conventions (>= 1.8.0) + opentelemetry-registry (0.4.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.8.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.11.0) + opentelemetry-api (~> 1.0) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + pg (1.6.1) + pg (1.6.1-aarch64-linux) + pg (1.6.1-aarch64-linux-musl) + pg (1.6.1-arm64-darwin) + pg (1.6.1-x86_64-darwin) + pg (1.6.1-x86_64-linux) + pg (1.6.1-x86_64-linux-musl) + playwright-ruby-client (1.54.1) + concurrent-ruby (>= 1.1.6) + mime-types (>= 3.0) + plek (5.2.2) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.4.0) + prometheus_exporter (2.2.0) + webrick + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.11.0) + byebug (~> 12.0) + pry (>= 0.13, < 0.16) + pry-rails (0.3.11) + pry (>= 0.13.0) + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + puma (6.6.1) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.2.0) + rack-protection (4.1.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-proxy (0.7.7) + rack + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + bundler (>= 1.15.0) + railties (= 8.0.2.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.0) + rdoc (6.14.2) + erb + psych (>= 4.0.0) + record_tag_helper (1.0.1) + actionview (>= 5) + regexp_parser (2.11.2) + reline (0.6.2) + io-console (~> 0.5) + request_store (1.7.0) + rack (>= 1.4) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + rexml (3.4.1) + rinku (2.0.6) + rouge (4.6.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.4) + rubocop (1.79.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-govuk (5.1.19) + rubocop (= 1.79.2) + rubocop-ast (= 1.46.0) + rubocop-capybara (= 2.22.1) + rubocop-rails (= 2.32.0) + rubocop-rake (= 0.7.1) + rubocop-rspec (= 3.6.0) + rubocop-rails (2.32.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.6.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + rubyzip (3.0.1) + sanitize (7.0.0) + crass (~> 1.0.2) + nokogiri (>= 1.16.8) + sass-embedded (1.90.0-aarch64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-aarch64-linux-musl) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-arm-linux-gnueabihf) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-arm-linux-musleabihf) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-arm64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-x86_64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-x86_64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.90.0-x86_64-linux-musl) + google-protobuf (~> 4.31) + securerandom (0.4.1) + selenium-webdriver (4.35.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + sentry-rails (5.26.0) + railties (>= 5.0) + sentry-ruby (~> 5.26.0) + sentry-ruby (5.26.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + smart_properties (1.17.0) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) + sprockets (4.2.2) + concurrent-ruby (~> 1.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + statsd-ruby (1.5.0) + stringio (3.1.7) + sys-uname (1.3.1) + ffi (~> 1.1) + thor (1.4.0) + thruster (0.1.15) + thruster (0.1.15-aarch64-linux) + thruster (0.1.15-arm64-darwin) + thruster (0.1.15-x86_64-darwin) + thruster (0.1.15-x86_64-linux) + timeout (0.4.3) + transitions (1.3.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) + useragent (0.16.11) + version_gem (1.1.8) + view_component (4.0.2) + activesupport (>= 7.1.0, < 8.1) + concurrent-ruby (~> 1) + warden (1.2.9) + rack (>= 2.0.9) + warden-oauth2 (0.0.1) + warden + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.1) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + babosa + better_errors + binding_of_caller + bootsnap + brakeman + byebug + capybara + capybara-playwright-driver + content_block_tools + cucumber + cucumber-rails + dartsass-rails + database_cleaner-active_record + debug + erb_lint + factory_bot + friendly_id + gds-api-adapters + gds-sso + govuk_app_config + govuk_frontend_toolkit + govuk_publishing_components + govuk_test + jbuilder + kaminari + launchy + minitest + minitest-fail-fast + minitest-stub-const + mocha + pg (~> 1.1) + plek + pry-byebug + pry-rails + puma (>= 5.0) + rails (~> 8.0.2, >= 8.0.2.1) + record_tag_helper + rspec-rails + rubocop-govuk + simplecov + sprockets-rails + thruster + transitions + tzinfo-data + view_component + webmock + +BUNDLED WITH + 2.6.9 diff --git a/LICENCE b/LICENCE new file mode 100644 index 000000000..83f53044f --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Crown Copyright (Government Digital Service) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..c0e48a451 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ + +web: bin/rails server -p 3000 --restart +css: bin/rails dartsass:watch diff --git a/README.md b/README.md index 896eac91a..b229bb2f1 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,40 @@ Content Block Manager is a tool for publishers to create and manage reusable blocks of content such as a simple ["Pension rate"][] or a complex ["Contact"][]. +## Live examples + +TO DO + +## Nomenclature + +TO DO + +## Technical documentation + +### Before running the app (if applicable) + +Anything that's not done automatically by the development environment: + +- Dependencies that need to be installed manually. +- One-off commands that need to be run manually. + +### Running the test suite + +TO DO + +### Further documentation + +TO DO + +## Licence + +[MIT Licence][] + ["Pension rate"]: https://github.com/alphagov/publishing-api/blob/main/content_schemas/formats/content_block_pension.jsonnet ["Contact"]: https://github.com/alphagov/publishing-api/blob/main/content_schemas/formats/content_block_contact.jsonnet + +[MIT Licence]: +./LICENCE diff --git a/Rakefile b/Rakefile index 9a5ea7383..5ab6f111f 100644 --- a/Rakefile +++ b/Rakefile @@ -3,4 +3,17 @@ require_relative "config/application" +require "minitest/test_task" +# We only set this var when running via Rake, so that we can get +# sensible coverage reports when running a full test suite, +# without overwriting them when we're just running a single test +ENV["COVERAGE"] = "true" + Rails.application.load_tasks + +Minitest::TestTask.create do |t| + t.test_globs = %w[test/**/*_test.rb] +end + +Rake::Task[:default].clear if Rake::Task.task_defined?(:default) +task default: %i[lint test] diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 000000000..6378a23d3 --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,4 @@ +//= link_tree ../builds +//= link application.js +//= link domain-config.js +//= link es6-components.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 000000000..18794efb1 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,9 @@ +//= require govuk_publishing_components/dependencies +//= require govuk_publishing_components/components/add-another +//= require govuk_publishing_components/components/copy-to-clipboard +//= require govuk_publishing_components/components/govspeak +//= require govuk_publishing_components/lib/cookie-functions + +'use strict' +window.GOVUK.approveAllCookieTypes() +window.GOVUK.cookie('cookies_preferences_set', 'true', { days: 365 }) diff --git a/app/assets/javascripts/domain-config.js b/app/assets/javascripts/domain-config.js new file mode 100644 index 000000000..45783f25b --- /dev/null +++ b/app/assets/javascripts/domain-config.js @@ -0,0 +1,25 @@ +'use strict' +window.GOVUK = window.GOVUK || {} +window.GOVUK.vars = window.GOVUK.vars || {} +window.GOVUK.vars.extraDomains = [ + { + name: 'production', + domains: ['content-block-manager.publishing.service.gov.uk'], + initialiseGA4: true, + id: 'GTM-xxx', + gaProperty: 'UA-xxx' + }, + { + name: 'staging', + domains: ['content-block-manager.staging.publishing.service.gov.uk'], + initialiseGA4: false + }, + { + name: 'integration', + domains: ['content-block-manager.integration.publishing.service.gov.uk'], + initialiseGA4: true, + id: 'GTM-xxx', + auth: 'xxxx', + preview: 'env-50' + } +] diff --git a/app/assets/javascripts/es6-components.js b/app/assets/javascripts/es6-components.js new file mode 100644 index 000000000..3bb6d48d2 --- /dev/null +++ b/app/assets/javascripts/es6-components.js @@ -0,0 +1,17 @@ +// These modules from govuk_publishing_components +// depend on govuk-frontend modules. govuk-frontend +// now targets browsers that support `type="module"`. +// +// To gracefully prevent execution of these scripts +// on browsers that don't support ES6, this script +// should be included in a `type="module"` script tag +// which will ensure they are never loaded. + +//= require govuk_publishing_components/components/button +//= require govuk_publishing_components/components/checkboxes +//= require govuk_publishing_components/components/character-count +//= require govuk_publishing_components/components/error-summary +//= require govuk_publishing_components/components/layout-header +//= require govuk_publishing_components/components/radio +//= require govuk_publishing_components/components/skip-link +//= require govuk_publishing_components/components/tabs diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index fe93333c0..000000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1,10 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. - */ diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss new file mode 100644 index 000000000..43b73d379 --- /dev/null +++ b/app/assets/stylesheets/application.scss @@ -0,0 +1,26 @@ +$govuk-page-width: 1140px; + +@import "govuk_publishing_components/all_components"; + +.app-js-only { + display: none; +} + +.js-enabled { + .app-no-js { + display: none; + } + + .app-js-only { + display: block; + } + + // The .js-enabled class gets applied immediately while the page is still loading, + // whereas GOV.UK JS Modules only initialise once the entire HTML document has loaded (on DOMContentLoaded). + // To avoid a 'flash of unstyled content', elements can use .hide-before-js-module-init to + // temporarily hide the element. The element's JS module can then remove it once + // initialised and apply the correct styling. + .hide-before-js-module-init { + display: none; + } +} diff --git a/app/components/content_block_manager/content_block/document/embedded_objects/new/select_subschema_component.html.erb b/app/components/content_block_manager/content_block/document/embedded_objects/new/select_subschema_component.html.erb new file mode 100644 index 000000000..798eabc13 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/embedded_objects/new/select_subschema_component.html.erb @@ -0,0 +1,9 @@ +<%= render "govuk_publishing_components/components/radio", { + heading:, + heading_caption:, + name: "object_type", + id: "object_type", + heading_size: "xl", + items: items, + error_message:, +} %> diff --git a/app/components/content_block_manager/content_block/document/embedded_objects/new/select_subschema_component.rb b/app/components/content_block_manager/content_block/document/embedded_objects/new/select_subschema_component.rb new file mode 100644 index 000000000..3fb818842 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/embedded_objects/new/select_subschema_component.rb @@ -0,0 +1,21 @@ +class ContentBlockManager::ContentBlock::Document::EmbeddedObjects::New::SelectSubschemaComponent < ViewComponent::Base + def initialize(schemas:, heading:, heading_caption:, error_message:) + @schemas = schemas + @heading = heading + @heading_caption = heading_caption + @error_message = error_message + end + +private + + attr_reader :heading, :heading_caption, :error_message + + def items + @schemas.map do |schema| + { + value: schema.id, + text: schema.name.singularize, + } + end + end +end diff --git a/app/components/content_block_manager/content_block/document/index/date_filter_component.html.erb b/app/components/content_block_manager/content_block/document/index/date_filter_component.html.erb new file mode 100644 index 000000000..37e4522c2 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/index/date_filter_component.html.erb @@ -0,0 +1,58 @@ +<%= render("components/datetime_fields", { + heading_size: "s", + field_name: "last_updated_from", + prefix: "last_updated_from", + date_heading: "From", + date_only: true, + error_items: helpers.errors_for(@errors, "last_updated_from"), + year: { + id: "last_updated_from_1i", + name: "last_updated_from[1i]", + label: "Year", + width: 4, + value: date_value(:last_updated_from, "1i"), + }, + month: { + id: "last_updated_from_2i", + name: "last_updated_from[2i]", + label: "Month", + width: 2, + value: date_value(:last_updated_from, "2i"), + }, + day: { + id: "last_updated_from_3i", + name: "last_updated_from[3i]", + label: "Day", + width: 2, + value: date_value(:last_updated_from, "3i"), + }, +}) %> +<%= render("components/datetime_fields", { + heading_size: "s", + field_name: "last_updated_to", + prefix: "last_updated_to", + date_heading: "To", + date_only: true, + error_items: helpers.errors_for(@errors, "last_updated_to"), + year: { + id: "last_updated_to_1i", + name: "last_updated_to[1i]", + label: "Year", + width: 4, + value: date_value(:last_updated_to, "1i"), + }, + month: { + id: "last_updated_to_2i", + name: "last_updated_to[2i]", + label: "Month", + width: 2, + value: date_value(:last_updated_to, "2i"), + }, + day: { + id: "last_updated_to_3i", + name: "last_updated_to[3i]", + label: "Day", + width: 2, + value: date_value(:last_updated_to, "3i"), + }, +}) %> diff --git a/app/components/content_block_manager/content_block/document/index/date_filter_component.rb b/app/components/content_block_manager/content_block/document/index/date_filter_component.rb new file mode 100644 index 000000000..dc1dd44e0 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/index/date_filter_component.rb @@ -0,0 +1,14 @@ +class ContentBlockManager::ContentBlock::Document::Index::DateFilterComponent < ViewComponent::Base + def initialize(filters: nil, errors: nil) + @filters = filters + @errors = errors + end + +private + + attr_reader :filters + + def date_value(date, date_part) + filters&.dig(date, date_part) + end +end diff --git a/app/components/content_block_manager/content_block/document/index/filter_options_component.html.erb b/app/components/content_block_manager/content_block/document/index/filter_options_component.html.erb new file mode 100644 index 000000000..6a92456e4 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/index/filter_options_component.html.erb @@ -0,0 +1,85 @@ +<%= form_with url: content_block_manager_content_block_documents_path, method: :get, class: "app-c-content-block-manager-filter-options" do %> + <%= render "govuk_publishing_components/components/accordion", { + disable_ga4: true, + items: [ + { + heading: { + text: "Search by keyword", + }, + content: { + html: ( + render "govuk_publishing_components/components/input", { + label: { + text: "Keyword", + bold: true, + }, + name: "keyword", + id: "keyword_filter", + value: @filters.present? && @filters[:keyword], + } + ), + }, + expanded: true, + }, + { + heading: { + text: "Content block type", + }, + content: { + html: ( + render "govuk_publishing_components/components/checkboxes", { + heading: "Content block type", + visually_hide_heading: true, + heading_size: "s", + no_hint_text: true, + id: "block_type", + name: "block_type[]", + items: items_for_block_type, + } + ), + }, + expanded: true, + }, + { + heading: { + text: "Lead organisation", + }, + content: { + html: ( + render "govuk_publishing_components/components/select_with_search", { + id: "lead_organisation", + name: "lead_organisation", + label: "Lead organisation", + include_blank: false, + options: options_for_lead_organisation([@filters[:lead_organisation]]), + } + ), + }, + expanded: true, + }, + { + heading: { + text: "Last updated date", + }, + content: { + html: ( + render(ContentBlockManager::ContentBlock::Document::Index::DateFilterComponent.new(filters: @filters, errors: @errors)) + ), + }, + expanded: true, + }, + ], + } %> + +
+ <%= render "govuk_publishing_components/components/button", { + text: "View results", + margin_bottom: 4, + } %> + + <%= link_to "Reset all fields", + content_block_manager_root_path(reset_fields: true), + class: "govuk-link" %> +
+ +<% end %> diff --git a/app/components/content_block_manager/content_block/document/index/filter_options_component.rb b/app/components/content_block_manager/content_block/document/index/filter_options_component.rb new file mode 100644 index 000000000..db7118dee --- /dev/null +++ b/app/components/content_block_manager/content_block/document/index/filter_options_component.rb @@ -0,0 +1,39 @@ +class ContentBlockManager::ContentBlock::Document::Index::FilterOptionsComponent < ViewComponent::Base + include ActionView::Helpers::RecordTagHelper + def initialize(filters:, errors: nil) + @filters = filters + @errors = errors + end + +private + + def items_for_block_type + ContentBlockManager::ContentBlock::Schema.valid_schemas.map do |schema_name| + { + label: schema_name.humanize, + value: schema_name, + checked: @filters.any? && @filters[:block_type]&.include?(schema_name), + } + end + end + + def all_organisations_option(selected_orgs) + { + text: "All organisations", + value: "", + selected: selected_orgs.compact.empty?, + } + end + + def taggable_organisations_options(_selected_orgs) + # helpers.taggable_organisations_container(selected_orgs) + [ + { text: "HM Revenue & Customs (HMRC)", value: 1, selected: false }, + { text: "Test Organisation (TO)", value: 2, selected: false }, + ] + end + + def options_for_lead_organisation(selected_orgs = []) + [all_organisations_option(selected_orgs), taggable_organisations_options(selected_orgs)].flatten + end +end diff --git a/app/components/content_block_manager/content_block/document/index/summary_card_component.html.erb b/app/components/content_block_manager/content_block/document/index/summary_card_component.html.erb new file mode 100644 index 000000000..ac8f8efc4 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/index/summary_card_component.html.erb @@ -0,0 +1,5 @@ +<%= render "govuk_publishing_components/components/summary_card", { + title:, + rows:, + summary_card_actions:, +} %> diff --git a/app/components/content_block_manager/content_block/document/index/summary_card_component.rb b/app/components/content_block_manager/content_block/document/index/summary_card_component.rb new file mode 100644 index 000000000..981e99428 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/index/summary_card_component.rb @@ -0,0 +1,91 @@ +class ContentBlockManager::ContentBlock::Document::Index::SummaryCardComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::EditionHelper + + def initialize(content_block_document:) + @content_block_document = content_block_document + end + +private + + attr_reader :content_block_document + + def rows + [ + title_item, + *details_items, + organisation_item, + status_item, + ].compact + end + + def title_item + { + key: "Title", + value: content_block_document.title, + } + end + + def details_items + schema.fields.map do |field| + { + key: field.name.humanize, + value: content_block_edition.details[field.name], + } + end + end + + def schema + @schema ||= content_block_edition.schema + end + + def organisation_item + { + key: "Lead organisation", + value: content_block_edition.lead_organisation, + } + end + + def status_item + if content_block_edition.state == "scheduled" + { + key: "Status", + value: scheduled_value, + edit: { + href: helpers.content_block_manager.content_block_manager_content_block_document_schedule_edit_path(content_block_document), + link_text: sanitize("Edit schedule"), + link_text_no_enhance: true, + }, + } + else + { + key: "Status", + value: last_updated_value, + } + end + end + + def title + content_block_document.title + end + + def summary_card_actions + [ + { + label: "View", + href: helpers.content_block_manager.content_block_manager_content_block_document_path(content_block_document), + }, + ] + end + + def content_block_edition + @content_block_edition = content_block_document.latest_edition + end + + def last_updated_value + "Published on #{published_date(content_block_edition)} by #{content_block_edition.creator.name}".html_safe + end + + def scheduled_value + "Scheduled for publication at #{scheduled_date(content_block_edition)}".html_safe + end +end diff --git a/app/components/content_block_manager/content_block/document/show/default_block_component.html.erb b/app/components/content_block_manager/content_block/document/show/default_block_component.html.erb new file mode 100644 index 000000000..b04f448a7 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/default_block_component.html.erb @@ -0,0 +1,16 @@ +
+ <%= render "govuk_publishing_components/components/summary_card", { + title: "Default block", + rows: [ + { + key: "Block content", + value: block_content, + data: data_attributes, + }, + { + key: "Embed code", + value: embed_code_row_value, + }, + ], + } %> +
diff --git a/app/components/content_block_manager/content_block/document/show/default_block_component.rb b/app/components/content_block_manager/content_block/document/show/default_block_component.rb new file mode 100644 index 000000000..62df605ff --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/default_block_component.rb @@ -0,0 +1,34 @@ +class ContentBlockManager::ContentBlock::Document::Show::DefaultBlockComponent < ViewComponent::Base + def initialize(content_block_document:) + @content_block_document = content_block_document + end + +private + + attr_reader :content_block_document + + def content_block_edition + @content_block_edition = content_block_document.latest_edition + end + + def block_content + content_tag(:div, class: "govspeak") do + content_block_edition.render(embed_code) + end + end + + def embed_code_row_value + content_tag(:p, embed_code, class: "app-c-content-block-manager-default-block__embed_code") + end + + def embed_code + @embed_code ||= content_block_document.embed_code + end + + def data_attributes + { + module: "copy-embed-code", + "embed-code": embed_code, + } + end +end diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline/embedded_object/field_changes_table_component.html.erb b/app/components/content_block_manager/content_block/document/show/document_timeline/embedded_object/field_changes_table_component.html.erb new file mode 100644 index 000000000..71fe58c25 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline/embedded_object/field_changes_table_component.html.erb @@ -0,0 +1,21 @@ +<% if rows.present? %> + <%= render "govuk_publishing_components/components/table", { + caption:, + caption_classes: "govuk-heading-m", + first_cell_is_header: true, + head: [ + { + text: tag.span("Fields", class: "govuk-visually-hidden"), + }, + { + text: "Previous version", + format: "string", + }, + { + text: "This version", + format: "string", + }, + ], + rows:, + } %> +<% end %> diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline/embedded_object/field_changes_table_component.rb b/app/components/content_block_manager/content_block/document/show/document_timeline/embedded_object/field_changes_table_component.rb new file mode 100644 index 000000000..e23a40776 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline/embedded_object/field_changes_table_component.rb @@ -0,0 +1,41 @@ +class ContentBlockManager::ContentBlock::Document::Show::DocumentTimeline::EmbeddedObject::FieldChangesTableComponent < ViewComponent::Base + def initialize(object_id:, field_diff:, subschema_id:, content_block_edition:) + @object_id = object_id + @field_diff = field_diff + @subschema_id = subschema_id + @content_block_edition = content_block_edition + end + + def field_diff + flatten_hash_from(@field_diff) + end + +private + + attr_reader :object_id, :subschema_id, :content_block_edition + + def rows + field_diff.map do |field, diffs| + [ + { text: field.humanize }, + { text: diffs.previous_value }, + { text: diffs.new_value }, + ] + end + end + + def caption + content_block_edition.details.dig(subschema_id, object_id, "title") || object_id.underscore.humanize + end + + def flatten_hash_from(hash) + hash.each_with_object({}) do |(key, value), memo| + if value.is_a? Hash + next flatten_hash_from(value).each do |k, v| + memo["#{key}_#{k}"] = v + end + end + memo[key] = value + end + end +end diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline/field_changes_table_component.html.erb b/app/components/content_block_manager/content_block/document/show/document_timeline/field_changes_table_component.html.erb new file mode 100644 index 000000000..72b3f68a6 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline/field_changes_table_component.html.erb @@ -0,0 +1,19 @@ +<% if rows.present? %> + <%= render "govuk_publishing_components/components/table", { + first_cell_is_header: true, + head: [ + { + text: tag.span("Fields", class: "govuk-visually-hidden"), + }, + { + text: "Previous version", + format: "string", + }, + { + text: "This version", + format: "string", + }, + ], + rows:, + } %> +<% end %> diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline/field_changes_table_component.rb b/app/components/content_block_manager/content_block/document/show/document_timeline/field_changes_table_component.rb new file mode 100644 index 000000000..60b94c0f4 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline/field_changes_table_component.rb @@ -0,0 +1,56 @@ +class ContentBlockManager::ContentBlock::Document::Show::DocumentTimeline::FieldChangesTableComponent < ViewComponent::Base + def initialize(version:, schema:) + @version = version + @schema = schema + end + +private + + attr_reader :version, :schema + + def rows + rows = [] + rows.push(title_row) if version.field_diffs["title"] + rows.push(*details_rows) + rows.push(organisation_row) if version.field_diffs["lead_organisation"] + rows.push(instructions_to_publishers_row) if version.field_diffs["instructions_to_publishers"] + rows.compact + end + + def title_row + [ + { text: "Title" }, + { text: version.field_diffs["title"].previous_value }, + { text: version.field_diffs["title"].new_value }, + ] + end + + def organisation_row + [ + { text: "Lead organisation" }, + { text: version.field_diffs["lead_organisation"].previous_value }, + { text: version.field_diffs["lead_organisation"].new_value }, + ] + end + + def details_rows + schema.fields.map do |field| + field_diff = version.field_diffs.dig("details", field) + next unless field_diff + + [ + { text: field.humanize }, + { text: field_diff.previous_value }, + { text: field_diff.new_value }, + ] + end + end + + def instructions_to_publishers_row + [ + { text: "Instructions to publishers" }, + { text: version.field_diffs["instructions_to_publishers"].previous_value }, + { text: version.field_diffs["instructions_to_publishers"].new_value }, + ] + end +end diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline/timeline_item_component.html.erb b/app/components/content_block_manager/content_block/document/show/document_timeline/timeline_item_component.html.erb new file mode 100644 index 000000000..e20353fb3 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline/timeline_item_component.html.erb @@ -0,0 +1,50 @@ +
+
+ <% if is_latest %> + Latest +
+ <% end %> +

<%= title %>

+ +
+ + <% if version.is_embedded_update? %> + + <% end %> + +

+ <%= date %> +

+ + <% if show_details_of_changes? %> +
+ <%= render "govuk_publishing_components/components/details", { + title: "Details of changes", + open: is_latest, + } do %> + <% details_of_changes %> + <% end %> +
+ <% end %> + +
+ <% if internal_change_note.present? %> +
+

Internal note

+

<%= internal_change_note %>

+
+ <% end %> + + <% if change_note.present? %> +
+

Public note

+

<%= change_note %>

+
+ <% end %> +
+
diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline/timeline_item_component.rb b/app/components/content_block_manager/content_block/document/show/document_timeline/timeline_item_component.rb new file mode 100644 index 000000000..11ae8b3c9 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline/timeline_item_component.rb @@ -0,0 +1,97 @@ +class ContentBlockManager::ContentBlock::Document::Show::DocumentTimeline::TimelineItemComponent < ViewComponent::Base + include ActionView::Helpers::RecordTagHelper + + def initialize(version:, schema:, is_first_published_version:, is_latest:) + @version = version + @schema = schema + @is_first_published_version = is_first_published_version + @is_latest = is_latest + end + +private + + attr_reader :version, :schema, :is_first_published_version, :is_latest + + def title + if version.is_embedded_update? + "#{updated_subschema_id.humanize.singularize} added" + elsif version.state == "published" + is_first_published_version ? "#{version.item.block_type.humanize} created" : version.state.capitalize + elsif version.state == "scheduled" + "Scheduled for publishing on #{version.item.scheduled_publication.to_fs(:long_ordinal_with_at)}" + else + "#{version.item.block_type.humanize} #{version.state}" + end + end + + def updated_subschema_id + version.updated_embedded_object_type + end + + def new_subschema_item_details + field_diff = version.field_diffs.dig("details", updated_subschema_id, version.updated_embedded_object_title).first + { field: field_diff[0].humanize, new_value: field_diff[1].new_value } + end + + def date + tag.time( + version.created_at.to_fs(:long_ordinal_with_at), + class: "date", + datetime: version.created_at.iso8601, + lang: "en", + ) + end + + def byline + User.find_by_id(version.whodunnit)&.then { |user| helpers.linked_author(user, { class: "govuk-link" }) } || "unknown user" + end + + def internal_change_note + version.item.internal_change_note + end + + def change_note + version.item.change_note + end + + def embedded_object_diffs + schema.subschemas.map { |subschema| + version.field_diffs.dig("details", subschema.id)&.map do |object_id, field_diff| + { object_id:, field_diff:, subschema_id: subschema.id } + end + }.flatten.compact + end + + def show_details_of_changes? + !version.is_embedded_update? && details_of_changes.present? + end + + def details_of_changes + @details_of_changes ||= begin + return "" if version.field_diffs.blank? + + [ + main_object_field_changes, + embedded_object_field_changes, + ].join.html_safe + end + end + + def main_object_field_changes + render ContentBlockManager::ContentBlock::Document::Show::DocumentTimeline::FieldChangesTableComponent.new( + version:, + schema:, + ) + end + + def embedded_object_field_changes + if embedded_object_diffs.any? + embedded_object_diffs.map do |item| + render ContentBlockManager::ContentBlock::Document::Show::DocumentTimeline::EmbeddedObject::FieldChangesTableComponent.new( + **item, + content_block_edition: version.item, + ) + end + end + end +end diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline_component.html.erb b/app/components/content_block_manager/content_block/document/show/document_timeline_component.html.erb new file mode 100644 index 000000000..0f9967fc0 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline_component.html.erb @@ -0,0 +1,12 @@ +

Change history

+ +
+ <% versions.each_with_index do |version, i| %> + <%= render ContentBlockManager::ContentBlock::Document::Show::DocumentTimeline::TimelineItemComponent.new( + version:, + schema:, + is_first_published_version: version.id == first_published_version.id, + is_latest: i == 0, + ) %> + <% end %> +
diff --git a/app/components/content_block_manager/content_block/document/show/document_timeline_component.rb b/app/components/content_block_manager/content_block/document/show/document_timeline_component.rb new file mode 100644 index 000000000..513dda5b7 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/document_timeline_component.rb @@ -0,0 +1,23 @@ +class ContentBlockManager::ContentBlock::Document::Show::DocumentTimelineComponent < ViewComponent::Base + include ActionView::Helpers::RecordTagHelper + def initialize(content_block_versions:, schema:) + @content_block_versions = content_block_versions + @schema = schema + end + +private + + attr_reader :content_block_versions, :schema + + def versions + content_block_versions.reject { |version| hide_from_user?(version) } + end + + def hide_from_user?(version) + version.state.nil? || version.state == "superseded" + end + + def first_published_version + @first_published_version ||= content_block_versions.filter { |v| v.state == "published" }.min_by(&:created_at) + end +end diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/blocks_component.html.erb b/app/components/content_block_manager/content_block/document/show/embedded_objects/blocks_component.html.erb new file mode 100644 index 000000000..0357165b5 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/blocks_component.html.erb @@ -0,0 +1,35 @@ +<%= tag.div(class: component_classes) do %> +
+ <%= render "govuk_publishing_components/components/summary_card", { + title: "Content blocks", + rows: summary_card_rows, + } %> +
+ + <% if !schema.embeddable_as_block? %> + <% nested_blocks.each do |args| %> + <%= render "govuk_publishing_components/components/summary_card", **args %> + <% end %> + <% else %> +
+ <%= render "govuk_publishing_components/components/details", { + title: "All #{object_name} attributes", + } do %> + <% capture do %> +
+ These are all the <%= object_name %> attributes that make up the <%= object_name %>. You can use the embed code for each attribute separately in your content if required. +
+
+ <%= render "govuk_publishing_components/components/summary_list", { + items: attribute_rows(:field), + } %> + + <% nested_blocks.each do |args| %> + <%= render "govuk_publishing_components/components/summary_card", **args %> + <% end %> +
+ <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/blocks_component.rb b/app/components/content_block_manager/content_block/document/show/embedded_objects/blocks_component.rb new file mode 100644 index 000000000..65b7756c9 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/blocks_component.rb @@ -0,0 +1,124 @@ +class ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::BlocksComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::EmbedCodeHelper + include ContentBlockManager::ContentBlock::SummaryListHelper + + def initialize(items:, object_type:, object_title:, content_block_document:) + @items = items + @object_type = object_type + @object_title = object_title + @content_block_document = content_block_document + end + +private + + attr_reader :items, :object_type, :object_title, :content_block_document + + def component_classes + [ + "app-c-embedded-objects-blocks-component", + ("app-c-embedded-objects-blocks-component--with-block" if schema.embeddable_as_block?), + ].compact.join(" ") + end + + def summary_card_rows + if schema.embeddable_as_block? + [block_row] + else + attribute_rows + end + end + + def attribute_rows(key_name = :key) + first_class_items(items).map do |key, value| + { + "#{key_name}": key_to_title(key), + value: content_for_row(key, value), + data: data_attributes_for_row(key), + } + end + end + + def nested_blocks + blocks = [] + + nested_items(items).each do |key, items| + if items.is_a?(Array) + items.each_with_index do |nested_items, index| + blocks << { + title: "#{key.singularize.titleize} #{index + 1}", + rows: rows_for_nested_items(nested_items, key, index), + } + end + else + blocks << { + title: key.titleize, + rows: rows_for_nested_items(items, key, nil), + } + end + end + + blocks + end + + def rows_for_nested_items(items, nested_name, index) + items.map do |key, value| + { + key: key_to_title(key), + value: content_for_row(embed_code_identifier(nested_name, index, key), value), + data: data_attributes_for_row(embed_code_identifier(nested_name, index, key)), + } + end + end + + def object_name + object_type.singularize.humanize.downcase + end + + def block_row + { + key: object_type.singularize.titleize, + value: content_for_block_row, + data: data_attributes_for_block_row, + } + end + + def content_for_row(key, value) + content = content_tag(:p, value, class: "app-c-embedded-objects-blocks-component__content govspeak") + content << content_tag(:p, content_block_document.embed_code_for_field("#{object_type}/#{object_title}/#{key}"), class: "app-c-embedded-objects-blocks-component__embed-code") + content + end + + def data_attributes_for_row(key) + { + testid: (object_title.parameterize + "_#{key}").underscore, + **copy_embed_code_data_attributes("#{object_type}/#{object_title}/#{key}", content_block_document), + } + end + + def content_for_block_row + content = content_tag(:div, + content_block_edition.render(content_block_document.embed_code_for_field("#{object_type}/#{object_title}")), + class: "app-c-embedded-objects-blocks-component__content govspeak") + content << content_tag(:p, content_block_document.embed_code_for_field("#{object_type}/#{object_title}"), class: "app-c-embedded-objects-blocks-component__embed-code") + content + end + + def data_attributes_for_block_row + { + testid: object_title.parameterize.underscore, + **copy_embed_code_data_attributes("#{object_type}/#{object_title}", content_block_document), + } + end + + def schema + @schema ||= content_block_document.schema.subschema(object_type) + end + + def content_block_edition + @content_block_edition ||= content_block_document.latest_edition + end + + def embed_code_identifier(*arr) + arr.compact.join("/") + end +end diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/metadata_component.html.erb b/app/components/content_block_manager/content_block/document/show/embedded_objects/metadata_component.html.erb new file mode 100644 index 000000000..ac075b2d7 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/metadata_component.html.erb @@ -0,0 +1,5 @@ +
+ <%= render "govuk_publishing_components/components/summary_list", { + items: rows, + } %> +
diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/metadata_component.rb b/app/components/content_block_manager/content_block/document/show/embedded_objects/metadata_component.rb new file mode 100644 index 000000000..e7d1bee99 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/metadata_component.rb @@ -0,0 +1,41 @@ +class ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::MetadataComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::TranslationHelper + def initialize(items:, object_type:, schema:) + @items = items + @object_type = object_type + @schema = schema + end + +private + + attr_reader :items + + def rows + unordered_rows.sort_by { |row| row_ordering_rule(row) } + end + + def unordered_rows + items.map do |key, value| + { + field: humanized_label(relative_key: key, root_object: @object_type), + value: translated_value(key, value), + } + end + end + + def row_ordering_rule(row) + field = row.fetch(:field).is_a?(String) ? row.fetch(:field).downcase : row.fetch(:field) + + if field_order + # If a field order is found in the config, order by the index. If a field is not found, put it to the end + field_order.index(field) || Float::INFINITY + else + # By default, order with title first + field == "title" ? 0 : 1 + end + end + + def field_order + @schema.config["field_order"] + end +end diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_item_component.html.erb b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_item_component.html.erb new file mode 100644 index 000000000..fc6f9361e --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_item_component.html.erb @@ -0,0 +1,5 @@ +<%= render ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::MetadataComponent.new( + items: metadata_items, object_type:, schema: schema) %> + +<%= render ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::BlocksComponent.new( + items: block_items, object_type:, object_title:, content_block_document: content_block_edition.document) %> diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_item_component.rb b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_item_component.rb new file mode 100644 index 000000000..663af3ced --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_item_component.rb @@ -0,0 +1,32 @@ +class ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::SubschemaItemComponent < ViewComponent::Base + def initialize(content_block_edition:, object_type:, object_title:) + @content_block_edition = content_block_edition + @object_type = object_type + @object_title = object_title + end + +private + + attr_reader :content_block_edition, :object_type, :object_title + + def metadata_items + object.reject { |k, _v| embeddable_fields.include?(k) } + end + + def block_items + object.select { |k, v| v.present? && embeddable_fields.include?(k) } + .sort_by { |k, _v| schema.field_ordering_rule(k) }.to_h + end + + def embeddable_fields + @embeddable_fields ||= schema.embeddable_fields + end + + def schema + @schema ||= content_block_edition.document.schema.subschema(object_type) + end + + def object + @object ||= content_block_edition.details.dig(object_type, object_title) + end +end diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_items_component.html.erb b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_items_component.html.erb new file mode 100644 index 000000000..665b39841 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_items_component.html.erb @@ -0,0 +1,6 @@ +<% embedded_objects.keys.each do |key| %> + <%= render ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::SubschemaItemComponent.new( + content_block_edition:, + object_type:, + object_title: key) %> +<% end %> diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_items_component.rb b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_items_component.rb new file mode 100644 index 000000000..5e1d01e49 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/subschema_items_component.rb @@ -0,0 +1,26 @@ +class ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::SubschemaItemsComponent < ViewComponent::Base + def initialize(content_block_edition:, subschema:) + @content_block_edition = content_block_edition + @subschema = subschema + end + + def id + object_type + end + + def label + "#{subschema.name.pluralize} (#{embedded_objects.count})" + end + +private + + attr_reader :show_button, :content_block_edition, :subschema + + def embedded_objects + @embedded_objects ||= content_block_edition.details.fetch(object_type, {}) + end + + def object_type + @object_type ||= subschema.id + end +end diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/tab_group_component.html.erb b/app/components/content_block_manager/content_block/document/show/embedded_objects/tab_group_component.html.erb new file mode 100644 index 000000000..77ee47d0d --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/tab_group_component.html.erb @@ -0,0 +1,3 @@ +<%= render "govuk_publishing_components/components/tabs", { + tabs:, +} %> diff --git a/app/components/content_block_manager/content_block/document/show/embedded_objects/tab_group_component.rb b/app/components/content_block_manager/content_block/document/show/embedded_objects/tab_group_component.rb new file mode 100644 index 000000000..3d046f137 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/embedded_objects/tab_group_component.rb @@ -0,0 +1,32 @@ +class ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::TabGroupComponent < ViewComponent::Base + def initialize(content_block_document:, subschemas:) + @content_block_edition = content_block_document.latest_edition + @subschemas = subschemas + end + +private + + attr_reader :content_block_edition, :subschemas + + def tabs + subschemas.sort_by(&:group_order).map do |subschema| + tab_for_subschema(subschema) + end + end + + def tab_for_subschema(subschema) + component = component_for_subschema(subschema) + { + id: component.id, + label: component.label, + content: render(component), + } + end + + def component_for_subschema(subschema) + ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::SubschemaItemsComponent.new( + content_block_edition:, + subschema:, + ) + end +end diff --git a/app/components/content_block_manager/content_block/document/show/host_editions_rollup_component.html.erb b/app/components/content_block_manager/content_block/document/show/host_editions_rollup_component.html.erb new file mode 100644 index 000000000..12fd94c6e --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/host_editions_rollup_component.html.erb @@ -0,0 +1,19 @@ +
+
+

Metrics

+ <% metrics.each do |name, value| %> +
+ <%= + render( + "govuk_publishing_components/components/glance_metric", + name: name.to_s.titleize, + figure: value[:figure], + measurement_display_label: value[:display_label], + measurement_explicit_label: value[:explicit_label], + heading_level: 3, + ) + %> +
+ <% end %> +
+
diff --git a/app/components/content_block_manager/content_block/document/show/host_editions_rollup_component.rb b/app/components/content_block_manager/content_block/document/show/host_editions_rollup_component.rb new file mode 100644 index 000000000..f13630126 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/host_editions_rollup_component.rb @@ -0,0 +1,25 @@ +class ContentBlockManager::ContentBlock::Document::Show::HostEditionsRollupComponent < ViewComponent::Base + METRICS = %i[locations instances organisations views].freeze + + def initialize(rollup:) + @rollup = rollup + end + +private + + attr_reader :rollup + + def metrics + METRICS.index_with do |metric| + abbreviate(rollup.send(metric)) + end + end + + def abbreviate(number) + { + figure: number_to_human(number, format: "%n"), + display_label: number_to_human(number, format: "%u", units: { unit: "", thousand: "k", million: "m", billion: "b" }), + explicit_label: number_to_human(number, format: "%u"), + } + end +end diff --git a/app/components/content_block_manager/content_block/document/show/host_editions_table_component.html.erb b/app/components/content_block_manager/content_block/document/show/host_editions_table_component.html.erb new file mode 100644 index 000000000..72c18e9a9 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/host_editions_table_component.html.erb @@ -0,0 +1,22 @@ + + +
+ <%= render "govuk_publishing_components/components/table", { + caption:, + caption_classes: "govuk-heading-l", + head:, + rows:, + } %> + + <% if total_pages > 1 %> + <% Admin::PaginationHelper.pagination_hash(current_page:, total_pages:, path: base_pagination_path).tap do |hash| %> + <%= render "components/pagination", { + previous_href: hash[:previous_href], + next_href: hash[:next_href], + items: hash[:items], + } %> + <% end %> + <% end %> +
diff --git a/app/components/content_block_manager/content_block/document/show/host_editions_table_component.rb b/app/components/content_block_manager/content_block/document/show/host_editions_table_component.rb new file mode 100644 index 000000000..c9739262c --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/host_editions_table_component.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +class ContentBlockManager::ContentBlock::Document::Show::HostEditionsTableComponent < ViewComponent::Base + TABLE_ID = "host_editions" + + def initialize(caption:, host_content_items:, content_block_edition:, current_page: nil, order: nil) + @caption = caption + @host_content_items = host_content_items + @current_page = current_page.presence || 1 + @order = order.presence || ContentBlockManager::HostContentItem::DEFAULT_ORDER + @content_block_edition = content_block_edition + end + + def current_page + @current_page.to_i + end + + def total_pages + host_content_items.total_pages.to_i + end + + def base_pagination_path + "#{request.url}##{TABLE_ID}" + end + +private + + attr_reader :caption, :host_content_items, :order, :content_block_edition + + def head + [ + { + text: "Title", + href: sort_link("title"), + sort_direction: sort_direction("title"), + }, + { + text: "Type", + href: sort_link("document_type"), + sort_direction: sort_direction("document_type"), + }, + { + text: "Views (30 days)", + href: sort_link("unique_pageviews"), + sort_direction: sort_direction("unique_pageviews"), + }, + { + text: "Instances", + href: sort_link("instances"), + sort_direction: sort_direction("instances"), + }, + { + text: "Lead organisation", + href: sort_link("primary_publishing_organisation_title"), + sort_direction: sort_direction("primary_publishing_organisation_title"), + }, + { + text: "Last updated", + href: sort_link("last_edited_at"), + sort_direction: sort_direction("last_edited_at"), + }, + ].compact + end + + def rows + return [] unless host_content_items + + host_content_items.map do |content_item| + row_for_content_item(content_item) + end + end + + def row_for_content_item(content_item) + [ + title_row(content_item), + { + text: content_item.document_type.humanize, + }, + { + text: content_item.unique_pageviews ? number_to_human(content_item.unique_pageviews, format: "%n%u", precision: 3, significant: true, units: { thousand: "k", million: "m", billion: "b" }) : 0, + }, + { + text: content_item.instances, + }, + { + text: organisation_link(content_item), + }, + { + text: updated_field_for(content_item), + }, + ].compact + end + + def sort_direction(param) + case order + when param + "ascending" + when "-#{param}" + "descending" + end + end + + def sort_link(param) + if sort_direction(param) == "ascending" + param = "-#{param}" + end + helpers.content_block_manager.url_for(only_path: false, params: { order: param }, anchor: TABLE_ID) + end + + def frontend_path(content_item) + Plek.website_root + content_item.base_path + end + + def title_row(content_item) + { + text: content_link(content_item), + } + end + + def content_link_text(content_item) + sanitize [ + content_item.title, + tag.span("(opens in new tab)", class: "govuk-visually-hidden"), + ].join(" ") + end + + def content_link(content_item) + link_to(content_link_text(content_item), + frontend_path(content_item), class: "govuk-link", target: "_blank", rel: "noopener") + end + + def organisation_link(content_item) + return nil if content_item.nil? + + matching_organisation = all_publishing_organisations.find_by_content_id(content_item.publishing_organisation["content_id"]) + if matching_organisation + link_to(matching_organisation.name, admin_organisation_path(matching_organisation), class: "govuk-link") + else + content_item.publishing_organisation.fetch("title", nil) + end + end + + def all_publishing_organisations + @all_publishing_organisations ||= begin + host_content_ids = host_content_items.map { |content_item| + content_item.publishing_organisation.fetch("content_id", nil) + }.compact + + Organisation.where(content_id: host_content_ids) + end + end + + def updated_field_for(content_item) + user_copy = if content_item.last_edited_by_editor + link_to( + content_item.last_edited_by_editor.name, + helpers.content_block_manager.content_block_manager_user_path(content_item.last_edited_by_editor.uid), { class: "govuk-link" } + ) + else + "Unknown user" + end + "#{time_ago_in_words(content_item.last_edited_at)} ago by #{user_copy}".html_safe + end +end diff --git a/app/components/content_block_manager/content_block/document/show/summary_list_component.html.erb b/app/components/content_block_manager/content_block/document/show/summary_list_component.html.erb new file mode 100644 index 000000000..6c9b30952 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/summary_list_component.html.erb @@ -0,0 +1,3 @@ +<%= render "govuk_publishing_components/components/summary_list", { + items:, +} %> diff --git a/app/components/content_block_manager/content_block/document/show/summary_list_component.rb b/app/components/content_block_manager/content_block/document/show/summary_list_component.rb new file mode 100644 index 000000000..ab4388792 --- /dev/null +++ b/app/components/content_block_manager/content_block/document/show/summary_list_component.rb @@ -0,0 +1,102 @@ +class ContentBlockManager::ContentBlock::Document::Show::SummaryListComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::EditionHelper + include ContentBlockManager::ContentBlock::EmbedCodeHelper + + def initialize(content_block_document:) + @content_block_document = content_block_document + end + +private + + attr_reader :content_block_document + + def items + [ + title_item, + *details_items, + organisation_item, + instructions_item, + status_item, + ].compact + end + + def title_item + { + field: "Title", + value: content_block_document.title, + } + end + + def organisation_item + { + field: "Lead organisation", + value: content_block_edition.lead_organisation, + } + end + + def instructions_item + { + field: "Instructions to publishers", + value: formatted_instructions_to_publishers(content_block_edition), + } + end + + def details_items + schema.fields.map { |field| + key = field.name + rows = [{ + field: key.humanize, + value: content_block_edition.details[key], + data: data_attributes_for_row(key), + }] + rows.push(embed_code_row(key, content_block_document)) if should_show_embed_code?(key) + rows + }.flatten + end + + def data_attributes_for_row(key) + copy_embed_code_data_attributes(key, content_block_document) if should_show_embed_code?(key) + end + + def should_show_embed_code?(key) + embeddable_fields.include?(key) + end + + def schema + @schema ||= content_block_document.schema + end + + def embeddable_fields + @embeddable_fields = schema.embeddable_fields + end + + def status_item + if content_block_edition.state == "scheduled" + { + field: "Status", + value: scheduled_value, + edit: { + href: helpers.content_block_manager.content_block_manager_content_block_document_schedule_edit_path(content_block_document), + link_text: sanitize("Edit schedule"), + }, + } + else + { + field: "Status", + value: last_updated_value, + } + end + end + + def last_updated_value + "Published on #{published_date(content_block_edition)} by #{content_block_edition.creator.name}".html_safe + end + + def scheduled_value + "Scheduled for publication at #{scheduled_date(content_block_edition)}".html_safe + end + + def content_block_edition + @content_block_edition = content_block_document.latest_edition + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/embedded_objects/form_component.rb b/app/components/content_block_manager/content_block_edition/details/embedded_objects/form_component.rb new file mode 100644 index 000000000..b72644fee --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/embedded_objects/form_component.rb @@ -0,0 +1,26 @@ +class ContentBlockManager::ContentBlockEdition::Details::EmbeddedObjects::FormComponent < ContentBlockManager::ContentBlockEdition::Details::FormComponent + def initialize(content_block_edition:, subschema:, params:, object_title: nil) + @content_block_edition = content_block_edition + @subschema = subschema + @params = params || {} + @object_title = object_title + end + +private + + attr_reader :content_block_edition, :subschema, :params, :object_title + + def schema + @subschema + end + + def component_args(field) + { + content_block_edition:, + field: field, + subschema:, + value: params[field.name], + object_title:, + }.compact + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/array/item_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/array/item_component.html.erb new file mode 100644 index 000000000..4a102bd7d --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/array/item_component.html.erb @@ -0,0 +1,47 @@ +<%= content_tag :div, class: wrapper_classes do %> + <% if array_items["type"] == "string" %> + <% if array_items["enum"] %> + <%= render "govuk_publishing_components/components/select", { + label: field_name.humanize, + name:, + id:, + options: select_options(array_items["enum"], value), + error_message: select_error_message, + } %> + <% else %> + <%= render "govuk_publishing_components/components/input", { + label: { + text: field_name.humanize, + }, + name:, + id:, + value: field_value, + error_items:, + } %> + <% end %> + <% elsif array_items["type"] == "object" %> + <% array_items["properties"].keys.each do |field| %> + <% properties = array_items["properties"][field] %> + <% if properties["enum"] %> + <%= render "govuk_publishing_components/components/select", { + label: field.humanize, + name: object_field_name(field), + id: object_field_id(field), + options: select_options(properties["enum"], object_field_value(field)), + full_width: true, + error_message: select_error_message(field), + } %> + <% else %> + <%= render "govuk_publishing_components/components/input", { + label: { + text: field.humanize, + }, + name: object_field_name(field), + id: object_field_id(field), + value: object_field_value(field), + error_items: error_items(field), + } %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/array/item_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/array/item_component.rb new file mode 100644 index 000000000..2be8a488d --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/array/item_component.rb @@ -0,0 +1,76 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::Array::ItemComponent < ViewComponent::Base + include ErrorsHelper + + def initialize(field_name:, array_items:, name_prefix:, id_prefix:, value:, index:, errors:, error_lookup_prefix:, can_be_deleted:) + @field_name = field_name + @array_items = array_items + @name_prefix = name_prefix + @id_prefix = id_prefix + @value = value + @index = index + @errors = errors + @error_lookup_prefix = error_lookup_prefix + @can_be_deleted = can_be_deleted + end + +private + + attr_reader :field_name, :array_items, :name_prefix, :id_prefix, :value, :index, :errors, :error_lookup_prefix, :can_be_deleted + + def wrapper_classes + [ + "app-c-content-block-manager-array-item-component", + ("app-c-content-block-manager-array-item-component--immutable" unless can_be_deleted), + ].join(" ") + end + + def name + "#{name_prefix}[]" + end + + def id + "#{id_prefix}_#{index}" + end + + def field_value + value[index] + end + + def error_items(field = nil) + errors_for(errors, [error_lookup_prefix, index, field].compact.join("_").to_sym) + end + + def select_error_message(field = nil) + error_items(field)&.first&.fetch(:text) + end + + def object_field_name(field) + "#{name}[#{field}]" + end + + def object_field_id(field) + "#{id}_#{field}" + end + + def object_field_value(field) + value ? field_value&.fetch(field) : nil + end + + def select_options(enum, value) + options = [{ + text: "Select", + value: "", + selected: value.nil?, + }] + + enum.each do |item| + options << { + text: item.humanize, + value: item, + selected: item == value, + } + end + + options + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/array_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/array_component.html.erb new file mode 100644 index 000000000..56d037a89 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/array_component.html.erb @@ -0,0 +1,11 @@ +
+ <%= render "govuk_publishing_components/components/add_another", { + fieldset_legend: label, + add_button_text: "Add another #{label}", + items:, + empty:, + data_attributes: { + ga4_start_index: 0, + }, + } %> +
diff --git a/app/components/content_block_manager/content_block_edition/details/fields/array_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/array_component.rb new file mode 100644 index 000000000..f66cbb7a0 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/array_component.rb @@ -0,0 +1,71 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::ArrayComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent + def initialize(object_title: nil, **args) + @object_title = object_title + super(**args) + end + +private + + def label + super.singularize + end + + def value + super || [] + end + + def items + if value.count.positive? + Array.new(value.count) do |index| + { + fields: render(component(index)), + destroy_checkbox: destroy_checkbox(index), + } + end + else + [{ fields: render(component(0)) }] + end + end + + def empty + render component(value.count.positive? ? value.count : 1) + end + + def component(index) + ContentBlockManager::ContentBlockEdition::Details::Fields::Array::ItemComponent.new( + field_name: label, + array_items: field.array_items, + name_prefix: name, + id_prefix: id, + value: value, + index: index, + errors:, + error_lookup_prefix: "details_#{id_suffix}", + can_be_deleted: can_be_deleted?(index), + ) + end + + def destroy_checkbox(index) + field_name = "#{name}[][_destroy]" + if can_be_deleted?(index) + render("govuk_publishing_components/components/checkboxes", { name: field_name, items: [{ label: "Delete", value: "1" }] }) + else + hidden_field_tag(field_name, 0) + end + end + + def errors + content_block_edition.errors + end + + def can_be_deleted?(index) + immutability_checker&.can_be_deleted?(index) + end + + def immutability_checker + @immutability_checker ||= ContentBlockManager::EmbeddedObjectImmutabilityCheck.new( + edition: content_block_edition.document.latest_edition, + field_reference: [subschema_block_type, @object_title, field.name].compact, + ) + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/base_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/base_component.rb new file mode 100644 index 000000000..193937247 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/base_component.rb @@ -0,0 +1,59 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent < ViewComponent::Base + include ErrorsHelper + include ContentBlockManager::ContentBlock::TranslationHelper + + PARENT_CLASS = "content_block_manager_content_block_edition".freeze + + def initialize(content_block_edition:, field:, value: nil, subschema: nil, **_args) + @content_block_edition = content_block_edition + @field = field + @value = value || field.default_value + @subschema = subschema + end + +private + + attr_reader :content_block_edition, :field, :subschema, :value + + def subschema_block_type + @subschema_block_type ||= subschema&.block_type + end + + def label + optional = field.is_required? ? nil : optional_label + "#{humanized_label(relative_key: field.name, root_object: subschema_block_type)}" \ + "#{optional}" + end + + def optional_label + " (optional)" + end + + def name + if subschema_block_type + "content_block/edition[details][#{subschema_block_type}][#{field.name}]" + else + "content_block/edition[details][#{field.name}]" + end + end + + def id + "#{PARENT_CLASS}_details_#{id_suffix}" + end + + def error_items + errors_for(content_block_edition.errors, "details_#{id_suffix}".to_sym) + end + + def hint + I18n.t("content_block_edition.details.hints.#{translation_lookup}", default: nil) + end + + def translation_lookup + @translation_lookup ||= subschema_block_type ? "#{subschema_block_type}.#{field.name}" : field.name + end + + def id_suffix + subschema_block_type ? "#{subschema_block_type}_#{field.name}" : field.name + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/boolean_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/boolean_component.html.erb new file mode 100644 index 000000000..40317cb2a --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/boolean_component.html.erb @@ -0,0 +1,11 @@ +<%= hidden_field_tag name, false %> +<%= render "govuk_publishing_components/components/checkboxes", { + heading: label, + visually_hide_heading: true, + no_hint_text: true, + small: true, + name:, + id:, + heading_size: "s", + items:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/boolean_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/boolean_component.rb new file mode 100644 index 000000000..11f22d58a --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/boolean_component.rb @@ -0,0 +1,13 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::BooleanComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent +private + + def items + [ + { + value: true, + label:, + checked: value.present? ? ActiveModel::Type::Boolean.new.cast(value) : false, + }, + ] + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/bsl_guidance_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/bsl_guidance_component.html.erb new file mode 100644 index 000000000..52b34ff42 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/bsl_guidance_component.html.erb @@ -0,0 +1,33 @@ +
+ <% value_input = capture do %> + <%= render( + ContentBlockManager::ContentBlockEdition::Details::Fields::GovspeakEnabledTextareaComponent.new( + content_block_edition: content_block_edition, + value: value_for_field(value_field), + nested_object_key: field.name, + field: value_field, + subschema: subschema, + ), + ) %> + <% end %> + + + + <%= render "govuk_publishing_components/components/checkboxes", { + name: name_for_field(show_field), + id: id_for_field(show_field), + text: label_for("show"), + heading: label_for("show"), + visually_hide_heading: true, + no_hint_text: true, + small: true, + items: [ + { + label: label_for("show"), + value: "true", + checked: value_for_field(show_field) == true, + conditional: value_input, + }, + ], + } %> +
diff --git a/app/components/content_block_manager/content_block_edition/details/fields/bsl_guidance_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/bsl_guidance_component.rb new file mode 100644 index 000000000..f729e4272 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/bsl_guidance_component.rb @@ -0,0 +1,13 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::BSLGuidanceComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::ObjectComponent + def show_field + field.nested_field("show") + end + + def value_field + field.nested_field("value") + end + + def label_for(field_name) + humanized_label(relative_key: field_name, root_object: "telephones.bsl_guidance") + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/call_charges_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/call_charges_component.html.erb new file mode 100644 index 000000000..c11a9a357 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/call_charges_component.html.erb @@ -0,0 +1,42 @@ +
+ + <% call_charges_url_input = capture do %> + <%= render("govuk_publishing_components/components/input", { + label: { + text: label_for("label"), + }, + name: name_for_field(label) , + id: id_for_field(label), + value: value_for_field(label) || label.default_value, + }) %> + + <%= render("govuk_publishing_components/components/input", { + label: { + text: label_for("call_charges_info_url"), + }, + name: name_for_field(call_charges_info_url) , + id: id_for_field(call_charges_info_url), + value: value_for_field(call_charges_info_url) || call_charges_info_url.default_value, + }) %> + <% end %> + + + + <%= render "govuk_publishing_components/components/checkboxes", { + name: name_for_field(show_call_charges_info_url), + id: id_for_field(show_call_charges_info_url), + heading: label_for("show_call_charges_info_url"), + visually_hide_heading: true, + no_hint_text: true, + small: true, + items: [ + { + label: label_for("show_call_charges_info_url"), + value: "true", + checked: value_for_field(show_call_charges_info_url) == true, + conditional: call_charges_url_input, + }, + ], + } %> + +
diff --git a/app/components/content_block_manager/content_block_edition/details/fields/call_charges_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/call_charges_component.rb new file mode 100644 index 000000000..38da454a4 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/call_charges_component.rb @@ -0,0 +1,17 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::CallChargesComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::ObjectComponent + def show_call_charges_info_url + field.nested_field("show_call_charges_info_url") + end + + def call_charges_info_url + field.nested_field("call_charges_info_url") + end + + def label + field.nested_field("label") + end + + def label_for(field_name) + humanized_label(relative_key: field_name, root_object: "telephones.call_charges") + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/country_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/country_component.rb new file mode 100644 index 000000000..ca544be14 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/country_component.rb @@ -0,0 +1,18 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::CountryComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::EnumComponent + BLANK_OPTION = "United Kingdom".freeze + + def initialize(**args) + countries = WorldLocation.geographical.map(&:name) + super(**args.merge(enum: countries)) + end + +private + + def enum + @enum.excluding(blank_option) + end + + def blank_option + BLANK_OPTION + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/enum_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/enum_component.html.erb new file mode 100644 index 000000000..b7648601d --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/enum_component.html.erb @@ -0,0 +1,11 @@ +<%= render "govuk_publishing_components/components/select", { + label:, + name:, + id:, + value:, + hint:, + options:, + error_message:, + full_width: true, + display_empty: true, +} %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/enum_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/enum_component.rb new file mode 100644 index 000000000..f52434acd --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/enum_component.rb @@ -0,0 +1,43 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::EnumComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent + def initialize(enum:, default: "", **args) + @enum = enum + @default = default + super(**args) + end + + def options + options = [ + { + text: blank_option, + value: "", + selected: selected?(blank_option), + }, + ] + + enum.each do |item| + options.push({ + text: item, + value: item, + selected: selected?(item), + }) + end + + options + end + +private + + attr_reader :enum + + def error_message + error_items&.first&.fetch(:text) + end + + def selected?(item) + item == (value.presence || @default) + end + + def blank_option + @default.empty? ? "" : nil + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/govspeak_enabled_textarea_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/govspeak_enabled_textarea_component.html.erb new file mode 100644 index 000000000..02223c87e --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/govspeak_enabled_textarea_component.html.erb @@ -0,0 +1,21 @@ +
+ <% if govspeak_enabled? %> + <%= render "components/govspeak_editor", { + name: name_attribute, + id: id_attribute, + label: { text: label, hint_text: hint_text, hint_id: aria_described_by }, + value:, + error_items: error_items, + hint_id: aria_described_by, + rows: 5, + } %> + <% else %> + <%= render "govuk_publishing_components/components/textarea", { + label: { text: label }, + name: name_attribute, + id: id_attribute, + value:, + error_items: error_items, + } %> + <% end %> +
diff --git a/app/components/content_block_manager/content_block_edition/details/fields/govspeak_enabled_textarea_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/govspeak_enabled_textarea_component.rb new file mode 100644 index 000000000..2071552d5 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/govspeak_enabled_textarea_component.rb @@ -0,0 +1,53 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::GovspeakEnabledTextareaComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent + def initialize(content_block_edition:, value:, nested_object_key:, field:, subschema:) + @nested_object_key = nested_object_key + @field = field + @value = value + @content_block_edition = content_block_edition + + super( + subschema: subschema, + content_block_edition: content_block_edition, + field: field, + value: value, + ) + end + + attr_reader :content_block_edition, :nested_object_key, :field, :subschema, :errors + + def govspeak_enabled? + subschema.govspeak_enabled?(nested_object_key: nested_object_key, field_name: field.name) + end + + def id_attribute + "#{PARENT_CLASS}_details_#{subschema_block_type}_#{nested_object_key}_#{field.name}" + end + + def aria_described_by + "#{id_attribute}-hint" + end + + def hint_text + return nil unless govspeak_enabled? + + "Govspeak supported" + end + + def label + helpers.humanized_label( + relative_key: field.name, + root_object: "#{subschema_block_type}.#{nested_object_key}", + ) + end + + def name_attribute + "content_block/edition[details][#{subschema_block_type}][#{nested_object_key}][#{field.name}]" + end + + def error_items + errors_for( + content_block_edition.errors, + "details_#{subschema_block_type}_#{nested_object_key}_#{field.name}".to_sym, + ) + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/object_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/object_component.html.erb new file mode 100644 index 000000000..ba20e0d0a --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/object_component.html.erb @@ -0,0 +1,19 @@ +<%= render "govuk_publishing_components/components/fieldset", { + legend_text: label, + heading_level: 3, + heading_size: "m", +} do %> + <% capture do %> + <% field.nested_fields.each do |embedded_field| %> + <%= render "govuk_publishing_components/components/input", { + label: { + text: embedded_field.name.humanize, + }, + name: name_for_field(embedded_field), + id: id_for_field(embedded_field), + value: value_for_field(embedded_field), + error_items: errors_for_field(embedded_field), + } %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/object_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/object_component.rb new file mode 100644 index 000000000..47f0275d1 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/object_component.rb @@ -0,0 +1,19 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::ObjectComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent +private + + def name_for_field(field) + "#{name}[#{field.name}]" + end + + def id_for_field(field) + "#{id}_#{field.name}" + end + + def errors_for_field(field) + errors_for(content_block_edition.errors, "details_#{id_suffix}_#{field.name}".to_sym) + end + + def value_for_field(field) + value&.fetch(field.name, nil) || field.default_value + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/opening_hours_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/opening_hours_component.html.erb new file mode 100644 index 000000000..a71777116 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/opening_hours_component.html.erb @@ -0,0 +1,27 @@ + + +<%= render "govuk_publishing_components/components/checkboxes", { + name: name_for_field(show_opening_hours), + id: id_for_field(show_opening_hours), + text: label_for("show_opening_hours"), + heading: label_for("show_opening_hours"), + visually_hide_heading: true, + no_hint_text: true, + small: true, + items: [ + { + label: label_for("show_opening_hours"), + value: "true", + checked: value_for_field(show_opening_hours) == true, + conditional: capture do + render("govuk_publishing_components/components/textarea", { + label: { text: label_for("opening_hours") }, + name: name_for_field(opening_hours) , + id: id_for_field(opening_hours), + value: value_for_field(opening_hours), + error_items: errors_for_field(opening_hours), + }) + end, + }, + ], +} %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/opening_hours_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/opening_hours_component.rb new file mode 100644 index 000000000..34f7521b3 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/opening_hours_component.rb @@ -0,0 +1,15 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::OpeningHoursComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::ObjectComponent +private + + def show_opening_hours + field.nested_field("show_opening_hours") + end + + def opening_hours + field.nested_field("opening_hours") + end + + def label_for(field_name) + humanized_label(relative_key: field_name, root_object: "telephones.opening_hours") + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/string_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/string_component.html.erb new file mode 100644 index 000000000..ae83192f9 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/string_component.html.erb @@ -0,0 +1,10 @@ +<%= render "govuk_publishing_components/components/input", { + label: { + text: label, + }, + name:, + id:, + value:, + error_items:, + hint:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/string_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/string_component.rb new file mode 100644 index 000000000..e274eae28 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/string_component.rb @@ -0,0 +1,2 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::StringComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/textarea_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/textarea_component.html.erb new file mode 100644 index 000000000..2ba87a685 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/textarea_component.html.erb @@ -0,0 +1,10 @@ +<%= render "govuk_publishing_components/components/textarea", { + label: { + text: label, + }, + name:, + textarea_id: id, + value:, + error_items:, + hint:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/details/fields/textarea_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/textarea_component.rb new file mode 100644 index 000000000..a32c0643a --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/textarea_component.rb @@ -0,0 +1,2 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::TextareaComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::BaseComponent +end diff --git a/app/components/content_block_manager/content_block_edition/details/fields/video_relay_service_component.html.erb b/app/components/content_block_manager/content_block_edition/details/fields/video_relay_service_component.html.erb new file mode 100644 index 000000000..ca296f682 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/video_relay_service_component.html.erb @@ -0,0 +1,43 @@ +
+ + <% video_relay_service_inputs = capture do %> + <%= render( + ContentBlockManager::ContentBlockEdition::Details::Fields::GovspeakEnabledTextareaComponent.new( + content_block_edition: content_block_edition, + value: value_for_field(prefix) , + nested_object_key: field.name, + field: prefix, + subschema: subschema, + ), + ) %> + + <%= render("govuk_publishing_components/components/input", { + label: { text: label_for("telephone_number") }, + name: name_for_field(telephone_number) , + id: id_for_field(telephone_number), + value: value_for_field(telephone_number) || telephone_number.default_value, + error_items: errors_for_field(telephone_number), + }) %> + <% end %> + + + + <%= render "govuk_publishing_components/components/checkboxes", { + name: name_for_field(show_video_relay_service), + id: id_for_field(show_video_relay_service), + text: label_for("show"), + heading: label_for("show"), + visually_hide_heading: true, + no_hint_text: true, + small: true, + items: [ + { + label: label_for("show"), + value: "true", + checked: value_for_field(show_video_relay_service) == true, + conditional: video_relay_service_inputs, + }, + ], + } %> + +
diff --git a/app/components/content_block_manager/content_block_edition/details/fields/video_relay_service_component.rb b/app/components/content_block_manager/content_block_edition/details/fields/video_relay_service_component.rb new file mode 100644 index 000000000..449c9d779 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/fields/video_relay_service_component.rb @@ -0,0 +1,17 @@ +class ContentBlockManager::ContentBlockEdition::Details::Fields::VideoRelayServiceComponent < ContentBlockManager::ContentBlockEdition::Details::Fields::ObjectComponent + def show_video_relay_service + field.nested_field("show") + end + + def prefix + field.nested_field("prefix") + end + + def telephone_number + field.nested_field("telephone_number") + end + + def label_for(field_name) + humanized_label(relative_key: field_name, root_object: "telephones.video_relay_service") + end +end diff --git a/app/components/content_block_manager/content_block_edition/details/form_component.html.erb b/app/components/content_block_manager/content_block_edition/details/form_component.html.erb new file mode 100644 index 000000000..c6caa1dd4 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/form_component.html.erb @@ -0,0 +1,5 @@ +<% schema.fields.each do |field| %> + <%= content_tag :div, data: field.data_attributes do %> + <%= render component_for_field(field) %> + <% end %> +<% end %> diff --git a/app/components/content_block_manager/content_block_edition/details/form_component.rb b/app/components/content_block_manager/content_block_edition/details/form_component.rb new file mode 100644 index 000000000..64cf5d64a --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/details/form_component.rb @@ -0,0 +1,26 @@ +class ContentBlockManager::ContentBlockEdition::Details::FormComponent < ViewComponent::Base + def initialize(content_block_edition:, schema:) + @content_block_edition = content_block_edition + @schema = schema + end + +private + + attr_reader :content_block_edition, :schema + + def component_for_field(field) + component_name = field.component_name + component_class = "ContentBlockManager::ContentBlockEdition::Details::Fields::#{component_name.camelize}Component".constantize + args = component_args(field).merge(enum: field.enum_values, default: field.default_value) + + component_class.new(**args.compact) + end + + def component_args(field) + { + content_block_edition:, + field:, + value: content_block_edition.details&.fetch(field.name, nil), + } + end +end diff --git a/app/components/content_block_manager/content_block_edition/host_content/preview_details_component.html.erb b/app/components/content_block_manager/content_block_edition/host_content/preview_details_component.html.erb new file mode 100644 index 000000000..ed4965c3c --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/host_content/preview_details_component.html.erb @@ -0,0 +1,5 @@ + diff --git a/app/components/content_block_manager/content_block_edition/host_content/preview_details_component.rb b/app/components/content_block_manager/content_block_edition/host_content/preview_details_component.rb new file mode 100644 index 000000000..d935ce77f --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/host_content/preview_details_component.rb @@ -0,0 +1,24 @@ +class ContentBlockManager::ContentBlockEdition::HostContent::PreviewDetailsComponent < ViewComponent::Base + def initialize(content_block_edition:, preview_content:) + @content_block_edition = content_block_edition + @preview_content = preview_content + end + +private + + def list_items + [*details_items.compact, instances_item] + end + + def details_items + @content_block_edition.details.map do |key, value| + next unless value.is_a?(String) + + { key: key.humanize, value: } + end + end + + def instances_item + { key: "Instances", value: @preview_content.instances_count } + end +end diff --git a/app/components/content_block_manager/content_block_edition/host_content/table_component.rb b/app/components/content_block_manager/content_block_edition/host_content/table_component.rb new file mode 100644 index 000000000..ad3b6cb14 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/host_content/table_component.rb @@ -0,0 +1,31 @@ +class ContentBlockManager::ContentBlockEdition::HostContent::TableComponent < ContentBlockManager::ContentBlock::Document::Show::HostEditionsTableComponent +private + + def head + [super, { text: "Preview (Opens in new tab)" }].flatten + end + + def row_for_content_item(content_item) + [super(content_item), { text: preview_link(content_item) }].flatten + end + + def title_row(content_item) + { text: content_item.title } + end + + def preview_link(content_item) + link_to(preview_link_text(content_item), + frontend_path(content_item), class: "govuk-link", target: "_blank", rel: "noopener") + end + + def preview_link_text(content_item) + sanitize [ + "Preview", + tag.span("#{content_item.title} (opens in new tab)", class: "govuk-visually-hidden"), + ].join(" ") + end + + def frontend_path(content_item) + helpers.host_content_preview_content_block_manager_content_block_edition_path(id: content_block_edition.id, host_content_id: content_item.host_content_id, locale: content_item.host_locale) + end +end diff --git a/app/components/content_block_manager/content_block_edition/new/error_summary_component.html.erb b/app/components/content_block_manager/content_block_edition/new/error_summary_component.html.erb new file mode 100644 index 000000000..ffa94f0e8 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/new/error_summary_component.html.erb @@ -0,0 +1,22 @@ +<% if error_message %> + <%= render "govuk_publishing_components/components/error_summary", { + title: "There is a problem", + items: [ + { + text: error_message, + href: "#block_type", + data_attributes: { + module: "ga4-auto-tracker", + "ga4-auto": { + event_name: "form_error", + type: "New content block", + text: error_message, + section: "Content block", + action: "error", + tool_name: "Whitehall", + }.to_json, + }, + }, + ], + } %> +<% end %> diff --git a/app/components/content_block_manager/content_block_edition/new/error_summary_component.rb b/app/components/content_block_manager/content_block_edition/new/error_summary_component.rb new file mode 100644 index 000000000..4ae679d93 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/new/error_summary_component.rb @@ -0,0 +1,9 @@ +class ContentBlockManager::ContentBlockEdition::New::ErrorSummaryComponent < ViewComponent::Base + def initialize(error_message:) + @error_message = error_message + end + +private + + attr_reader :error_message +end diff --git a/app/components/content_block_manager/content_block_edition/new/select_schema_component.html.erb b/app/components/content_block_manager/content_block_edition/new/select_schema_component.html.erb new file mode 100644 index 000000000..c92c457be --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/new/select_schema_component.html.erb @@ -0,0 +1,9 @@ +<%= render "govuk_publishing_components/components/radio", { + heading:, + heading_caption:, + name: "block_type", + id: "block_type", + heading_size: "xl", + items: items, + error_message:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/new/select_schema_component.rb b/app/components/content_block_manager/content_block_edition/new/select_schema_component.rb new file mode 100644 index 000000000..fa22fb5ac --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/new/select_schema_component.rb @@ -0,0 +1,21 @@ +class ContentBlockManager::ContentBlockEdition::New::SelectSchemaComponent < ViewComponent::Base + def initialize(schemas:, heading:, heading_caption:, error_message:) + @heading = heading + @heading_caption = heading_caption + @error_message = error_message + @schemas = schemas + end + +private + + attr_reader :heading, :heading_caption, :error_message + + def items + @schemas.map do |schema| + { + value: schema.parameter, + text: schema.name, + } + end + end +end diff --git a/app/components/content_block_manager/content_block_edition/show/confirm_summary_card_component.html.erb b/app/components/content_block_manager/content_block_edition/show/confirm_summary_card_component.html.erb new file mode 100644 index 000000000..e9043bf02 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/show/confirm_summary_card_component.html.erb @@ -0,0 +1,5 @@ +<%= render "components/summary_card", { + title:, + rows:, + summary_card_actions:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/show/confirm_summary_card_component.rb b/app/components/content_block_manager/content_block_edition/show/confirm_summary_card_component.rb new file mode 100644 index 000000000..dbc86eade --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/show/confirm_summary_card_component.rb @@ -0,0 +1,67 @@ +class ContentBlockManager::ContentBlockEdition::Show::ConfirmSummaryCardComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::EditionHelper + + def initialize(content_block_edition:) + @content_block_edition = content_block_edition + end + +private + + attr_reader :content_block_edition + + def title + "#{content_block_edition.document.block_type.humanize} details" + end + + def rows + [ + title_item, + *details_items, + organisation_item, + instructions_item, + ].compact + end + + def title_item + { + key: "Title", + value: content_block_edition.title, + } + end + + def details_items + schema.fields.map do |field| + { + key: field.name.humanize, + value: content_block_edition.details[field.name], + } + end + end + + def organisation_item + { + key: "Lead organisation", + value: content_block_edition.lead_organisation, + } + end + + def instructions_item + { + key: "Instructions to publishers", + value: formatted_instructions_to_publishers(content_block_edition), + } + end + + def summary_card_actions + [ + { + label: "Edit", + href: helpers.content_block_manager.content_block_manager_content_block_workflow_path(id: content_block_edition.id, step: :edit_draft), + }, + ] + end + + def schema + @schema ||= content_block_edition.document.schema + end +end diff --git a/app/components/content_block_manager/content_block_edition/show/notes_summary_card_component.html.erb b/app/components/content_block_manager/content_block_edition/show/notes_summary_card_component.html.erb new file mode 100644 index 000000000..dbbdcf723 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/show/notes_summary_card_component.html.erb @@ -0,0 +1,4 @@ +<%= render "components/summary_card", { + title:, + rows:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/show/notes_summary_card_component.rb b/app/components/content_block_manager/content_block_edition/show/notes_summary_card_component.rb new file mode 100644 index 000000000..17063731c --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/show/notes_summary_card_component.rb @@ -0,0 +1,57 @@ +class ContentBlockManager::ContentBlockEdition::Show::NotesSummaryCardComponent < ViewComponent::Base + def initialize(content_block_edition:) + @content_block_edition = content_block_edition + end + +private + + attr_reader :content_block_edition + + def title + "Notes" + end + + def rows + content_block_edition.major_change ? [internal_change_note_item, major_change_item, external_change_note_item] : [internal_change_note_item, major_change_item] + end + + def internal_change_note_item + { + key: "Internal note", + value: content_block_edition.internal_change_note.presence || "None", + actions: [ + { + label: "Edit", + href: helpers.content_block_manager.content_block_manager_content_block_workflow_path(id: content_block_edition.id, step: :internal_note), + }, + ], + } + end + + def major_change_item + { + key: "Do users have to know the content has changed?", + value: content_block_edition.major_change ? "Yes" : "No", + actions: [ + { + href: helpers.content_block_manager.content_block_manager_content_block_workflow_path(id: content_block_edition.id, step: :change_note), + label: "Edit", + }, + ], + } + end + + def external_change_note_item + { + key: "Public change note", + value: content_block_edition.change_note, + actions: + [ + { + href: helpers.content_block_manager.content_block_manager_content_block_workflow_path(id: content_block_edition.id, step: :change_note), + label: "Edit", + }, + ], + } + end +end diff --git a/app/components/content_block_manager/content_block_edition/show/publication_details_summary_card_component.html.erb b/app/components/content_block_manager/content_block_edition/show/publication_details_summary_card_component.html.erb new file mode 100644 index 000000000..e9043bf02 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/show/publication_details_summary_card_component.html.erb @@ -0,0 +1,5 @@ +<%= render "components/summary_card", { + title:, + rows:, + summary_card_actions:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/show/publication_details_summary_card_component.rb b/app/components/content_block_manager/content_block_edition/show/publication_details_summary_card_component.rb new file mode 100644 index 000000000..ce3340e8a --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/show/publication_details_summary_card_component.rb @@ -0,0 +1,46 @@ +class ContentBlockManager::ContentBlockEdition::Show::PublicationDetailsSummaryCardComponent < ViewComponent::Base + def initialize(content_block_edition:) + @content_block_edition = content_block_edition + end + +private + + attr_reader :content_block_edition + + def title + "Publication details" + end + + def rows + [ + status_item, + ] + end + + def summary_card_actions + [ + { + label: "Edit", + href: helpers.content_block_manager.content_block_manager_content_block_workflow_path(id: content_block_edition.id, step: :schedule_publishing), + }, + ] + end + + def scheduled_value + I18n.l(content_block_edition.scheduled_publication, format: :long_ordinal) + end + + def status_item + if content_block_edition.scheduled_publication + { + key: "Scheduled date and time", + value: scheduled_value, + } + else + { + key: "Publish date", + value: I18n.l(Time.zone.today, format: :long_ordinal), + } + end + end +end diff --git a/app/components/content_block_manager/content_block_edition/workflow/group_component.html.erb b/app/components/content_block_manager/content_block_edition/workflow/group_component.html.erb new file mode 100644 index 000000000..77ee47d0d --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/workflow/group_component.html.erb @@ -0,0 +1,3 @@ +<%= render "govuk_publishing_components/components/tabs", { + tabs:, +} %> diff --git a/app/components/content_block_manager/content_block_edition/workflow/group_component.rb b/app/components/content_block_manager/content_block_edition/workflow/group_component.rb new file mode 100644 index 000000000..3a3f6de98 --- /dev/null +++ b/app/components/content_block_manager/content_block_edition/workflow/group_component.rb @@ -0,0 +1,44 @@ +class ContentBlockManager::ContentBlockEdition::Workflow::GroupComponent < ViewComponent::Base + def initialize(content_block_edition:, subschemas:) + @content_block_edition = content_block_edition + @subschemas = subschemas + end + +private + + attr_reader :content_block_edition, :subschemas + + def tabs + subschemas.sort_by(&:group_order).map { |subschema| + tab_for_subschema(subschema) + }.compact + end + + def tab_for_subschema(subschema) + items = items_for_subschema(subschema) + if items.any? + { + id: subschema.id, + label: tab_label(subschema, items), + content: content_for_tab(subschema, items), + } + end + end + + def tab_label(subschema, items) + "#{subschema.name.titleize} (#{items.values.count})" + end + + def content_for_tab(subschema, items) + render ContentBlockManager::Shared::EmbeddedObjects::SummaryCardComponent.with_collection( + items.keys, + content_block_edition: content_block_edition, + object_type: subschema.block_type, + redirect_url: request.fullpath, + ) + end + + def items_for_subschema(subschema) + content_block_edition.details.fetch(subschema.block_type, {}) + end +end diff --git a/app/components/content_block_manager/shared/cancel_and_delete_button_component.html.erb b/app/components/content_block_manager/shared/cancel_and_delete_button_component.html.erb new file mode 100644 index 000000000..4c76ee46b --- /dev/null +++ b/app/components/content_block_manager/shared/cancel_and_delete_button_component.html.erb @@ -0,0 +1,9 @@ +<%= form_with url: url, method: :patch do %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + name: "_method", + value: "delete", + type: "submit", + secondary_solid: true, + } %> +<% end %> diff --git a/app/components/content_block_manager/shared/cancel_and_delete_button_component.rb b/app/components/content_block_manager/shared/cancel_and_delete_button_component.rb new file mode 100644 index 000000000..7b1e7a6db --- /dev/null +++ b/app/components/content_block_manager/shared/cancel_and_delete_button_component.rb @@ -0,0 +1,9 @@ +class ContentBlockManager::Shared::CancelAndDeleteButtonComponent < ViewComponent::Base + def initialize(url:) + @url = url + end + +private + + attr_reader :url +end diff --git a/app/components/content_block_manager/shared/continue_or_cancel_button_group.html.erb b/app/components/content_block_manager/shared/continue_or_cancel_button_group.html.erb new file mode 100644 index 000000000..e8a6cc5c6 --- /dev/null +++ b/app/components/content_block_manager/shared/continue_or_cancel_button_group.html.erb @@ -0,0 +1,26 @@ +
+
+ <%= render "govuk_publishing_components/components/button", { + text: button_text, + name: "confirm", + type: "submit", + form: form_id, + } %> +
+
+ <% if is_editing? %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + href: helpers.content_block_manager.cancel_content_block_manager_content_block_workflow_index_path(content_block_edition), + secondary_solid: true, + } %> + <% else %> + <%= render ContentBlockManager::Shared::CancelAndDeleteButtonComponent.new( + url: helpers.content_block_manager.content_block_manager_content_block_edition_path( + content_block_edition, + redirect_path: helpers.content_block_manager.content_block_manager_content_block_documents_path, + ), + ) %> + <% end %> +
+
diff --git a/app/components/content_block_manager/shared/continue_or_cancel_button_group.rb b/app/components/content_block_manager/shared/continue_or_cancel_button_group.rb new file mode 100644 index 000000000..47127d2de --- /dev/null +++ b/app/components/content_block_manager/shared/continue_or_cancel_button_group.rb @@ -0,0 +1,15 @@ +class ContentBlockManager::Shared::ContinueOrCancelButtonGroup < ViewComponent::Base + def initialize(form_id:, content_block_edition:, button_text: "Save and continue") + @button_text = button_text + @form_id = form_id + @content_block_edition = content_block_edition + end + +private + + attr_reader :button_text, :form_id, :content_block_edition + + def is_editing? + content_block_edition.document.editions.count > 1 + end +end diff --git a/app/components/content_block_manager/shared/embedded_objects/summary_card/nested_item_component.html.erb b/app/components/content_block_manager/shared/embedded_objects/summary_card/nested_item_component.html.erb new file mode 100644 index 000000000..1991dc159 --- /dev/null +++ b/app/components/content_block_manager/shared/embedded_objects/summary_card/nested_item_component.html.erb @@ -0,0 +1,6 @@ +
+ <%= render "govuk_publishing_components/components/summary_card", { + title:, + rows:, + } %> +
diff --git a/app/components/content_block_manager/shared/embedded_objects/summary_card/nested_item_component.rb b/app/components/content_block_manager/shared/embedded_objects/summary_card/nested_item_component.rb new file mode 100644 index 000000000..fb63583a1 --- /dev/null +++ b/app/components/content_block_manager/shared/embedded_objects/summary_card/nested_item_component.rb @@ -0,0 +1,39 @@ +class ContentBlockManager::Shared::EmbeddedObjects::SummaryCard::NestedItemComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::TranslationHelper + include ContentBlockManager::ContentBlock::GovspeakHelper + + with_collection_parameter :nested_items + + def initialize(nested_items:, object_key:, title:, subschema:, nested_items_counter: nil) + @nested_items = nested_items + @object_key = object_key + @title = title + @subschema = subschema + @nested_items_counter = nested_items_counter + end + +private + + attr_reader :nested_items, :object_key, :subschema, :nested_items_counter + + def title + if @nested_items_counter + "#{@title} #{@nested_items_counter + 1}" + else + @title + end + end + + def rows + nested_items.map do |field_name, value| + { + key: humanized_label(relative_key: field_name), + value: render_govspeak_if_enabled_for_field( + object_key: object_key, + field_name: field_name, + value: translated_value(field_name, value), + ), + } + end + end +end diff --git a/app/components/content_block_manager/shared/embedded_objects/summary_card_component.html.erb b/app/components/content_block_manager/shared/embedded_objects/summary_card_component.html.erb new file mode 100644 index 000000000..69e0cde0c --- /dev/null +++ b/app/components/content_block_manager/shared/embedded_objects/summary_card_component.html.erb @@ -0,0 +1,36 @@ +<%= content_tag(:div, wrapper_attributes) do %> +
+

+ <%= title %> +

+ +
+
+ <%= render "govuk_publishing_components/components/summary_list", { + items: rows, + } %> + <% nested_items(items).each do |key, items| %> + <% if items.is_a?(Array) %> + <%= render ContentBlockManager::Shared::EmbeddedObjects::SummaryCard::NestedItemComponent.with_collection( + items, + object_key: key, + title: key.singularize.titleize, + subschema: schema, + ) %> + <% else %> + <%= render ContentBlockManager::Shared::EmbeddedObjects::SummaryCard::NestedItemComponent.new( + nested_items: items, + object_key: key, + title: key.singularize.titleize, + subschema: schema, + ) %> + <% end %> + <% end %> +
+<% end %> diff --git a/app/components/content_block_manager/shared/embedded_objects/summary_card_component.rb b/app/components/content_block_manager/shared/embedded_objects/summary_card_component.rb new file mode 100644 index 000000000..1434a83c2 --- /dev/null +++ b/app/components/content_block_manager/shared/embedded_objects/summary_card_component.rb @@ -0,0 +1,79 @@ +class ContentBlockManager::Shared::EmbeddedObjects::SummaryCardComponent < ViewComponent::Base + include ContentBlockManager::ContentBlock::SummaryListHelper + include ContentBlockManager::ContentBlock::TranslationHelper + + delegate :document, to: :content_block_edition + + with_collection_parameter :object_title + + def initialize(content_block_edition:, object_type:, object_title:, redirect_url: nil, test_id_prefix: nil) + @content_block_edition = content_block_edition + @object_type = object_type + @object_title = object_title + @redirect_url = redirect_url + @test_id_prefix = test_id_prefix + end + +private + + attr_reader :content_block_edition, :object_type, :object_title, :redirect_url, :test_id_prefix + + def title + "#{object_type.titleize.singularize} details" + end + + def items + schema.fields.map { |field| + [field.name, object[field.name]] + }.to_h + end + + def rows + first_class_items(items).map do |key, value| + { + field: key_to_title(key, object_type), + value: translated_value(key, value), + data: { + testid: [object_title.parameterize, key].compact.join("_").underscore, + }, + } + end + end + + def embeddable_fields + @embeddable_fields = schema.embeddable_fields + end + + def object + @object ||= content_block_edition.details.dig(object_type, object_title) + end + + def schema + @schema ||= content_block_edition.document.schema.subschema(object_type) + end + + def summary_card_actions + [ + { + label: "Edit", + href: helpers.content_block_manager.edit_embedded_object_content_block_manager_content_block_edition_path( + content_block_edition, + object_type:, + object_title:, + redirect_url:, + ), + }, + ] + end + + def wrapper_attributes + { + "class" => "govuk-summary-card", + **data_attributes, + } + end + + def data_attributes + test_id_prefix.present? ? { "data-test-id" => [test_id_prefix, object_title].join("_") } : {} + end +end diff --git a/app/components/content_block_manager/shared/embedded_objects_component.html.erb b/app/components/content_block_manager/shared/embedded_objects_component.html.erb new file mode 100644 index 000000000..52fd24bcd --- /dev/null +++ b/app/components/content_block_manager/shared/embedded_objects_component.html.erb @@ -0,0 +1,27 @@ +
+
+ <% if show_title? %> +

<%= subschema_name.pluralize.titleize %>

+ <% end %> + + <%= render ContentBlockManager::Shared::EmbeddedObjects::SummaryCardComponent.with_collection( + subschema_keys, + content_block_edition: content_block_edition, + object_type: subschema.block_type, + redirect_url:, + test_id_prefix: "embedded", + ) %> + + <% if show_add_button? %> + <%= render "govuk_publishing_components/components/button", { + text: add_button_text, + href: helpers.content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path( + content_block_edition, + object_type: subschema.block_type, + ), + secondary_solid: true, + margin_bottom: 6, + } %> + <% end %> +
+
diff --git a/app/components/content_block_manager/shared/embedded_objects_component.rb b/app/components/content_block_manager/shared/embedded_objects_component.rb new file mode 100644 index 000000000..e1d1c5c32 --- /dev/null +++ b/app/components/content_block_manager/shared/embedded_objects_component.rb @@ -0,0 +1,39 @@ +class ContentBlockManager::Shared::EmbeddedObjectsComponent < ViewComponent::Base + def initialize(content_block_edition:, subschema:, redirect_url:) + @content_block_edition = content_block_edition + @subschema = subschema + @redirect_url = redirect_url + end + +private + + attr_reader :content_block_edition, :subschema, :redirect_url + + def subschema_name + subschema.name.humanize.singularize.downcase + end + + def subschema_keys + @subschema_keys ||= content_block_edition.details[subschema.block_type]&.keys || [] + end + + def show_add_button? + content_block_edition.document.is_new_block? + end + + def show_title? + if !content_block_edition.document.is_new_block? + has_embedded_objects? + else + true + end + end + + def add_button_text + has_embedded_objects? ? "Add another #{subschema_name}" : "Add #{helpers.add_indefinite_article subschema_name}" + end + + def has_embedded_objects? + subschema_keys.any? + end +end diff --git a/app/components/content_block_manager/shared/schedule_publishing_component.html.erb b/app/components/content_block_manager/shared/schedule_publishing_component.html.erb new file mode 100644 index 000000000..b77dc8b6e --- /dev/null +++ b/app/components/content_block_manager/shared/schedule_publishing_component.html.erb @@ -0,0 +1,114 @@ +<% content_for :context, context %> +<% content_for :title, "Select publish date" %> +<% content_for :title_margin_bottom, 4 %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_link, + } %> +<% end %> + +<% + is_scheduled_param = params["schedule_publishing"] +%> + +<% content_for :error_summary, render(Admin::ErrorSummaryComponent.new(object: content_block_edition)) %> + +
+
+ <%= form_with url: form_url, method: :put, id: "schedule_publishing" do %> + + <%= render "govuk_publishing_components/components/radio", { + name: "schedule_publishing", + id: "schedule_publishing", + heading_size: "xl", + error_items: helpers.errors_for(content_block_edition.errors, :schedule_publishing), + items: [ + { + value: "now", + checked: is_scheduled_param == "now", + text: "Publish the edit now", + hint_text: "The edit will be made when you select publish and users will see it immediately.", + bold: true, + }, + { + value: "schedule", + checked: is_scheduled_param == "schedule", + text: "Schedule the edit for the future", + hint_text: "The edit will be published on a date and time you choose.", + bold: true, + conditional: render("components/datetime_fields", { + heading_size: "s", + field_name: "scheduled_publication", + prefix: "scheduled_at", + date_heading: "Date", + date_hint: "For example, 01 08 2025", + time_hint: "For example, 09:30 or 19:30", + error_items: helpers.errors_for(content_block_edition.errors, :scheduled_publication), + year: { + value: year_param.blank? ? nil : year_param.to_i, + id: "scheduled_at_scheduled_publication_1i", + name: "scheduled_at[scheduled_publication(1i)]", + label: "Year", + width: 4, + }, + month: { + value: month_param.blank? ? nil : month_param.to_i, + id: "scheduled_at_scheduled_publication_2i", + name: "scheduled_at[scheduled_publication(2i)]", + label: "Month", + width: 2, + }, + day: { + value: day_param.blank? ? nil : day_param.to_i, + id: "content_block_manager/content_block/edition_scheduled_publication", + name: "scheduled_at[scheduled_publication(3i)]", + label: "Day", + width: 2, + }, + hour: { + value: hour_param.blank? ? nil : hour_param.to_i, + id: "scheduled_at_scheduled_publication_4i", + name: "scheduled_at[scheduled_publication(4i)]", + }, + minute: { + value: minute_param.blank? ? nil : minute_param.to_i, + id: "scheduled_at_scheduled_publication_5i", + name: "scheduled_at[scheduled_publication(5i)]", + }, + }), + }, + ], + } %> + <% end %> + +
+
+ <%= render "govuk_publishing_components/components/button", { + text: "Save and continue", + name: "save_and_continue", + value: "Save and continue", + type: "submit", + form: "schedule_publishing", + } %> +
+
+ <% if is_rescheduling %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + name: "Cancel", + value: "Cancel", + href: back_link, + secondary_solid: true, + } %> + <% else %> + <%= render ContentBlockManager::Shared::CancelAndDeleteButtonComponent.new(url: helpers.content_block_manager. + content_block_manager_content_block_edition_path( + content_block_edition, redirect_path: + helpers.content_block_manager.content_block_manager_content_block_document_path(content_block_edition.document) + ), + ) %> + <% end %> +
+
+
+
diff --git a/app/components/content_block_manager/shared/schedule_publishing_component.rb b/app/components/content_block_manager/shared/schedule_publishing_component.rb new file mode 100644 index 000000000..64824e320 --- /dev/null +++ b/app/components/content_block_manager/shared/schedule_publishing_component.rb @@ -0,0 +1,34 @@ +class ContentBlockManager::Shared::SchedulePublishingComponent < ViewComponent::Base + def initialize(content_block_edition:, params:, context:, back_link:, form_url:, is_rescheduling:) + @content_block_edition = content_block_edition + @params = params + @context = context + @back_link = back_link + @form_url = form_url + @is_rescheduling = is_rescheduling + end + +private + + attr_reader :is_rescheduling, :content_block_edition, :params, :context, :back_link, :form_url + + def year_param + content_block_edition.scheduled_publication&.year || params.dig("scheduled_at", "scheduled_publication(1i)") + end + + def month_param + content_block_edition.scheduled_publication&.month || params.dig("scheduled_at", "scheduled_publication(2i)") + end + + def day_param + content_block_edition.scheduled_publication&.day || params.dig("scheduled_at", "scheduled_publication(3i)") + end + + def hour_param + content_block_edition.scheduled_publication&.hour || params.dig("scheduled_at", "scheduled_publication(4i)") + end + + def minute_param + content_block_edition.scheduled_publication&.min || params.dig("scheduled_at", "scheduled_publication(5i)") + end +end diff --git a/app/components/content_block_manager/signon_user/show/summary_list_component.html.erb b/app/components/content_block_manager/signon_user/show/summary_list_component.html.erb new file mode 100644 index 000000000..fea81f350 --- /dev/null +++ b/app/components/content_block_manager/signon_user/show/summary_list_component.html.erb @@ -0,0 +1,3 @@ +<%= render "govuk_publishing_components/components/summary_list", { + items:, + } %> diff --git a/app/components/content_block_manager/signon_user/show/summary_list_component.rb b/app/components/content_block_manager/signon_user/show/summary_list_component.rb new file mode 100644 index 000000000..f046e2210 --- /dev/null +++ b/app/components/content_block_manager/signon_user/show/summary_list_component.rb @@ -0,0 +1,36 @@ +class ContentBlockManager::SignonUser::Show::SummaryListComponent < ViewComponent::Base + def initialize(user:) + @user = user + end + +private + + def items + [ + name_item, + email_item, + organisation_item, + ].compact + end + + def name_item + { + field: "Name", + value: @user.name, + } + end + + def email_item + { + field: "Email", + value: @user.email, + } + end + + def organisation_item + { + field: "Organisation", + value: @user.organisation.name, + } + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..23fdc170f --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,66 @@ +class Admin::BaseController < ApplicationController + # include Admin::EditionRoutesHelper + # include PermissionsCheckerConcern + + layout "design_system" + prepend_before_action :authenticate_user! + + def limit_edition_access! + enforce_permission!(:see, @edition) + end + + def require_fatality_handling_permission! + forbidden! unless current_user.can_handle_fatalities? + end + + def enforce_permission!(action, subject) + unless can?(action, subject) + raise Whitehall::Authority::Errors::PermissionDenied.new(action, subject) + end + end + + rescue_from Whitehall::Authority::Errors::PermissionDenied do |exception| + logger.warn "Attempt to perform '#{exception.action}' on #{exception.subject} prevented." + forbidden! + end + + rescue_from Whitehall::Authority::Errors::InvalidAction do |exception| + logger.warn "Attempt to perform unknown action '#{exception.action}' prevented." + forbidden! + end + + def prevent_modification_of_unmodifiable_edition + if @edition.unmodifiable? + alert = "You cannot modify a #{@edition.state} #{@edition.type.titleize}" + redirect_to admin_edition_path(@edition), alert: + end + end + + def product_name + Whitehall.product_name + end + helper_method :product_name + +private + + def forbidden! + prepend_view_path Rails.root.join("lib/engines/content_block_manager/app/views") if request.path.start_with?(ContentBlockManager.router_prefix) + render "admin/errors/forbidden", status: :forbidden + end + + def typecast_for_attachable_routing(attachable) + case attachable + when Edition then attachable.becomes(Edition) + when ConsultationResponse then attachable.becomes(ConsultationResponse) + when CallForEvidenceResponse then attachable.becomes(CallForEvidenceResponse) + else attachable + end + end + helper_method :typecast_for_attachable_routing + + # Override the default Rails behaviour to raise an exception when receiving + # unverified requests instead of nullifying the session + def handle_unverified_request + raise ActionController::InvalidAuthenticityToken + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0d95db22b..fbbd579a7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,20 @@ class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. - allow_browser versions: :modern + include GDS::SSO::ControllerMethods + + protect_from_forgery + + before_action :set_authenticated_user_header + +private + + def set_authenticated_user_header + if current_user && GdsApi::GovukHeaders.headers[:x_govuk_authenticated_user].nil? + GdsApi::GovukHeaders.set_header(:x_govuk_authenticated_user, current_user.uid) + end + end + + def product_name + "Content Block Manager" + end + helper_method :product_name end diff --git a/app/controllers/concerns/can_schedule_or_publish.rb b/app/controllers/concerns/can_schedule_or_publish.rb new file mode 100644 index 000000000..27ed5b577 --- /dev/null +++ b/app/controllers/concerns/can_schedule_or_publish.rb @@ -0,0 +1,80 @@ +module CanScheduleOrPublish + extend ActiveSupport::Concern + + def self.included(base) + base.helper_method :is_scheduling? + end + + def schedule_or_publish + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type) + + if is_scheduling? + ContentBlockManager::ScheduleEditionService.new(@schema).call(@content_block_edition) + else + publish and return + end + + redirect_to content_block_manager.content_block_manager_content_block_workflow_path(id: @content_block_edition.id, + step: :confirmation, + is_scheduled: true) + end + + def publish + new_edition = ContentBlockManager::PublishEditionService.new.call(@content_block_edition) + redirect_to content_block_manager.content_block_manager_content_block_workflow_path(id: new_edition.id, step: :confirmation) + end + + def validate_scheduled_edition + case params[:schedule_publishing] + when "schedule" + validate_scheduled_publication_params + + @content_block_edition.update!(scheduled_publication_params) + if @content_block_edition.valid?(:scheduling) + @content_block_edition.save! + else + raise ActiveRecord::RecordInvalid, @content_block_edition + end + when "now" + @content_block_edition.update!(scheduled_publication: nil, state: "draft") + ContentBlockManager::SchedulePublishingWorker.dequeue(@content_block_edition) + else + @content_block_edition.errors.add(:schedule_publishing, t("activerecord.errors.models.content_block_manager/content_block/edition.attributes.schedule_publishing.blank")) + raise ActiveRecord::RecordInvalid, @content_block_edition + end + end + + def validate_scheduled_publication_params + error_base = "activerecord.errors.models.content_block_manager/content_block/edition.attributes.scheduled_publication" + if scheduled_publication_params.values.all?(&:blank?) + @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.blank")) + elsif scheduled_publication_time_params.all?(&:blank?) + @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.time.blank")) + elsif scheduled_publication_date_params.all?(&:blank?) + @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.date.blank")) + elsif scheduled_publication_params.values.any?(&:blank?) + @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.invalid_date")) + end + + raise ActiveRecord::RecordInvalid, @content_block_edition if @content_block_edition.errors.any? + end + + def scheduled_publication_time_params + [ + scheduled_publication_params["scheduled_publication(4i)"], + scheduled_publication_params["scheduled_publication(5i)"], + ] + end + + def scheduled_publication_date_params + [ + scheduled_publication_params["scheduled_publication(1i)"], + scheduled_publication_params["scheduled_publication(2i)"], + scheduled_publication_params["scheduled_publication(3i)"], + ] + end + + def is_scheduling? + @content_block_edition.scheduled_publication.present? + end +end diff --git a/app/controllers/concerns/embedded_objects.rb b/app/controllers/concerns/embedded_objects.rb new file mode 100644 index 000000000..dd7ec834c --- /dev/null +++ b/app/controllers/concerns/embedded_objects.rb @@ -0,0 +1,27 @@ +module EmbeddedObjects + extend ActiveSupport::Concern + include ParamsPreprocessor + + def get_schema_and_subschema(block_type, object_type) + schema = get_schema(block_type) + subschema = get_subschema(schema, object_type) + + [schema, subschema] + end + + def get_schema(block_type) + ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type) + end + + def get_subschema(schema, object_type) + schema.subschema(object_type) or raise(ActionController::RoutingError, "Subschema for #{object_type} not found") + end + + def object_params(subschema) + processed_params.require("content_block/edition").permit( + details: { + subschema.block_type.to_s => subschema.permitted_params, + }, + ) + end +end diff --git a/app/controllers/concerns/params_preprocessor.rb b/app/controllers/concerns/params_preprocessor.rb new file mode 100644 index 000000000..d272fd27c --- /dev/null +++ b/app/controllers/concerns/params_preprocessor.rb @@ -0,0 +1,18 @@ +module ParamsPreprocessor + extend ActiveSupport::Concern + + PREPROCESSORS = { + "telephones" => ParamsPreprocessors::TelephonePreprocessor, + }.freeze + + def processed_params + @processed_params ||= begin + preprocessor = PREPROCESSORS[params[:object_type]] + if preprocessor + preprocessor.new(params).processed_params + else + params + end + end + end +end diff --git a/app/controllers/concerns/params_preprocessors/telephone_preprocessor.rb b/app/controllers/concerns/params_preprocessors/telephone_preprocessor.rb new file mode 100644 index 000000000..7fb36dee5 --- /dev/null +++ b/app/controllers/concerns/params_preprocessors/telephone_preprocessor.rb @@ -0,0 +1,86 @@ +class ParamsPreprocessors::TelephonePreprocessor + def initialize(params) + @params = params + end + + def processed_params + process! + params + end + + def process! + params["content_block/edition"]["details"]["telephones"]["opening_hours"] = format_opening_hours + params["content_block/edition"]["details"]["telephones"]["call_charges"] = format_call_charges + params["content_block/edition"]["details"]["telephones"]["bsl_guidance"] = format_bsl_guidance + params["content_block/edition"]["details"]["telephones"]["video_relay_service"] = video_relay_service + end + +private + + attr_accessor :params + + def format_call_charges + call_charges = params["content_block/edition"]["details"]["telephones"]["call_charges"] + if call_charges + call_charges["show_call_charges_info_url"] = ActiveRecord::Type::Boolean.new.cast(call_charges["show_call_charges_info_url"]) || false + + if call_charges["show_call_charges_info_url"] == false + call_charges = {} + end + + call_charges + end + end + + def format_bsl_guidance + bsl_guidance = params["content_block/edition"]["details"]["telephones"]["bsl_guidance"] + if bsl_guidance + bsl_guidance["show"] = ActiveRecord::Type::Boolean.new.cast(bsl_guidance["show"]) || false + + if bsl_guidance["show"] == false + bsl_guidance = {} + end + + bsl_guidance + end + end + + def video_relay_service + obj = params["content_block/edition"]["details"]["telephones"]["video_relay_service"] + if obj + obj["show"] = ActiveRecord::Type::Boolean.new + .cast(obj["show"]) || false + + if obj["show"] == false + obj = {} + end + + obj + end + end + + def format_opening_hours + obj = params["content_block/edition"]["details"]["telephones"]["opening_hours"] + if obj + obj["show_opening_hours"] = ActiveRecord::Type::Boolean.new + .cast(obj["show_opening_hours"]) || false + + if obj["show_opening_hours"] == false + obj = {} + end + + obj + end + end + + def strip_opening_hours + params["content_block/edition"]["details"]["telephones"]["opening_hours"] = [] + end + + def format_time(hours, prefix) + h = hours["#{prefix}(h)"] + m = hours["#{prefix}(m)"] + meridian = hours["#{prefix}(meridian)"] + "#{h}:#{m}#{meridian}" + end +end diff --git a/app/controllers/concerns/permissions_checker_concern.rb b/app/controllers/concerns/permissions_checker_concern.rb new file mode 100644 index 000000000..37c521bb6 --- /dev/null +++ b/app/controllers/concerns/permissions_checker_concern.rb @@ -0,0 +1,22 @@ +module PermissionsCheckerConcern + extend ActiveSupport::Concern + + def can?(action, subject) + enforcer_for(subject).can?(action) + end + + def can_preview?(subject) + can?(:see, subject) + end + + included do + helper_method :can? + end + +private + + def enforcer_for(subject) + actor = current_user || User.new + Whitehall::Authority::Enforcer.new(actor, subject) + end +end diff --git a/app/controllers/concerns/workflow.rb b/app/controllers/concerns/workflow.rb new file mode 100644 index 000000000..b7cf81e2e --- /dev/null +++ b/app/controllers/concerns/workflow.rb @@ -0,0 +1,20 @@ +module Workflow + class Step < Data.define(:name, :show_action, :update_action, :included_in_create_journey) + SUBSCHEMA_PREFIX = "embedded_".freeze + GROUP_PREFIX = "group_".freeze + + ALL = [ + Step.new(:edit_draft, :edit_draft, :update_draft, true), + Step.new(:review_links, :review_links, :redirect_to_next_step, false), + Step.new(:internal_note, :internal_note, :update_internal_note, false), + Step.new(:change_note, :change_note, :update_change_note, false), + Step.new(:schedule_publishing, :schedule_publishing, :validate_schedule, false), + Step.new(:review, :review, :validate_review_page, true), + Step.new(:confirmation, :confirmation, nil, true), + ].freeze + + def is_subschema? + name.to_s.start_with?(SUBSCHEMA_PREFIX) + end + end +end diff --git a/app/controllers/concerns/workflow/show_methods.rb b/app/controllers/concerns/workflow/show_methods.rb new file mode 100644 index 000000000..04a2e4d10 --- /dev/null +++ b/app/controllers/concerns/workflow/show_methods.rb @@ -0,0 +1,136 @@ +module Workflow::ShowMethods + extend ActiveSupport::Concern + + def edit_draft + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type) + @form = ContentBlockManager::ContentBlock::EditionForm::Edit.new(content_block_edition: @content_block_edition, schema: @schema) + + @title = @content_block_edition.document.is_new_block? ? "Create #{@form.schema.name}" : "Change #{@form.schema.name}" + @back_path = @content_block_edition.document.is_new_block? ? content_block_manager.new_content_block_manager_content_block_document_path : @form.back_path + + render :edit_draft + end + + # This handles the optional embedded objects and groups in the flow, delegating to `embedded_objects` + # or `embedded_group_objects` as appropriate + def method_missing(method_name, *arguments, &block) + if method_name.to_s =~ /#{Workflow::Step::SUBSCHEMA_PREFIX}(.*)/ + embedded_objects(::Regexp.last_match(1)) + elsif method_name.to_s =~ /#{Workflow::Step::GROUP_PREFIX}(.*)/ + group_objects(::Regexp.last_match(1)) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.start_with?(Workflow::Step::SUBSCHEMA_PREFIX) || super + end + + def review_links + @content_block_document = @content_block_edition.document + @order = params[:order] + @page = params[:page] + + @host_content_items = ContentBlockManager::HostContentItem.for_document( + @content_block_document, + order: @order, + page: @page, + ) + + if @host_content_items.empty? + referred_from_next_step = request.referer && URI.parse(request.referer).path&.end_with?(next_step.name.to_s) + + redirect_to content_block_manager.content_block_manager_content_block_workflow_path( + id: @content_block_edition.id, + step: referred_from_next_step ? previous_step.name : next_step.name, + ) + else + render :review_links + end + end + + def schedule_publishing + @content_block_document = @content_block_edition.document + + render :schedule_publishing + end + + def internal_note + @content_block_document = @content_block_edition.document + + render :internal_note + end + + def change_note + @content_block_document = @content_block_edition.document + + render :change_note + end + + def review + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + + render :review + end + + def confirmation + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + + @confirmation_copy = ContentBlockManager::ConfirmationCopyPresenter.new(@content_block_edition) + + render :confirmation + end + + def back_path + content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: previous_step.name, + ) + end + included do + helper_method :back_path + end + +private + + def embedded_objects(subschema_name) + @subschema = @schema.subschema(subschema_name) + @step_name = current_step.name + @action = @content_block_edition.document.is_new_block? ? "Add" : "Edit" + @add_button_text = has_embedded_objects ? "Add another #{subschema_name.humanize.singularize.downcase}" : "Add #{helpers.add_indefinite_article @subschema.name.humanize.singularize.downcase}" + + if @subschema + render :embedded_objects + else + raise ActionController::RoutingError, "Subschema #{subschema_name} does not exist" + end + end + + def group_objects(group_name) + @group_name = group_name + @subschemas = @schema.subschemas_for_group(group_name) + @step_name = current_step.name + @action = @content_block_edition.document.is_new_block? ? "Add" : "Edit" + + if @subschemas.any? + if @subschemas.none? { |subschema| has_embedded_objects(subschema) } + @group = group_name + @back_link = back_path + @redirect_path = content_block_manager.new_embedded_objects_options_redirect_content_block_manager_content_block_edition_path(@content_block_edition) + @context = @content_block_edition.title + + render "content_block_manager/content_block/shared/embedded_objects/select_subschema" + else + render :group_objects + end + else + raise ActionController::RoutingError, "Subschema group #{group_name} does not exist" + end + end + + def has_embedded_objects(subschema = @subschema) + @content_block_edition.details[subschema.block_type].present? + end +end diff --git a/app/controllers/concerns/workflow/steps.rb b/app/controllers/concerns/workflow/steps.rb new file mode 100644 index 000000000..1c8e40c84 --- /dev/null +++ b/app/controllers/concerns/workflow/steps.rb @@ -0,0 +1,91 @@ +module Workflow::Steps + extend ActiveSupport::Concern + include ContentBlockManager::ContentBlock::SchemaHelper + + included do + before_action :initialize_edition_and_schema + end + + def steps + @steps ||= [ + *all_steps[0], + *group_steps, + *subschema_steps, + *all_steps[1..], + ].compact + end + + def current_step + steps.find { |step| step.name == params[:step].to_sym } + end + + def previous_step + steps[index - 1] + end + + def next_step + steps[index + 1] + end + +private + + def all_steps + if @content_block_edition.document.is_new_block? + Workflow::Step::ALL.select { |s| s.included_in_create_journey == true } + else + Workflow::Step::ALL + end + end + + def initialize_edition_and_schema + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type) + end + + def index + steps.find_index { |step| step.name == params[:step]&.to_sym } || 0 + end + + def skip_subschema?(subschema) + !@content_block_edition.document.is_new_block? && + !@content_block_edition.has_entries_for_subschema_id?(subschema.id) + end + + def skip_group?(subschemas) + subschemas.all? { |subschema| skip_subschema?(subschema) } + end + + def subschemas + @subschemas ||= ungrouped_subschemas(@schema) + end + + def groups + @groups ||= grouped_subschemas(@schema) + end + + def subschema_steps + subschemas.map do |subschema| + next if skip_subschema?(subschema) + + Workflow::Step.new( + "#{Workflow::Step::SUBSCHEMA_PREFIX}#{subschema.id}".to_sym, + "#{Workflow::Step::SUBSCHEMA_PREFIX}#{subschema.id}".to_sym, + :redirect_to_next_step, + true, + ) + end + end + + def group_steps + groups.keys.map do |group| + next if skip_group?(groups[group]) + + Workflow::Step.new( + "#{Workflow::Step::GROUP_PREFIX}#{group}".to_sym, + "#{Workflow::Step::GROUP_PREFIX}#{group}".to_sym, + :redirect_to_next_step, + true, + ) + end + end +end diff --git a/app/controllers/concerns/workflow/update_methods.rb b/app/controllers/concerns/workflow/update_methods.rb new file mode 100644 index 000000000..aeb20123d --- /dev/null +++ b/app/controllers/concerns/workflow/update_methods.rb @@ -0,0 +1,68 @@ +module Workflow::UpdateMethods + extend ActiveSupport::Concern + + REVIEW_ERROR = Data.define(:attribute, :full_message) + + def update_draft + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + + @content_block_edition.assign_attributes( + title: edition_params[:title], + organisation_id: edition_params[:organisation_id], + instructions_to_publishers: edition_params[:instructions_to_publishers], + details: @content_block_edition.details.merge(edition_params[:details]), + ) + @content_block_edition.save! + + redirect_to_next_step + rescue ActiveRecord::RecordInvalid + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type) + @form = ContentBlockManager::ContentBlock::EditionForm::Edit.new(content_block_edition: @content_block_edition, schema: @schema) + + render :edit_draft + end + + def validate_schedule + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + + validate_scheduled_edition + + redirect_to_next_step + rescue ActiveRecord::RecordInvalid + render "content_block_manager/content_block/editions/workflow/schedule_publishing" + end + + def update_internal_note + @content_block_edition.update!(internal_change_note: edition_params[:internal_change_note]) + + redirect_to_next_step + end + + def update_change_note + @content_block_edition.assign_attributes(change_note: edition_params[:change_note], major_change: edition_params[:major_change]) + @content_block_edition.save!(context: :change_note) + + redirect_to_next_step + rescue ActiveRecord::RecordInvalid + render :change_note + end + + def validate_review_page + if params[:is_confirmed].blank? + @confirm_error_copy = I18n.t("content_block_edition.review_page.errors.confirm") + @error_summary_errors = [{ text: @confirm_error_copy, href: "#is_confirmed-0" }] + render :review + else + schedule_or_publish + end + end + +private + + def redirect_to_next_step + redirect_to content_block_manager.content_block_manager_content_block_workflow_path( + id: @content_block_edition.id, + step: next_step&.name, + ) + end +end diff --git a/app/controllers/content_block_manager/base_controller.rb b/app/controllers/content_block_manager/base_controller.rb new file mode 100644 index 000000000..525d39c14 --- /dev/null +++ b/app/controllers/content_block_manager/base_controller.rb @@ -0,0 +1,47 @@ +class ContentBlockManager::BaseController < Admin::BaseController + before_action :check_block_manager_permissions, :set_sentry_tags + + def check_block_manager_permissions + forbidden! unless current_user.gds_admin? + end + + def scheduled_publication_params + params.require(:scheduled_at).permit("scheduled_publication(1i)", + "scheduled_publication(2i)", + "scheduled_publication(3i)", + "scheduled_publication(4i)", + "scheduled_publication(5i)") + end + + def edition_params + params.require("content_block/edition") + .permit( + :organisation_id, + :creator, + :instructions_to_publishers, + "scheduled_publication(1i)", + "scheduled_publication(2i)", + "scheduled_publication(3i)", + "scheduled_publication(4i)", + "scheduled_publication(5i)", + :title, + :internal_change_note, + :change_note, + :major_change, + document_attributes: %w[block_type], + details: @schema.permitted_params, + ) + .merge!(creator: current_user) + end + + def set_sentry_tags + Sentry.set_tags(engine: "content_block_manager") + end + + def product_name + "Content Block Manager" + end + + delegate :support_url, to: :ContentBlockManager + helper_method :support_url +end diff --git a/app/controllers/content_block_manager/content_block/documents/schedule_controller.rb b/app/controllers/content_block_manager/content_block/documents/schedule_controller.rb new file mode 100644 index 000000000..2e427f0b9 --- /dev/null +++ b/app/controllers/content_block_manager/content_block/documents/schedule_controller.rb @@ -0,0 +1,19 @@ +class ContentBlockManager::ContentBlock::Documents::ScheduleController < ContentBlockManager::BaseController + include CanScheduleOrPublish + + def edit + document = ContentBlockManager::ContentBlock::Document.find(params[:document_id]) + @content_block_edition = document.latest_edition + end + + def update + document = ContentBlockManager::ContentBlock::Document.find(params[:document_id]) + @content_block_edition = document.latest_edition.clone_edition(creator: current_user) + + validate_scheduled_edition + + redirect_to content_block_manager.content_block_manager_content_block_workflow_path(@content_block_edition, step: :review) + rescue ActiveRecord::RecordInvalid + render "content_block_manager/content_block/documents/schedule/edit" + end +end diff --git a/app/controllers/content_block_manager/content_block/documents_controller.rb b/app/controllers/content_block_manager/content_block/documents_controller.rb new file mode 100644 index 000000000..d867ba23b --- /dev/null +++ b/app/controllers/content_block_manager/content_block/documents_controller.rb @@ -0,0 +1,64 @@ +class ContentBlockManager::ContentBlock::DocumentsController < ContentBlockManager::BaseController + def index + if params_filters.any? + @filters = params_filters + filter_result = ContentBlockManager::ContentBlock::Document::DocumentFilter.new(@filters) + @content_block_documents = filter_result.paginated_documents + unless filter_result.valid? + @errors = filter_result.errors + @error_summary_errors = @errors.map { |error| { text: error.full_message, href: "##{error.attribute}_3i" } } + end + render :index + else + redirect_to content_block_manager_root_path(default_filters) + end + end + + def show + @content_block_document = ContentBlockManager::ContentBlock::Document.find(params[:id]) + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_document.block_type) + @content_block_versions = @content_block_document.versions + @order = params[:order] + @page = params[:page] + + @host_content_items = ContentBlockManager::HostContentItem.for_document( + @content_block_document, + order: @order, + page: @page, + ) + end + + def content_id + content_block_document = ContentBlockManager::ContentBlock::Document.where(content_id: params[:content_id]).first + + if content_block_document.present? + redirect_to content_block_manager_content_block_document_path(content_block_document) + else + raise ActiveRecord::RecordNotFound, "Could not find Content Block with Content ID #{params[:content_id]}" + end + end + + def new + @schemas = ContentBlockManager::ContentBlock::Schema.all + end + + def new_document_options_redirect + if params[:block_type].present? + redirect_to new_content_block_manager_content_block_edition_path(block_type: params.require(:block_type)) + else + redirect_to new_content_block_manager_content_block_document_path, flash: { error: I18n.t("activerecord.errors.models.content_block_manager/content_block/document.attributes.block_type.blank") } + end + end + +private + + def params_filters + params.slice(:keyword, :block_type, :lead_organisation, :page, :last_updated_to, :last_updated_from) + .permit! + .to_h + end + + def default_filters + { lead_organisation: "" } + end +end diff --git a/app/controllers/content_block_manager/content_block/editions/embedded_objects_controller.rb b/app/controllers/content_block_manager/content_block/editions/embedded_objects_controller.rb new file mode 100644 index 000000000..6e2df37d3 --- /dev/null +++ b/app/controllers/content_block_manager/content_block/editions/embedded_objects_controller.rb @@ -0,0 +1,137 @@ +class ContentBlockManager::ContentBlock::Editions::EmbeddedObjectsController < ContentBlockManager::BaseController + include EmbeddedObjects + + before_action :initialize_edition + + def new + @schema = get_schema(@content_block_edition.document.block_type) + + if params[:object_type] + @subschema = get_subschema(@schema, params[:object_type]) + @back_link = embedded_objects_path + + render :new + else + @group = params[:group] + @subschemas = @schema.subschemas_for_group(@group) + @back_link = content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: "#{Workflow::Step::GROUP_PREFIX}#{@group}", + ) + @redirect_path = content_block_manager.new_embedded_objects_options_redirect_content_block_manager_content_block_edition_path(@content_block_edition) + @context = @content_block_edition.title + + if @subschemas.blank? + render "admin/errors/not_found", status: :not_found + else + render "content_block_manager/content_block/shared/embedded_objects/select_subschema" + end + end + end + + def create + @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type]) + @object = object_params(@subschema).dig(:details, @subschema.block_type) + @content_block_edition.add_object_to_details(@subschema.block_type, @object) + @content_block_edition.save! + + object_or_group = @subschema.group ? @subschema.group.humanize.singularize : @subschema.name.singularize + + flash[:notice] = I18n.t( + "content_block_edition.create.embedded_objects.added_confirmation", + object_name: @subschema.name.singularize, + object_or_group: object_or_group.downcase, + schema_name: @schema.name.singularize.downcase, + ) + redirect_to embedded_objects_path + rescue ActiveRecord::RecordInvalid + @back_link = embedded_objects_path + render :new + end + + def edit + @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type]) + @redirect_url = params[:redirect_url] + @object_title = params[:object_title] + @object = @content_block_edition.details.dig(params[:object_type], params[:object_title]) + + render "admin/errors/not_found", status: :not_found unless @object + end + + def update + @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type]) + @object = object_params(@subschema).dig(:details, @subschema.block_type) + @content_block_edition.update_object_with_details(params[:object_type], params[:object_title], @object) + @content_block_edition.save! + + if params[:redirect_url].present? + object_or_group = @subschema.group ? @subschema.group.humanize.singularize : @subschema.name.singularize + + flash[:notice] = I18n.t( + "content_block_edition.create.embedded_objects.edited_confirmation", + object_name: @subschema.name.singularize, + object_or_group: object_or_group.downcase, + schema_name: @schema.name.singularize.downcase, + ) + redirect_to params[:redirect_url], allow_other_host: false + else + redirect_to content_block_manager.review_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + object_title: params[:object_title], + ) + end + rescue ActiveRecord::RecordInvalid + @redirect_url = params[:redirect_url] + @object_title = params[:object_title] + render :edit + end + + def review + @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type]) + @object_title = params[:object_title] + end + + def publish + @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type]) + if params[:is_confirmed].blank? + flash[:error] = I18n.t("content_block_edition.review_page.errors.confirm") + redirect_path = content_block_manager.review_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + object_title: params[:object_title], + ) + else + @content_block_edition.updated_embedded_object_type = @subschema.block_type + @content_block_edition.updated_embedded_object_title = params[:object_title] + ContentBlockManager::PublishEditionService.new.call(@content_block_edition) + flash[:notice] = "#{@subschema.name.singularize} created" + redirect_path = content_block_manager.content_block_manager_content_block_document_path(@content_block_edition.document) + end + + redirect_to redirect_path + end + + def new_embedded_objects_options_redirect + if params[:object_type].present? + flash[:back_link] = content_block_manager.new_embedded_objects_options_redirect_content_block_manager_content_block_edition_path( + @content_block_edition, + group: params.require(:group), + ) + redirect_to content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path(@content_block_edition, object_type: params.require(:object_type)) + else + redirect_to content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path(@content_block_edition, group: params.require(:group)), flash: { error: I18n.t("activerecord.errors.models.content_block_manager/content_block/document.attributes.block_type.blank") } + end + end + +private + + def initialize_edition + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + end + + def embedded_objects_path + step = @subschema.group ? "#{Workflow::Step::GROUP_PREFIX}#{@subschema.group}" : "#{Workflow::Step::SUBSCHEMA_PREFIX}#{@subschema.id}" + content_block_manager.content_block_manager_content_block_workflow_path(@content_block_edition, step:) + end +end diff --git a/app/controllers/content_block_manager/content_block/editions/host_content_controller.rb b/app/controllers/content_block_manager/content_block/editions/host_content_controller.rb new file mode 100644 index 000000000..28ec3f953 --- /dev/null +++ b/app/controllers/content_block_manager/content_block/editions/host_content_controller.rb @@ -0,0 +1,12 @@ +class ContentBlockManager::ContentBlock::Editions::HostContentController < ContentBlockManager::BaseController + def preview + host_content_id = params[:host_content_id] + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + @preview_content = ContentBlockManager::PreviewContent.for_content_id( + content_id: host_content_id, + content_block_edition: @content_block_edition, + base_path: params[:base_path], + locale: params[:locale], + ) + end +end diff --git a/app/controllers/content_block_manager/content_block/editions/workflow_controller.rb b/app/controllers/content_block_manager/content_block/editions/workflow_controller.rb new file mode 100644 index 000000000..5e27669e4 --- /dev/null +++ b/app/controllers/content_block_manager/content_block/editions/workflow_controller.rb @@ -0,0 +1,45 @@ +class ContentBlockManager::ContentBlock::Editions::WorkflowController < ContentBlockManager::BaseController + include CanScheduleOrPublish + + include Workflow::Steps + include Workflow::ShowMethods + include Workflow::UpdateMethods + + def show + action = current_step&.show_action + + if action + send(action) + else + raise ActionController::RoutingError, "Step #{params[:step]} does not exist" + end + end + + def cancel + @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + end + + def update + action = current_step&.update_action + + if action + send(action) + else + raise ActionController::RoutingError, "Step #{params[:step]} does not exist" + end + end + + def context + @content_block_edition.title + end + helper_method :context + +private + + def review_url + content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: :review, + ) + end +end diff --git a/app/controllers/content_block_manager/content_block/editions_controller.rb b/app/controllers/content_block_manager/content_block/editions_controller.rb new file mode 100644 index 000000000..23eadeb4b --- /dev/null +++ b/app/controllers/content_block_manager/content_block/editions_controller.rb @@ -0,0 +1,44 @@ +class ContentBlockManager::ContentBlock::EditionsController < ContentBlockManager::BaseController + include Workflow::Steps + + skip_before_action :initialize_edition_and_schema + + def new + if params[:document_id] + @content_block_document = ContentBlockManager::ContentBlock::Document.find(params[:document_id]) + @title = @content_block_document.title + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_document.block_type) + content_block_edition = @content_block_document.latest_edition + else + @title = "Create content block" + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(params[:block_type].underscore) + content_block_edition = ContentBlockManager::ContentBlock::Edition.new + end + @form = ContentBlockManager::ContentBlock::EditionForm.for( + content_block_edition:, + schema: @schema, + ) + end + + def create + @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type_param) + @content_block_edition = ContentBlockManager::CreateEditionService.new(@schema).call(edition_params, document_id: params[:document_id]) + redirect_to content_block_manager.content_block_manager_content_block_workflow_path(id: @content_block_edition.id, step: next_step.name) + rescue ActiveRecord::RecordInvalid => e + @title = params[:document_id] ? e.record.document.title : "Create content block" + @form = ContentBlockManager::ContentBlock::EditionForm.for(content_block_edition: e.record, schema: @schema) + render "content_block_manager/content_block/editions/new" + end + + def destroy + edition_to_delete = ContentBlockManager::ContentBlock::Edition.find(params[:id]) + ContentBlockManager::DeleteEditionService.new.call(edition_to_delete) + redirect_to params[:redirect_path] || content_block_manager.content_block_manager_root_path + end + +private + + def block_type_param + params.require("content_block/edition").require("document_attributes").require(:block_type) + end +end diff --git a/app/controllers/content_block_manager/users_controller.rb b/app/controllers/content_block_manager/users_controller.rb new file mode 100644 index 000000000..d186713d9 --- /dev/null +++ b/app/controllers/content_block_manager/users_controller.rb @@ -0,0 +1,7 @@ +class ContentBlockManager::UsersController < ContentBlockManager::BaseController + def show + @user = ContentBlockManager::SignonUser.with_uuids([params[:id]]).first + + raise ActiveRecord::RecordNotFound, "Could not find User with ID #{params[:id]}" if @user.blank? + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 000000000..e230c5dba --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -0,0 +1,5 @@ +class PagesController < ApplicationController + layout "design_system" + + def show; end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be7945..196bef423 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,13 @@ +require "record_tag_helper/helper" + module ApplicationHelper + include ActionView::Helpers::RecordTagHelper + + def get_content_id(edition) + return if edition.nil? + + return unless edition.respond_to?("content_id") + + edition.content_id + end end diff --git a/app/helpers/content_block_manager/content_block/edition_helper.rb b/app/helpers/content_block_manager/content_block/edition_helper.rb new file mode 100644 index 000000000..f53b3056c --- /dev/null +++ b/app/helpers/content_block_manager/content_block/edition_helper.rb @@ -0,0 +1,31 @@ +module ContentBlockManager::ContentBlock::EditionHelper + def published_date(content_block_edition) + tag.time( + content_block_edition.updated_at.to_fs(:long_ordinal_with_at), + class: "date", + datetime: content_block_edition.updated_at.iso8601, + lang: "en", + ) + end + + def scheduled_date(content_block_edition) + tag.time( + content_block_edition.scheduled_publication.to_fs(:long_ordinal_with_at), + class: "date", + datetime: content_block_edition.scheduled_publication.iso8601, + lang: "en", + ) + end + + def formatted_instructions_to_publishers(content_block_edition) + if content_block_edition.instructions_to_publishers.present? + simple_format( + auto_link(content_block_edition.instructions_to_publishers, html: { class: "govuk-link", target: "_blank", rel: "noopener" }), + { class: "govuk-!-margin-top-0" }, + { sanitize_options: { attributes: %w[href class target rel] } }, + ) + else + "None" + end + end +end diff --git a/app/helpers/content_block_manager/content_block/embed_code_helper.rb b/app/helpers/content_block_manager/content_block/embed_code_helper.rb new file mode 100644 index 000000000..6bb2c4388 --- /dev/null +++ b/app/helpers/content_block_manager/content_block/embed_code_helper.rb @@ -0,0 +1,20 @@ +module ContentBlockManager::ContentBlock::EmbedCodeHelper + def copy_embed_code_data_attributes(key, content_block_document) + { + module: "copy-embed-code", + "embed-code": content_block_document.embed_code_for_field(key), + } + end + + # This generates a row containing the embed code for the field above it - + # it will be deleted if javascript is enabled by copy-embed-code.js. + def embed_code_row(key, content_block_document) + { + key: "Embed code", + value: content_block_document.embed_code_for_field(key), + data: { + "embed-code-row": "true", + }, + } + end +end diff --git a/app/helpers/content_block_manager/content_block/govspeak_helper.rb b/app/helpers/content_block_manager/content_block/govspeak_helper.rb new file mode 100644 index 000000000..c3c5ece56 --- /dev/null +++ b/app/helpers/content_block_manager/content_block/govspeak_helper.rb @@ -0,0 +1,13 @@ +module ContentBlockManager::ContentBlock::GovspeakHelper + include ContentBlockTools::Govspeak + + def render_govspeak_if_enabled_for_field(object_key:, field_name:, value:) + return value unless field_enabled_for_govspeak?(object_key, field_name) + + render_govspeak(value) + end + + def field_enabled_for_govspeak?(object_key, field_name) + subschema.govspeak_enabled?(nested_object_key: object_key, field_name: field_name) + end +end diff --git a/app/helpers/content_block_manager/content_block/schema_helper.rb b/app/helpers/content_block_manager/content_block/schema_helper.rb new file mode 100644 index 000000000..f43879896 --- /dev/null +++ b/app/helpers/content_block_manager/content_block/schema_helper.rb @@ -0,0 +1,16 @@ +module ContentBlockManager::ContentBlock::SchemaHelper + def grouped_subschemas(schema) + schema.subschemas + .select { |subschema| subschema.group.present? } + .group_by(&:group) + end + + def ungrouped_subschemas(schema) + schema.subschemas.select { |subschema| subschema.group.blank? } + end + + def redirect_url_for_subschema(subschema, content_block_edition) + step = subschema.group.present? ? "#{Workflow::Step::GROUP_PREFIX}#{subschema.group}" : "#{Workflow::Step::SUBSCHEMA_PREFIX}#{subschema.id}" + content_block_manager.content_block_manager_content_block_workflow_path(content_block_edition, step:) + end +end diff --git a/app/helpers/content_block_manager/content_block/summary_list_helper.rb b/app/helpers/content_block_manager/content_block/summary_list_helper.rb new file mode 100644 index 000000000..c37adf564 --- /dev/null +++ b/app/helpers/content_block_manager/content_block/summary_list_helper.rb @@ -0,0 +1,34 @@ +module ContentBlockManager::ContentBlock::SummaryListHelper + include ContentBlockManager::ContentBlock::TranslationHelper + def first_class_items(input) + result = {} + + input.each do |key, value| + case value + when String + result[key] = value + when Array + value.each_with_index do |item, index| + result["#{key}/#{index}"] = item if item.is_a?(String) + end + end + end + + result + end + + def nested_items(input) + input.select do |_key, value| + value.is_a?(Hash) || value.is_a?(Array) && value.all? { |item| item.is_a?(Hash) } + end + end + + def key_to_title(key, object_type = nil) + subject, count = key.split("/") + if count + humanized_label(relative_key: "#{subject.singularize} #{count.to_i + 1}", root_object: object_type) + else + humanized_label(relative_key: subject, root_object: object_type) + end + end +end diff --git a/app/helpers/content_block_manager/content_block/translation_helper.rb b/app/helpers/content_block_manager/content_block/translation_helper.rb new file mode 100644 index 000000000..ff077e5ef --- /dev/null +++ b/app/helpers/content_block_manager/content_block/translation_helper.rb @@ -0,0 +1,17 @@ +module ContentBlockManager::ContentBlock::TranslationHelper + def humanized_label(relative_key:, root_object: nil) + translation_path = root_object ? "#{root_object}.#{relative_key}" : relative_key + + I18n.t( + "content_block_edition.details.labels.#{translation_path}", + default: relative_key.humanize.gsub("-", " "), + ) + end + + def translated_value(key, value) + default_path = "content_block_edition.details.values.#{value}" + translation_path = "content_block_edition.details.values.#{key}.#{value}" + + I18n.t(translation_path, default: [default_path.to_sym, value]) + end +end diff --git a/app/helpers/errors_helper.rb b/app/helpers/errors_helper.rb new file mode 100644 index 000000000..f838406f3 --- /dev/null +++ b/app/helpers/errors_helper.rb @@ -0,0 +1,38 @@ +module ErrorsHelper + def errors_for_input(errors, attribute) + return nil if errors.blank? + + errors.filter_map { |error| + if error.attribute == attribute + error.full_message + end + } + .join(tag.br) + .html_safe + .presence + end + + def errors_for(errors, attribute) + return nil if errors.blank? + + errors.filter_map { |error| + if error.attribute == attribute + { + text: error.full_message, + } + end + } + .presence + end + + def errors_from_flash(flash) + return nil if flash.blank? + + flash.map do |array| + { + href: "##{array.first}", + text: array.last, + } + end + end +end diff --git a/app/helpers/header_helper.rb b/app/helpers/header_helper.rb new file mode 100644 index 000000000..ae98165c4 --- /dev/null +++ b/app/helpers/header_helper.rb @@ -0,0 +1,17 @@ +module HeaderHelper + def sub_nav_item(name, path) + { + label: name, + href: path, + current: request.path.start_with?(path), + } + end + + def main_nav_item(name, path) + { + text: name, + href: path, + active: request.path.end_with?(path), + } + end +end diff --git a/app/models/concerns/date_validation.rb b/app/models/concerns/date_validation.rb new file mode 100644 index 000000000..abc1cdf84 --- /dev/null +++ b/app/models/concerns/date_validation.rb @@ -0,0 +1,63 @@ +module DateValidation + extend ActiveSupport::Concern + + included do + attr_reader :invalid_date_attributes + + validates_with DateValidator + + after_validation :rationalise_date_errors + + private + + # In cases when a date attribute is invalid, it will be set to nil by pre_validate_date_attribute and will therefore + # fail the presence validation. We therefore need to remove the presence error from each invalid date attribute to + # avoid a confusing user experience where both the invalid date and presence errors show simultaneously + def rationalise_date_errors + @invalid_date_attributes&.each do |invalid_date_attribute| + if errors.of_kind?(invalid_date_attribute, :blank) + errors.delete(invalid_date_attribute, :blank) + end + end + end + end + + def pre_validate_date_attribute(attribute, date) + @invalid_date_attributes = Set.new if @invalid_date_attributes.nil? + if date.is_a?(Hash) + begin + # Rails will cast the year part of the date to 0 if the year input parameter is a non-numeric string + # This only seems to happen to the year part, other parts remain as strings + raise TypeError if date[1].zero? + + # Rails does not accept negative month values, but the Date constructor does + raise TypeError if date[2].negative? + + Date.new(date[1], date[2], date[3]) + @invalid_date_attributes.delete(attribute) + rescue ArgumentError, TypeError, NoMethodError + @invalid_date_attributes.add(attribute) + date = nil + end + end + date + end + + class_methods do + def date_attributes(*attributes) + attributes.each do |attribute| + define_method("#{attribute}=") do |value| + super(pre_validate_date_attribute(attribute, value)) + end + end + end + end + + class DateValidator < ActiveModel::Validator + def validate(record) + record.invalid_date_attributes&.each do |date_attribute| + record.errors.add date_attribute, :invalid_date + end + end + end +end diff --git a/app/models/concerns/edition/workflow.rb b/app/models/concerns/edition/workflow.rb new file mode 100644 index 000000000..b75b4f3a5 --- /dev/null +++ b/app/models/concerns/edition/workflow.rb @@ -0,0 +1,128 @@ +module Edition::Workflow + extend ActiveSupport::Concern + + module ClassMethods + def active + where(arel_table[:state].not_eq("superseded")) + end + + def in_state(state) + valid_state?(state) && public_send(state) + end + + def valid_state?(state) + %w[active draft submitted rejected published scheduled force_published withdrawn not_published unpublished].include?(state) + end + end + + included do + include ActiveRecord::Transitions + + default_scope -> { where(arel_table[:state].not_eq("deleted")) } + + state_machine auto_scopes: true do + state :draft + state :submitted + state :rejected + state :scheduled + state :published + state :superseded + state :deleted + state :withdrawn + state :unpublished + + event :delete do + transitions from: %i[draft submitted rejected], to: :deleted + end + + event :submit do + transitions from: %i[draft rejected], to: :submitted + end + + event :reject do + transitions from: :submitted, to: :rejected + end + + event :schedule do + transitions from: :submitted, to: :scheduled + end + + event :force_schedule do + transitions from: %i[draft submitted], to: :scheduled + end + + event :unschedule do + transitions from: :scheduled, to: :submitted + end + + event :publish do + transitions from: %i[submitted scheduled], to: :published + end + + event :force_publish do + transitions from: %i[draft submitted], to: :published + end + + event :unpublish do + transitions from: %i[published unpublished], to: :unpublished + end + + event :supersede, success: :destroy_associations_with_edition_dependencies_and_dependants do + transitions from: %i[published unpublished], to: :superseded + end + + event :withdraw do + transitions from: %i[published withdrawn], to: :withdrawn + end + + event :unwithdraw do + transitions from: :withdrawn, to: :superseded + end + end + + validate :edition_has_no_unpublished_editions, on: :create + + scope :in_pre_publication_state, -> { where(state: Edition::PRE_PUBLICATION_STATES) } + scope :force_published, -> { where(state: "published", force_published: true) } + scope :not_published, -> { where(state: %w[draft submitted rejected]) } + scope :without_not_published, -> { where.not(state: %w[draft submitted rejected]) } + scope :publicly_visible, -> { where(state: Edition::PUBLICLY_VISIBLE_STATES) } + scope :scheduled, -> { where(state: "scheduled") } + + scope :future_scheduled_editions, -> { scheduled.where(Edition.arel_table[:scheduled_publication].gteq(Time.zone.now)) } + scope :due_for_publication, lambda { |within_time = 0| + cutoff = Time.zone.now + within_time + scheduled.where(arel_table[:scheduled_publication].lteq(cutoff)) + } + end + + def pre_publication? + Edition::PRE_PUBLICATION_STATES.include?(state.to_s) + end + + def save_as(user) + if save + edition_authors.create!(user:) + recent_edition_openings.where(editor_id: user).delete_all + end + end + + def edition_has_no_unpublished_editions + return unless document + + if (existing_edition = document.non_published_edition) + errors.add(:base, "There is already an active #{existing_edition.state} edition for this document") + end + end + + def has_workflow? + true + end + +private + + def destroy_associations_with_edition_dependencies_and_dependants + edition_dependencies.destroy_all + records_of_dependent_editions.destroy_all + end +end diff --git a/app/models/concerns/simple_workflow.rb b/app/models/concerns/simple_workflow.rb new file mode 100644 index 000000000..cb77ee092 --- /dev/null +++ b/app/models/concerns/simple_workflow.rb @@ -0,0 +1,24 @@ +# Expects Searchable to be included and destroyable? defined. +module SimpleWorkflow + extend ActiveSupport::Concern + + included do + include ActiveRecord::Transitions + + default_scope -> { where(arel_table[:state].not_eq("deleted")) } + + state_machine auto_scopes: true, initial: :current do + state :current + state :deleted + + event :delete, success: ->(document) { document.remove_from_search_index if document.respond_to?(:remove_from_search_index) } do + transitions from: [:current], to: :deleted, guard: :destroyable? + end + end + + # Overwrite this + def destroyable? + true + end + end +end diff --git a/app/models/content_block_manager/content_block.rb b/app/models/content_block_manager/content_block.rb new file mode 100644 index 000000000..e60a39888 --- /dev/null +++ b/app/models/content_block_manager/content_block.rb @@ -0,0 +1,7 @@ +module ContentBlockManager + module ContentBlock + def self.table_name_prefix + "content_block_" + end + end +end diff --git a/app/models/content_block_manager/content_block/diff_item.rb b/app/models/content_block_manager/content_block/diff_item.rb new file mode 100644 index 000000000..17c336089 --- /dev/null +++ b/app/models/content_block_manager/content_block/diff_item.rb @@ -0,0 +1,13 @@ +module ContentBlockManager + class ContentBlock::DiffItem < Data.define(:previous_value, :new_value) + def self.from_hash(hash) + hash.with_indifferent_access.map { |key, value| + if value.key?("new_value") && value.key?("previous_value") + [key, ContentBlock::DiffItem.new(previous_value: value["previous_value"].presence, new_value: value["new_value"].presence)] + else + [key, ContentBlock::DiffItem.from_hash(value)] + end + }.to_h + end + end +end diff --git a/app/models/content_block_manager/content_block/document.rb b/app/models/content_block_manager/content_block/document.rb new file mode 100644 index 000000000..a5f6feec1 --- /dev/null +++ b/app/models/content_block_manager/content_block/document.rb @@ -0,0 +1,66 @@ +module ContentBlockManager + module ContentBlock + class Document < ApplicationRecord + include Scopes::SearchableByKeyword + include Scopes::SearchableByLeadOrganisation + include Scopes::SearchableByUpdatedDate + + include SoftDeletable + + extend FriendlyId + friendly_id :sluggable_string, use: :slugged, slug_column: :content_id_alias, routes: :default + + has_many :editions, + -> { order(created_at: :asc, id: :asc) }, + inverse_of: :document + + enum :block_type, ContentBlockManager::ContentBlock::Schema.valid_schemas.index_with(&:to_s) + attr_readonly :block_type + + validates :block_type, :sluggable_string, presence: true + + has_one :latest_edition, + -> { joins(:document).where("content_block_documents.latest_edition_id = content_block_editions.id") }, + class_name: "ContentBlockManager::ContentBlock::Edition", + inverse_of: :document + + has_many :versions, through: :editions, source: :versions + + scope :live, -> { where.not(latest_edition_id: nil) } + + def embed_code(use_friendly_id: Flipflop.use_friendly_embed_codes?) + "#{embed_code_prefix(use_friendly_id)}}}" + end + + def embed_code_for_field(field_path, use_friendly_id: Flipflop.use_friendly_embed_codes?) + "#{embed_code_prefix(use_friendly_id)}/#{field_path}}}" + end + + def title + @title ||= latest_edition&.title + end + + def is_new_block? + editions.count == 1 + end + + def has_newer_draft? + latest_edition_id != editions.select(:id, :created_at).order(created_at: :asc).last.id + end + + def latest_draft + editions.where(state: :draft).order(created_at: :asc).last + end + + def schema + @schema ||= ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type) + end + + private + + def embed_code_prefix(use_friendly_id) + "{{embed:content_block_#{block_type}:#{use_friendly_id ? content_id_alias : content_id}" + end + end + end +end diff --git a/app/models/content_block_manager/content_block/document/document_filter.rb b/app/models/content_block_manager/content_block/document/document_filter.rb new file mode 100644 index 000000000..e561f0b92 --- /dev/null +++ b/app/models/content_block_manager/content_block/document/document_filter.rb @@ -0,0 +1,91 @@ +module ContentBlockManager + class ContentBlock::Document::DocumentFilter + FILTER_ERROR = Data.define(:attribute, :full_message) + + def initialize(filters = {}) + @filters = filters + end + + def paginated_documents + unpaginated_documents.page(page).per(default_page_size) + end + + def errors + @errors ||= begin + @errors = [] + from = validate_date(:last_updated_from) + to = validate_date(:last_updated_to) + + if @errors.empty? && to.present? && from.present? && from.after?(to) + @errors << FILTER_ERROR.new(attribute: "last_updated_from", full_message: I18n.t("content_block_document.index.errors.date.range.invalid")) + end + + @errors + end + end + + def valid? + errors.empty? + end + + private + + def validate_date(key) + return unless is_date_present?(key) + + date = date_from_filters(key) + Time.zone.local(date[:year], date[:month], date[:day]) + rescue ArgumentError, TypeError, NoMethodError, RangeError + @errors << FILTER_ERROR.new(attribute: key.to_s, full_message: I18n.t("content_block_document.index.errors.date.invalid", attribute: key.to_s.humanize)) + nil + end + + def page + @filters[:page].presence || 1 + end + + def default_page_size + 10 + end + + def is_date_present?(date_key) + @filters[date_key].present? && @filters[date_key].any? { |_, value| value.present? } + end + + def date_from_filters(date_key) + filter = @filters[date_key] + year = filter["1i"].to_i + month = filter["2i"].to_i + day = filter["3i"].to_i + { year:, month:, day: } + end + + def from_date + @from_date ||= if is_date_present?(:last_updated_from) + date = date_from_filters(:last_updated_from) + Time.zone.local(date[:year], date[:month], date[:day]) + end + end + + def to_date + @to_date ||= if is_date_present?(:last_updated_to) + date = date_from_filters(:last_updated_to) + Time.zone.local(date[:year], date[:month], date[:day]).end_of_day + end + end + + def unpaginated_documents + documents = ContentBlock::Document + documents = documents.where(block_type: ContentBlock::Schema.valid_schemas) + documents = documents.live + documents = documents.joins(:latest_edition) + documents = documents.with_keyword(@filters[:keyword]) if @filters[:keyword].present? + documents = documents.where(block_type: @filters[:block_type]) if @filters[:block_type].present? + documents = documents.with_lead_organisation(@filters[:lead_organisation]) if @filters[:lead_organisation].present? + documents = documents.from_date(from_date) if valid? && from_date + documents = documents.to_date(to_date) if valid? && to_date + documents.order("content_block_editions.updated_at DESC") + documents.distinct + end + end +end diff --git a/app/models/content_block_manager/content_block/document/scopes/searchable_by_keyword.rb b/app/models/content_block_manager/content_block/document/scopes/searchable_by_keyword.rb new file mode 100644 index 000000000..186d088ff --- /dev/null +++ b/app/models/content_block_manager/content_block/document/scopes/searchable_by_keyword.rb @@ -0,0 +1,26 @@ +module ContentBlockManager + module ContentBlock::Document::Scopes::SearchableByKeyword + extend ActiveSupport::Concern + + SQL = <<-SQL.freeze + MATCH( + content_block_editions.title,#{' '} + content_block_editions.details_for_indexing,#{' '} + content_block_editions.instructions_to_publishers + ) AGAINST (:pattern IN BOOLEAN MODE) + SQL + + included do + scope :with_keyword, + lambda { |keywords| + split_keywords = keywords.split + pattern = split_keywords.map { |k| + escaped_word = Regexp.escape(k) + "+#{escaped_word}" + }.join(" ") + joins(:latest_edition) + .where(SQL, pattern:) + } + end + end +end diff --git a/app/models/content_block_manager/content_block/document/scopes/searchable_by_lead_organisation.rb b/app/models/content_block_manager/content_block/document/scopes/searchable_by_lead_organisation.rb new file mode 100644 index 000000000..6bc1cb189 --- /dev/null +++ b/app/models/content_block_manager/content_block/document/scopes/searchable_by_lead_organisation.rb @@ -0,0 +1,12 @@ +module ContentBlockManager + module ContentBlock::Document::Scopes::SearchableByLeadOrganisation + extend ActiveSupport::Concern + + included do + scope :with_lead_organisation, + lambda { |id| + joins(latest_edition: :edition_organisation).where("content_block_edition_organisations.organisation_id = :id", id:) + } + end + end +end diff --git a/app/models/content_block_manager/content_block/document/scopes/searchable_by_updated_date.rb b/app/models/content_block_manager/content_block/document/scopes/searchable_by_updated_date.rb new file mode 100644 index 000000000..55bcbe1ad --- /dev/null +++ b/app/models/content_block_manager/content_block/document/scopes/searchable_by_updated_date.rb @@ -0,0 +1,11 @@ +module ContentBlockManager + module ContentBlock::Document::Scopes::SearchableByUpdatedDate + extend ActiveSupport::Concern + + included do + scope :latest_edition, -> { joins(:editions).where("content_block_documents.latest_edition_id = content_block_editions.id") } + scope :from_date, ->(date) { latest_edition.where("content_block_editions.updated_at >= ?", date) } + scope :to_date, ->(date) { latest_edition.where("content_block_editions.updated_at <= ?", date) } + end + end +end diff --git a/app/models/content_block_manager/content_block/document/soft_deletable.rb b/app/models/content_block_manager/content_block/document/soft_deletable.rb new file mode 100644 index 000000000..3f8d2598b --- /dev/null +++ b/app/models/content_block_manager/content_block/document/soft_deletable.rb @@ -0,0 +1,17 @@ +module ContentBlockManager + module ContentBlock::Document::SoftDeletable + extend ActiveSupport::Concern + + included do + default_scope { where(deleted_at: nil) } + end + + def soft_delete + update_column :deleted_at, Time.zone.now + end + + def soft_deleted? + deleted_at.present? + end + end +end diff --git a/app/models/content_block_manager/content_block/edition.rb b/app/models/content_block_manager/content_block/edition.rb new file mode 100644 index 000000000..94be987ff --- /dev/null +++ b/app/models/content_block_manager/content_block/edition.rb @@ -0,0 +1,94 @@ +module ContentBlockManager + module ContentBlock + class Edition < ApplicationRecord + validates :title, presence: true + validates :change_note, presence: true, if: :major_change?, on: :change_note + validates :major_change, inclusion: [true, false], on: :change_note + + include Documentable + include HasAuditTrail + include HasAuthors + include ValidatesDetails + include HasLeadOrganisation + include Workflow + + scope :current_versions, lambda { + joins( + "LEFT JOIN content_block_documents document ON document.latest_edition_id = content_block_editions.id", + ).where(state: "published") + } + + def update_document_reference_to_latest_edition! + document.update!(latest_edition_id: id) + end + + def render(embed_code) + ContentBlockTools::ContentBlock.new( + document_type: "content_block_#{block_type}", + content_id: document.content_id, + title:, + details:, + embed_code:, + ).render + rescue TypeError + # TODO: Remove this when we've updated Content Block Tools to support an array of telephones + nil + end + + def clone_edition(creator:) + new_edition = dup + new_edition.assign_attributes( + state: "draft", + organisation: lead_organisation, + creator: creator, + change_note: nil, + internal_change_note: nil, + ) + new_edition + end + + def add_object_to_details(object_type, body) + key = key_for_object(object_type, body["title"]) + + details[object_type] ||= {} + details[object_type][key] = remove_destroyed body.to_h + end + + def update_object_with_details(object_type, object_title, body) + details[object_type][object_title] = remove_destroyed body.to_h + end + + def key_for_object(object_type, title) + base_key = (title.presence || object_type).parameterize + key = base_key + counter = 1 + + while details.dig(object_type, key).present? + key = "#{base_key}-#{counter}" + counter += 1 + end + + key + end + + def has_entries_for_subschema_id?(subschema_id) + details[subschema_id].present? + end + + private + + def remove_destroyed(item) + item.transform_values { |value| + case value + when Hash + remove_destroyed(value) + when Array + value.select { |i| !i.is_a?(Hash) || i.delete("_destroy") != "1" } + else + value + end + }.to_h + end + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/diffable.rb b/app/models/content_block_manager/content_block/edition/diffable.rb new file mode 100644 index 000000000..2a1856414 --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/diffable.rb @@ -0,0 +1,51 @@ +module ContentBlockManager + module ContentBlock::Edition::Diffable + extend ActiveSupport::Concern + + def generate_diff + diff = {} + unless document.is_new_block? + diff["title"] = ContentBlock::DiffItem.new(previous_value: previous_edition.title, new_value: title) if previous_edition.title != title + diff["details"] = details_diff if details_diff.any? + diff["lead_organisation"] = ContentBlock::DiffItem.new(previous_value: previous_org.name, new_value: lead_organisation.name) if lead_organisation != previous_org + diff["instructions_to_publishers"] = ContentBlock::DiffItem.new(previous_value: previous_edition.instructions_to_publishers, new_value: instructions_to_publishers) if previous_edition.instructions_to_publishers != instructions_to_publishers + end + diff + end + + # This is a temporary solution to allow us to specifically set the previous edition when backfilling + # the diffs. This can be deleted once the rake task has been run + def previous_edition=(edition) + @previous_edition = edition + end + + def previous_edition + @previous_edition ||= document.editions.includes(:edition_organisation, :organisation)[-2] + end + + def previous_org + previous_edition.lead_organisation + end + + def details_diff + @details_diff ||= generate_details_diff + end + + def generate_details_diff(previous_details = previous_edition.details, current_details = details) + diff = {} + keys = [*previous_details&.keys, *current_details&.keys].uniq + keys.each do |key| + previous_value = previous_details&.fetch(key, nil) + new_value = current_details&.fetch(key, nil) + if previous_value.is_a?(String) || new_value.is_a?(String) + next unless previous_value != new_value + + diff[key] = ContentBlock::DiffItem.new(previous_value:, new_value:) + elsif previous_value.is_a?(Hash) || new_value.is_a?(Hash) + diff[key] = generate_details_diff(previous_value, new_value) + end + end + diff + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/documentable.rb b/app/models/content_block_manager/content_block/edition/documentable.rb new file mode 100644 index 000000000..c34506714 --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/documentable.rb @@ -0,0 +1,29 @@ +module ContentBlockManager + module ContentBlock::Edition::Documentable + extend ActiveSupport::Concern + + included do + belongs_to :document, touch: true + validates :document, presence: true + + before_validation :ensure_presence_of_document, on: :create + + accepts_nested_attributes_for :document + end + + def block_type + @block_type ||= document&.block_type + end + + def ensure_presence_of_document + if document.new_record? + document.content_id = create_random_id if document.content_id.blank? + document.sluggable_string = title if document.sluggable_string.blank? + end + end + + def create_random_id + SecureRandom.uuid + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/has_audit_trail.rb b/app/models/content_block_manager/content_block/edition/has_audit_trail.rb new file mode 100644 index 000000000..77b0927aa --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/has_audit_trail.rb @@ -0,0 +1,45 @@ +module ContentBlockManager + module ContentBlock::Edition::HasAuditTrail + extend ActiveSupport::Concern + + def self.acting_as(actor) + original_actor = Current.user + Current.user = actor + yield + ensure + Current.user = original_actor + end + + included do + include ContentBlock::Edition::Diffable + + has_many :versions, -> { order(created_at: :desc, id: :desc) }, as: :item + + after_create :record_create + after_update :record_update + end + + attr_accessor :updated_embedded_object_type, :updated_embedded_object_title + + private + + def record_create + user = Current.user + versions.create!(event: "created", user:) + end + + def record_update + unless draft? + user = Current.user + state = try(:state) + versions.create!( + event: "updated", + user:, state:, + field_diffs: generate_diff, + updated_embedded_object_type:, + updated_embedded_object_title: + ) + end + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/has_authors.rb b/app/models/content_block_manager/content_block/edition/has_authors.rb new file mode 100644 index 000000000..fda1835f4 --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/has_authors.rb @@ -0,0 +1,10 @@ +module ContentBlockManager + module ContentBlock::Edition::HasAuthors + extend ActiveSupport::Concern + include ContentBlock::Edition::HasCreator + + included do + has_many :edition_authors, dependent: :destroy, class_name: "ContentBlockManager::ContentBlock::EditionAuthor" + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/has_creator.rb b/app/models/content_block_manager/content_block/edition/has_creator.rb new file mode 100644 index 000000000..248b3de17 --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/has_creator.rb @@ -0,0 +1,22 @@ +module ContentBlockManager + module ContentBlock::Edition::HasCreator + extend ActiveSupport::Concern + + included do + validates :creator, presence: true + end + + def creator + edition_authors.first&.user + end + + def creator=(user) + if new_record? + edition_author = edition_authors.first || edition_authors.build + edition_author.user = user + else + raise "author can only be set on new records" + end + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/has_lead_organisation.rb b/app/models/content_block_manager/content_block/edition/has_lead_organisation.rb new file mode 100644 index 000000000..5b59b7ea5 --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/has_lead_organisation.rb @@ -0,0 +1,27 @@ +module ContentBlockManager + module ContentBlock::Edition::HasLeadOrganisation + extend ActiveSupport::Concern + + included do + has_one :edition_organisation, foreign_key: :content_block_edition_id, + dependent: :destroy, + class_name: "ContentBlockManager::ContentBlock::EditionOrganisation" + has_one :organisation, through: :edition_organisation + + validates_with ContentBlockManager::OrganisationValidator + end + + def organisation_id=(organisation_id) + if organisation_id.blank? + self.edition_organisation = nil + else + edition_organisation = build_edition_organisation + edition_organisation.organisation_id = organisation_id + end + end + + def lead_organisation + organisation + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/validates_details.rb b/app/models/content_block_manager/content_block/edition/validates_details.rb new file mode 100644 index 000000000..8a23d271e --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/validates_details.rb @@ -0,0 +1,41 @@ +module ContentBlockManager + module ContentBlock::Edition::ValidatesDetails + extend ActiveSupport::Concern + + DETAILS_PREFIX = "details_".freeze + + included do + validates_with ContentBlockManager::DetailsValidator + + # Only used in tests, so we can easily add a schema to an edition, without + # having to resort to mocks, which are difficult to setup/clean between tests + attr_writer :schema + + def self.human_attribute_name(attr, options = {}) + if attr.starts_with?(DETAILS_PREFIX) + key = attr.to_s.delete_prefix(DETAILS_PREFIX) + key.humanize + else + super attr, options + end + end + end + + def schema + @schema ||= ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type) + end + + # When an error is raised about a field within the details hash + # we have to prefix it. This overrides the default `read_attribute_for_validation` + # method, and reads it from the details hash if the attribute name + # is prefixes + def read_attribute_for_validation(attr) + if attr.starts_with?(DETAILS_PREFIX) + key = attr.to_s.delete_prefix(DETAILS_PREFIX) + details&.fetch(key, nil) + else + super(attr) + end + end + end +end diff --git a/app/models/content_block_manager/content_block/edition/workflow.rb b/app/models/content_block_manager/content_block/edition/workflow.rb new file mode 100644 index 000000000..c03084fb1 --- /dev/null +++ b/app/models/content_block_manager/content_block/edition/workflow.rb @@ -0,0 +1,37 @@ +module ContentBlockManager + module ContentBlock::Edition::Workflow + extend ActiveSupport::Concern + include DateValidation + + module ClassMethods + def valid_state?(state) + %w[draft published scheduled superseded].include?(state) + end + end + + included do + include ActiveRecord::Transitions + + date_attributes :scheduled_publication + + validates_with ContentBlockManager::ScheduledPublicationValidator, if: -> { validation_context == :scheduling || state == "scheduled" } + + state_machine auto_scopes: true do + state :draft + state :published + state :scheduled + state :superseded + + event :publish do + transitions from: %i[draft scheduled], to: :published + end + event :schedule do + transitions from: %i[draft], to: :scheduled + end + event :supersede do + transitions from: %i[scheduled], to: :superseded + end + end + end + end +end diff --git a/app/models/content_block_manager/content_block/edition_author.rb b/app/models/content_block_manager/content_block/edition_author.rb new file mode 100644 index 000000000..46725a95e --- /dev/null +++ b/app/models/content_block_manager/content_block/edition_author.rb @@ -0,0 +1,8 @@ +module ContentBlockManager + module ContentBlock + class EditionAuthor < ApplicationRecord + belongs_to :edition + belongs_to :user + end + end +end diff --git a/app/models/content_block_manager/content_block/edition_organisation.rb b/app/models/content_block_manager/content_block/edition_organisation.rb new file mode 100644 index 000000000..afdf8a0fd --- /dev/null +++ b/app/models/content_block_manager/content_block/edition_organisation.rb @@ -0,0 +1,8 @@ +module ContentBlockManager + module ContentBlock + class EditionOrganisation < ApplicationRecord + belongs_to :edition + belongs_to :organisation + end + end +end diff --git a/app/models/content_block_manager/content_block/schema.rb b/app/models/content_block_manager/content_block/schema.rb new file mode 100644 index 000000000..54c208464 --- /dev/null +++ b/app/models/content_block_manager/content_block/schema.rb @@ -0,0 +1,121 @@ +module ContentBlockManager + module ContentBlock + class Schema + SCHEMA_PREFIX = "content_block".freeze + + VALID_SCHEMAS = %w[pension contact].freeze + private_constant :VALID_SCHEMAS + + CONFIG_PATH = Rails.root.join("config/content_block_manager.yml").to_s + + class << self + def valid_schemas + VALID_SCHEMAS + end + + def all + @all ||= Services.publishing_api.get_schemas.select { |k, _v| + is_valid_schema?(k) + }.map { |id, full_schema| + full_schema.dig("definitions", "details")&.yield_self { |schema| new(id, schema) } + }.compact + end + + def find_by_block_type(block_type) + all.find { |schema| schema.block_type == block_type } || raise(ArgumentError, "Cannot find schema for #{block_type}") + end + + def is_valid_schema?(key) + key.start_with?(SCHEMA_PREFIX) && key.end_with?(*valid_schemas) + end + + def schema_settings + @schema_settings ||= YAML.load_file(CONFIG_PATH) + end + end + + attr_reader :id, :body + + def initialize(id, body) + @id = id + @body = body + end + + def name + block_type.humanize + end + + def parameter + block_type.dasherize + end + + def fields + field_names.map { |field_name| Field.new(field_name, self) } + end + + def subschema(name) + subschemas.find { |s| s.id == name } + end + + def subschemas + @subschemas ||= embedded_objects.map { |object| EmbeddedSchema.new(*object, @id) } + end + + def subschemas_for_group(group) + subschemas.select { |s| s.group == group } + end + + def permitted_params + field_names + end + + def block_type + @block_type ||= id.delete_prefix("#{SCHEMA_PREFIX}_") + end + + def embeddable_fields + config["embeddable_fields"] || [] + end + + def embeddable_as_block? + config["embeddable_as_block"].present? + end + + def config + @config ||= self.class.schema_settings.dig("schemas", @id) || {} + end + + def field_ordering_rule(field) + if field_order + # If a field order is found in the config, order by the index. If a field is not found, put it to the end + field_order.index(field) || 99 + else + # By default, order with title first + field == "title" ? 0 : 1 + end + end + + def required_fields + @body["required"] || [] + end + + private + + def field_names + sort_fields (@body["properties"].to_a - embedded_objects.to_a).to_h.keys + end + + def sort_fields(fields) + fields.sort_by { |field| field_ordering_rule(field) } + end + + def field_order + @field_order ||= config["field_order"] + end + + def embedded_objects + @body["properties"].select { |_k, v| v["type"] == "object" } + end + end + end +end diff --git a/app/models/content_block_manager/content_block/schema/embedded_schema.rb b/app/models/content_block_manager/content_block/schema/embedded_schema.rb new file mode 100644 index 000000000..e4764e202 --- /dev/null +++ b/app/models/content_block_manager/content_block/schema/embedded_schema.rb @@ -0,0 +1,67 @@ +module ContentBlockManager + module ContentBlock + class Schema + class EmbeddedSchema < ContentBlockManager::ContentBlock::Schema + GOVSPEAK_ENABLED_PROPERTY_KEY = "x-govspeak_enabled".freeze + + def initialize(id, body, parent_schema_id) + @parent_schema_id = parent_schema_id + body = body["patternProperties"]&.values&.first || raise(ArgumentError, "Subschema `#{id}` is invalid") + super(id, body) + end + + def block_type + @id + end + + def embeddable_as_block? + @embeddable_as_block ||= config["embeddable_as_block"].present? + end + + def config + @config ||= self.class.schema_settings.dig("schemas", @parent_schema_id, "subschemas", @id) || {} + end + + def group + @group ||= config["group"] + end + + def group_order + @group_order ||= config["group_order"]&.to_i || Float::INFINITY + end + + def permitted_params + fields.map do |field| + if field.nested_fields.present? + { field.name => field.nested_fields.map(&:name) } + elsif field.format == "array" + { field.name => [*field.array_items["properties"]&.keys, "_destroy"] || [] } + else + field.name + end + end + end + + def govspeak_enabled?(field_name:, nested_object_key: nil) + return top_level_govspeak_enabled_fields.include?(field_name) unless nested_object_key + + govspeak_enabled_fields_for_nested_object(nested_object_key).include?(field_name) + end + + def govspeak_enabled_fields_for_nested_object(object_key) + body.dig("properties", object_key, GOVSPEAK_ENABLED_PROPERTY_KEY) || [] + end + + def top_level_govspeak_enabled_fields + body.dig("properties", GOVSPEAK_ENABLED_PROPERTY_KEY) || [] + end + + private + + def field_names + sort_fields @body["properties"].keys + end + end + end + end +end diff --git a/app/models/content_block_manager/content_block/schema/field.rb b/app/models/content_block_manager/content_block/schema/field.rb new file mode 100644 index 000000000..805833fa6 --- /dev/null +++ b/app/models/content_block_manager/content_block/schema/field.rb @@ -0,0 +1,101 @@ +module ContentBlockManager + module ContentBlock + class Schema + class Field + attr_reader :name, :schema + + NestedField = Data.define(:name, :format, :enum_values, :default_value) do + def initialize(name:, format:, enum_values:, default_value: nil) + super(name:, format:, enum_values:, default_value:) + end + end + + def initialize(name, schema) + @name = name + @schema = schema + end + + def to_s + name + end + + def component_name + if custom_component + custom_component + elsif enum_values + "enum" + else + format + end + end + + def format + @format ||= properties["type"] + end + + def enum_values + @enum_values ||= properties["enum"] + end + + def default_value + @default_value ||= properties["default"] + end + + def nested_fields + if format == "object" + properties.fetch("properties", {}).map do |key, value| + NestedField.new( + name: key, + format: value["type"], + enum_values: value["enum"], + default_value: value["default"], + ) + end + end + end + + def nested_field(name) + raise(ArgumentError, "Provide the name of a nested field") if name.blank? + + nested_fields.find { |field| field.name == name } + end + + def array_items + properties.fetch("items", nil)&.tap do |array_items| + if array_items["type"] == "object" + array_items["properties"] = array_items["properties"].sort_by { |k, _v| + field_ordering_rule.find_index(k) || Float::INFINITY + }.to_h + end + end + end + + def is_required? + schema.required_fields.include?(name) + end + + def data_attributes + @data_attributes ||= config["data_attributes"] || {} + end + + private + + def custom_component + @custom_component ||= config["component"] + end + + def properties + @properties ||= schema.body.dig("properties", name) || {} + end + + def config + @config ||= schema.config.dig("fields", name) || {} + end + + def field_ordering_rule + @field_ordering_rule ||= config["field_order"] || [] + end + end + end + end +end diff --git a/app/models/content_block_manager/content_block/version.rb b/app/models/content_block_manager/content_block/version.rb new file mode 100644 index 000000000..f8927fd7c --- /dev/null +++ b/app/models/content_block_manager/content_block/version.rb @@ -0,0 +1,19 @@ +module ContentBlockManager + module ContentBlock + class Version < ApplicationRecord + enum :event, { created: 0, updated: 1 } + + belongs_to :item, polymorphic: true + validates :event, presence: true + belongs_to :user, foreign_key: "whodunnit" + + def field_diffs + self[:field_diffs] ? ContentBlock::DiffItem.from_hash(self[:field_diffs]) : {} + end + + def is_embedded_update? + updated_embedded_object_type && updated_embedded_object_title + end + end + end +end diff --git a/app/models/content_block_manager/embedded_object_immutability_check.rb b/app/models/content_block_manager/embedded_object_immutability_check.rb new file mode 100644 index 000000000..17c1f297b --- /dev/null +++ b/app/models/content_block_manager/embedded_object_immutability_check.rb @@ -0,0 +1,18 @@ +class ContentBlockManager::EmbeddedObjectImmutabilityCheck + def initialize(edition:, field_reference:) + @edition = edition + @field_reference = field_reference + end + + def can_be_deleted?(index) + live_fields[index].blank? + end + +private + + def live_fields + @live_fields ||= edition&.details&.dig(*field_reference) || [] + end + + attr_reader :edition, :field_reference +end diff --git a/app/models/content_block_manager/host_content_item.rb b/app/models/content_block_manager/host_content_item.rb new file mode 100644 index 000000000..5f16fa273 --- /dev/null +++ b/app/models/content_block_manager/host_content_item.rb @@ -0,0 +1,75 @@ +module ContentBlockManager + class HostContentItem < Data.define( + :title, + :base_path, + :document_type, + :publishing_organisation, + :publishing_app, + :last_edited_by_editor, + :last_edited_at, + :unique_pageviews, + :instances, + :host_content_id, + :host_locale, + ) + + DEFAULT_ORDER = "-unique_pageviews".freeze + + class << self + def for_document(content_block_document, page: nil, order: nil) + api_response = Services.publishing_api.get_host_content_for_content_id( + content_block_document.content_id, + { + page:, + order: order || DEFAULT_ORDER, + }.compact, + ).parsed_content + + editor_uuids = api_response["results"].map { |c| c["last_edited_by_editor_id"] }.compact.uniq + editors = editor_uuids.present? ? ContentBlockManager::SignonUser.with_uuids(editor_uuids) : [] + + items = api_response["results"].map do |record| + from_api_record(record, editors) + end + + ContentBlockManager::HostContentItem::Items.new( + items:, + total: api_response["total"], + total_pages: api_response["total_pages"], + rollup: rollup(api_response), + ) + end + + private + + def rollup(api_response) + ContentBlockManager::HostContentItem::Items::Rollup.new( + views: api_response["rollup"]["views"], + locations: api_response["rollup"]["locations"], + instances: api_response["rollup"]["instances"], + organisations: api_response["rollup"]["organisations"], + ) + end + + def from_api_record(record, editors) + new( + title: record["title"], + base_path: record["base_path"], + document_type: record["document_type"], + publishing_organisation: record["primary_publishing_organisation"], + publishing_app: record["publishing_app"], + last_edited_by_editor: editors.find { |editor| editor.uid == record["last_edited_by_editor_id"] }, + last_edited_at: record["last_edited_at"], + unique_pageviews: record["unique_pageviews"], + instances: record["instances"], + host_content_id: record["host_content_id"], + host_locale: record["host_locale"], + ) + end + end + + def last_edited_at + Time.zone.parse(super) + end + end +end diff --git a/app/models/content_block_manager/host_content_item/items.rb b/app/models/content_block_manager/host_content_item/items.rb new file mode 100644 index 000000000..3799e1698 --- /dev/null +++ b/app/models/content_block_manager/host_content_item/items.rb @@ -0,0 +1,12 @@ +module ContentBlockManager + class HostContentItem + class Items < Data.define(:items, :total, :total_pages, :rollup) + extend Forwardable + + ARRAY_METHODS = ([].methods - Object.methods) + Rollup = Data.define(:views, :locations, :instances, :organisations) + + def_delegators :items, *ARRAY_METHODS + end + end +end diff --git a/app/models/content_block_manager/host_content_items.rb b/app/models/content_block_manager/host_content_items.rb new file mode 100644 index 000000000..d0002202a --- /dev/null +++ b/app/models/content_block_manager/host_content_items.rb @@ -0,0 +1,11 @@ +module ContentBlockManager + class HostContentItems < Data.define(:items, :total, :total_pages, :rollup) + extend Forwardable + + ARRAY_METHODS = ([].methods - Object.methods) + + def_delegators :items, *ARRAY_METHODS + + class Rollup < Data.define(:views, :locations, :instances, :organisations); end + end +end diff --git a/app/models/content_block_manager/preview_content.rb b/app/models/content_block_manager/preview_content.rb new file mode 100644 index 000000000..811a08169 --- /dev/null +++ b/app/models/content_block_manager/preview_content.rb @@ -0,0 +1,26 @@ +module ContentBlockManager + class PreviewContent < Data.define(:title, :html, :instances_count) + class << self + def for_content_id(content_id:, content_block_edition:, base_path: nil, locale: "en") + content_item = Services.publishing_api.get_content(content_id, { locale: }).parsed_content + metadata = Services.publishing_api.get_host_content_item_for_content_id( + content_block_edition.document.content_id, + content_id, + { locale: }, + ).parsed_content + html = ContentBlockManager::GeneratePreviewHtml.new( + content_id:, + content_block_edition:, + base_path: base_path || content_item["base_path"], + locale:, + ).call + + ContentBlockManager::PreviewContent.new( + title: content_item["title"], + html:, + instances_count: metadata["instances"], + ) + end + end + end +end diff --git a/app/models/content_block_manager/signon_user.rb b/app/models/content_block_manager/signon_user.rb new file mode 100644 index 000000000..b1c280a50 --- /dev/null +++ b/app/models/content_block_manager/signon_user.rb @@ -0,0 +1,14 @@ +module ContentBlockManager + class SignonUser < Data.define(:uid, :name, :email, :organisation) + def self.with_uuids(uuids) + Services.signon_api_client.get_users(uuids:).map do |user| + new( + uid: user["uid"], + name: user["name"], + email: user["email"], + organisation: ContentBlockManager::SignonUser::Organisation.from_user_hash(user), + ) + end + end + end +end diff --git a/app/models/content_block_manager/signon_user/organisation.rb b/app/models/content_block_manager/signon_user/organisation.rb new file mode 100644 index 000000000..2edca6994 --- /dev/null +++ b/app/models/content_block_manager/signon_user/organisation.rb @@ -0,0 +1,15 @@ +module ContentBlockManager + class SignonUser + class Organisation < Data.define(:content_id, :name, :slug) + def self.from_user_hash(user) + if user["organisation"].present? + new( + content_id: user["organisation"]["content_id"], + name: user["organisation"]["name"], + slug: user["organisation"]["slug"], + ) + end + end + end + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..73a9744b3 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user +end diff --git a/app/models/organisation.rb b/app/models/organisation.rb new file mode 100644 index 000000000..bbf1e2c4f --- /dev/null +++ b/app/models/organisation.rb @@ -0,0 +1,107 @@ +class Organisation < ApplicationRecord + has_many :editions, through: :edition_organisations + + has_many :users, foreign_key: :organisation_slug, primary_key: :slug, dependent: :nullify + + validates :slug, presence: true, uniqueness: { case_sensitive: false } + # validates_with SafeHtmlValidator + validates :name, presence: true, uniqueness: { case_sensitive: false } + validates :govuk_status, presence: true, inclusion: { in: %w[live joining exempt transitioning closed] } + validates :govuk_closed_status, inclusion: { in: %w[no_longer_exists replaced split merged changed_name left_gov devolved] }, presence: true, if: :closed? + + delegate :ministerial_department?, to: :type + delegate :devolved_administration?, to: :type + + scope :closed, -> { where(govuk_status: "closed") } + + def self.with_published_editions + where("exists ( + SELECT 1 FROM `editions` + INNER JOIN `edition_organisations` ON `edition_organisations`.`edition_id` = `editions`.`id` + WHERE `editions`.`state` = 'published' + AND (edition_organisations.organisation_id = organisations.id) + )") + end + + def live? + govuk_status == "live" + end + + def closed? + govuk_status == "closed" + end + + def exempt? + govuk_status == "exempt" + end + + def replaced? + govuk_closed_status == "replaced" + end + + def split? + govuk_closed_status == "split" + end + + def merged? + govuk_closed_status == "merged" + end + + def changed_name? + govuk_closed_status == "changed_name" + end + + def devolved? + govuk_closed_status == "devolved" + end + + def name_without_prefix + name.gsub(/^The/, "").strip + end + + def display_name + [acronym, name].detect(&:present?) + end + + def select_name + [name, ("(#{acronym})" if acronym.present?), ("[Closed]" if closed?)].compact.join(" ") + end + + def published_editions + editions.published + end + + def to_s + name + end + + def news_priority_homepage? + homepage_type == "news" + end + + def base_path + "/government/organisations/#{slug}" + end + + def public_path(options = {}) + append_url_options(base_path, options) + end + + def link_to_section_on_organisation_list_page + append_url_options("/government/organisations", anchor: slug) + end + + def public_url(options = {}) + website_root = if options[:draft] + Plek.external_url_for("draft-origin") + else + Plek.website_root + end + + website_root + public_path(options) + end + + def publishing_api_presenter + PublishingApi::OrganisationPresenter + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..080fc1fa2 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,85 @@ +class User < ApplicationRecord + include GDS::SSO::User + + belongs_to :organisation, foreign_key: :organisation_slug, primary_key: :slug, + optional: true + + serialize :permissions, coder: YAML, type: Array + + validates :name, presence: true + + scope :enabled, -> { where(disabled: false) } + + module Permissions + SIGNIN = "signin".freeze + DEPARTMENTAL_EDITOR = "Editor".freeze + MANAGING_EDITOR = "Managing Editor".freeze + GDS_EDITOR = "GDS Editor".freeze + VIP_EDITOR = "VIP Editor".freeze + PUBLISH_SCHEDULED_EDITIONS = "Publish scheduled editions".freeze + GDS_ADMIN = "GDS Admin".freeze + SIDEKIQ_ADMIN = "Sidekiq Admin".freeze + VISUAL_EDITOR_PRIVATE_BETA = "Visual editor private beta".freeze + end + + def role + if gds_editor? then "GDS Editor" + elsif departmental_editor? then "Departmental Editor" + elsif managing_editor? then "Managing Editor" + else + "Writer" + end + end + + def departmental_editor? + has_permission?(Permissions::DEPARTMENTAL_EDITOR) + end + + def managing_editor? + has_permission?(Permissions::MANAGING_EDITOR) + end + + def gds_editor? + has_permission?(Permissions::GDS_EDITOR) + end + + def vip_editor? + has_permission?(Permissions::VIP_EDITOR) + end + + def gds_admin? + has_permission?(Permissions::GDS_ADMIN) + end + + def can_see_visual_editor_private_beta? + has_permission?(Permissions::VISUAL_EDITOR_PRIVATE_BETA) + end + + def organisation_name + organisation ? organisation.name : nil + end + + def has_email? + email.present? + end + + def editable_by?(user) + user.gds_editor? + end + + def can_handle_fatalities? + gds_editor? || (organisation && organisation.handles_fatalities?) + end + + def fuzzy_last_name + name.split(/ +/, 2).last + end + + def organisation_content_id + return organisation.content_id if organisation + + @organisation_content_id || "" + end + + attr_writer :organisation_content_id +end diff --git a/app/presenters/content_block_manager/confirmation_copy_presenter.rb b/app/presenters/content_block_manager/confirmation_copy_presenter.rb new file mode 100644 index 000000000..8ac8a2808 --- /dev/null +++ b/app/presenters/content_block_manager/confirmation_copy_presenter.rb @@ -0,0 +1,37 @@ +module ContentBlockManager + class ConfirmationCopyPresenter + def initialize(content_block_edition) + @content_block_edition = content_block_edition + end + + def for_panel + I18n.t("content_block_edition.confirmation_page.#{state}.banner", block_type:, date:) + end + + def for_paragraph + I18n.t("content_block_edition.confirmation_page.#{state}.detail") + end + + def state + if content_block_edition.scheduled? + :scheduled + elsif content_block_edition.document.editions.count > 1 + :updated + else + :created + end + end + + private + + attr_reader :content_block_edition + + def date + I18n.l(content_block_edition.scheduled_publication, format: :long_ordinal) if content_block_edition.scheduled_publication + end + + def block_type + content_block_edition.block_type.humanize + end + end +end diff --git a/app/services/content_block_manager/concerns/dequeueable.rb b/app/services/content_block_manager/concerns/dequeueable.rb new file mode 100644 index 000000000..5bcf54a31 --- /dev/null +++ b/app/services/content_block_manager/concerns/dequeueable.rb @@ -0,0 +1,16 @@ +module ContentBlockManager + module Concerns + module Dequeueable + extend ActiveSupport::Concern + + def dequeue_all_previously_queued_editions(content_block_edition) + content_block_edition.document.editions.where(state: :scheduled).find_each do |edition| + next if content_block_edition.id == edition.id + + ContentBlockManager::SchedulePublishingWorker.dequeue(edition) + edition.supersede! + end + end + end + end +end diff --git a/app/services/content_block_manager/create_edition_service.rb b/app/services/content_block_manager/create_edition_service.rb new file mode 100644 index 000000000..b6946f9bc --- /dev/null +++ b/app/services/content_block_manager/create_edition_service.rb @@ -0,0 +1,43 @@ +module ContentBlockManager + class CreateEditionService + def initialize(schema) + @schema = schema + end + + def call(edition_params, document_id: nil) + @new_edition = build_edition(edition_params, document_id) + params = build_params(edition_params, document_id) + @new_edition.assign_attributes(params) + @new_edition.save! + @new_edition + end + + private + + def build_edition(edition_params, document_id) + if document_id.nil? + ContentBlockManager::ContentBlock::Edition.new(edition_params) + else + document = ContentBlockManager::ContentBlock::Document.find(document_id) + new_edition = document.latest_edition.dup + ContentBlockManager::ContentBlock::Edition.new( + document_id:, + title: edition_params[:title], + details: new_edition.details, + document_attributes: edition_params.delete(:document_attributes) + .except(:block_type) + .merge({ id: document_id }), + ) + end + end + + def build_params(edition_params, document_id) + unless document_id.nil? + document = ContentBlockManager::ContentBlock::Document.find(document_id) + latest_edition = document.latest_edition + edition_params[:details] = latest_edition.details.merge(edition_params[:details]) + end + edition_params + end + end +end diff --git a/app/services/content_block_manager/delete_edition_service.rb b/app/services/content_block_manager/delete_edition_service.rb new file mode 100644 index 000000000..5448d579a --- /dev/null +++ b/app/services/content_block_manager/delete_edition_service.rb @@ -0,0 +1,23 @@ +module ContentBlockManager + class DeleteEditionService + def call(content_block_edition) + if content_block_edition.draft? + document = content_block_edition.document + document.with_lock do + content_block_edition.destroy! + if document_has_no_more_editions?(document) + document.destroy! + end + end + else + raise ArgumentError, "Could not delete Content Block Edition #{content_block_edition.id} because it is not in draft" + end + end + + private + + def document_has_no_more_editions?(document) + document.editions.count.zero? + end + end +end diff --git a/app/services/content_block_manager/find_and_replace_embed_codes_service.rb b/app/services/content_block_manager/find_and_replace_embed_codes_service.rb new file mode 100644 index 000000000..da65fd486 --- /dev/null +++ b/app/services/content_block_manager/find_and_replace_embed_codes_service.rb @@ -0,0 +1,44 @@ +module ContentBlockManager + class FindAndReplaceEmbedCodesService + def self.call(html) + new(html).call + end + + def call + embed_content_references.uniq.each do |reference| + content_block = content_blocks.find do |c| + c.document.content_id == reference.identifier || c.document.content_id_alias == reference.identifier + end + next if content_block.nil? + + html.gsub!(reference.embed_code, content_block.render(reference.embed_code)) + end + + html + end + + private + + attr_reader :html + + def initialize(html) + @html = html + end + + def embed_content_references + @embed_content_references ||= ContentBlockTools::ContentBlockReference.find_all_in_document(html) + end + + def identifiers + embed_content_references.map(&:identifier) + end + + def content_blocks + @content_blocks ||= begin + scope = ContentBlockManager::ContentBlock::Edition.current_versions + scope.where(document: { content_id: identifiers }) + .or(scope.where(document: { content_id_alias: identifiers })) + end + end + end +end diff --git a/app/services/content_block_manager/generate_preview_html.rb b/app/services/content_block_manager/generate_preview_html.rb new file mode 100644 index 000000000..0b61ae511 --- /dev/null +++ b/app/services/content_block_manager/generate_preview_html.rb @@ -0,0 +1,91 @@ +require "net/http" +require "json" +require "uri" + +module ContentBlockManager + class GeneratePreviewHtml + include ContentBlockManager::Engine.routes.url_helpers + + def initialize(content_id:, content_block_edition:, base_path:, locale:) + @content_id = content_id + @content_block_edition = content_block_edition + @base_path = base_path + @locale = locale + end + + def call + uri = URI(frontend_path) + nokogiri_html = html_snapshot_from_frontend(uri) + update_local_link_paths(nokogiri_html) + add_draft_style(nokogiri_html) + replace_existing_content_blocks(nokogiri_html).to_s + end + + private + + BLOCK_STYLE = "background-color: yellow;".freeze + ERROR_HTML = "

Preview not found

".freeze + + attr_reader :content_block_edition, :content_id, :base_path, :locale + + def frontend_path + frontend_base_path + base_path + end + + def frontend_base_path + Rails.env.development? ? Plek.external_url_for("government-frontend") : Plek.website_root + end + + def html_snapshot_from_frontend(uri) + begin + raw_html = Net::HTTP.get(uri) + rescue StandardError + raw_html = ERROR_HTML + end + Nokogiri::HTML.parse(raw_html) + end + + def update_local_link_paths(nokogiri_html) + url = host_content_preview_content_block_manager_content_block_edition_path(id: content_block_edition.id, host_content_id: content_id, locale:) + nokogiri_html.css("a").each do |link| + next if link[:href].start_with?("//") || link[:href].start_with?("http") + + link[:href] = "#{url}&base_path=#{link[:href]}" + link[:target] = "_parent" + end + + nokogiri_html + end + + def add_draft_style(nokogiri_html) + nokogiri_html.css("body").each do |body| + body["class"] ||= "" + body["class"] += " draft" + end + nokogiri_html + end + + def replace_existing_content_blocks(nokogiri_html) + replace_blocks(nokogiri_html) + style_blocks(nokogiri_html) + nokogiri_html + end + + def replace_blocks(nokogiri_html) + content_block_wrappers(nokogiri_html).each do |wrapper| + embed_code = wrapper["data-embed-code"] + wrapper.replace content_block_edition.render(embed_code) + end + end + + def style_blocks(nokogiri_html) + content_block_wrappers(nokogiri_html).each do |wrapper| + wrapper["style"] = BLOCK_STYLE + end + end + + def content_block_wrappers(nokogiri_html) + nokogiri_html.css("[data-content-id=\"#{@content_block_edition.document.content_id}\"]") + end + end +end diff --git a/app/services/content_block_manager/publish_edition_service.rb b/app/services/content_block_manager/publish_edition_service.rb new file mode 100644 index 000000000..029d0c71d --- /dev/null +++ b/app/services/content_block_manager/publish_edition_service.rb @@ -0,0 +1,78 @@ +module ContentBlockManager + class PublishEditionService + class PublishingFailureError < StandardError; end + + include Concerns::Dequeueable + + def call(edition) + publish_with_rollback(edition) + end + + private + + def publish_with_rollback(content_block_edition) + document = content_block_edition.document + schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(document.block_type) + content_id = document.content_id + content_id_alias = document.content_id_alias + + create_publishing_api_edition( + content_id:, + content_id_alias:, + schema_id: schema.id, + content_block_edition:, + ) + dequeue_all_previously_queued_editions(content_block_edition) + publish_publishing_api_edition(content_id:) + update_content_block_document_with_latest_edition(content_block_edition) + content_block_edition.public_send(:publish!) + content_block_edition + rescue PublishingFailureError => e + discard_publishing_api_edition(content_id:) + raise e + end + + def create_publishing_api_edition(content_id:, content_id_alias:, schema_id:, content_block_edition:) + Services.publishing_api.put_content( + content_id, + publishing_api_payload(schema_id, content_id_alias, content_block_edition), + ) + end + + def publishing_api_payload(schema_id, content_id_alias, content_block_edition) + { + schema_name: schema_id, + document_type: schema_id, + publishing_app: Whitehall::PublishingApp::WHITEHALL, + title: content_block_edition.title, + instructions_to_publishers: content_block_edition.instructions_to_publishers, + content_id_alias:, + details: content_block_edition.details, + links: { + primary_publishing_organisation: [ + content_block_edition.lead_organisation.content_id, + ], + }, + update_type: content_block_edition.major_change ? "major" : "minor", + change_note: content_block_edition.major_change ? content_block_edition.change_note : nil, + } + end + + def publish_publishing_api_edition(content_id:) + Services.publishing_api.publish(content_id) + rescue GdsApi::HTTPErrorResponse => e + raise PublishingFailureError, "Could not publish #{content_id} because: #{e.message}" + end + + def update_content_block_document_with_latest_edition(content_block_edition) + content_block_edition.document.update!( + latest_edition_id: content_block_edition.id, + live_edition_id: content_block_edition.id, + ) + end + + def discard_publishing_api_edition(content_id:) + Services.publishing_api.discard_draft(content_id) + end + end +end diff --git a/app/services/content_block_manager/schedule_edition_service.rb b/app/services/content_block_manager/schedule_edition_service.rb new file mode 100644 index 000000000..0547479be --- /dev/null +++ b/app/services/content_block_manager/schedule_edition_service.rb @@ -0,0 +1,44 @@ +module ContentBlockManager + class ScheduleEditionService + include Concerns::Dequeueable + + def initialize(schema) + @schema = schema + end + + def call(edition) + schedule_with_rollback do + edition.update_document_reference_to_latest_edition! + edition + end + send_publish_intents_for_host_documents(content_block_edition: edition) + edition + end + + private + + def schedule_with_rollback + raise ArgumentError, "Local database changes not given" unless block_given? + + ActiveRecord::Base.transaction do + content_block_edition = yield + + content_block_edition.schedule! unless content_block_edition.scheduled? + + dequeue_all_previously_queued_editions(content_block_edition) + ContentBlockManager::SchedulePublishingWorker.queue(content_block_edition) + end + end + + def send_publish_intents_for_host_documents(content_block_edition:) + host_content_items = ContentBlockManager::HostContentItem.for_document(content_block_edition.document) + host_content_items.each do |host_content_item| + ContentBlockManager::PublishIntentWorker.perform_async( + host_content_item.base_path, + host_content_item.publishing_app, + content_block_edition.scheduled_publication.to_s, + ) + end + end + end +end diff --git a/app/validators/content_block_manager/details_validator.rb b/app/validators/content_block_manager/details_validator.rb new file mode 100644 index 000000000..a32ea01e7 --- /dev/null +++ b/app/validators/content_block_manager/details_validator.rb @@ -0,0 +1,77 @@ +class ContentBlockManager::DetailsValidator < ActiveModel::Validator + attr_reader :edition + + def validate(edition) + @edition = edition + errors = validate_with_schema(edition) + errors.each do |e| + if e["type"] == "required" + add_blank_errors(e) + elsif %w[format pattern].include?(e["type"]) + add_format_errors(e) + end + end + end + + def add_blank_errors(error) + missing_keys = error.dig("details", "missing_keys") || [] + missing_keys.each do |k| + key = key_with_optional_prefix(error, k) + edition.errors.add( + "details_#{key}", + translate_error("blank", k), + ) + end + end + + def add_format_errors(error) + data_pointer = error["data_pointer"].delete_prefix("/") + field_items = data_pointer.split("/") + attribute = field_items.last + key = key_with_optional_prefix(error, nil) + edition.errors.add( + "details_#{key}", + translate_error("invalid", attribute), + ) + end + + def validate_with_schema(edition) + # Fetch the details and remove any blank fields (JSONSchema classes an empty string as valid, + # unless a specific format has been specified) + details = compact_nested(edition.details) + schemer = JSONSchemer.schema(edition.schema.body) + schemer.validate(details) + end + + def key_with_optional_prefix(error, key) + if error["data_pointer"].present? + keys = error["data_pointer"].split("/") + [ + keys[1], + *keys[3..], + key, + ].compact.join("_") + else + key + end + end + + def translate_error(type, attribute) + default = "activerecord.errors.models.content_block_manager/content_block/edition.#{type}".to_sym + I18n.t( + "activerecord.errors.models.content_block_manager/content_block/edition.attributes.#{attribute}.#{type}", + attribute: attribute.humanize, + default: [default], + ) + end + +private + + def compact_nested(object) + return object unless object.respond_to?(:compact_blank!) + + object.compact_blank! + object.each { |o| compact_nested(o) } + object + end +end diff --git a/app/validators/content_block_manager/organisation_validator.rb b/app/validators/content_block_manager/organisation_validator.rb new file mode 100644 index 000000000..ef71fa48a --- /dev/null +++ b/app/validators/content_block_manager/organisation_validator.rb @@ -0,0 +1,10 @@ +class ContentBlockManager::OrganisationValidator < ActiveModel::Validator + attr_reader :edition + + def validate(edition) + @edition = edition + if edition.edition_organisation.blank? + edition.errors.add("lead_organisation", :blank) + end + end +end diff --git a/app/validators/content_block_manager/scheduled_publication_validator.rb b/app/validators/content_block_manager/scheduled_publication_validator.rb new file mode 100644 index 000000000..9b2b76d83 --- /dev/null +++ b/app/validators/content_block_manager/scheduled_publication_validator.rb @@ -0,0 +1,11 @@ +class ContentBlockManager::ScheduledPublicationValidator < ActiveModel::Validator + attr_reader :edition + + def validate(edition) + if edition.scheduled_publication.blank? + edition.errors.add("scheduled_publication", :blank) + elsif edition.scheduled_publication < Time.zone.now + edition.errors.add("scheduled_publication", :future_date) + end + end +end diff --git a/app/views/admin/errors/bad_request.html.erb b/app/views/admin/errors/bad_request.html.erb new file mode 100644 index 000000000..6968201cd --- /dev/null +++ b/app/views/admin/errors/bad_request.html.erb @@ -0,0 +1,15 @@ +<% content_for :product_name, ContentBlockManager.product_name %> +<% content_for :page_title, "Something went wrong" %> +<% content_for :title, "Something went wrong" %> +<% content_for :title_margin_bottom, 6 %> + +
+
+

Please try again by selecting the browser’s back button or email us at + <%= mail_to( + ContentBlockManager.support_email_address, + { class: "govuk-link" }, + ) %> + to raise a support ticket.

+
+
diff --git a/app/views/admin/errors/down_for_maintenance.html.erb b/app/views/admin/errors/down_for_maintenance.html.erb new file mode 100644 index 000000000..f7b10d362 --- /dev/null +++ b/app/views/admin/errors/down_for_maintenance.html.erb @@ -0,0 +1,10 @@ +<% content_for :product_name, ContentBlockManager.product_name %> +<% content_for :page_title, "Down for maintenance" %> +<% content_for :title, "Down for maintenance" %> +<% content_for :title_margin_bottom, 6 %> + +
+
+

<%= ContentBlockManager.product_name %> is currently down for maintenance. We'll be back soon.

+
+
diff --git a/app/views/admin/errors/forbidden.html.erb b/app/views/admin/errors/forbidden.html.erb new file mode 100644 index 000000000..7018aaa38 --- /dev/null +++ b/app/views/admin/errors/forbidden.html.erb @@ -0,0 +1,16 @@ +<% content_for :product_name, ContentBlockManager.product_name %> +<% content_for :page_title, "Permissions error" %> +<% content_for :title, "Permissions error" %> +<% content_for :title_margin_bottom, 6 %> + +
+
+

You do not have permission to access this page.

+

If you need to access this page, please email + <%= mail_to( + ContentBlockManager.support_email_address, + { class: "govuk-link" }, + ) %> + to raise a support ticket.

+
+
diff --git a/app/views/admin/errors/internal_server_error.html.erb b/app/views/admin/errors/internal_server_error.html.erb new file mode 100644 index 000000000..79f244515 --- /dev/null +++ b/app/views/admin/errors/internal_server_error.html.erb @@ -0,0 +1,23 @@ +<% content_for :product_name, ContentBlockManager.product_name %> +<% content_for :page_title, "Server error" %> +<% content_for :title, "Server error" %> +<% content_for :title_margin_bottom, 6 %> + +
+
+

The error has been logged in our system.

+

You can also email us at + <%= mail_to( + ContentBlockManager.support_email_address, + { class: "govuk-link" }, + ) %> with the following information:

+ + <%= render "govuk_publishing_components/components/list", { + visible_counters: true, + items: [ + "the url of this page", + "the time of the error", + ], + } %> +
+
diff --git a/app/views/admin/errors/not_found.html.erb b/app/views/admin/errors/not_found.html.erb new file mode 100644 index 000000000..2274181d4 --- /dev/null +++ b/app/views/admin/errors/not_found.html.erb @@ -0,0 +1,11 @@ +<% content_for :product_name, ContentBlockManager.product_name %> +<% content_for :page_title, "There is a mistake in the URL" %> +<% content_for :title, "There is a mistake in the URL" %> +<% content_for :title_margin_bottom, 6 %> + +
+
+

If you typed the URL, check it is correct.

+

If you pasted the URL, check you copied the entire address.

+
+
diff --git a/app/views/admin/errors/unprocessable_entity.html.erb b/app/views/admin/errors/unprocessable_entity.html.erb new file mode 100644 index 000000000..6968201cd --- /dev/null +++ b/app/views/admin/errors/unprocessable_entity.html.erb @@ -0,0 +1,15 @@ +<% content_for :product_name, ContentBlockManager.product_name %> +<% content_for :page_title, "Something went wrong" %> +<% content_for :title, "Something went wrong" %> +<% content_for :title_margin_bottom, 6 %> + +
+
+

Please try again by selecting the browser’s back button or email us at + <%= mail_to( + ContentBlockManager.support_email_address, + { class: "govuk-link" }, + ) %> + to raise a support ticket.

+
+
diff --git a/app/views/components/_datetime_fields.html.erb b/app/views/components/_datetime_fields.html.erb new file mode 100644 index 000000000..9129d53ef --- /dev/null +++ b/app/views/components/_datetime_fields.html.erb @@ -0,0 +1,119 @@ +<% + prefix ||= nil + field_name ||= nil + id = id + date_id = "#{id}_date" + date_only ||= false + date_heading ||= nil + + error_items = error_items ||= nil + + heading_level = heading_level ||= nil + heading_size = heading_size ||= nil + + date_hint = date_hint ||= nil + + time_hint = time_hint ||= nil + time_hint_id = "time-hint-#{SecureRandom.hex(4)}" + + year ||= nil + month ||= nil + day ||= nil + date_input_items = [] + date_input_items << day if day + date_input_items << month if month + date_input_items << year if year + date_input_items = nil unless date_input_items.any? + + hour ||= {} + hour_value = hour[:value] + hour_select_id = hour[:id] || "select-hour-#{SecureRandom.hex(4)}" + hour_label_id = "hour-#{SecureRandom.hex(4)}" + + minute ||= {} + minute_value = minute[:value] + minute_select_id = minute[:id] || "select-minute-#{SecureRandom.hex(4)}" + minute_label_id = "minute-#{SecureRandom.hex(4)}" + + root_classes = %w[app-c-datetime-fields govuk-form-group] + root_classes << "govuk-form-group--error" if error_items.present? + data_attributes ||= {} +%> +<% if prefix && field_name %> + <%= tag.div class: root_classes, data: data_attributes, id: id do %> + <% date_input = capture do %> + <%= render "govuk_publishing_components/components/date_input", { + id: date_id, + hint: date_hint, + error_items: error_items, + items: date_input_items, + } %> + <% end %> + + <%= render "govuk_publishing_components/components/fieldset", { + heading_level: heading_level || 3, + heading_size: heading_size || "m", + legend_text: date_heading || "Date (required)", + text: date_input, + } %> + + <% unless date_only %> + <%= render "govuk_publishing_components/components/fieldset", { + legend_text: "Time", + heading_level: heading_level || 3, + heading_size: heading_size || "m", + } do %> + <% if time_hint %> + <%= render "govuk_publishing_components/components/hint", { + text: time_hint, + id: time_hint_id, + } %> + <% end %> + +
+
+ <%= render "govuk_publishing_components/components/label", { + text: "Hour", + html_for: hour_select_id, + id: hour_label_id, + } %> + + <%= select_hour hour_value, + { + include_blank: true, + prefix: prefix, + field_name: "#{field_name}(4i)", + }, + { + id: hour_select_id, + class: "govuk-select app-c-datetime-fields__date-time-input", + "aria-describedby": "#{hour_label_id} #{time_hint_id if time_hint.present?}".strip, + } %> +
+ +

:

+ +
+ <%= render "govuk_publishing_components/components/label", { + text: "Minute", + html_for: minute_select_id, + id: minute_label_id, + } %> + + <%= select_minute minute_value, + { + include_blank: true, + prefix: prefix, + field_name: "#{field_name}(5i)", + }, + { + id: minute_select_id, + class: "govuk-select app-c-datetime-fields__date-time-input", + "aria-describedby": "#{minute_label_id} #{time_hint_id if time_hint.present?}".strip, + } %> +
+
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/components/_sub_navigation.html.erb b/app/views/components/_sub_navigation.html.erb new file mode 100644 index 000000000..b9adf1855 --- /dev/null +++ b/app/views/components/_sub_navigation.html.erb @@ -0,0 +1,17 @@ +<% + items ||= [] +%> +<%= tag.nav class: "app-c-sub-navigation", role: "navigation", aria: { label: "Sub Navigation" } do %> + <%= tag.ul class: "app-c-sub-navigation__list" do %> + <% items.each do |item| %> + <% + item_classes = %w( app-c-sub-navigation__list-item ) + item_classes << "app-c-sub-navigation__list-item--current" if item[:current] + item_aria_attributes = { current: "page" } if item[:current] + %> + <%= tag.li class: item_classes do %> + <%= link_to item[:label], item[:href], class: "govuk-link govuk-link--no-visited-state govuk-link--no-underline app-c-sub-navigation__list-item-link", data: item[:data_attributes], aria: item_aria_attributes %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/content_block_manager/content_block/documents/_schemas.html.erb b/app/views/content_block_manager/content_block/documents/_schemas.html.erb new file mode 100644 index 000000000..9bab72fb3 --- /dev/null +++ b/app/views/content_block_manager/content_block/documents/_schemas.html.erb @@ -0,0 +1,32 @@ +<% if flash[:error] %> + <%= render ContentBlockManager::ContentBlockEdition::New::ErrorSummaryComponent.new( + error_message: flash[:error], + ) %> +<% end %> + +<%= form_with url: content_block_manager.new_document_options_redirect_content_block_manager_content_block_documents_path, method: :post do %> + <%= hidden_field_tag :block_type, "" %> + + <%= render ContentBlockManager::ContentBlockEdition::New::SelectSchemaComponent.new( + heading: content_for(:page_title), + heading_caption: content_for(:context), + error_message: flash[:error], + schemas: @schemas, + ) %> + +
+ <%= render "govuk_publishing_components/components/button", { + text: "Save and continue", + name: "save_and_continue", + value: "Save and continue", + type: "submit", + } %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + name: "cancel", + value: "cancel", + href: content_block_manager.content_block_manager_content_block_documents_path, + secondary_solid: true, + } %> +
+<% end %> diff --git a/app/views/content_block_manager/content_block/documents/index.html.erb b/app/views/content_block_manager/content_block/documents/index.html.erb new file mode 100644 index 000000000..be2c2a725 --- /dev/null +++ b/app/views/content_block_manager/content_block/documents/index.html.erb @@ -0,0 +1,50 @@ + +<% content_for :title_margin_bottom, 6 %> + +<% content_for :page_title, "Home" %> + +<% if @error_summary_errors %> + <%= render "govuk_publishing_components/components/error_summary", { + title: "There is a problem", + items: @error_summary_errors, + } %> +<% end %> + +
+
+

+ Content Block Manager +

+ +

Create, edit and use standardised content across GOV.UK

+
+
+
+ <%= render "govuk_publishing_components/components/button", { + text: "Create content block", + href: new_content_block_manager_content_block_document_path, + } %> +
+
+
+ +
+
+ <%= render( + ContentBlockManager::ContentBlock::Document::Index::FilterOptionsComponent.new( + filters: @filters, + errors: @errors, + ), + ) %> +
+
+

<%= pluralize(@content_block_documents.total_count, "result") %>

+
+

Sorted by last updated first

+ <% @content_block_documents.each do |content_block_document| %> + <%= render ContentBlockManager::ContentBlock::Document::Index::SummaryCardComponent.new(content_block_document:) %> + <% end %> + + <%= paginate(@content_block_documents, theme: "govuk_paginator") %> +
+
diff --git a/app/views/content_block_manager/content_block/documents/new.html.erb b/app/views/content_block_manager/content_block/documents/new.html.erb new file mode 100644 index 000000000..eeb4b74fa --- /dev/null +++ b/app/views/content_block_manager/content_block/documents/new.html.erb @@ -0,0 +1,19 @@ +<% content_for :context, "Create content block" %> + +<% if @schemas %> + <% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: content_block_manager.content_block_manager_content_block_documents_path, + } %> + <% end %> + <% content_for :page_title, "Select a content block" %> + <%= render partial: "content_block_manager/content_block/documents/schemas", locals: { schemas: @schemas} %> +<% else %> + <% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: content_block_manager.new_content_block_manager_content_block_document_path, + } %> + <% end %> + <% content_for :title, "Create #{@schema.name}" %> + <%= render partial: "content_block_manager/content_block/shared/form", locals: { form: @form } %> +<% end %> diff --git a/app/views/content_block_manager/content_block/documents/schedule/edit.html.erb b/app/views/content_block_manager/content_block/documents/schedule/edit.html.erb new file mode 100644 index 000000000..decddda60 --- /dev/null +++ b/app/views/content_block_manager/content_block/documents/schedule/edit.html.erb @@ -0,0 +1,10 @@ +<%= + render ContentBlockManager::Shared::SchedulePublishingComponent.new( + context: "Edit content block", + content_block_edition: @content_block_edition, + params:, + back_link: content_block_manager.content_block_manager_content_block_document_path(id: @content_block_edition.document.id), + form_url: content_block_manager.content_block_manager_content_block_document_update_schedule_path(document_id: @content_block_edition.document.id), + is_rescheduling: true, + ) +%> diff --git a/app/views/content_block_manager/content_block/documents/show.html.erb b/app/views/content_block_manager/content_block/documents/show.html.erb new file mode 100644 index 000000000..f0395a1bc --- /dev/null +++ b/app/views/content_block_manager/content_block/documents/show.html.erb @@ -0,0 +1,118 @@ +<% content_for :page_full_width, true %> +<% content_for :page_title, @content_block_document.title %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: content_block_manager.content_block_manager_content_block_documents_path, + } %> +<% end %> + +
+
+

+ <%= @content_block_document.schema.name %> + <%= @content_block_document.title %> +

+
+
+ <%= render "govuk_publishing_components/components/button", { + text: "Edit #{@content_block_document.block_type.humanize.downcase}", + href: content_block_manager.new_content_block_manager_content_block_document_edition_path(@content_block_document), + } %> +
+
+ +<% if @content_block_document.has_newer_draft? %> + <% + content_for :banner, render("govuk_publishing_components/components/notice", { + title: "There’s a saved draft of this content block", + description_govspeak: sanitize("

#{link_to "Continue editing", content_block_manager.content_block_manager_content_block_workflow_path(@content_block_document.latest_draft, step: :edit_draft)}

"), + show_banner_title: false, + }) + %> +<% end %> + +<%= + render( + ContentBlockManager::ContentBlock::Document::Show::HostEditionsRollupComponent.new( + rollup: @host_content_items.rollup, + ), + ) +%> + +
+
+ <%= render( + ContentBlockManager::ContentBlock::Document::Show::SummaryListComponent.new( + content_block_document: @content_block_document, + ), + ) %> +
+
+ +<% if @schema.embeddable_as_block? %> +
+
+ <%= render( + ContentBlockManager::ContentBlock::Document::Show::DefaultBlockComponent.new( + content_block_document: @content_block_document, + ), + ) %> +
+
+<% end %> + +<% grouped_subschemas(@schema).each do |group, subschemas| %> +
+
+

<%= h group.humanize %>

+
+
+
+
+ <%= render ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::TabGroupComponent.new( + content_block_document: @content_block_document, + subschemas: subschemas, + ) %> +
+
+<% end %> + +<% ungrouped_subschemas(@schema).each do |subschema| %> +
+
+
+

<%= h subschema.name %>

+ + <%= render ContentBlockManager::ContentBlock::Document::Show::EmbeddedObjects::SubschemaItemsComponent.new( + content_block_edition: @content_block_document.latest_edition, + subschema: subschema, + ) %> +
+
+
+<% end %> + +
+
+ <%= render( + ContentBlockManager::ContentBlock::Document::Show::HostEditionsTableComponent.new( + caption: "List of locations", + host_content_items: @host_content_items, + current_page: @page, + order: @order, + content_block_edition: @content_block_document.latest_edition, + ), + ) %> +
+
+ +
+
+ <%= render( + ContentBlockManager::ContentBlock::Document::Show::DocumentTimelineComponent.new( + content_block_versions: @content_block_versions, + schema: @schema, + ), + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/edit.html.erb b/app/views/content_block_manager/content_block/editions/edit.html.erb new file mode 100644 index 000000000..c9a4a1d3e --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/edit.html.erb @@ -0,0 +1,16 @@ +<% content_for :context, product_name %> +<% content_for :title, "Change #{@form.schema.name}" %> + +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: @form.back_path, + } %> +<% end %> + +<% if @content_block_edition.scheduled? %> + <%= render "govuk_publishing_components/components/warning_text", { + text: "There is currently a change scheduled for this content block. If you continue, the scheduled update will be cancelled.", + } %> +<% end %> + +<%= render partial: "content_block_manager/content_block/shared/form", locals: { form: @form } %> diff --git a/app/views/content_block_manager/content_block/editions/embedded_objects/edit.html.erb b/app/views/content_block_manager/content_block/editions/embedded_objects/edit.html.erb new file mode 100644 index 000000000..638b4cce7 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/embedded_objects/edit.html.erb @@ -0,0 +1,14 @@ +<% content_for :context, @content_block_edition.title %> +<% content_for :title, "Edit #{@subschema.name.singularize.downcase}" %> + +<% content_for :error_summary, render(Admin::ErrorSummaryComponent.new(object: @content_block_edition, parent_class: "content_block_manager_content_block_edition")) %> + +<%= form_with( + url: content_block_manager.embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + object_title: @object_title, + ), method: :put) do |form| %> + <%= form.hidden_field :redirect_url, value: @redirect_url %> + <%= render partial: "content_block_manager/content_block/editions/embedded_objects/shared/form", locals: { form: } %> +<% end %> diff --git a/app/views/content_block_manager/content_block/editions/embedded_objects/new.html.erb b/app/views/content_block_manager/content_block/editions/embedded_objects/new.html.erb new file mode 100644 index 000000000..ec8d7b0bd --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/embedded_objects/new.html.erb @@ -0,0 +1,16 @@ +<% content_for :context, @content_block_edition.title %> +<% content_for :title, "Add #{add_indefinite_article @subschema.name.singularize.downcase}" %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: @back_link, + } %> +<% end %> +<% content_for :error_summary, render(Admin::ErrorSummaryComponent.new(object: @content_block_edition, parent_class: "content_block_manager_content_block_edition")) %> + +<%= form_with( + url: content_block_manager.create_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + ), method: :post) do |form| %> + <%= render partial: "content_block_manager/content_block/editions/embedded_objects/shared/form", locals: { form: } %> +<% end %> diff --git a/app/views/content_block_manager/content_block/editions/embedded_objects/review.html.erb b/app/views/content_block_manager/content_block/editions/embedded_objects/review.html.erb new file mode 100644 index 000000000..5c6d0c431 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/embedded_objects/review.html.erb @@ -0,0 +1,68 @@ +<% content_for :context, @content_block_edition.title %> +<% content_for :title, "Review #{@subschema.name.singularize.downcase}" %> + +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: content_block_manager.edit_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + object_title: @object_title, + ), + } %> +<% end %> + +<% if flash[:error] %> + <%= render "govuk_publishing_components/components/error_summary", { + title: "There is a problem", + items: [{ text: flash[:error], href: "#is_confirmed-0" }], + } %> +<% end %> + +
+
+ <%= render ContentBlockManager::Shared::EmbeddedObjects::SummaryCardComponent.new( + content_block_edition: @content_block_edition, + object_type: @subschema.block_type, + object_title: @object_title, + ) %> +
+
+ +
+
+ <%= form_with( + url: content_block_manager.publish_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + object_title: @object_title, + ), + id: "review", + method: :post, + ) do %> + <%= render "govuk_publishing_components/components/checkboxes", { + name: "is_confirmed", + id: "is_confirmed", + heading: "Confirm", + visually_hide_heading: true, + no_hint_text: true, + error: flash[:error], + items: [ + { + label: "I confirm that the details I’ve put into the content block have been checked and are factually correct.", + value: true, + }, + ], + } %> + <% end %> +
+
+ +
+
+ <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: "review", + button_text: "Create", + content_block_edition: @content_block_edition, + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/embedded_objects/shared/_form.html.erb b/app/views/content_block_manager/content_block/editions/embedded_objects/shared/_form.html.erb new file mode 100644 index 000000000..37ed51b78 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/embedded_objects/shared/_form.html.erb @@ -0,0 +1,24 @@ +<%= form.hidden_field :object_type, value: @subschema.block_type %> + +<%= + render ContentBlockManager::ContentBlockEdition::Details::EmbeddedObjects::FormComponent.new( + content_block_edition: @content_block_edition, + subschema: @subschema, + params: @object, + object_title: @object_title, + ) +%> + +
+ <%= render "govuk_publishing_components/components/button", { + text: "Save and continue", + name: "save_and_continue", + value: "Save and continue", + type: "submit", + } %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + href: @redirect_url.presence || content_block_manager.content_block_manager_content_block_workflow_path(id: @content_block_edition.id, step: "embedded_#{@subschema.block_type}"), + secondary_solid: true, + } %> +
diff --git a/app/views/content_block_manager/content_block/editions/host_content/preview.html.erb b/app/views/content_block_manager/content_block/editions/host_content/preview.html.erb new file mode 100644 index 000000000..bb3846556 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/host_content/preview.html.erb @@ -0,0 +1,18 @@ +<% content_for :page_title, "Preview content block in host document" %> +<% content_for :context, "Preview #{@content_block_edition.block_type.humanize.downcase}" %> +<% content_for :title, @preview_content.title %> +<% content_for :title_margin_bottom, 1 %> + +
+
+ <%= render(ContentBlockManager::ContentBlockEdition::HostContent::PreviewDetailsComponent.new( + content_block_edition: @content_block_edition, + preview_content: @preview_content)) %> +
+
+
+
+
+ +
+
diff --git a/app/views/content_block_manager/content_block/editions/new.html.erb b/app/views/content_block_manager/content_block/editions/new.html.erb new file mode 100644 index 000000000..d24f46f79 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/new.html.erb @@ -0,0 +1,16 @@ +<% content_for :context, @title %> +<% content_for :title, @form.title %> + +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: @form.back_path, + } %> +<% end %> + +<% if @content_block_document && @content_block_document.latest_edition.scheduled? %> + <%= render "govuk_publishing_components/components/warning_text", { + text: "There is currently a change scheduled for this content block. If you continue, the scheduled update will be cancelled.", + } %> +<% end %> + +<%= render partial: "content_block_manager/content_block/shared/form", locals: { form: @form } %> diff --git a/app/views/content_block_manager/content_block/editions/workflow/cancel.html.erb b/app/views/content_block_manager/content_block/editions/workflow/cancel.html.erb new file mode 100644 index 000000000..f3b917f2f --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/cancel.html.erb @@ -0,0 +1,28 @@ +<% content_for :context, context %> +<% content_for :title, "Do you want to save your draft, or delete it?" %> + +
+
+ <%= render "govuk_publishing_components/components/button", { + text: "Save for later", + href: content_block_manager.content_block_manager_content_block_document_path(@content_block_edition.document), + } %> +
+
+ <%= form_with( + url: content_block_manager.content_block_manager_content_block_edition_path( + @content_block_edition, + redirect_path: content_block_manager.content_block_manager_content_block_document_path(@content_block_edition.document), + ), + method: :patch, + ) do %> + <%= render "govuk_publishing_components/components/button", { + text: "Delete draft", + name: "_method", + value: "delete", + type: "submit", + destructive: true, + } %> + <% end %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/change_note.html.erb b/app/views/content_block_manager/content_block/editions/workflow/change_note.html.erb new file mode 100644 index 000000000..285d05a86 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/change_note.html.erb @@ -0,0 +1,59 @@ +<% content_for :context, context %> +<% content_for :title, "Do users have to know the content has changed?" %> +<% content_for :title_margin_bottom, 4 %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_path, + } %> +<% end %> + +<% content_for :error_summary, render(Admin::ErrorSummaryComponent.new(object: @content_block_edition)) %> + +
+
+ <%= form_with url: content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: :change_note, + ), method: :put, id: "change_note" do %> + + <%= render "govuk_publishing_components/components/radio", { + name: "content_block/edition[major_change]", + hint: "Some content types show public change notes. GOV.UK users can subscribe to email alerts and RSS feeds to receive public change notes. Telling users when published information has changed is important for transparency.", + id: "content_block_manager_content_block_edition_major_change", + error_items: errors_for(@content_block_edition.errors, :major_change), + items: [ + { + value: "1", + checked: @content_block_edition.major_change === true, + text: "Yes - information has been added, updated or removed", + hint_text: "A change note will be published on every relevant page containing the content block you've changed. Change notes will also be emailed to users subscribed to email alerts for every page affected by this change. The 'last updated' date will change on pages that display it.", + bold: true, + conditional: render("govuk_publishing_components/components/textarea", { + label: { + text: "Describe the edit for users", + bold: true, + }, + name: "content_block/edition[change_note]", + id: "content_block_manager_content_block_edition_change_note", + error_items: errors_for(@content_block_edition.errors, :change_note), + value: @content_block_edition.change_note, + hint: "Tell users what has been edited, where and why. Write in full sentences, leading with the most important words. For example, \"The full basic State Pension rate has changed from £156.20 per week to £169.50 per week.\"", + }), + }, + { + value: "0", + checked: @content_block_edition.major_change === false, + text: "No - it's a minor edit that does not change the meaning", + hint_text: "This includes fixing a typo or broken link, a style change or similar. Users signed up to email alerts will not get notified and the 'last updated' date will not change.", + bold: true, + }, + ], + } %> + <% end %> + + <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: "change_note", + content_block_edition: @content_block_edition, + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/confirmation.html.erb b/app/views/content_block_manager/content_block/editions/workflow/confirmation.html.erb new file mode 100644 index 000000000..f5e307ac9 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/confirmation.html.erb @@ -0,0 +1,27 @@ +<% content_for :page_title, "Your content block is available for use" %> + +
+
+
+

+ <%= @confirmation_copy.for_panel %> +

+
+
+
+ +
+
+

What happens next?

+

<%= @confirmation_copy.for_paragraph %> If you need any support or want to delete a content block you can raise a support request. +

+
+ <%= render "govuk_publishing_components/components/button", { + text: "View content block", + name: "view", + value: "view", + href: content_block_manager.content_block_manager_content_block_document_path(@content_block_edition.document.id), + } %> +
+
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/edit_draft.html.erb b/app/views/content_block_manager/content_block/editions/workflow/edit_draft.html.erb new file mode 100644 index 000000000..62785d29c --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/edit_draft.html.erb @@ -0,0 +1,16 @@ +<% content_for :context, context %> +<% content_for :title, @title %> + +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: @back_path, + } %> +<% end %> + +<% if @content_block_edition.scheduled? %> + <%= render "govuk_publishing_components/components/warning_text", { + text: "There is currently a change scheduled for this content block. If you continue, the scheduled update will be cancelled.", + } %> +<% end %> + +<%= render partial: "content_block_manager/content_block/shared/form", locals: { form: @form } %> diff --git a/app/views/content_block_manager/content_block/editions/workflow/embedded_objects.html.erb b/app/views/content_block_manager/content_block/editions/workflow/embedded_objects.html.erb new file mode 100644 index 000000000..223fa1f68 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/embedded_objects.html.erb @@ -0,0 +1,49 @@ +<% content_for :context, context %> +<% content_for :title, "#{@action} #{@subschema.name.humanize(capitalize: false)}" %> +<% content_for :title_margin_bottom, 4 %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_path, + } %> +<% end %> + +
+
+ <%= form_with url: content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: @step_name, + ), method: :put, id: "embedded_objects" do %> + + <% if @content_block_edition.details[@subschema.block_type] %> + <%= render ContentBlockManager::Shared::EmbeddedObjects::SummaryCardComponent.with_collection( + @content_block_edition.details[@subschema.block_type].keys, + content_block_edition: @content_block_edition, + object_type: @subschema.block_type, + redirect_url: request.fullpath, + test_id_prefix: "embedded", + ) %> + <% else %> + <% if I18n.exists?("content_block_edition.create.embedded_objects.#{@subschema.id}") %> + <%= render "govuk_publishing_components/components/hint", { + text: t("content_block_edition.create.embedded_objects.#{@subschema.id}", block_type: @subschema.name.humanize(capitalize: false)), + } %> + <% end %> + <% end %> + + <%= render "govuk_publishing_components/components/button", { + text: @add_button_text, + href: content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + object_type: @subschema.block_type, + ), + secondary_solid: true, + margin_bottom: 6, + } %> + <% end %> + + <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: "embedded_objects", + content_block_edition: @content_block_edition, + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/group_objects.html.erb b/app/views/content_block_manager/content_block/editions/workflow/group_objects.html.erb new file mode 100644 index 000000000..f08c1e248 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/group_objects.html.erb @@ -0,0 +1,43 @@ +<% content_for :context, context %> +<% content_for :title, "#{@action} #{@group_name.humanize(capitalize: false)}" %> +<% content_for :title_margin_bottom, 4 %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_path, + } %> +<% end %> + +
+
+ <% if flash[:error] %> + <%= render ContentBlockManager::ContentBlockEdition::New::ErrorSummaryComponent.new( + error_message: flash[:error], + ) %> + <% end %> + + <%= form_with url: content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: @step_name, + ), method: :put, id: @step_name do %> + <%= render ContentBlockManager::ContentBlockEdition::Workflow::GroupComponent.new( + content_block_edition: @content_block_edition, + subschemas: @subschemas, + ) %> + <% end %> + + <%= render("govuk_publishing_components/components/button", { + text: "Add another #{@group_name.humanize.singularize.downcase}", + href: content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path( + @content_block_edition, + group: @group_name, + ), + margin_bottom: 6, + secondary_solid: true, + }) %> + + <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: @step_name, + content_block_edition: @content_block_edition, + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/internal_note.html.erb b/app/views/content_block_manager/content_block/editions/workflow/internal_note.html.erb new file mode 100644 index 000000000..e066dae5e --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/internal_note.html.erb @@ -0,0 +1,31 @@ +<% content_for :context, context %> +<% content_for :title, "Create internal note" %> +<% content_for :title_margin_bottom, 4 %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_path, + } %> +<% end %> + +
+
+ <%= form_with url: content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: :internal_note, + ), method: :put, id: "internal_note" do %> + <%= render "govuk_publishing_components/components/textarea", { + label: { + text: '

Explain what changes you did or did not make and why.

'.html_safe, + }, + name: "content_block/edition[internal_change_note]", + id: "content_block_manager_content_block_edition_title", + value: @content_block_edition.internal_change_note, + } %> + <% end %> + + <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: "internal_note", + content_block_edition: @content_block_edition, + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/review.html.erb b/app/views/content_block_manager/content_block/editions/workflow/review.html.erb new file mode 100644 index 000000000..f01fb6b50 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/review.html.erb @@ -0,0 +1,86 @@ +<% content_for :context, context %> +<% content_for :title, "Review #{@content_block_edition.block_type.humanize.downcase}" %> + +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_path, + } %> +<% end %> + +<% if @error_summary_errors %> + <%= render "govuk_publishing_components/components/error_summary", { + title: "There is a problem", + items: @error_summary_errors, + } %> +<% end %> + +
+
+ <%= render( + ContentBlockManager::ContentBlockEdition::Show::ConfirmSummaryCardComponent.new( + content_block_edition: @content_block_edition, + ), + ) %> + + <% @schema.subschemas.each do |subschema| %> + <% if @content_block_edition.details[subschema.id] %> + <%= render ContentBlockManager::Shared::EmbeddedObjects::SummaryCardComponent.with_collection( + @content_block_edition.details[subschema.id].keys, + content_block_edition: @content_block_edition, + object_type: subschema.id, + redirect_url: redirect_url_for_subschema(subschema, @content_block_edition), + test_id_prefix: "review_embedded", + ) %> + <% end %> + <% end %> + + <% unless @content_block_edition.document.is_new_block? %> + <%= render( + ContentBlockManager::ContentBlockEdition::Show::NotesSummaryCardComponent.new( + content_block_edition: @content_block_edition, + ), + ) %> + + <%= render( + ContentBlockManager::ContentBlockEdition::Show::PublicationDetailsSummaryCardComponent.new( + content_block_edition: @content_block_edition, + ), + ) %> + <% end %> +
+
+ +
+
+ <%= form_with( + url: content_block_manager.content_block_manager_content_block_workflow_path(@content_block_edition, step: :review), + method: :put, + id: "review", + ) do %> + <%= render "govuk_publishing_components/components/checkboxes", { + name: "is_confirmed", + id: "is_confirmed", + heading: "Confirm", + visually_hide_heading: true, + no_hint_text: true, + error: @confirm_error_copy, + items: [ + { + label: "I confirm that the details I’ve put into the content block have been checked and are factually correct.", + value: true, + }, + ], + } %> + <% end %> +
+
+ +
+
+ <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: "review", + button_text: is_scheduling? ? "Schedule" : "Publish", + content_block_edition: @content_block_edition, + ) %> +
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/review_links.html.erb b/app/views/content_block_manager/content_block/editions/workflow/review_links.html.erb new file mode 100644 index 000000000..2ce168bf6 --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/review_links.html.erb @@ -0,0 +1,48 @@ +<% content_for :context, context %> +<% content_for :title, "Preview #{@content_block_document.block_type.humanize.downcase}" %> +<% content_for :title_margin_bottom, 4 %> +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: back_path, + } %> +<% end %> + +

+ This list shows the places where the change will be made. It does not include content in PDFs or beyond GOV.UK. +

+ +
+
+ <%= form_with( + url: content_block_manager.content_block_manager_content_block_workflow_path( + edition_id: @content_block_edition.id, + step: :review_links, + ), + id: "review_links", + method: :put, + ) do %> + <%= render( + ContentBlockManager::ContentBlockEdition::HostContent::TableComponent.new( + caption: "List of locations", + host_content_items: @host_content_items, + current_page: @page, + order: @order, + content_block_edition: @content_block_edition, + ), + ) %> + <% end %> +
+
+ +
+
+
+
+ <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new( + form_id: "review_links", + content_block_edition: @content_block_edition, + ) %> +
+
+
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/schedule_publishing.html.erb b/app/views/content_block_manager/content_block/editions/workflow/schedule_publishing.html.erb new file mode 100644 index 000000000..b1439862e --- /dev/null +++ b/app/views/content_block_manager/content_block/editions/workflow/schedule_publishing.html.erb @@ -0,0 +1,13 @@ +<%= + render ContentBlockManager::Shared::SchedulePublishingComponent.new( + content_block_edition: @content_block_edition, + params:, + context:, + back_link: back_path, + form_url: content_block_manager.content_block_manager_content_block_workflow_path( + @content_block_edition, + step: :schedule_publishing, + ), + is_rescheduling: false, + ) +%> diff --git a/app/views/content_block_manager/content_block/shared/_form.html.erb b/app/views/content_block_manager/content_block/shared/_form.html.erb new file mode 100644 index 000000000..ce842f502 --- /dev/null +++ b/app/views/content_block_manager/content_block/shared/_form.html.erb @@ -0,0 +1,62 @@ +<% parent_class = "content_block_manager_content_block_edition" %> + +<% content_for :error_summary, render(Admin::ErrorSummaryComponent.new(object: @form.content_block_edition, parent_class:)) %> + +<%= form_with( + url: @form.url, + method: @form.form_method, + model: [content_block_manager, @form.content_block_edition]) do |f| %> + <%= hidden_field_tag "content_block/edition[document_attributes][block_type]", + @form.schema.block_type %> + + <%= render "govuk_publishing_components/components/input", { + label: { + text: "Title", + }, + name: "content_block/edition[title]", + id: "#{parent_class}_title", + value: @form.content_block_edition&.title, + error_items: errors_for(@form.content_block_edition.errors, "title".to_sym), + } %> + + <%= + render ContentBlockManager::ContentBlockEdition::Details::FormComponent.new( + content_block_edition: @form.content_block_edition, + schema: @form.schema, + ) + %> + + <%= render "govuk_publishing_components/components/select_with_search", { + id: "#{parent_class}_lead_organisation", + name: "content_block/edition[organisation_id]", + error_items: errors_for(@form.content_block_edition.errors, "lead_organisation".to_sym), + include_blank: true, + label: "Lead organisation", + options: taggable_organisations_container([@form.content_block_edition.edition_organisation&.organisation_id]), + } %> + + <%= render "govuk_publishing_components/components/textarea", { + label: { + text: "Instructions to publishers (optional)", + }, + name: "content_block/edition[instructions_to_publishers]", + hint: "Add information that’s important for anyone editing this block to know. For example, who to contact about the block if you have questions.", + textarea_id: "#{parent_class}_instructions_to_publishers", + value: @form.content_block_edition&.instructions_to_publishers, + error_items: errors_for(@form.content_block_edition.errors, "instructions_to_publishers".to_sym), + } %> + +
+ <%= render "govuk_publishing_components/components/button", { + text: "Save and continue", + name: "save_and_continue", + value: "Save and continue", + type: "submit", + } %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + href: @form.back_path, + secondary_solid: true, + } %> +
+<% end %> diff --git a/app/views/content_block_manager/content_block/shared/embedded_objects/select_subschema.html.erb b/app/views/content_block_manager/content_block/shared/embedded_objects/select_subschema.html.erb new file mode 100644 index 000000000..765709f7a --- /dev/null +++ b/app/views/content_block_manager/content_block/shared/embedded_objects/select_subschema.html.erb @@ -0,0 +1,42 @@ +<% content_for :context, @context %> +<% content_for :title, "Add #{add_indefinite_article @group.humanize.singularize.downcase}" %> + +<% content_for :back_link do %> + <%= render "govuk_publishing_components/components/back_link", { + href: @back_link, + } %> +<% end %> + +<% if flash[:error] %> + <%= render ContentBlockManager::ContentBlockEdition::New::ErrorSummaryComponent.new( + error_message: flash[:error], + ) %> +<% end %> + +<%= form_with url: @redirect_path, method: :post do %> + <%= hidden_field_tag :object_type, "" %> + <%= hidden_field_tag :group, @group %> + + <%= render ContentBlockManager::ContentBlock::Document::EmbeddedObjects::New::SelectSubschemaComponent.new( + heading: content_for(:page_title), + heading_caption: content_for(:context), + error_message: flash[:error], + schemas: @subschemas, + ) %> + +
+ <%= render "govuk_publishing_components/components/button", { + text: "Save and continue", + name: "save_and_continue", + value: "Save and continue", + type: "submit", + } %> + <%= render "govuk_publishing_components/components/button", { + text: "Cancel", + name: "cancel", + value: "cancel", + href: @back_link, + secondary_solid: true, + } %> +
+<% end %> diff --git a/app/views/content_block_manager/users/show.html.erb b/app/views/content_block_manager/users/show.html.erb new file mode 100644 index 000000000..ef95f25d1 --- /dev/null +++ b/app/views/content_block_manager/users/show.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, @user.name %> + +
+
+ <%= render( + ContentBlockManager::SignonUser::Show::SummaryListComponent.new( + user: @user, + ), + ) %> +
+
diff --git a/app/views/kaminari/govuk_paginator/_paginator.erb b/app/views/kaminari/govuk_paginator/_paginator.erb new file mode 100644 index 000000000..4ae4639fc --- /dev/null +++ b/app/views/kaminari/govuk_paginator/_paginator.erb @@ -0,0 +1,11 @@ +<% anchor = anchor.presence || "" %> + +<% if total_pages > 1 %> + <% Admin::PaginationHelper.pagination_hash(current_page: current_page.to_i, total_pages:, path: request.url + anchor).tap do |hash| %> + <%= render "components/pagination", { + previous_href: hash[:previous_href], + next_href: hash[:next_href], + items: hash[:items], + } %> + <% end %> +<% end %> diff --git a/app/views/kaminari/history/_next_page.html.erb b/app/views/kaminari/history/_next_page.html.erb new file mode 100644 index 000000000..a3fcc9e93 --- /dev/null +++ b/app/views/kaminari/history/_next_page.html.erb @@ -0,0 +1 @@ +<%= link_to_next_page @document_history, "Older", class: "govuk-body govuk-link govuk-link--no-visited-state govuk-!-margin-bottom-0 app-view-document-history-tab__older-pagination-link", data: {"remote-pagination" => document_history_admin_edition_path(page: current_page + 1, only: @document_history.only, editing:) } %> diff --git a/app/views/kaminari/history/_paginator.html.erb b/app/views/kaminari/history/_paginator.html.erb new file mode 100644 index 000000000..7876d7ac2 --- /dev/null +++ b/app/views/kaminari/history/_paginator.html.erb @@ -0,0 +1,6 @@ +<%= paginator.render do -%> + +<% end -%> diff --git a/app/views/kaminari/history/_prev_page.html.erb b/app/views/kaminari/history/_prev_page.html.erb new file mode 100644 index 000000000..c96e9d3ea --- /dev/null +++ b/app/views/kaminari/history/_prev_page.html.erb @@ -0,0 +1 @@ +<%= link_to_previous_page @document_history, "Newer", class: "govuk-body govuk-link govuk-link--no-visited-state govuk-!-margin-bottom-0 app-view-document-history-tab__newer-pagination-link", data: {"remote-pagination" => document_history_admin_edition_path(page: current_page - 1, only: @document_history.only, editing:) } %> diff --git a/app/views/layouts/design_system.html.erb b/app/views/layouts/design_system.html.erb new file mode 100644 index 000000000..a52f771e3 --- /dev/null +++ b/app/views/layouts/design_system.html.erb @@ -0,0 +1,122 @@ +<% environment = GovukPublishingComponents::AppHelpers::Environment.current_acceptance_environment %> + +<% if user_signed_in? %> + <% content_for :head do %> + + + + + "> + + <% if get_content_id(@edition) %> + + <% end %> + <%= javascript_include_tag "domain-config" %> + <%= javascript_include_tag "govuk_publishing_components/load-analytics" %> + <% end %> +<% end %> + +<% sanitized_title = sanitize((yield(:page_title).presence || yield(:title))) %> + +<%= render "govuk_publishing_components/components/layout_for_admin", + product_name: yield(:product_name).presence || product_name, + environment: environment, + browser_title: ("Error: " if yield(:error_summary).present?).to_s + sanitized_title do %> + + <%= render "govuk_publishing_components/components/skip_link" %> + + <%= render partial: "shared/header" %> + + <%= tag.div( + class: "govuk-width-container", + data: { + ga4_no_copy: "", + module: "ga4-event-tracker ga4-paste-tracker ga4-button-setup ga4-index-section-setup ga4-form-setup", + ga4_search_section_type: "Filter by", + ga4_section: yield(:page_title).presence || yield(:title), + ga4_filter_type: controller_name, + ga4_document_type: "#{action_name}-#{controller_name}", + }, + ) do %> + <%= render "shared/phase_banner", { + show_feedback_banner: t("admin.feedback.show_banner"), + } %> + + <%= yield(:back_link) %> + <%= yield(:breadcrumbs) %> + +
" id="main-content" data-module="ga4-link-tracker" role="main" data-ga4-link='{ "event_name": "navigation", "type": "generic_link" }'> + + <%= render Admin::FlashNoticeComponent.new(message: flash[:notice], html_safe: flash["html_safe"]) if flash[:notice] %> + <%= render Admin::FlashAlertComponent.new(message: flash[:alert], html_safe: flash["html_safe"]) if flash[:alert] && yield(:error_summary).blank? %> + + <% column_width = yield(:page_full_width).present? ? "full" : "two-thirds" %> + +
+
+ <%= yield(:error_summary) %> +
+
+ + <% if yield(:error_summary).blank? %> +
+
+ <%= yield(:banner) %> +
+
+ <% end %> + + <% if yield(:title).present? %> +
+
+ <% + heading_options = { + context: yield(:context), + text: yield(:title), + heading_level: 1, + font_size: "xl", + } + heading_options[:margin_bottom] = yield(:title_margin_bottom).present? ? yield(:title_margin_bottom).to_i : 8 + %> + <%= render "govuk_publishing_components/components/heading", heading_options %> +
+ + <% if yield(:page_full_width).blank? %> +
+ <%= yield(:title_side) %> +
+ <% end %> +
+ <% end %> + <%= yield %> +
+ <% end %> + + <%= render "govuk_publishing_components/components/layout_footer", { + data_attributes: { + ga4_link: '{ "event_name": "navigation", "type": "generic_link" }', + module: "ga4-link-tracker", + ga4_no_copy: true, + }, + hide_licence: true, + navigation: [ + { + title: "Support and feedback", + items: [ + { + href: Plek.external_url_for("support"), + text: "Raise a support request", + }, + { + href: "https://www.gov.uk/government/content-publishing", + text: "How to write, publish, and improve content", + }, + { + href: "https://status.publishing.service.gov.uk/", + text: "Check if publishing apps are working or if there’s any maintenance planned", + }, + ], + }, + ], + } %> +<% end %> diff --git a/app/views/pages/show.html.erb b/app/views/pages/show.html.erb new file mode 100644 index 000000000..945a1f42c --- /dev/null +++ b/app/views/pages/show.html.erb @@ -0,0 +1 @@ +

Show page

diff --git a/app/views/shared/_header.html.erb b/app/views/shared/_header.html.erb new file mode 100644 index 000000000..c4491a2cd --- /dev/null +++ b/app/views/shared/_header.html.erb @@ -0,0 +1,30 @@ +<% environment = GovukPublishingComponents::AppHelpers::Environment.current_acceptance_environment %> +<% organisation = current_user&.organisation %> +<% user = current_user %> + +<%= render "govuk_publishing_components/components/layout_header", { + data_attributes: { + ga4_link: '{ "event_name": "navigation", "type": "generic_link" }', + module: "ga4-link-tracker", + ga4_no_copy: true, + }, + product_name:, + environment: environment, + navigation_items: [ + main_nav_item("Dashboard", "/"), + { + text: "View website", + href: "/", + }, + main_nav_item("All users", "/"), + ], +} %> + +
+ <%= render "components/sub_navigation", { + items: [ + sub_nav_item("New document", "/"), + sub_nav_item("Documents", "/"), + ], + } %> +
diff --git a/app/views/shared/_phase_banner.html.erb b/app/views/shared/_phase_banner.html.erb new file mode 100644 index 000000000..06ae439dc --- /dev/null +++ b/app/views/shared/_phase_banner.html.erb @@ -0,0 +1,13 @@ +<%= render "govuk_publishing_components/components/phase_banner", { + phase: "Beta", + message: sanitize("For support, to delete a content block or to give feedback, email + #{ + mail_to( + "email@example.com", + "email@example.com", + { + class: "govuk-link", + }, + ) + }.", attributes: %w(class href)), + } %> diff --git a/bin/dev b/bin/dev index 5f91c2054..b6d1f9b9c 100755 --- a/bin/dev +++ b/bin/dev @@ -1,2 +1,6 @@ -#!/usr/bin/env ruby -exec "./bin/rails", "server", *ARGV +#!/usr/bin/env sh +if !(gem list foreman -i --silent); then + echo "Installing foreman..." + gem install foreman +fi +exec foreman start -f Procfile.dev "$@" diff --git a/config/application.rb b/config/application.rb index d96b3db0c..9f9e08f96 100644 --- a/config/application.rb +++ b/config/application.rb @@ -7,12 +7,13 @@ require "active_record/railtie" # require "active_storage/engine" require "action_controller/railtie" -# require "action_mailer/railtie" +require "action_mailer/railtie" # require "action_mailbox/engine" # require "action_text/engine" require "action_view/railtie" -require "action_cable/engine" -# require "rails/test_unit/railtie" +require "sprockets/railtie" +# require "action_cable/engine" +require "rails/test_unit/railtie" # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -34,7 +35,10 @@ class Application < Rails::Application # in config/environments, which are processed later. # # config.time_zone = "Central Time (US & Canada)" - # config.eager_load_paths << Rails.root.join("extras") + + config.eager_load_paths += %W[ + #{config.root}/lib + ] # Don't generate system test files. config.generators.system_tests = nil diff --git a/config/content_block_manager.yml b/config/content_block_manager.yml new file mode 100644 index 000000000..c09042ce7 --- /dev/null +++ b/config/content_block_manager.yml @@ -0,0 +1,131 @@ +schemas: + content_block_pension: + fields: + description: + component: + textarea + subschemas: + rates: + embeddable_fields: + - amount + field_order: + - title + - amount + - frequency + - description + content_block_contact: + embeddable_as_block: true + field_order: + - title + - description + - contact_type + fields: + description: + component: + textarea + subschemas: + email_addresses: + group: contact_methods + group_order: 1 + embeddable_as_block: true + embeddable_fields: + - email_address + field_order: + - title + - label + - email_address + - subject + - body + - description + fields: + body: + component: + textarea + description: + component: + textarea + telephones: + group: contact_methods + group_order: 2 + embeddable_as_block: true + embeddable_fields: + - telephone_numbers + - video_relay_service + - opening_hours + - call_charges + - bsl_guidance + field_order: + - title + - description + - telephone_numbers + - video_relay_service + - bsl_guidance + - opening_hours + - call_charges + fields: + description: + component: + textarea + opening_hours: + component: + opening_hours + call_charges: + component: + call_charges + bsl_guidance: + component: + bsl_guidance + video_relay_service: + component: + video_relay_service + telephone_numbers: + data_attributes: + module: auto-populate-telephone-number-label + field_order: + - type + - label + - telephone_number + addresses: + group: contact_methods + group_order: 3 + field_order: + - title + - recipient + - street_address + - town_or_city + - state_or_county + - postal_code + - country + - description + embeddable_as_block: true + embeddable_fields: + - title + - street_address + - town_or_city + - state_or_county + - postal_code + - country + - description + fields: + country: + component: + country + description: + component: + textarea + contact_links: + group: contact_methods + embeddable_as_block: true + group_order: 4 + fields: + description: + component: + textarea + embeddable_fields: + - url + field_order: + - title + - label + - url + - description + diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc deleted file mode 100644 index 22839c68f..000000000 --- a/config/credentials.yml.enc +++ /dev/null @@ -1 +0,0 @@ -I4XXtZ0TknEONVEUcmE1t0xa2H4TWlpdVA+2vfLrDy3woJxBwDRlopNpuOwauhelZVyT4sR4Mn1pnPVtqqU/KQO55pkbBgQR+qE4G38648jnqHB8H5OWJKN07knN3+Clo/kGRVNQIzaqo0DYUr5kKYPGXqXnkJFYLJa++hPTn3RYRPBqXVJEjAcGoZByerUOrs/ltGIwfi3lYNqvedxRSa3gBV51ymtrdhUqqfrHQUuAGN+d6GzzVCYHlpt6/R9I33x1GGK+O9LDJcjnydjyl0myIhnUZKtScvwf+LG3ha+EyXmaRLWceHjO1g4kG0UVx17IOlLmHnps+/AaWkncxzFITsCVIefAV5zTOsR93LkdOzzAgqb3W9YOVlFAISJicLbY+rvTrwDYdAHzFhL770Kbo/dYim4TnpVdWxbW6r/GgaQMYo0O6yD33wjrWdScGGO91UABDvTg6D6GjaqhSEi+xmQ2dGOxQP87Kebz8o2DU5NUrxvTUM1c--/cmi/KDn3om6YPgv--oB9LKUiBoqipfTYaX2aC1w== \ No newline at end of file diff --git a/config/cucumber.yml b/config/cucumber.yml new file mode 100644 index 000000000..7fd8059f8 --- /dev/null +++ b/config/cucumber.yml @@ -0,0 +1,10 @@ +<% +rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : "" +rerun = rerun.strip.gsub /\s/, ' ' +rerun_opts = rerun.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}" +std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} --strict --tags 'not @wip'" +dirs = ["features"].flatten.join(" ") +%> +default: <%= std_opts %> <%= dirs %> --publish-quiet +wip: --tags @wip:3 --wip <%= dirs %> --publish-quiet +rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip' --publish-quiet diff --git a/config/database.yml b/config/database.yml index 4bbee365f..747677c2b 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,85 +1,20 @@ -# PostgreSQL. Versions 9.3 and up are supported. -# -# Install the pg driver: -# gem install pg -# On macOS with Homebrew: -# gem install pg -- --with-pg-config=/usr/local/bin/pg_config -# On Windows: -# gem install pg -# Choose the win32 build. -# Install PostgreSQL and put its /bin directory on your path. -# -# Configure Using Gemfile -# gem "pg" -# default: &default adapter: postgresql encoding: unicode - # For details on connection pooling, see Rails configuration guide - # https://guides.rubyonrails.org/configuring.html#database-pooling - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - + pool: 12 + template: template0 development: <<: *default - database: govuk_content_block_manager_development - - # The specified database role being used to connect to PostgreSQL. - # To create additional roles in PostgreSQL see `$ createuser --help`. - # When left blank, PostgreSQL will use the default role. This is - # the same name as the operating system user running Rails. - #username: govuk_content_block_manager - - # The password associated with the PostgreSQL role (username). - #password: - - # Connect on a TCP socket. Omitted by default since the client uses a - # domain socket that doesn't need configuration. Windows does not have - # domain sockets, so uncomment these lines. - #host: localhost - - # The TCP port the server listens on. Defaults to 5432. - # If your server runs on a different port number, change accordingly. - #port: 5432 - - # Schema search path. The server defaults to $user,public - #schema_search_path: myapp,sharedapp,public - - # Minimum log levels, in increasing order: - # debug5, debug4, debug3, debug2, debug1, - # log, notice, warning, error, fatal, and panic - # Defaults to warning. - #min_messages: notice + database: govuk-content-block-manager_development + url: <%= ENV["DATABASE_URL"]%> -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. test: <<: *default - database: govuk_content_block_manager_test + database: govuk-content-block-manager_test + url: <%= ENV["TEST_DATABASE_URL"] %> -# As with config/credentials.yml, you never want to store sensitive information, -# like your database password, in your source code. If your source code is -# ever seen by anyone, they now have access to your database. -# -# Instead, provide the password or a full connection URL as an environment -# variable when you boot the app. For example: -# -# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" -# -# If the connection URL is provided in the special DATABASE_URL environment -# variable, Rails will automatically merge its configuration values on top of -# the values provided in this file. Alternatively, you can specify a connection -# URL environment variable explicitly: -# -# production: -# url: <%= ENV["MY_APP_DATABASE_URL"] %> -# -# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database -# for a full overview on how database connection configuration can be specified. -# production: <<: *default - database: govuk_content_block_manager_production - username: govuk_content_block_manager - password: <%= ENV["GOVUK_CONTENT_BLOCK_MANAGER_DATABASE_PASSWORD"] %> + database: govuk-content-block-manager_production + url: <%= ENV["DATABASE_URL"]%> diff --git a/config/environments/development.rb b/config/environments/development.rb index 3665f6f16..a2ad03444 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -20,13 +20,33 @@ if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = false + + config.cache_store = :null_store end - # Change to :null_store to avoid any caching. - config.cache_store = :memory_store + # Debug mode disables concatenation and preprocessing of assets. + # This option may cause significant delays in view rendering with a large + # number of complex assets. + config.assets.debug = ENV["DISABLE_ASSETS_DEBUG"].nil? + + # Asset digests allow you to set far-future HTTP expiration dates on all assets, + # yet still be able to expire them through the digest params. + config.assets.digest = false + + # Suppress logger output for asset requests. + config.assets.quiet = false + + # Adds additional error checking when serving assets at runtime. + # Checks for improperly declared sprockets dependencies. + # Raises helpful error messages. + config.assets.raise_runtime_errors = true + + config.assets.cache_store = :null_store # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -43,6 +63,8 @@ # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true + config.hosts.clear + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 487324424..493e0c098 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -3,5 +3,5 @@ # Version of your assets, change this if you want to expire all your assets. Rails.application.config.assets.version = "1.0" -# Add additional assets to the asset load path. -# Rails.application.config.assets.paths << Emoji.images_path +# Add Yarn node_modules folder to the asset load path. +Rails.application.config.assets.paths << Rails.root.join("node_modules") diff --git a/config/initializers/better_errors.rb b/config/initializers/better_errors.rb new file mode 100644 index 000000000..bb3c5ac1d --- /dev/null +++ b/config/initializers/better_errors.rb @@ -0,0 +1,3 @@ +if Rails.env.development? + BetterErrors::Middleware.allow_ip! "0.0.0.0/0" +end diff --git a/config/initializers/dartsass.rb b/config/initializers/dartsass.rb new file mode 100644 index 000000000..e39ee0e17 --- /dev/null +++ b/config/initializers/dartsass.rb @@ -0,0 +1,8 @@ +APP_STYLESHEETS = { + "application.scss" => "application.css", +}.freeze + +all_stylesheets = APP_STYLESHEETS.merge(GovukPublishingComponents::Config.all_stylesheets) +Rails.application.config.dartsass.builds = all_stylesheets + +Rails.application.config.dartsass.build_options << " --quiet-deps" diff --git a/config/initializers/friendly_id.rb b/config/initializers/friendly_id.rb new file mode 100644 index 000000000..96f7a4717 --- /dev/null +++ b/config/initializers/friendly_id.rb @@ -0,0 +1,6 @@ +FriendlyId.defaults do |config| + config.base = :name + config.use :slugged, :finders, :sequentially_slugged, FriendlyId::CustomNormalise + + config.sequence_separator = "--" +end diff --git a/config/puma.rb b/config/puma.rb index 787e4ce98..d08b16a62 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,38 +1,2 @@ -# This configuration file will be evaluated by Puma. The top-level methods that -# are invoked here are part of Puma's configuration DSL. For more information -# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. -# -# Puma starts a configurable number of processes (workers) and each process -# serves each request in a thread from an internal thread pool. -# -# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You -# should only set this value when you want to run 2 or more workers. The -# default is already 1. -# -# The ideal number of threads per worker depends both on how much time the -# application spends waiting for IO operations and on how much you wish to -# prioritize throughput over latency. -# -# As a rule of thumb, increasing the number of threads will increase how much -# traffic a given process can handle (throughput), but due to CRuby's -# Global VM Lock (GVL) it has diminishing returns and will degrade the -# response time (latency) of the application. -# -# The default is set to 3 threads as it's deemed a decent compromise between -# throughput and latency for the average Rails application. -# -# Any libraries that use a connection pool or another resource pool should -# be configured to provide at least as many connections as the number of -# threads. This includes Active Record's `pool` parameter in `database.yml`. -threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) -threads threads_count, threads_count - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT", 3000) - -# Allow puma to be restarted by `bin/rails restart` command. -plugin :tmp_restart - -# Specify the PID file. Defaults to tmp/pids/server.pid in development. -# In other environments, only set the PID file if requested. -pidfile ENV["PIDFILE"] if ENV["PIDFILE"] +require "govuk_app_config/govuk_puma" +GovukPuma.configure_rails(self) diff --git a/config/routes.rb b/config/routes.rb index 48254e88e..088b7f46c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,42 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + get "/healthcheck/live", to: proc { [200, {}, %w[OK]] } + get "/healthcheck/ready", to: GovukHealthcheck.rack_response - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check + namespace :content_block_manager, path: "/" do + root to: "content_block/documents#index", via: :get - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + resources :users, only: %i[show] - # Defines the root path route ("/") - # root "posts#index" + namespace :content_block, path: "content-block" do + get "content-id/:content_id", to: "documents#content_id", as: :content_id + resources :documents, only: %i[index show new], path_names: { new: "(:block_type)/new" }, path: "" do + collection do + post :new_document_options_redirect + end + resources :editions, only: %i[new create] + get "schedule/edit", to: "documents/schedule#edit", as: :schedule_edit + put "schedule", to: "documents/schedule#update", as: :update_schedule + patch "schedule", to: "documents/schedule#update" + end + resources :editions, only: %i[new create destroy], path_names: { new: ":block_type/new" } do + member do + resources :workflow, only: %i[show update], controller: "editions/workflow", param: :step do + collection do + get :cancel, to: "editions/workflow#cancel" + end + end + get "embedded-objects/(:object_type)/new", to: "editions/embedded_objects#new", as: :new_embedded_object + post "embedded-objects", to: "editions/embedded_objects#new_embedded_objects_options_redirect", as: :new_embedded_objects_options_redirect + post "embedded-objects/:object_type", to: "editions/embedded_objects#create", as: :create_embedded_object + get "embedded-objects/:object_type/:object_title/edit", to: "editions/embedded_objects#edit", as: :edit_embedded_object + put "embedded-objects/:object_type/:object_title", to: "editions/embedded_objects#update", as: :embedded_object + get "embedded-objects/:object_type/:object_title/review", to: "editions/embedded_objects#review", as: :review_embedded_object + post "embedded-objects/:object_type/:object_title/publish", to: "editions/embedded_objects#publish", as: :publish_embedded_object + get :preview, to: "editions/host_content#preview", path: "host-content/:host_content_id/preview", as: :host_content_preview + end + end + end + end + + get "/page" => "pages#show" end diff --git a/config/secrets.yml b/config/secrets.yml new file mode 100644 index 000000000..8240e59a5 --- /dev/null +++ b/config/secrets.yml @@ -0,0 +1,8 @@ +development: + secret_key_base: secret + +test: + secret_key_base: secret + +production: + secret_key_base: <%= ENV['SECRET_KEY_BASE'] %> diff --git a/db/migrate/20250819132716_create_users.rb b/db/migrate/20250819132716_create_users.rb new file mode 100644 index 000000000..4aae1398c --- /dev/null +++ b/db/migrate/20250819132716_create_users.rb @@ -0,0 +1,18 @@ +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users do |t| + t.text :name, null: false + t.text :uid, null: false + t.text :email, null: false + t.boolean :disabled, default: false + t.boolean :remotely_signed_out, default: false + t.text :organisation_slug + t.text :permissions + + t.timestamps + end + add_index :users, :uid + add_index :users, :email + add_index :users, :organisation_slug + end +end diff --git a/db/migrate/20250819142711_create_organisations.rb b/db/migrate/20250819142711_create_organisations.rb new file mode 100644 index 000000000..0b22252bb --- /dev/null +++ b/db/migrate/20250819142711_create_organisations.rb @@ -0,0 +1,20 @@ +class CreateOrganisations < ActiveRecord::Migration[8.0] + def change + create_table :organisations do |t| + t.text :name, null: false + t.text :slug, null: false + t.text :url + t.text :govuk_status, null: false, default: "live" + t.datetime :closed_at + t.text :content_id + t.boolean :political, null: false, default: false + t.text :organisation_type_key, null: false + t.text :govuk_closed_status + + t.timestamps + end + add_index :organisations, :slug + add_index :organisations, :organisation_type_key + add_index :organisations, :content_id + end +end diff --git a/db/migrate/20250819165035_create_content_block_documents.rb b/db/migrate/20250819165035_create_content_block_documents.rb new file mode 100644 index 000000000..c76c067c5 --- /dev/null +++ b/db/migrate/20250819165035_create_content_block_documents.rb @@ -0,0 +1,18 @@ +class CreateContentBlockDocuments < ActiveRecord::Migration[8.0] + def change + create_table :content_block_documents do |t| + t.text :content_id + t.text :sluggable_string + t.text :block_type + t.integer :latest_edition_id + t.integer :live_edition_id + t.string :content_id_alias + t.datetime :deleted_at + + t.timestamps + end + add_index :content_block_documents, :latest_edition_id + add_index :content_block_documents, :live_edition_id + add_index :content_block_documents, :content_id_alias, unique: true + end +end diff --git a/db/migrate/20250820105744_create_content_block_editions.rb b/db/migrate/20250820105744_create_content_block_editions.rb new file mode 100644 index 000000000..512d32478 --- /dev/null +++ b/db/migrate/20250820105744_create_content_block_editions.rb @@ -0,0 +1,20 @@ +class CreateContentBlockEditions < ActiveRecord::Migration[8.0] + def change + create_table :content_block_editions do |t| + t.json :details, null: false + t.integer :document_id, null: false + t.text :state, null: false, default: "draft" + t.datetime :scheduled_publication + t.text :instructions_to_publishers + t.text :title, null: false, default: "" + t.text :internal_change_note + t.text :change_note + t.boolean :major_change + + t.timestamps + end + + add_index :content_block_editions, :title + add_index :content_block_editions, :document_id + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..cd36494d4 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,79 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 2025_08_20_105744) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "content_block_documents", force: :cascade do |t| + t.text "content_id" + t.text "sluggable_string" + t.text "block_type" + t.integer "latest_edition_id" + t.integer "live_edition_id" + t.string "content_id_alias" + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["content_id_alias"], name: "index_content_block_documents_on_content_id_alias", unique: true + t.index ["latest_edition_id"], name: "index_content_block_documents_on_latest_edition_id" + t.index ["live_edition_id"], name: "index_content_block_documents_on_live_edition_id" + end + + create_table "content_block_editions", force: :cascade do |t| + t.json "details", null: false + t.integer "document_id", null: false + t.text "state", default: "draft", null: false + t.datetime "scheduled_publication" + t.text "instructions_to_publishers" + t.text "title", default: "", null: false + t.text "internal_change_note" + t.text "change_note" + t.boolean "major_change" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["document_id"], name: "index_content_block_editions_on_document_id" + t.index ["title"], name: "index_content_block_editions_on_title" + end + + create_table "organisations", force: :cascade do |t| + t.text "name", null: false + t.text "slug", null: false + t.text "url" + t.text "govuk_status", default: "live", null: false + t.datetime "closed_at" + t.text "content_id" + t.boolean "political", default: false, null: false + t.text "organisation_type_key", null: false + t.text "govuk_closed_status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["content_id"], name: "index_organisations_on_content_id" + t.index ["organisation_type_key"], name: "index_organisations_on_organisation_type_key" + t.index ["slug"], name: "index_organisations_on_slug" + end + + create_table "users", force: :cascade do |t| + t.text "name", null: false + t.text "uid", null: false + t.text "email", null: false + t.boolean "disabled", default: false + t.boolean "remotely_signed_out", default: false + t.text "organisation_slug" + t.text "permissions" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email" + t.index ["organisation_slug"], name: "index_users_on_organisation_slug" + t.index ["uid"], name: "index_users_on_uid" + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..c3b7f44c7 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,11 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +return if Rails.env.test? + +gds_organisation_id = "af07d5a5-df63-4ddc-9383-6a666845ebe9" +User.create!( + name: "Test user", + uid: "test-user-1", + email: "test@gds.example.com", + permissions: ["signin", "GDS Admin", "GDS Editor", "Managing Editor", "Sidekiq Admin"], + organisation_content_id: gds_organisation_id, + organisation_slug: "government-digital-service", +) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..124a3faa2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +const { defineConfig, globalIgnores } = require('eslint/config') + +const globals = require('globals') +const js = require('@eslint/js') + +const { FlatCompat } = require('@eslint/eslintrc') + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}) + +module.exports = defineConfig([ + { + extends: compat.extends('standard', 'prettier'), + + languageOptions: { + globals: { + ...globals.browser, + ...globals.jasmine, + GOVUK: 'readonly' + } + } + }, + globalIgnores(['app/assets/javascripts/vendor/']) +]) diff --git a/features/create_contact_object.feature b/features/create_contact_object.feature new file mode 100644 index 000000000..681dd7d68 --- /dev/null +++ b/features/create_contact_object.feature @@ -0,0 +1,264 @@ +Feature: Create a contact object + + Background: + Given I am a GDS admin + And the organisation "Ministry of Example" exists + And a schema "contact" exists: + """ + { + "type":"object", + "required":[ + "description" + ], + "additionalProperties":false, + "properties":{ + "description": { + "type": "string" + } + } + } + """ + And the schema has a subschema "email_addresses": + """ + { + "type":"object", + "required": ["title", "email_address"], + "properties": { + "title": { + "type": "string" + }, + "label": { + "type": "string" + }, + "email_address": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "body": { + "type": "string" + } + } + } + """ + And the schema has a subschema "telephones": + """ + { + "type":"object", + "required": [ + "title", + "telephone_numbers" + ], + "properties": { + "telephone_numbers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "label", + "telephone_number" + ], + "properties": { + "label": { + "type": "string" + }, + "telephone_number": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "", + "telephone", + "textphone" + ] + } + } + } + }, + "title": { + "type": "string" + }, + "video_relay_service": { + "type": "object", + "properties": { + "show": { + "type": "boolean", + "default": false + }, + "prefix": { + "type": "string", + "default": "**Default** prefix: 18000 then" + }, + "telephone_number": { + "type": "string", + "default": "0800 123 4567" + } + }, + "x-govspeak_enabled": ["prefix"] + }, + "call_charges": { + "type": "object", + "properties": { + "label": { + "type": "string", + "default": "Find out about call charges" + }, + "call_charges_info_url": { + "type": "string", + "default": "https://gov.uk/call-charges" + }, + "show_call_charges_info_url": { + "type": "boolean", + "default": false + } + } + }, + "bsl_guidance": { + "type": "object", + "properties": { + "show": { + "type": "boolean", + "default": false + }, + "value": { + "type": "string", + "default": "British Sign Language (BSL) [video relay service](https://connect.interpreterslive.co.uk/vrs?ilc=DWP)> if you’re on a computer - find out how to [use the service on mobile or tablet](https://www.youtube.com/watch?v=oELNMfAvDxw)" + } + } + }, + "opening_hours": { + "type": "object", + "properties": { + "opening_hours": { + "type": "string" + }, + "show_opening_hours": { + "type": "boolean", + "default": false + } + }, + "if": { + "properties": { + "show_opening_hours": { + "const": true + } + } + }, + "then": { + "required": [ + "opening_hours" + ] + }, + "else": { + "required": [] + } + } + } + } + """ + And the schema has a subschema "contact_links": + """ + { + "type":"object", + "required": ["url"], + "properties": { + "title": { + "type": "string" + }, + "label": { + "type": "string" + }, + "url": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + """ + And the schema "contact" has a group "contact_methods" with the following subschemas: + | email_addresses | telephones | contact_links | + And I visit the Content Block Manager home page + And I click to create an object + And I click on the "contact" schema + And I complete the form with the following fields: + | title | description | organisation | instructions_to_publishers | + | my basic contact | this is basic | Ministry of Example | this is important | + + @javascript + Scenario: GDS editor creates a Contact with an email address and a telephone + And I click on the "email_addresses" subschema + And I complete the "email_address" form with the following fields: + | title | label | email_address | subject | body | + | Email us | Send an email | foo@example.com | Your ref | Name and address | + And I click to add another "contact_method" + And I click on the "telephones" subschema + And I fill in the "telephone" form with the following fields: + | title | + | New phone number | + And I add the following "telephone_numbers" to the form: + | label | telephone_number | type | + | Telephone 1 | 12345 | Telephone | + | Telephone 2 | 6789 | Textphone | + And I indicate that the video relay service info should be displayed + And I provide custom video relay service info where available + And I indicate that the call charges info URL should be shown + And I change the call charges info URL from its default value + And I change the call charges info label from its default value + And I indicate that BSL guidance should be shown + And I change the BSL guidance label from its default value + And I indicate that the opening hours should be shown + And I input the opening hours + And I save and continue + And I click to add another "contact_method" + And I click on the "contact_links" subschema + And I fill in the "contact_link" form with the following fields: + | title | label | url | description | + | Contact Form | Contact Us | http://example.com | Description | + When I save and continue + Then I should be on the "add_group_contact_methods" step + When I save and continue + And I review and confirm my answers are correct + Then I should be taken to the confirmation page for a new "contact" + When I click to view the content block + And I should see the created embedded object of type "email_address" + And I should see the created embedded object of type "telephone" + And I should see the created embedded object of type "contact_link" + When I view all the telephone attributes + Then I should see that the call charges fields have been changed + And I should see that the video relay service info is to be shown + And I should see that the custom video relay info has been recorded + And I should see that the BSL guidance fields have been changed + + @javascript + Scenario: GDS editor sees errors for invalid telephone objects + When I save and continue + And I click on the "telephones" subschema + When I save and continue + Then I should see errors for the required nested "telephone_number" fields + + @javascript + Scenario: Telephone number label is automatically populated + When I click on the "telephones" subschema + And I choose "Textphone" from the type dropdown + Then the label should be set to "Textphone" + + Scenario: GDS editor edits answers during creation of an object + And I click on the "email_addresses" subschema + And I complete the "email_address" form with the following fields: + | title | email_address | + | New email | foo@example.com | + And I save and continue + When I click the first edit link + And I complete the form with the following fields: + | title | + | New email 2 | + And I save and continue + Then I am asked to review my answers + And I confirm my answers are correct + And I review and confirm my answers are correct + And I should be taken to the confirmation page for a new "contact" diff --git a/features/step_definitions/content_block_manager_steps.rb b/features/step_definitions/content_block_manager_steps.rb new file mode 100644 index 000000000..42cfe5173 --- /dev/null +++ b/features/step_definitions/content_block_manager_steps.rb @@ -0,0 +1,442 @@ +require_relative "../support/stubs" +require_relative "../support/helpers" + +# Suppress noisy Sidekiq logging in the test output +# Sidekiq.configure_client do |cfg| +# cfg.logger.level = ::Logger::WARN +# end + +Given("I am in the staging or integration environment") do + Whitehall.stubs(:integration_or_staging?).returns(true) +end + +When("I click to create an object") do + click_link "Create content block" +end + +When("I click cancel") do + click_button "Cancel" +end + +When("I choose to delete the in-progress draft") do + click_button "Delete draft" +end + +When("I click to save and come back later") do + click_link "Save for later" +end + +When("I click the cancel link") do + click_link "Cancel" +end + +Then(/^I click on page ([^"]*)$/) do |page_number| + click_link page_number +end + +When("I click to view results") do + click_button "View results" +end + +When("I complete the form with the following fields:") do |table| + fields = table.hashes.first + @title = fields.delete("title") + @organisation = fields.delete("organisation") + @instructions_to_publishers = fields.delete("instructions_to_publishers") + @details = fields + + fill_in "Title", with: @title if @title.present? + + select @organisation, from: "content_block_manager_content_block_edition_lead_organisation" if @organisation.present? + + fill_in "Instructions to publishers", with: @instructions_to_publishers if @instructions_to_publishers.present? + + fields.keys.each do |k| + fill_in "content_block_manager_content_block_edition_details_#{k}", with: @details[k] + end + + click_save_and_continue +end + +Then("the edition should have been created successfully") do + edition = ContentBlockManager::ContentBlock::Edition.all.last + + assert_not_nil edition + assert_not_nil edition.document + + assert_equal @title, edition.title if @title.present? + assert_equal @instructions_to_publishers, edition.instructions_to_publishers if @instructions_to_publishers.present? + + @details.keys.each do |k| + assert_equal edition.details[k], @details[k] + end +end + +And("I should be taken to the confirmation page for a published block") do + content_block_edition = ContentBlockManager::ContentBlock::Edition.last + + assert_text I18n.t("content_block_edition.confirmation_page.updated.banner", block_type: content_block_edition.document.block_type.humanize) + assert_text I18n.t("content_block_edition.confirmation_page.updated.detail") + + expect(page).to have_link( + "View content block", + href: content_block_manager.content_block_manager_content_block_document_path( + content_block_edition.document, + ), + ) +end + +And("I should be taken to the confirmation page for a new {string}") do |block_type| + content_block = ContentBlockManager::ContentBlock::Edition.last + + assert_text I18n.t("content_block_edition.confirmation_page.created.banner", block_type: block_type.titlecase) + assert_text I18n.t("content_block_edition.confirmation_page.created.detail") + + expect(page).to have_link( + "View content block", + href: content_block_manager.content_block_manager_content_block_document_path( + content_block.document, + ), + ) +end + +When("I click to view the content block") do + click_link href: content_block_manager.content_block_manager_content_block_document_path( + ContentBlockManager::ContentBlock::Edition.last.document, + ) +end + +When("I should be taken to the scheduled confirmation page") do + content_block_edition = ContentBlockManager::ContentBlock::Edition.last + + assert_text I18n.t( + "content_block_edition.confirmation_page.scheduled.banner", + block_type: "Pension", + date: I18n.l(content_block_edition.scheduled_publication, format: :long_ordinal), + ).squish + assert_text I18n.t("content_block_edition.confirmation_page.scheduled.detail") + + expect(page).to have_link( + "View content block", + href: content_block_manager.content_block_manager_content_block_document_path( + content_block_edition.document, + ), + ) +end + +Then("I should be taken back to the document page") do + expect(page.current_url).to match(content_block_manager.content_block_manager_content_block_document_path( + ContentBlockManager::ContentBlock::Edition.last.document, + )) +end + +Given("a pension content block has been created") do + @content_blocks ||= [] + organisation = create(:organisation) + @content_block = create( + :content_block_edition, + :pension, + details: { description: "Some text" }, + creator: @user, + organisation:, + title: "My pension", + ) + ContentBlockManager::ContentBlock::Edition::HasAuditTrail.acting_as(@user) do + @content_block.publish! + end + @content_blocks.push(@content_block) +end + +Given("a contact content block has been created") do + @content_blocks ||= [] + organisation = create(:organisation) + @content_block = create( + :content_block_edition, + :contact, + details: { description: "Some text" }, + creator: @user, + organisation:, + title: "My contact", + ) + ContentBlockManager::ContentBlock::Edition::HasAuditTrail.acting_as(@user) do + @content_block.publish! + end + @content_blocks.push(@content_block) +end + +Given(/^([^"]*) content blocks of type ([^"]*) have been created with the fields:$/) do |count, block_type, table| + fields = table.rows_hash + organisation_name = fields.delete("organisation") + organisation = Organisation.where(name: organisation_name).first + title = fields.delete("title") || "title" + instructions_to_publishers = fields.delete("instructions_to_publishers") + + (1..count.to_i).each do |_i| + document = create(:content_block_document, block_type.to_sym, sluggable_string: title.parameterize(separator: "_")) + + editions = create_list( + :content_block_edition, + 3, + block_type.to_sym, + document:, + organisation:, + details: fields, + creator: @user, + instructions_to_publishers:, + title:, + ) + + document.latest_edition = editions.last + document.save! + end +end + +Then("I am taken back to Content Block Manager home page") do + assert_equal current_path, content_block_manager.content_block_manager_root_path +end + +And("no draft Content Block Edition has been created") do + assert_equal 0, ContentBlockManager::ContentBlock::Edition.where(state: "draft").count +end + +And("no draft Content Block Document has been created") do + assert_equal 0, ContentBlockManager::ContentBlock::Document.count +end + +Then("I should see the details for all documents") do + assert_text "Content Block Manager" + + ContentBlockManager::ContentBlock::Document.find_each do |document| + should_show_summary_title_for_generic_content_block( + document.title, + ) + end +end + +Then("I should see the details for all documents from my organisation") do + ContentBlockManager::ContentBlock::Document.with_lead_organisation(@user.organisation.id).each do |document| + should_show_summary_title_for_generic_content_block( + document.title, + ) + end +end + +Then("I should see the content block with title {string} returned") do |title| + expect(page).to have_selector(".govuk-summary-card__title", text: title) +end + +Then("{string} content blocks are returned in total") do |count| + assert_text "#{count} #{'result'.pluralize(count.to_i)}" +end + +When("I click to view the document") do + @schema = @schemas[@content_block.document.block_type] + click_link href: content_block_manager.content_block_manager_content_block_document_path(@content_block.document) +end + +When("I click to view the document with title {string}") do |title| + content_block = ContentBlockManager::ContentBlock::Edition.where(title:).first + + click_link href: content_block_manager.content_block_manager_content_block_document_path(content_block.document) +end + +Then("I should see the details for the contact content block") do + expect(page).to have_selector("h1", text: @content_block.document.title) + should_show_generic_content_block_details(@content_block.document.title, @organisation) +end + +When("I click the first edit link") do + click_link "Edit", match: :first +end + +When("I click to edit the {string}") do |block_type| + click_link "Edit #{block_type}", match: :first +end + +When("I fill out the form") do + change_details(object_type: @content_block.document.block_type) +end + +When("I set all fields to blank") do + fill_in "Title", with: "" + fill_in "Description", with: "" + select "", from: "content_block/edition[organisation_id]" + click_save_and_continue +end + +Then("the edition should have been updated successfully") do + block_type = @content_block.document.block_type + + case block_type + when "pension" + should_show_summary_card_for_pension_content_block( + "Changed title", + "New description", + "Ministry of Example", + "new context information", + ) + else + should_show_summary_card_for_contact_content_block( + "Changed title", + "changed@example.com", + "Ministry of Example", + "new context information", + ) + end + + # TODO: this can be removed once the summary list is referring to the Edition's title, not the Document title + edition = ContentBlockManager::ContentBlock::Edition.all.last + assert_equal "Changed title", edition.title +end + +Then("I am asked to review my answers") do + assert_text "Review contact" +end + +Then("I am asked to review my answers for a {string}") do |block_type| + assert_text "Review #{block_type}" +end + +Then("I confirm my answers are correct") do + check "is_confirmed" +end + +When("I review and confirm my answers are correct") do + review_and_confirm +end + +When("I click publish without confirming my details") do + click_on "Publish" +end + +When(/^I save and continue$/) do + click_save_and_continue +end + +Then(/^I choose to publish the change now$/) do + @is_scheduled = false + publish_now +end + +Then("I check the block type {string}") do |checkbox_name| + check checkbox_name +end + +Then("I select the lead organisation {string}") do |organisation| + select organisation, from: "lead_organisation" +end + +When("I make the changes") do + change_details + click_save_and_continue +end + +When("I am updating a content block") do + update_content_block + add_internal_note + add_change_note +end + +When("one of the content blocks was updated 2 days ago") do + content_block_document = ContentBlockManager::ContentBlock::Document.all.last + content_block_document.latest_edition.updated_at = 2.days.before(Time.zone.now) + content_block_document.latest_edition.save! +end + +Then("the published state of the object should be shown") do + visit content_block_manager.content_block_manager_content_block_document_path(@content_block.document) + expect(page).to have_selector(".govuk-summary-list__key", text: "Status") + expect(page).to have_selector(".govuk-summary-list__value", text: "Published") +end + +Then("I should see the scheduled date on the object") do + expect(page).to have_selector(".govuk-summary-list__key", text: "Status") + expect(page).to have_selector(".govuk-summary-list__value", text: @future_date.to_fs(:long_ordinal_with_at).squish) +end + +When("I continue after reviewing the links") do + click_save_and_continue +end + +When(/^I add a change note$/) do + add_change_note +end + +Then(/^I should see the object store's title in the header$/) do + expect(page).to have_selector(".govuk-header__product-name", text: "Content Block Manager") +end + +Then(/^I should see the object store's home page title$/) do + expect(page).to have_title "Home - GOV.UK Content Block Manager" +end + +And(/^I should see the object store's navigation$/) do + expect(page).to have_selector("a.govuk-service-navigation__link[href='#{content_block_manager.content_block_manager_root_path}']", text: "Blocks") +end + +And("I should see the object store's phase banner") do + expect(page).to have_selector(".govuk-tag", text: "Beta") + expect(page).to have_link("feedback-content-modelling@digital.cabinet-office.gov.uk", href: "mailto:feedback-content-modelling@digital.cabinet-office.gov.uk") +end + +Then(/^I should still see the live edition on the homepage$/) do + within(".govuk-summary-card", text: @content_block.document.title) do + @content_block.details.keys.each do |key| + expect(page).to have_content(@content_block.details[key]) + end + end +end + +Then(/^I should not see the draft document$/) do + expect(page).not_to have_content(@title) +end + +Then("I should see the content block manager home page") do + expect(page).to have_content("Content Block Manager") +end + +When(/^I add an internal note$/) do + add_internal_note +end + +Then(/^I should see a notification that a draft is in progress$/) do + expect(page).to have_content("There’s a saved draft of this content block") +end + +Then(/^I should not see a notification that a draft is in progress$/) do + expect(page).to_not have_content("There’s a saved draft of this content block") +end + +Then("there should be no draft editions remaining") do + expect(@content_block.document.reload.editions.select { |e| e.state == "draft" }.count).to eq(0) +end + +When(/^I click on the link to continue editing$/) do + click_on "Continue editing" +end + +And(/^I update the content block and publish$/) do + change_details + click_save_and_continue + add_internal_note + add_change_note + publish_now + review_and_confirm +end + +And(/^I click the back link$/) do + click_on "Back" +end + +Given(/^my pension content block has no rates$/) do + @content_block.details["rates"] = {} + @content_block.save! +end + +When("I choose {string} from the type dropdown") do |type| + select type, from: "content_block_manager_content_block_edition_details_telephones_telephone_numbers_0_type" +end + +Then("the label should be set to {string}") do |label| + expect(find("#content_block_manager_content_block_edition_details_telephones_telephone_numbers_0_label").value).to eq(label) +end diff --git a/features/step_definitions/dependent_content_steps.rb b/features/step_definitions/dependent_content_steps.rb new file mode 100644 index 000000000..ab1b6c619 --- /dev/null +++ b/features/step_definitions/dependent_content_steps.rb @@ -0,0 +1,56 @@ +require_relative "../support/dependent_content" +require_relative "../support/helpers" + +Then(/^I should see the dependent content listed$/) do + assert_text "List of locations" + + @dependent_content.each do |item| + assert_text item["title"] + break if item == @dependent_content.last + end + + expect(page).to have_link(@host_content_editor.name, href: content_block_manager.content_block_manager_user_path(@host_content_editor.uid)) +end + +Then(/^I (should )?see the rollup data for the dependent content$/) do |_should| + should_show_rollup_data +end + +When(/^dependent content exists for a content block$/) do + host_editor_id = SecureRandom.uuid + @dependent_content = 10.times.map do |i| + { + "title" => "Content #{i}", + "document_type" => "document", + "base_path" => "/host-content-path-#{i}", + "content_id" => SecureRandom.uuid, + "last_edited_by_editor_id" => host_editor_id, + "last_edited_at" => 2.days.ago.to_s, + "host_content_id" => "abc12345", + "host_locale" => "en", + "instances" => 1, + "primary_publishing_organisation" => { + "content_id" => SecureRandom.uuid, + "title" => "Organisation #{i}", + "base_path" => "/organisation/#{i}", + }, + } + end + + @rollup = build(:rollup).to_h + + stub_publishing_api_has_embedded_content_for_any_content_id( + results: @dependent_content, + total: @dependent_content.length, + order: ContentBlockManager::HostContentItem::DEFAULT_ORDER, + rollup: @rollup, + ) + + stub_publishing_api_has_embedded_content_details(@dependent_content.first) + + @host_content_editor = build(:signon_user, uid: host_editor_id) + + stub_request(:get, "#{Plek.find('signon', external: true)}/api/users") + .with(query: { uuids: [host_editor_id] }) + .to_return(body: [@host_content_editor].to_json) +end diff --git a/features/step_definitions/embedded_object_steps.rb b/features/step_definitions/embedded_object_steps.rb new file mode 100644 index 000000000..1afbfaed8 --- /dev/null +++ b/features/step_definitions/embedded_object_steps.rb @@ -0,0 +1,192 @@ +require_relative "../support/form_step_helpers" +require_relative "./video_relay_service_steps" + +When("I complete the {string} form with the following fields:") do |object_type, table| + fill_in_embedded_object_form(object_type, table) + + click_save_and_continue +end + +And("I fill in the {string} form with the following fields:") do |object_type, table| + @object_type = object_type + fill_in_embedded_object_form(object_type, table) +end + +And("I add the following {string} to the form:") do |item_type, table| + fields = table.hashes + + if item_type == "opening_hours" + fields.map! do |item| + time_from = item.delete("time_from") + time_to = item.delete("time_to") + + item["time_from(h)"] = time_from.split(":")[0] + item["time_from(m)"] = time_from.split(":")[1][0..1] + item["time_from(meridian)"] = time_from[-2..] + + item["time_to(h)"] = time_to.split(":")[0] + item["time_to(m)"] = time_to.split(":")[1][0..1] + item["time_to(meridian)"] = time_to[-2..] + + item + end + + check "Show opening hours" + end + + fields.each do |row| + field_prefix = "content_block/edition[details][#{@object_type.pluralize}][#{item_type}][]" + + row.each do |key, value| + within all(".js-add-another__fieldset").last do + field = page.all(:css, "[name='#{field_prefix}[#{key}]']").last + if field.tag_name == "select" + select value, from: field[:id] + else + fill_in field[:id], with: value + end + end + end + + page.driver.with_playwright_page do |page| + page.get_by_text("Add another #{item_type.humanize.singularize}").click unless row == fields.last + end + end +end + +Given("I indicate that the call charges info URL should be shown") do + check "Show hyperlink to 'Find out about call charges'" +end + +Given("I change the call charges info URL from its default value") do + fill_in("URL to find out about call charges", with: "https://custom.example.com") +end + +Given("I change the call charges info label from its default value") do + within(".app-c-content-block-manager-call-charges-component") do + fill_in("Label", with: "Learn about the cost of calls (custom label)") + end +end + +When("I view all the telephone attributes") do + # navigate to "telephones" tab + find("#tab_telephones").click + # expand the list of telphone details + find("span[data-ga4-expandable='']", text: "All telephone attributes").click +end + +Then("I should see that the call charges fields have been changed") do + within(".gem-c-summary-card[title='Call Charges']") do + expect(page).to have_css("dt", text: "Show call charges info url") + expect(page).to have_css("dt", text: "on") + + expect(page).not_to have_content("https://gov.uk/call-charges") + expect(page).to have_content("https://custom.example.com") + + expect(page).not_to have_content("Find out about call charges") + expect(page).to have_content("Learn about the cost of calls (custom label)") + end +end + +When("I indicate that BSL guidance should be shown") do + check I18n.t("content_block_edition.details.labels.telephones.bsl_guidance.show") +end + +When("I change the BSL guidance label from its default value") do + fill_in(I18n.t("content_block_edition.details.labels.telephones.bsl_guidance.value"), with: "More about BSL") +end + +When("I indicate that the opening hours should be shown") do + check I18n.t("content_block_edition.details.labels.telephones.opening_hours.show_opening_hours") +end + +When("I input the opening hours") do + fill_in(I18n.t("content_block_edition.details.labels.telephones.opening_hours.opening_hours"), with: "Monday - Friday: 9am-5pm") +end + +Then("I should see that the BSL guidance fields have been changed") do + within(".gem-c-summary-card[title='BSL Guidance']") do + expect(page).to have_css("dt", text: "Show") + expect(page).to have_css("dt", text: "true") + + expect(page).to have_css("dt", text: "Value") + expect(page).to have_css("dt", text: "More about BSL") + end +end + +Then("I should see errors for the required {string} fields") do |object_type| + schema = @schemas.values.first.subschema(object_type.pluralize) + required_fields = schema.body["required"] + required_fields.each do |required_field| + assert_text "#{ContentBlockManager::ContentBlock::Edition.human_attribute_name("details_#{required_field}")} cannot be blank", minimum: 2 + end +end + +Then("I should see errors for the required nested {string} fields") do |nested_object_name| + subschema = @subschemas[@object_type.pluralize] + required_fields = subschema.dig("properties", nested_object_name.pluralize, "items", "required") + required_fields.each do |required_field| + assert_text "#{ContentBlockManager::ContentBlock::Edition.human_attribute_name("details_#{required_field}")} cannot be blank", minimum: 2 + end +end + +And("I should see details of my {string}") do |object_type| + within "div[data-testid='#{object_type.pluralize}_listing']" do + @details.keys.each do |k| + assert_text @details[k] + end + end +end + +And("I click to add a new {string}") do |object_type| + @object_type = object_type + click_on "Add #{add_indefinite_article object_type.humanize.downcase}" +end + +And("I click to add another {string}") do |object_type| + @object_type = object_type + click_on "Add another #{object_type.humanize.downcase}" +end + +And(/^that pension has a rate with the following fields:$/) do |table| + rate = table.hashes.first + @content_block.details["rates"] = { + rate[:title].parameterize.to_s => { + "title" => rate[:title], + "amount" => rate[:amount], + "frequency" => rate[:frequency], + }, + } + @content_block.save! +end + +And(/^I should see the rates for that block$/) do + @content_block.details["rates"].keys.each do |k| + within "div[data-test-id=embedded_#{k}]" do + @content_block.details["rates"][k].each do |_k, value| + assert_text value + end + end + end +end + +When(/^I click to edit the first rate$/) do + key = @content_block.details["rates"].keys.first + within "div[data-test-id=embedded_#{key}]" do + click_on "Edit" + end +end + +And(/^I should see the updated rates for that block$/) do + @details.keys.each do |k| + assert_text @details[k] + end +end + +And("I should not see a button to add a new {string}") do |object_type| + assert_no_text "Add #{add_indefinite_article object_type}" +end + +Then("I should see the created embedded object of type {string}") do |object_type| + assert_text "#{object_type.humanize.pluralize} (1)" +end diff --git a/features/step_definitions/form_step_steps.rb b/features/step_definitions/form_step_steps.rb new file mode 100644 index 000000000..fd972dba3 --- /dev/null +++ b/features/step_definitions/form_step_steps.rb @@ -0,0 +1,22 @@ +require_relative "../support/form_step_helpers" + +Then(/^I should be on the "([^"]*)" step$/) do |step| + case step + when "edit" + should_show_edit_form(object_type: @content_block.document.block_type) + when "review_links" + should_show_dependent_content(object_type: @content_block.document.block_type) + when "schedule_publishing" + should_show_publish_form + when "review" + should_be_on_review_step(object_type: @content_block.document.block_type) + when "change_note" + should_be_on_change_note_step + when /add_#{Workflow::Step::SUBSCHEMA_PREFIX}(.*)/ + should_be_on_subschema_step(::Regexp.last_match(1), "Add") + when /edit_#{Workflow::Step::SUBSCHEMA_PREFIX}(.*)/ + should_be_on_subschema_step(::Regexp.last_match(1), "Edit") + when /add_#{Workflow::Step::GROUP_PREFIX}(.*)/ + assert_text "Add #{::Regexp.last_match(1).humanize(capitalize: false)}" + end +end diff --git a/features/step_definitions/navigation_steps.rb b/features/step_definitions/navigation_steps.rb new file mode 100644 index 000000000..12faa86c5 --- /dev/null +++ b/features/step_definitions/navigation_steps.rb @@ -0,0 +1,17 @@ +When("I visit the Content Block Manager home page") do + visit content_block_manager.content_block_manager_root_path +end + +When("I visit a block's content ID endpoint") do + block = ContentBlockManager::ContentBlock::Document.last + visit content_block_manager.content_block_manager_content_block_content_id_path(block.content_id) +end + +When("I revisit the edit page") do + @content_block = @content_block.document.latest_edition + visit_edit_page +end + +def content_block_manager + Rails.application.routes.url_helpers +end diff --git a/features/step_definitions/organisation_steps.rb b/features/step_definitions/organisation_steps.rb new file mode 100644 index 000000000..ce4ec82d5 --- /dev/null +++ b/features/step_definitions/organisation_steps.rb @@ -0,0 +1,3 @@ +Given(/^the organisation "([^"]*)" exists$/) do |name| + create_org_and_stub_content_store(:ministerial_department, name:) +end diff --git a/features/step_definitions/schema_steps.rb b/features/step_definitions/schema_steps.rb new file mode 100644 index 000000000..558687483 --- /dev/null +++ b/features/step_definitions/schema_steps.rb @@ -0,0 +1,56 @@ +When("I click on the {string} schema") do |schema_id| + @schema = @schemas[schema_id] + ContentBlockManager::ContentBlock::Schema.expects(:find_by_block_type).with(schema_id).at_least_once.returns(@schema) + choose @schema.name + click_save_and_continue +end + +When("I click on the {string} subschema") do |schema_id| + schema = @schemas.values.last + subschema = schema.subschema(schema_id) + choose subschema.name.singularize + @object_type = schema_id + click_save_and_continue +end + +Then("I should see a form for the schema") do + expect(page).to have_text("Create #{@schema.name.downcase}") +end + +Then("I should see all the schemas listed") do + @schemas.values.each do |schema| + expect(page).to have_content(schema.name) + end +end + +And("the schema {string} has a group {string} with the following subschemas:") do |block_type, group, table| + subschemas = table.raw.first + schema = @schemas[block_type] + + subschemas.each do |subschema_id| + subschema = schema.subschema(subschema_id) + subschema.stubs(:group).returns(group) + end +end + +And("a schema {string} exists:") do |block_type, json| + @schemas ||= {} + body = JSON.parse(json) + @schema = build(:content_block_schema, block_type:, body:) + @schemas[block_type] = @schema + ContentBlockManager::ContentBlock::Schema.stubs(:all).returns(@schemas.values) +end + +And("the schema has a subschema {string}:") do |subschema_name, json| + @subschemas ||= {} + @subschemas[subschema_name] = JSON.parse(json) + @schema.body["properties"][subschema_name] = { + "type" => "object", + "patternProperties" => { + "^[a-z0-9]+(?:-[a-z0-9]+)*$" => @subschemas[subschema_name], + }, + } + @schema = build(:content_block_schema, block_type: @schema.block_type, body: @schema.body) + @schemas[@schema.block_type] = @schema + ContentBlockManager::ContentBlock::Schema.stubs(:all).returns(@schemas.values) +end diff --git a/features/step_definitions/session_steps.rb b/features/step_definitions/session_steps.rb new file mode 100644 index 000000000..5195de801 --- /dev/null +++ b/features/step_definitions/session_steps.rb @@ -0,0 +1,48 @@ +Given(/^I am (?:a|an) (writer|editor|admin|GDS editor|GDS admin|importer|managing editor)(?: called "([^"]*)")?$/) do |role, name| + binding.pry + @user = case role + when "writer" + create(:writer, name: name || "Wally Writer") + when "editor" + create(:departmental_editor, name: name || "Eddie Depteditor") + when "admin" + create(:user) + when "GDS editor" + create(:gds_editor) + when "GDS admin" + create(:gds_admin) + when "importer" + create(:importer) + when "managing editor" + create(:managing_editor) + end + @user.save! + login_as @user +end + +Given(/^I am (?:an?) (admin|writer|editor|GDS editor) in the organisation "([^"]*)"$/) do |role, organisation_name| + organisation = Organisation.find_by(name: organisation_name) || create_org_and_stub_content_store(:ministerial_department, name: organisation_name) + @user = case role + when "admin" + create(:user, organisation:) + when "writer" + create(:writer, name: "Wally Writer", organisation:) + when "editor" + create(:departmental_editor, name: "Eddie Depteditor", organisation:) + when "GDS editor" + create(:gds_editor, organisation:) + end + login_as @user +end + +Given(/^I have the "(.*?)" permission$/) do |perm| + @user.permissions << perm + @user.save! +end + +Around("@use_real_sso") do |_scenario, block| + current_sso_env = ENV["GDS_SSO_MOCK_INVALID"] + ENV["GDS_SSO_MOCK_INVALID"] = "1" + block.call + ENV["GDS_SSO_MOCK_INVALID"] = current_sso_env +end diff --git a/features/step_definitions/user_steps.rb b/features/step_definitions/user_steps.rb new file mode 100644 index 000000000..a95776a64 --- /dev/null +++ b/features/step_definitions/user_steps.rb @@ -0,0 +1,24 @@ +Given("A user exists with uuid {string}") do |uuid| + @user_from_signon = build( + :signon_user, + uid: uuid, + name: "John Doe", + email: "john@doe.com", + organisation: build(:signon_user_organisation, content_id: "456", name: "User's Org", slug: "users-org"), + ) + + stub_request(:get, "#{Plek.find('signon', external: true)}/api/users") + .with(query: { uuids: [uuid] }) + .to_return(body: [@user_from_signon].to_json) +end + +When("I visit the user page for uuid {string}") do |uuid| + visit content_block_manager_user_path(uuid) +end + +Then("I should see the details for that user") do + expect(page).to have_selector("h1", text: @user_from_signon.name) + expect(page).to have_selector(".govuk-summary-list__value", text: @user_from_signon.name) + expect(page).to have_selector(".govuk-summary-list__value", text: @user_from_signon.email) + expect(page).to have_selector(".govuk-summary-list__value", text: @user_from_signon.organisation.name) +end diff --git a/features/step_definitions/video_relay_service_steps.rb b/features/step_definitions/video_relay_service_steps.rb new file mode 100644 index 000000000..2c35963ec --- /dev/null +++ b/features/step_definitions/video_relay_service_steps.rb @@ -0,0 +1,39 @@ +Given("I indicate that the video relay service info should be displayed") do + check label_for("show") +end + +Given("I provide custom video relay service info where available") do + within(".app-c-content-block-manager-video-relay-service-component") do + fill_in(label_for("prefix"), with: "**Custom** prefix: 12345 then") + should_be_able_to_preview_the_govspeak_enabled_field + fill_in(label_for("telephone_number"), with: "01777 123 1234") + end +end + +When("I should see that the video relay service info is to be shown") do + within(".gem-c-summary-card[title='Video Relay Service']") do + expect(page).to have_css("dt", text: "Show") + expect(page).to have_css("dt", text: "true") + end +end + +When("I should see that the custom video relay info has been recorded") do + within(".gem-c-summary-card[title='Video Relay Service']") do + expect(page).to have_content("01777 123 1234") + expect(page).to have_content("**Custom** prefix: 12345 then") + end +end + +def label_for(field_name) + I18n.t("content_block_edition.details.labels.telephones.video_relay_service.#{field_name}") +end + +def should_be_able_to_preview_the_govspeak_enabled_field + click_button("Preview") + preliminary_preview_text = page.find(".app-c-govspeak-editor__preview p").text + + assert_equal( + "Generating preview, please wait.", + preliminary_preview_text, + ) +end diff --git a/features/support/capybara.rb b/features/support/capybara.rb new file mode 100644 index 000000000..ece8f9c42 --- /dev/null +++ b/features/support/capybara.rb @@ -0,0 +1,36 @@ +# On the CI box we have seen intermittent failures. +# We think this may be due to timeouts (the default is 2 secs), +# so we've increased the default timeout. +Capybara.default_max_wait_time = 5 + +# Allow Capybara to click a