diff --git a/.rubocop.yml b/.rubocop.yml index 6ab8baa1f..5742116a8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,7 +13,7 @@ AllCops: - spec/dummy/config/**/* - spec/dummy/db/**/* - spec/dummy/tmp/**/* - TargetRubyVersion: 3.4 + TargetRubyVersion: 4.0 Rails/RakeEnvironment: Exclude: diff --git a/Gemfile.lock b/Gemfile.lock index 4590408e9..f066cd5e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/katalyst/thermite.git - revision: e031fffc9e6eb91a6f330135c3086b3e2e6c0f27 + revision: cb7c4970b79d777d17aeb40afc3b8f83e8cf5f62 specs: katalyst-thermite (1.0.0) activesupport @@ -12,11 +12,12 @@ PATH katalyst-koi (5.8.0) bcrypt importmap-rails - katalyst-content (>= 3.0.0.alpha.1) - katalyst-govuk-formbuilder (< 2) + katalyst-content (>= 3.2) + katalyst-govuk-formbuilder (>= 1.28.0, < 2) katalyst-html-attributes katalyst-navigation (>= 2) katalyst-tables (>= 3.5) + lexxy pagy (>= 8.0) rails (>= 8.0) rotp @@ -143,8 +144,8 @@ GEM xpath (~> 3.2) cbor (0.5.10.3) chunky_png (1.4.0) - compare-xml (0.66) - nokogiri (~> 1.8) + compare-xml (1.0.0) + nokogiri (>= 1.6) concurrent-ruby (1.3.7) connection_pool (3.0.2) cose (1.3.1) @@ -153,7 +154,7 @@ GEM crack (1.0.1) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) cuprite (0.17) capybara (~> 3.0) ferrum (~> 0.17.0) @@ -189,12 +190,12 @@ GEM flipper-active_record (1.4.2) activerecord (>= 4.2, < 9) flipper (~> 1.4.2) - fugit (1.12.2) + fugit (1.12.3) et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) - govuk_design_system_formbuilder (6.2.1) + govuk_design_system_formbuilder (6.3.0) actionview (>= 7.2) activemodel (>= 7.2) activesupport (>= 7.2) @@ -215,17 +216,17 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.19.9) + json (2.20.0) jwt (3.2.0) base64 katalyst-basic-auth (1.0.0) - katalyst-content (3.1.3) + katalyst-content (3.2.0) active_storage_validations activerecord katalyst-govuk-formbuilder katalyst-html-attributes view_component - katalyst-govuk-formbuilder (1.27.0) + katalyst-govuk-formbuilder (1.28.0) govuk_design_system_formbuilder (>= 6.2.0) katalyst-html-attributes (1.1.2) activesupport @@ -238,6 +239,8 @@ GEM katalyst-html-attributes view_component (>= 4.0.0) language_server-protocol (3.17.0.5) + lexxy (0.9.21) + rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.1) @@ -300,9 +303,6 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.4.0) - date - stringio public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) @@ -355,9 +355,14 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.4.2) - rdoc (7.2.0) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) erb - psych (>= 4.0.0) + prism (>= 1.6.0) + rbs (>= 4.0.0) tsort regexp_parser (2.12.0) reline (0.6.3) @@ -467,7 +472,6 @@ GEM sqlite3 (2.9.5-x86_64-linux-musl) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) thor (1.5.0) timeout (0.6.1) tpm-key_attestation (0.14.1) @@ -502,7 +506,7 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.2) - websocket-driver (0.8.1) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -553,4 +557,4 @@ DEPENDENCIES webmock BUNDLED WITH - 4.0.14 + 4.0.15 diff --git a/app/assets/stylesheets/koi/forms/checkboxes.css b/app/assets/stylesheets/koi/forms/checkboxes.css index dd386d87e..9af81c7a4 100644 --- a/app/assets/stylesheets/koi/forms/checkboxes.css +++ b/app/assets/stylesheets/koi/forms/checkboxes.css @@ -33,7 +33,7 @@ margin-block-start: calc(-0.5 * var(--input-size)); translate: 0 calc(var(--stroke-input-width) + 0.25 * var(--input-size)); /* Firefox fix - unable to calculate baseline from dynamic height */ - min-height: var(--input-size); + min-block-size: var(--input-size); } .govuk-checkboxes__item::after { @@ -57,7 +57,7 @@ cursor: pointer; touch-action: manipulation; margin-block-end: 0; - min-height: var(--input-size); + min-block-size: var(--input-size); } .govuk-checkboxes__hint { diff --git a/app/assets/stylesheets/koi/forms/combobox.css b/app/assets/stylesheets/koi/forms/combobox.css index c3a0fb110..574eb4254 100644 --- a/app/assets/stylesheets/koi/forms/combobox.css +++ b/app/assets/stylesheets/koi/forms/combobox.css @@ -18,7 +18,7 @@ .hw-combobox__main__wrapper { display: flex; - min-height: var(--input-size); + min-block-size: var(--input-size); align-items: center; } @@ -36,7 +36,7 @@ width: 100%; padding: 0; height: unset; - min-height: unset; + min-block-size: unset; } /* disabled */ diff --git a/app/assets/stylesheets/koi/forms/index.css b/app/assets/stylesheets/koi/forms/index.css index ee27d52ef..3c8a88032 100644 --- a/app/assets/stylesheets/koi/forms/index.css +++ b/app/assets/stylesheets/koi/forms/index.css @@ -1,3 +1,4 @@ +@import url("/lexxy.css"); @import url("/trix.css"); @import url("label.css"); @@ -15,6 +16,7 @@ @import url("file-upload.css"); @import url("image-field.css"); @import url("document-field.css"); +@import url("lexxy.css"); @import url("radios.css"); @import url("password.css"); @import url("select.css"); diff --git a/app/assets/stylesheets/koi/forms/input.css b/app/assets/stylesheets/koi/forms/input.css index cd399583c..c4fb68177 100644 --- a/app/assets/stylesheets/koi/forms/input.css +++ b/app/assets/stylesheets/koi/forms/input.css @@ -1,15 +1,15 @@ -:is( - input:not([type="checkbox"], [type="radio"], [type="color"], [type="button"]) -), -:is(select, textarea), -trix-editor { +:where( + input:not([type="checkbox"], [type="radio"], [type="color"], [type="button"]), + select, + textarea +) { background: var(--color-light); border-radius: var(--input-radius); border: var(--stroke-input); color: var(--color-dark); padding: var(--space-3xs); width: 100%; - min-height: var(--input-size); + min-block-size: var(--input-size); &::placeholder { color: var(--color-mid); @@ -30,15 +30,13 @@ trix-editor { } } -:is(input, select, textarea, .govuk-richtextarea), -trix-editor { +:where(input, select, textarea) { accent-color: var(--color-primary); } -:is(textarea), -trix-editor { - resize: vertical; +:where(textarea) { line-height: var(--leading-fine); + resize: vertical; } .govuk-input { diff --git a/app/assets/stylesheets/koi/forms/lexxy.css b/app/assets/stylesheets/koi/forms/lexxy.css new file mode 100644 index 000000000..ba71e7844 --- /dev/null +++ b/app/assets/stylesheets/koi/forms/lexxy.css @@ -0,0 +1,53 @@ +:root { + /* Colors */ + --lexxy-color-ink: var(--color-dark); + --lexxy-color-ink-medium: var(--color-dark-glare); + --lexxy-color-ink-light: var(--color-mid); + --lexxy-color-ink-lighter: var(--color-tint); + --lexxy-color-ink-lightest: var(--color-tint); + --lexxy-color-ink-inverted: var(--color-light); + + /* Typography */ + --lexxy-font-base: var(--font-base); + --lexxy-font-mono: var(--font-mono); + --lexxy-text-small: var(--size-step--1); + --lexxy-content-margin: var(--space-s); + + /* Focus ring */ + --lexxy-focus-ring-color: var(--focus-color); +} + +:where(lexxy-toolbar) { + border: none; +} + +:where(lexxy-editor) { + --lexxy-editor-padding: var(--space-2xs); + --lexxy-editor-rows: 5lh; + + @supports (min-block-size: attr(rows lh)) { + --lexxy-editor-rows: attr(rows lh, 5lh); + } + + border: none; + margin-block-end: var(--flow-space, var(--space-2xs)); + + [role="textbox"] { + border-radius: var(--input-radius); + border: var(--stroke-input); + + &:focus-visible { + outline: var(--focus-outline); + outline-offset: 0; + box-shadow: var(--focus-shadow); + } + + .govuk-rich-textarea--error &:not(:focus-visible) { + border-color: var(--govuk-error-color); + } + } +} + +.govuk-error-message:has(+ lexxy-editor) { + margin-block-end: unset; +} diff --git a/app/assets/stylesheets/koi/forms/radios.css b/app/assets/stylesheets/koi/forms/radios.css index f5142e32a..106ecc674 100644 --- a/app/assets/stylesheets/koi/forms/radios.css +++ b/app/assets/stylesheets/koi/forms/radios.css @@ -33,7 +33,7 @@ margin-block-start: calc(-0.5 * var(--input-size)); translate: 0 calc(var(--stroke-input-width) + 0.25 * var(--input-size)); /* Firefox fix - unable to calculate baseline from dynamic height */ - min-height: var(--input-size); + min-block-size: var(--input-size); } .govuk-radios__item::after { @@ -58,7 +58,7 @@ cursor: pointer; touch-action: manipulation; margin-block-end: 0; - min-height: var(--input-size); + min-block-size: var(--input-size); } .govuk-radios__hint { diff --git a/app/assets/stylesheets/koi/forms/textarea.css b/app/assets/stylesheets/koi/forms/textarea.css index ba3fed5b5..7b33479d6 100644 --- a/app/assets/stylesheets/koi/forms/textarea.css +++ b/app/assets/stylesheets/koi/forms/textarea.css @@ -3,7 +3,7 @@ } .govuk-textarea { - min-height: calc( + min-block-size: calc( var(--size-step-0) * var(--leading-fine) * 5 + var(--space-3xs) * 2 ); } diff --git a/app/assets/stylesheets/koi/forms/trix.css b/app/assets/stylesheets/koi/forms/trix.css index bd80c6a90..17fb80d68 100644 --- a/app/assets/stylesheets/koi/forms/trix.css +++ b/app/assets/stylesheets/koi/forms/trix.css @@ -13,7 +13,7 @@ trix-toolbar { } .trix-button--dialog { - min-height: 2.2rem; + min-block-size: 2.2rem; } .trix-button:not(:first-child) { @@ -26,12 +26,36 @@ trix-toolbar { } } -.govuk-richtextarea { - min-height: calc( +trix-editor { + background: var(--color-light); + border-radius: var(--input-radius); + border: var(--stroke-input); + color: var(--color-dark); + padding: var(--space-2xs); + min-block-size: calc( var(--size-step-0) * var(--leading-fine) * 5 + var(--space-3xs) * 2 ); -} -.govuk-richtextarea--error:not(:focus-visible) { - border-color: var(--govuk-error-color); + &::placeholder { + color: var(--color-mid); + opacity: 1; + } + + &:disabled { + opacity: 0.5; + color: inherit; + background-color: transparent; + cursor: not-allowed; + } + + &:focus-visible { + outline: var(--focus-outline); + outline-offset: 0; + box-shadow: var(--focus-shadow); + } + + /* Note: turbo-permanent prevents govuk-rich-textarea--error toggle */ + .govuk-form-group--error > &:not(:focus-visible) { + border-color: var(--govuk-error-color); + } } diff --git a/app/assets/stylesheets/koi/index.css b/app/assets/stylesheets/koi/index.css index a55b4073d..7bc298a34 100644 --- a/app/assets/stylesheets/koi/index.css +++ b/app/assets/stylesheets/koi/index.css @@ -4,6 +4,7 @@ @import url("/katalyst/content/editor.css"); @import url("/katalyst/navigation.css"); @import url("/katalyst/tables.css"); +@import url("/lexxy-content.css"); @import url("global/variables.css"); @import url("global/global-styles.css"); diff --git a/app/controllers/admin/active_storage/direct_uploads_controller.rb b/app/controllers/admin/active_storage/direct_uploads_controller.rb new file mode 100644 index 000000000..5aed97253 --- /dev/null +++ b/app/controllers/admin/active_storage/direct_uploads_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Admin + module ActiveStorage + class DirectUploadsController < ::ActiveStorage::DirectUploadsController + include Koi::Controller + end + end +end diff --git a/app/helpers/koi/tags/trix_toolbar.rb b/app/helpers/koi/tags/trix_toolbar.rb new file mode 100644 index 000000000..106cd10ad --- /dev/null +++ b/app/helpers/koi/tags/trix_toolbar.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Koi + module Tags + class TrixToolbar + include ActionView::Helpers::TagHelper + include Katalyst::HtmlAttributes + + TRIX_BUTTON_ROW_TEMPLATE = <<~HTML.html_safe.freeze +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ HTML + + TRIX_DIALOGS_TEMPLATE = <<~HTML.html_safe.freeze +
+ +
+ HTML + + def render + content_tag("trix-toolbar", TRIX_BUTTON_ROW_TEMPLATE + TRIX_DIALOGS_TEMPLATE, **html_attributes) + end + + def default_html_attributes + { data: { turbo_permanent: "" } } + end + end + end +end diff --git a/app/javascript/koi/application.js b/app/javascript/koi/application.js index ca644bc15..ec35ac262 100644 --- a/app/javascript/koi/application.js +++ b/app/javascript/koi/application.js @@ -1,7 +1,8 @@ import "@hotwired/turbo-rails"; import { initAll } from "@katalyst/govuk-formbuilder"; import "@rails/actiontext"; -import "trix"; +import Lexxy from "./utils/lexxy"; +import Trix from "./utils/trix"; import "./controllers"; import "./elements"; @@ -18,3 +19,5 @@ function initGOVUK() { window.addEventListener("turbo:load", initGOVUK); if (window.Turbo) initGOVUK(); + +export default { Lexxy, Trix }; diff --git a/app/javascript/koi/utils/lexxy.js b/app/javascript/koi/utils/lexxy.js new file mode 100644 index 000000000..58e892bd6 --- /dev/null +++ b/app/javascript/koi/utils/lexxy.js @@ -0,0 +1,23 @@ +import * as Lexxy from "lexxy"; + +Lexxy.configure({ + global: { + authenticatedUploads: true, + }, +}); + +// The link toolbar's URL input is type=url, whose checkValidity() rejects +// schemeless values like #anchor and /relative that lexxy's link node happily +// preserves. Switch it to type=text with the same pattern Koi applies to the +// trix link dialog (see Koi::Tags::TrixToolbar::TRIX_DIALOGS_TEMPLATE) so both +// editors validate link input identically. +document.addEventListener("lexxy:initialize", ({ target }) => { + target + .querySelectorAll("lexxy-link-dropdown input[type=url]") + .forEach((input) => { + input.type = "text"; + input.pattern = "(https?|mailto:|tel:|/|#).*?"; + }); +}); + +export default Lexxy; diff --git a/app/javascript/koi/utils/trix.js b/app/javascript/koi/utils/trix.js new file mode 100644 index 000000000..fbae85e68 --- /dev/null +++ b/app/javascript/koi/utils/trix.js @@ -0,0 +1,17 @@ +import "trix"; + +// Trix does not provide an export +const Trix = window.Trix; + +// Koi uses H4 for headings instead of H1 +Trix.config.blockAttributes["heading4"] = { + tagName: "h4", + terminal: true, + breakOnReturn: true, + group: false, +}; + +// Remove H1 from trix list of acceptable tags +delete Trix.config.blockAttributes.heading1; + +export default Trix; diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb new file mode 100644 index 000000000..12f1708b6 --- /dev/null +++ b/app/views/layouts/action_text/contents/_content.html.erb @@ -0,0 +1,3 @@ +
+ <%= yield -%> +
diff --git a/config/importmap.rb b/config/importmap.rb index e183b2c01..34ba55fbc 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true -pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true -pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true -pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@katalyst/koi", to: "katalyst/koi.min.js" -pin "@rails/actiontext", to: "actiontext.js", preload: true +pin "@rails/actiontext", to: "actiontext.js" +pin "@rails/activestorage", to: "activestorage.esm.js" +pin "lexxy" pin "trix" diff --git a/config/routes.rb b/config/routes.rb index 299db7f97..54f7c7a50 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,10 @@ resources :device_authorizations, param: :user_code, only: %i[create show update] resources :device_tokens, only: %i[create] + scope :active_storage, module: :active_storage do + post :direct_uploads, to: "direct_uploads#create" + end + resource :profile, only: %i[show edit update], shallow: true do resources :credentials, only: %i[show new create update destroy] resource :otp, only: %i[new create destroy] @@ -62,4 +66,9 @@ end resources :well_knowns, path: ".well-known", param: :name, only: %i[show], name: /.+/ + + # Fallbacks for optional dependencies + unless Object.const_defined?("HotwireCombobox") + get "hotwire_combobox", to: ->(_) { [204, {}, []] }, as: nil + end end diff --git a/katalyst-koi.gemspec b/katalyst-koi.gemspec index 5bd9ff30b..ffcbe23a2 100644 --- a/katalyst-koi.gemspec +++ b/katalyst-koi.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |s| s.homepage = "https://github.com/katalyst/koi" s.license = "MIT" - s.required_ruby_version = ">= 3.4" + s.required_ruby_version = ">= 4.0" s.files = Dir["{app,config,db,lib}/**/*", "spec/factories/**/*", "MIT-LICENSE", "README.md", "Upgrade.md"] .grep_v(%r{^lib/tasks}) @@ -34,12 +34,13 @@ Gem::Specification.new do |s| s.add_dependency "webauthn", ">= 3.4.0" # Third party libraries for admin pages + s.add_dependency "lexxy" s.add_dependency "pagy", ">= 8.0" s.add_dependency "view_component" # Katalyst libraries - s.add_dependency "katalyst-content", ">= 3.0.0.alpha.1" - s.add_dependency "katalyst-govuk-formbuilder", "< 2" + s.add_dependency "katalyst-content", ">= 3.2" + s.add_dependency "katalyst-govuk-formbuilder", ">= 1.28.0", "< 2" s.add_dependency "katalyst-html-attributes" s.add_dependency "katalyst-navigation", ">= 2" s.add_dependency "katalyst-tables", ">= 3.5" diff --git a/lib/koi.rb b/lib/koi.rb index 1b125df06..24fea91bc 100644 --- a/lib/koi.rb +++ b/lib/koi.rb @@ -1,14 +1,20 @@ # frozen_string_literal: true module Koi - extend self + class << self + def config + @config ||= Config.new + end - def config - @config ||= Config.new - end + def configure + yield config + end + + def action_text_editor + return config.action_text_editor if config.action_text_editor.present? - def configure - yield config + defined?(::Lexxy) && defined?(::Flipper) && Flipper.enabled?(:lexxy, Koi::Current.admin_user) ? :lexxy : :trix + end end end diff --git a/lib/koi/action_text.rb b/lib/koi/action_text.rb new file mode 100644 index 000000000..ed62db907 --- /dev/null +++ b/lib/koi/action_text.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Koi + module ActionText + module TagHelper + using Katalyst::HtmlAttributes::HasHtmlAttributes + + def koi_trix_toolbar_tag(**) + Tags::TrixToolbar.new(**).render + end + + def koi_rich_textarea_tag(name, value = nil, options = {}, &) + case Koi.action_text_editor + when :lexxy + lexxy_rich_textarea_tag(name, value, options, &) + when :trix + options = options.symbolize_keys + trix_id = options[:id] + + toolbar = koi_trix_toolbar_tag(id: "trix-toolbar-#{trix_id}") + editor = trix_rich_textarea_tag(name, value, { + data: { turbo_permanent: "" }, + toolbar: "trix-toolbar-#{trix_id}", + trix_id:, + }.merge_html(options), &) + + toolbar + editor + end + end + end + + module FormHelper + def koi_rich_textarea(...) + case Koi.action_text_editor + when :lexxy + lexxy_rich_textarea(...) + when :trix + trix_rich_textarea(...) + end + end + end + + module FormBuilder + def koi_rich_textarea(...) + case Koi.action_text_editor + when :lexxy + lexxy_rich_textarea(...) + when :trix + trix_rich_textarea(...) + end + end + end + + module ActionTextTag + def koi_render(...) + case Koi.action_text_editor + when :lexxy + lexxy_render(...) + when :trix + trix_render(...) + end + end + end + + # Add aliases for trix default methods so that Koi can swap between trix and lexxy based on feature flags. + def capture_action_text_defaults + ::ActionText::TagHelper.module_eval do + alias_method :trix_rich_textarea_tag, :rich_textarea_tag + alias_method :trix_rich_text_area_tag, :rich_text_area_tag + end + + ::ActionView::Helpers::FormHelper.module_eval do + alias_method :trix_rich_textarea, :rich_textarea + alias_method :trix_rich_text_area, :rich_text_area + end + + ::ActionView::Helpers::FormBuilder.module_eval do + alias_method :trix_rich_textarea, :rich_textarea + alias_method :trix_rich_text_area, :rich_textarea + end + + ::ActionView::Helpers::Tags::ActionText.module_eval do + alias_method :trix_render, :render + end + end + module_function :capture_action_text_defaults + + # Add shims for rich_textarea and friends. + # This is intended as a migration path and will be removed when Rails 8.2 is released. + def override_action_text_defaults + ::ActionText::TagHelper.module_eval do + alias_method :rich_textarea_tag, :koi_rich_textarea_tag + alias_method :rich_text_area_tag, :koi_rich_textarea_tag + end + + ::ActionView::Helpers::FormHelper.module_eval do + alias_method :rich_textarea, :koi_rich_textarea + alias_method :rich_text_area, :koi_rich_textarea + end + + ::ActionView::Helpers::FormBuilder.module_eval do + alias_method :rich_textarea, :koi_rich_textarea + alias_method :rich_text_area, :koi_rich_textarea + end + + ::ActionView::Helpers::Tags::ActionText.module_eval do + alias_method :render, :koi_render + end + end + module_function :override_action_text_defaults + end +end diff --git a/lib/koi/config.rb b/lib/koi/config.rb index a970d650a..14b579c4f 100644 --- a/lib/koi/config.rb +++ b/lib/koi/config.rb @@ -4,7 +4,8 @@ module Koi class Config - attr_accessor :admin_name, + attr_accessor :action_text_editor, + :admin_name, :authenticate_local_admins, :resource_name_candidates, :admin_stylesheet, @@ -16,6 +17,7 @@ class Config :site_name def initialize + @action_text_editor = nil @admin_name = "Koi" @authenticate_local_admins = Rails.env.development? @resource_name_candidates = %i[title name] diff --git a/lib/koi/engine.rb b/lib/koi/engine.rb index bb9a1faca..ac64bebe3 100644 --- a/lib/koi/engine.rb +++ b/lib/koi/engine.rb @@ -5,6 +5,7 @@ require "katalyst-govuk-formbuilder" require "katalyst/navigation" require "katalyst/tables" +require "lexxy" require "pagy" require "rotp" require "rqrcode" @@ -39,6 +40,28 @@ class Engine < ::Rails::Engine end end + # Rails 8.0/8.1 shim for allowing flipper swapping between lexxy/trix + # Rails 8.2 will introduce action_text.editors, not yet implemented. + initializer "koi.actiontext.initialize", after: "lexxy.initialize" do |app| + # disable lexxy injection in favour of Koi injection + app.config.lexxy.override_action_text_defaults = false + + # Capture ActionText defaults (trix) once + ActionText.capture_action_text_defaults + + # Based on Lexxy::Engine's 'lexxy.initialize' + app.config.to_prepare do + ::ActionText::TagHelper.prepend(ActionText::TagHelper) + ::ActionView::Helpers::FormHelper.prepend(ActionText::FormHelper) + ::ActionView::Helpers::FormBuilder.prepend(ActionText::FormBuilder) + ::ActionView::Helpers::Tags::ActionText.prepend(ActionText::ActionTextTag) + + # Install Koi's stubs. + # In Rails 8.2 this should be replaced by action_text.editors + ActionText.override_action_text_defaults + end + end + initializer "koi.content" do Katalyst::Content.config.base_controller = "Admin::ApplicationController" Katalyst::Content.config.errors_component = "Koi::Content::Editor::ErrorsComponent" diff --git a/lib/koi/form/builder.rb b/lib/koi/form/builder.rb index f0d509142..bde8b77be 100644 --- a/lib/koi/form/builder.rb +++ b/lib/koi/form/builder.rb @@ -10,12 +10,14 @@ module Builder end # Generates a submit button for saving admin resources. + # @deprecated will be removed in Koi 6.0 def admin_save(text = "Save", name: :commit, value: :save, class: "button", **) button(text, name:, value:, class:, **) end # Generates a delete link formatted as a button that will perform a turbo # delete with a confirmation. + # @deprecated will be removed in Koi 6.0 def admin_delete(text = "Delete", url: nil, confirm: "Are you sure?", data: {}, **) return unless object.persisted? @@ -27,12 +29,14 @@ def admin_delete(text = "Delete", url: nil, confirm: "Are you sure?", data: {}, # Generates an archive link formatted as a button that will perform a turbo # delete with a confirmation. + # @deprecated will be removed in Koi 6.0 def admin_archive(text = "Archive", **) admin_delete(text, **) end # Generates a discard changes link formatted as a text button that navigates # the user back to the previous page. + # @deprecated will be removed in Koi 6.0 def admin_discard(text = "Discard", url: :back, **) link_to(text, url, class: "button", data: { text_button: "" }, **) end @@ -59,16 +63,31 @@ def govuk_image_field(attribute_name, hint: {}, **, &) super end - # Use content editor trix setup by default. + using Katalyst::HtmlAttributes::HasHtmlAttributes + + # Use Koi's admin direct uploads URL. + # + # @api internal + # @see Lexxy::FormBuilder#lexxy_rich_textarea + def lexxy_rich_textarea(attribute_name, **attributes, &) + attributes = { + class: "lexxy-content", + data: { direct_upload_url: @template.admin_direct_uploads_url }, + }.merge_html(**attributes) + + super + end + + # Use Koi's admin direct uploads URL. # # @api internal - # @see GOVUKDesignSystemFormBuilder::Builder#govuk_rich_text_area - def govuk_rich_text_area(attribute_name, data: {}, **, &) - data = data.reverse_merge( - direct_upload_url: @template.katalyst_content.direct_uploads_url, - controller: "content--editor--trix", - action: "trix-initialize->content--editor--trix#trixInitialize", - ) + # @see Koi::ActionText::FormBuilder#trix_rich_textarea + def trix_rich_textarea(attribute_name, **attributes, &) + attributes = { + class: "trix-content", + data: { direct_upload_url: @template.admin_direct_uploads_url }, + }.merge_html(**attributes) + super end end diff --git a/lib/koi/form/content.rb b/lib/koi/form/content.rb deleted file mode 100644 index e08f05034..000000000 --- a/lib/koi/form/content.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module Koi - module Form - module Content - extend ActiveSupport::Concern - - def content_heading_fieldset - govuk_fieldset(legend: { text: "Heading", size: "m" }) do - concat(content_heading_field(label: nil)) - concat(content_heading_style_field) - end - end - - def content_heading_field(args = {}) - govuk_text_field(:heading, - **{ label: { text: "Heading", size: "s" } }.deep_merge(args)) - end - - def content_heading_style_field(args = {}) - govuk_collection_radio_buttons(:heading_style, - Katalyst::Content.config.heading_styles, - :itself, - :itself, - **{ small: true, legend: { text: "Style", size: "s" } }.deep_merge(args)) - end - - def content_url_field(args = {}) - govuk_text_field(:url, - **{ label: { text: "URL", size: "s" } }.deep_merge(args)) - end - - def content_http_method_field(methods, args = {}) - govuk_select(:http_method, methods, - **{ label: { text: "HTTP method", size: "s" } }.deep_merge(args)) - end - - def content_target_field(targets, args = {}) - govuk_select(:target, targets, - **{ label: { text: "HTTP target", size: "s" } }.deep_merge(args)) - end - - def content_theme_field(args = {}) - govuk_select(:theme, Katalyst::Content.config.themes, - **{ label: { size: "s" }, options: { include_blank: true } }.deep_merge(args)) - end - alias_method :content_background_field, :content_theme_field - - def content_visible_field(args = {}) - govuk_check_box_field(:visible, - **{ label: { text: "Visible? ", size: "s" }, small: true }.deep_merge(args)) - end - end - end -end diff --git a/package.json b/package.json index 7ca6c38c3..a46ad2f2b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-terser": "^1.0.0", - "prettier": "^3.8.4", + "prettier": "^3.9.4", "rollup": "^4.62.2" } } diff --git a/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb b/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb new file mode 100644 index 000000000..6a581b694 --- /dev/null +++ b/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::ActiveStorage::DirectUploadsController do + subject { action && response } + + let(:action) { post admin_direct_uploads_path, params:, headers: } + let(:headers) { {} } + let(:file) { StringIO.new("test") } + let(:params) do + { + blob: { + filename: "fringe.jpg", + content_type: "image/jpeg", + byte_size: file.size, + checksum: OpenSSL::Digest::MD5.new(file.read).base64digest, + }, + } + end + + it { is_expected.to have_http_status(:see_other).and(redirect_to(new_admin_session_path)) } + it { expect { action }.not_to change(ActiveStorage::Blob, :count) } + + context "with an admin session" do + include_context "with admin session" + + it { is_expected.to be_successful } + it { expect { action }.to change(ActiveStorage::Blob, :count).by(1) } + end + + context "with an admin API token" do + let(:headers) { { "Authorization" => "Bearer #{access_token}" } } + let(:device_authorization) { create(:admin_device_authorization, :approved) } + let(:access_token) { device_authorization.generate_token_for(:api_access) } + + before do + device_authorization.consume!(token_expires_in: 12.hours) + end + + it { is_expected.to be_successful } + it { expect { action }.to change(ActiveStorage::Blob, :count).by(1) } + end +end diff --git a/spec/system/admin/rich_textarea_spec.rb b/spec/system/admin/rich_textarea_spec.rb new file mode 100644 index 000000000..daedc3e03 --- /dev/null +++ b/spec/system/admin/rich_textarea_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "admin rich textarea integration" do + before do + admin = create(:admin) + visit "/admin" + + fill_in "Email", with: admin.email + click_on "Next" + + fill_in "Password", with: admin.password + click_on "Next" + + fill_in "Token", with: admin.otp.now + click_on "Next" + end + + context "with the trix editor" do + it "uploads attachments via the admin direct uploads endpoint" do + announcement = create(:announcement, content: "testing uploads") + + visit edit_admin_announcement_path(announcement) + + expect(page).to have_css("trix-editor[data-direct-upload-url*='/admin/']") + end + + it "can add an anchor link" do + announcement = create(:announcement, content: "testing link editing") + + visit edit_admin_announcement_path(announcement) + + # wait for editor to load + expect(page).to have_button(text: "Link") + + find("trix-editor").click + + page.execute_script("document.querySelector('trix-editor').editor.setSelectedRange([8, 12])") + + click_on "Link" + fill_in "Enter a URL…", with: "#anchor" + within(".trix-dialog.trix-active") { click_on "Link" } + + click_button(type: "submit") + + expect(page).to have_link(text: "link", href: "#anchor") + end + + it "can add a heading" do + announcement = create(:announcement, content: "testing headings") + + visit edit_admin_announcement_path(announcement) + + # wait for editor to load + expect(page).to have_button(text: "Heading") + + find("trix-editor").click + + page.execute_script("document.querySelector('trix-editor').editor.setSelectedRange([0, 0])") + + click_on "Heading" + + click_button(type: "submit") + + # Koi uses h4 for headings rather than the trix default of h1 + expect(page).to have_css("h4", text: "testing headings") + end + + # regression test for headings not being detected on first interaction + it "can modify an existing heading" do + announcement = create(:announcement, content: "

testing headings

") + + visit edit_admin_announcement_path(announcement) + + # wait for editor to load + expect(page).to have_css("trix-editor h4") + + # ensure heading has correctly identified that the first element is a heading + expect(page).to have_css("button[data-trix-attribute=heading4]", class: "trix-active") + end + end + + context "with the lexxy editor enabled" do + before { Flipper.enable(:lexxy) } + + it "uploads attachments via the admin direct uploads endpoint" do + announcement = create(:announcement, content: "testing uploads") + + visit edit_admin_announcement_path(announcement) + + # Koi overrides lexxy's default rails_direct_uploads_url with the admin + # endpoint, so the URL must end with the admin path rather than /rails/. + expect(page).to have_css("lexxy-editor[data-direct-upload-url$='/admin/active_storage/direct_uploads']") + expect(page).to have_no_css("trix-editor") + end + + it "can add an anchor link" do + announcement = create(:announcement, content: "testing link editing") + + visit edit_admin_announcement_path(announcement) + + # wait for editor to load + expect(page).to have_button("Link") + + select_lexxy_range(8, 12) + + click_on "Link" + # Koi swaps lexxy's type=url link input for a type=text input with the + # same pattern as trix, so schemeless anchor/relative links pass + # validation (see koi/utils/lexxy.js). + fill_in "Enter a URL…", with: "#anchor" + within("lexxy-link-dropdown [role=dialog]") { click_on "Link" } + + click_button(type: "submit") + + expect(page).to have_link("link", href: "#anchor") + end + + it "can add a heading" do + announcement = create(:announcement, content: "testing headings") + + visit edit_admin_announcement_path(announcement) + + # wait for editor to load + expect(page).to have_button("Text formatting") + + select_lexxy_range(0, 0) + + click_on "Text formatting" + click_on "Large Heading" + + click_button(type: "submit") + + # lexxy's large heading maps to h2 (no Koi override, unlike trix) + expect(page).to have_css("h2", text: "testing headings") + end + + # regression test for headings not being detected on first interaction + it "can modify an existing heading" do + announcement = create(:announcement, content: "

testing headings

") + + visit edit_admin_announcement_path(announcement) + + # wait for editor to load + expect(page).to have_css("lexxy-editor h2") + + select_lexxy_range(0, 0) + + # ensure the toolbar has identified the current block as a large heading + expect(page).to have_css("button[name=heading-large][aria-pressed=true]", visible: :all) + end + end + + # Lexxy has no offset-based selection API like trix's editor.setSelectedRange, + # so we set a DOM range over the editor's contenteditable and let Lexical sync + # it into its editor state via the selectionchange event. + def select_lexxy_range(from, to) + page.execute_script(<<~JS, from, to) + const [from, to] = arguments; + const content = document.querySelector("lexxy-editor .lexxy-editor__content"); + content.focus(); + const textNode = document.createTreeWalker(content, NodeFilter.SHOW_TEXT).nextNode(); + const range = document.createRange(); + range.setStart(textNode, from); + range.setEnd(textNode, to); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + JS + end +end diff --git a/yarn.lock b/yarn.lock index a820135ea..6a877894f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -57,9 +57,9 @@ terser "^5.17.4" "@rollup/pluginutils@^5.0.1": - version "5.3.0" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" - integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + version "5.4.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz#ac23a29ced0247060a210815fca39c17de4d2f26" + integrity sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg== dependencies: "@types/estree" "^1.0.0" estree-walker "^2.0.2" @@ -201,9 +201,9 @@ integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== acorn@^8.15.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" - integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + version "8.17.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" + integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== buffer-from@^1.0.0: version "1.1.2" @@ -269,10 +269,10 @@ picomatch@^4.0.2: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== -prettier@^3.8.4: - version "3.8.4" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411" - integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q== +prettier@^3.9.4: + version "3.9.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.4.tgz#a9c477cf1614376bd1f6bbc593d8c0d414bcec87" + integrity sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg== resolve@^1.22.1: version "1.22.12" @@ -319,9 +319,9 @@ rollup@^4.62.2: fsevents "~2.3.2" serialize-javascript@^7.0.3: - version "7.0.5" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.5.tgz#c798cc0552ffbb08981914a42a8756e339d0d5b1" - integrity sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw== + version "7.0.7" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.7.tgz#06ec40576d4cea96d68010a534520bff1f948a72" + integrity sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g== smob@^1.0.0: version "1.6.2"