katalyst-govuk-formbuilder 1.30.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +141 -15
  3. data/app/assets/builds/katalyst/govuk/formbuilder.css +77 -0
  4. data/app/assets/builds/katalyst/govuk/formbuilder.js +573 -10
  5. data/app/assets/builds/katalyst/govuk/formbuilder.min.js +8 -8
  6. data/app/assets/builds/katalyst/govuk/formbuilder.min.js.map +1 -1
  7. data/app/assets/stylesheets/katalyst/govuk/components/_index.scss +1 -0
  8. data/app/assets/stylesheets/katalyst/govuk/components/attachment/_index.scss +3 -0
  9. data/app/assets/stylesheets/katalyst/govuk/components/attachment/_mixin.scss +89 -0
  10. data/app/helpers/katalyst/govuk/form_builder/builder.rb +504 -0
  11. data/app/helpers/katalyst/govuk/form_builder/containers/fieldset_context.rb +19 -0
  12. data/app/helpers/katalyst/govuk/form_builder/elements/attachment.rb +80 -0
  13. data/app/helpers/katalyst/govuk/form_builder/elements/combobox.rb +85 -0
  14. data/app/helpers/katalyst/govuk/form_builder/elements/document.rb +68 -0
  15. data/app/helpers/katalyst/govuk/form_builder/elements/image.rb +65 -0
  16. data/app/helpers/katalyst/govuk/form_builder/elements/label.rb +38 -0
  17. data/app/helpers/katalyst/govuk/form_builder/elements/legend.rb +32 -0
  18. data/app/helpers/katalyst/govuk/form_builder/elements/rich_textarea.rb +70 -0
  19. data/app/helpers/katalyst/govuk/form_builder/frontend.rb +34 -0
  20. data/app/helpers/katalyst/govuk/form_builder/traits/attachment.rb +215 -0
  21. data/app/helpers/katalyst/govuk/form_builder/traits/file.rb +104 -0
  22. data/app/helpers/katalyst/govuk/form_builder/traits/label.rb +33 -0
  23. data/config/importmap.rb +1 -0
  24. data/config/locales/en.yml +16 -0
  25. data/lib/katalyst/govuk/form_builder/config.rb +20 -0
  26. data/lib/katalyst/govuk/form_builder/engine.rb +1 -1
  27. metadata +31 -1
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module GOVUK
5
+ module FormBuilder
6
+ module Elements
7
+ class Attachment < GOVUKDesignSystemFormBuilder::Elements::File
8
+ include FormBuilder::Traits::Attachment
9
+
10
+ def initialize(builder, object_name, attribute_name, direct_upload_url:,
11
+ upload_succeeded_text: nil, upload_failed_text: nil,
12
+ retry_button_text: nil, file_removed_text: nil,
13
+ remove_button_text: nil, remove_button_content_text: nil, **, &)
14
+ super(builder, object_name, attribute_name, javascript: true, **, &)
15
+
16
+ @direct_upload_url = direct_upload_url
17
+ @upload_succeeded_text = upload_succeeded_text
18
+ @upload_failed_text = upload_failed_text
19
+ @retry_button_text = retry_button_text
20
+ @file_removed_text = file_removed_text
21
+ @remove_button_text = remove_button_text
22
+ @remove_button_content_text = remove_button_content_text
23
+
24
+ raise ArgumentError, "Unsupported attribute type #{value.class} for #{attribute_name}" unless attachment?
25
+ end
26
+
27
+ def options
28
+ super.merge(
29
+ "data-direct-upload-url" => @direct_upload_url,
30
+ include_hidden: false, # we always render remove_field
31
+ multiple: many?,
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ # Extends the file element's data-i18n.* attributes with the
38
+ # attachment strings: an explicit option wins, else the current
39
+ # locale's translation. When neither adds anything beyond the
40
+ # gem's en defaults the attribute is omitted and the JS falls
41
+ # back to its bundled mirror of the same table.
42
+ def i18n_data
43
+ super.merge({
44
+ "data-i18n.upload-succeeded" => attachment_text(@upload_succeeded_text, :upload_succeeded),
45
+ "data-i18n.upload-failed" => attachment_text(@upload_failed_text, :upload_failed),
46
+ "data-i18n.retry-button" => attachment_text(@retry_button_text, :retry_button),
47
+ "data-i18n.file-removed" => attachment_text(@file_removed_text, :file_removed),
48
+ "data-i18n.remove-button" => attachment_text(@remove_button_text, :remove_button),
49
+ "data-i18n.remove-button-content" => attachment_text(@remove_button_content_text, :remove_button_content),
50
+ }.compact)
51
+ end
52
+
53
+ def attachment_text(option, key)
54
+ return option if option
55
+
56
+ text = attachment_translation(key)
57
+ text unless text == Traits::Attachment::BUNDLED_DEFAULTS[key.to_s]
58
+ end
59
+
60
+ def file
61
+ safe_join([remove_field, attachment, @builder.file_field(@attribute_name, attributes(@html_attributes))])
62
+ end
63
+
64
+ def file_with_javascript_markup
65
+ tag.div(class: "#{brand}-file-upload-wrapper", data: { controller: "govuk-file-upload" }, **i18n_data) do
66
+ file
67
+ end
68
+ end
69
+
70
+ # A removed figure takes its select with it and an empty file input contributes
71
+ # no value. This input ensures that there's always a value to process so that
72
+ # attachments can be removed (both one? and many? cases).
73
+ def remove_field
74
+ tag.input(name: @builder.field_name(@attribute_name, multiple: many?), type: "hidden", value: "")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "govuk_design_system_formbuilder"
4
+
5
+ module Katalyst
6
+ module GOVUK
7
+ module FormBuilder
8
+ module Elements
9
+ class Combobox < GOVUKDesignSystemFormBuilder::Base
10
+ include GOVUKDesignSystemFormBuilder::Traits::Error
11
+ include GOVUKDesignSystemFormBuilder::Traits::Label
12
+ include GOVUKDesignSystemFormBuilder::Traits::Hint
13
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLAttributes
14
+ include GOVUKDesignSystemFormBuilder::Traits::ContentBeforeAndAfter
15
+
16
+ def initialize(builder,
17
+ object_name,
18
+ attribute_name,
19
+ options_or_src,
20
+ options:,
21
+ form_group:,
22
+ label:,
23
+ hint:,
24
+ caption:,
25
+ before_input:,
26
+ after_input:,
27
+ **kwargs,
28
+ &block)
29
+ # assign the block to a variable rather than passing to super so
30
+ # we can send it through to #combobox
31
+ super(builder, object_name, attribute_name)
32
+ @block = block
33
+
34
+ @form_group = form_group
35
+ @hint = hint
36
+ @label = label
37
+ @caption = caption
38
+ @options_or_src = options_or_src
39
+ @options = options
40
+ @html_attributes = kwargs
41
+ @before_input = before_input
42
+ @after_input = after_input
43
+ end
44
+
45
+ def html
46
+ GOVUKDesignSystemFormBuilder::Containers::FormGroup.new(*bound, **@form_group).html do
47
+ safe_join([
48
+ label_element,
49
+ hint_element,
50
+ error_element,
51
+ before_input_content,
52
+ combobox,
53
+ after_input_content,
54
+ ])
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def combobox
61
+ attrs = attributes(@html_attributes)
62
+ attrs[:class] = attrs.delete(:class).join(" ") # hotwire_combobox does not support flattening classes
63
+ @builder.combobox(@attribute_name, @options_or_src, **@options, **attrs, &@block)
64
+ end
65
+
66
+ def options
67
+ {
68
+ id: field_id(link_errors: true),
69
+ class: classes,
70
+ aria: { describedby: combine_references(hint_id, error_id) },
71
+ }
72
+ end
73
+
74
+ def classes
75
+ combine_references(%(#{brand}-combobox), error_class)
76
+ end
77
+
78
+ def error_class
79
+ %(#{brand}-combobox--error) if has_errors?
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "govuk_design_system_formbuilder"
4
+
5
+ module Katalyst
6
+ module GOVUK
7
+ module FormBuilder
8
+ module Elements
9
+ class Document < GOVUKDesignSystemFormBuilder::Base
10
+ include ActionDispatch::Routing::RouteSet::MountedHelpers
11
+
12
+ include GOVUKDesignSystemFormBuilder::Traits::Error
13
+ include GOVUKDesignSystemFormBuilder::Traits::Hint
14
+ include GOVUKDesignSystemFormBuilder::Traits::Label
15
+ include GOVUKDesignSystemFormBuilder::Traits::Supplemental
16
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLAttributes
17
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLClasses
18
+
19
+ include FormBuilder::Traits::File
20
+
21
+ def initialize(builder, object_name, attribute_name, hint:, label:, caption:, form_group:, mime_types:,
22
+ **kwargs, &)
23
+ super(builder, object_name, attribute_name, &)
24
+
25
+ @mime_types = mime_types
26
+ @label = label
27
+ @caption = caption
28
+ @hint = hint
29
+ @html_attributes = kwargs.merge(file_input_options)
30
+ @form_group = form_group
31
+ end
32
+
33
+ def html
34
+ GOVUKDesignSystemFormBuilder::Containers::FormGroup.new(
35
+ *bound,
36
+ **default_form_group_options(**@form_group),
37
+ ).html do
38
+ safe_join([label_element, preview, hint_element, error_element, file, destroy_element,
39
+ supplemental_content])
40
+ end
41
+ end
42
+
43
+ def preview
44
+ options = {}
45
+ add_option(options, :data, "#{stimulus_controller}_target", "preview")
46
+ add_option(options, :class, "preview-file")
47
+ options[:hidden] = "" unless preview?
48
+
49
+ tag.div(**options) do
50
+ filename = @builder.object.send(@attribute_name).filename.to_s
51
+ tag.p(filename, class: "preview-filename") + destroy_element_trigger
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def stimulus_controller
58
+ "govuk-document-field"
59
+ end
60
+
61
+ def form_group_class
62
+ "govuk-document-field"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module GOVUK
5
+ module FormBuilder
6
+ module Elements
7
+ class Image < GOVUKDesignSystemFormBuilder::Base
8
+ include ActionDispatch::Routing::RouteSet::MountedHelpers
9
+
10
+ include GOVUKDesignSystemFormBuilder::Traits::Error
11
+ include GOVUKDesignSystemFormBuilder::Traits::Hint
12
+ include GOVUKDesignSystemFormBuilder::Traits::Label
13
+ include GOVUKDesignSystemFormBuilder::Traits::Supplemental
14
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLAttributes
15
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLClasses
16
+
17
+ include FormBuilder::Traits::File
18
+
19
+ def initialize(builder, object_name, attribute_name, hint:, label:, caption:, form_group:, mime_types:,
20
+ **kwargs, &)
21
+ super(builder, object_name, attribute_name, &)
22
+
23
+ @mime_types = mime_types
24
+ @label = label
25
+ @caption = caption
26
+ @hint = hint
27
+ @html_attributes = kwargs.merge(file_input_options)
28
+ @form_group = form_group
29
+ end
30
+
31
+ def html
32
+ GOVUKDesignSystemFormBuilder::Containers::FormGroup.new(
33
+ *bound,
34
+ **default_form_group_options(**@form_group),
35
+ ).html do
36
+ safe_join([label_element, preview, hint_element, error_element, file, destroy_element,
37
+ supplemental_content])
38
+ end
39
+ end
40
+
41
+ def preview
42
+ options = {}
43
+ add_option(options, :data, "#{stimulus_controller}_target", "preview")
44
+ add_option(options, :class, "preview-image")
45
+ options[:hidden] = "" unless preview?
46
+
47
+ tag.div(**options) do
48
+ tag.img(src: preview_url, class: "image-thumbnail") + destroy_element_trigger
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def stimulus_controller
55
+ "govuk-image-field"
56
+ end
57
+
58
+ def form_group_class
59
+ "govuk-image-field"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "govuk_design_system_formbuilder"
4
+
5
+ module Katalyst
6
+ module GOVUK
7
+ module FormBuilder
8
+ module Elements
9
+ # Extend Elements::Label to add support for human_attribute_name as a fallback
10
+ module Label
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ def retrieve_text(option_text, hidden)
15
+ text = option_text.presence ||
16
+ localised_text(:label).presence ||
17
+ human_attribute_name.presence ||
18
+ @attribute_name.to_s.humanize.capitalize.presence
19
+
20
+ if hidden
21
+ tag.span(text, class: %(#{brand}-visually-hidden))
22
+ else
23
+ text
24
+ end
25
+ end
26
+ end
27
+
28
+ def human_attribute_name
29
+ return unless @object_name.present? && @attribute_name.present?
30
+ return unless @builder.object&.class.respond_to?(:human_attribute_name)
31
+
32
+ @builder.object.class.human_attribute_name(@attribute_name)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "govuk_design_system_formbuilder"
4
+
5
+ module Katalyst
6
+ module GOVUK
7
+ module FormBuilder
8
+ module Elements
9
+ # Extend Elements::Legend to add support for human_attribute_name as a fallback
10
+ module Legend
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ def retrieve_text(supplied_text)
15
+ supplied_text.presence ||
16
+ localised_text(:legend).presence ||
17
+ human_attribute_name.presence ||
18
+ @attribute_name.to_s.humanize.capitalize.presence
19
+ end
20
+ end
21
+
22
+ def human_attribute_name
23
+ return unless @object_name.present? && @attribute_name.present?
24
+ return unless @builder.object&.class.respond_to?(:human_attribute_name)
25
+
26
+ @builder.object.class.human_attribute_name(@attribute_name)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "govuk_design_system_formbuilder"
4
+
5
+ module Katalyst
6
+ module GOVUK
7
+ module FormBuilder
8
+ module Elements
9
+ class RichTextarea < GOVUKDesignSystemFormBuilder::Base
10
+ using GOVUKDesignSystemFormBuilder::PrefixableArray
11
+
12
+ include GOVUKDesignSystemFormBuilder::Traits::Error
13
+ include GOVUKDesignSystemFormBuilder::Traits::Hint
14
+ include GOVUKDesignSystemFormBuilder::Traits::Label
15
+ include GOVUKDesignSystemFormBuilder::Traits::Supplemental
16
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLAttributes
17
+ include GOVUKDesignSystemFormBuilder::Traits::HTMLClasses
18
+
19
+ def initialize(
20
+ builder,
21
+ object_name,
22
+ attribute_name,
23
+ hint:,
24
+ label:,
25
+ caption:,
26
+ form_group:,
27
+ **kwargs,
28
+ &
29
+ )
30
+ super(builder, object_name, attribute_name, &)
31
+
32
+ @label = label
33
+ @caption = caption
34
+ @hint = hint
35
+ @form_group = form_group
36
+ @html_attributes = kwargs
37
+ end
38
+
39
+ def html
40
+ GOVUKDesignSystemFormBuilder::Containers::FormGroup.new(*bound, **@form_group).html do
41
+ safe_join([label_element, supplemental_content, hint_element, error_element, rich_textarea])
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def rich_textarea
48
+ @builder.rich_textarea(@attribute_name, **attributes(@html_attributes))
49
+ end
50
+
51
+ def classes
52
+ build_classes(%(rich-textarea), %(rich-textarea--error) => has_errors?).prefix(brand)
53
+ end
54
+
55
+ def options
56
+ {
57
+ id: field_id,
58
+ class: classes,
59
+ aria: { describedby: combine_references(hint_id, error_id) },
60
+ }
61
+ end
62
+
63
+ def label_content
64
+ super.merge(link_errors: false)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module GOVUK
5
+ module FormBuilder
6
+ module Frontend
7
+ # Marks the page as JS-capable and enhances govuk-frontend components,
8
+ # on load and as the DOM changes (Turbo morphs, lazily-loaded frames,
9
+ # stream inserts). Render at the end of <body>; on a Turbo replace
10
+ # render the snippet re-executes with the new body, and everything it
11
+ # sets up is scoped to the body element it ran against.
12
+ # rubocop:disable Rails/OutputSafety
13
+ def govuk_formbuilder_init
14
+ tag.script type: "module", nonce: request.content_security_policy_nonce do
15
+ <<~JS.html_safe
16
+ import {initAll} from "@katalyst/govuk-formbuilder";
17
+ initAll(#{govuk_formbuilder_init_options});
18
+ JS
19
+ end
20
+ end
21
+ # rubocop:enable Rails/OutputSafety
22
+
23
+ private
24
+
25
+ # The bundle's own default is govuk; only a non-default brand renders.
26
+ def govuk_formbuilder_init_options
27
+ brand = GOVUKDesignSystemFormBuilder.brand
28
+
29
+ brand.to_s == "govuk" ? "" : "{brand: #{brand.to_json}}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "govuk_design_system_formbuilder"
4
+
5
+ module Katalyst
6
+ module GOVUK
7
+ module FormBuilder
8
+ module Traits
9
+ # Generates attachment inputs and previews for ActiveStorage associations.
10
+ module Attachment
11
+ extend ActiveSupport::Concern
12
+
13
+ include ActionView::Helpers::NumberHelper
14
+
15
+ # The attachment strings' canonical home (config/locales).
16
+ I18N_SCOPE = %i[katalyst govuk attachment].freeze
17
+
18
+ # The gem's own en strings, read straight from its locale file:
19
+ # resolving them through I18n would absorb a host app's en
20
+ # overrides, and this table is the baseline those overrides are
21
+ # detected against.
22
+ BUNDLED_DEFAULTS = YAML.load_file(
23
+ Engine.root.join("config/locales/en.yml"),
24
+ ).dig("en", *I18N_SCOPE.map(&:to_s)).freeze
25
+
26
+ def attachment?
27
+ value.is_a?(ActiveStorage::Attached)
28
+ end
29
+
30
+ def one?
31
+ value.is_a?(ActiveStorage::Attached::One)
32
+ end
33
+
34
+ def many?
35
+ value.is_a?(ActiveStorage::Attached::Many)
36
+ end
37
+
38
+ def value
39
+ @builder.object.send(@attribute_name)
40
+ end
41
+
42
+ delegate :attached?, to: :value
43
+
44
+ # @return [ActiveSupport::SafeBuffer,nil]
45
+ def attachment
46
+ return unless attached?
47
+
48
+ # Preserve unsaved multi-part form uploads before rendering
49
+ # mimics direct-upload for non-js consumers.
50
+ persist_pending_blobs
51
+
52
+ blobs = (one? ? [value.blob] : value.blobs).select(&:persisted?)
53
+ safe_join(blobs.map { |blob| attachment_for(blob) })
54
+ end
55
+
56
+ # @param [ActiveStorage::Blob] blob
57
+ # @return [ActiveSupport::SafeBuffer,nil]
58
+ def attachment_for(blob)
59
+ tag.figure(class: "#{brand}-attachment",
60
+ aria: { labelledby: attachment_id_for(blob, :filename) },
61
+ data: { controller: "govuk-attachment" }) do
62
+ safe_join([
63
+ attachment_preview_for(blob),
64
+ attachment_caption_for(blob),
65
+ attachment_actions_for(blob),
66
+ ])
67
+ end
68
+ end
69
+
70
+ # A <div> with form elements for managing the attachment.
71
+ # @param [ActiveStorage::Blob] blob
72
+ # @return [ActiveSupport::SafeBuffer|nil]
73
+ def attachment_actions_for(blob)
74
+ return if blob.nil?
75
+
76
+ tag.div(class: "actions") do
77
+ safe_join([
78
+ attachment_input_for(blob),
79
+ attachment_remove_for(blob),
80
+ ])
81
+ end
82
+ end
83
+
84
+ # A <select> with options to keep or remove the attachment that can be used without JavaScript.
85
+ # @param [ActiveStorage::Blob] blob
86
+ # @return [ActiveSupport::SafeBuffer,nil]
87
+ def attachment_input_for(blob)
88
+ @builder.select(
89
+ @attribute_name,
90
+ [[blob.filename.to_s, blob.signed_id],
91
+ [remove_button_label(blob), ""]],
92
+ { selected: blob.signed_id },
93
+ id: attachment_id_for(blob, :input),
94
+ name: @builder.field_name(@attribute_name, multiple: many?),
95
+ aria: { labelledby: attachment_id_for(blob, :filename) },
96
+ )
97
+ end
98
+
99
+ # A <button> that will remove the attachment when clicked (requires javascript).
100
+ # @param [ActiveStorage::Blob] blob
101
+ # @return [ActiveSupport::SafeBuffer,nil]
102
+ def attachment_remove_for(blob)
103
+ tag.button(remove_button_content,
104
+ type: "button",
105
+ class: "#{brand}-button #{brand}-button--secondary #{brand}-attachment__remove",
106
+ aria: { label: remove_button_label(blob) },
107
+ data: { action: "govuk-attachment#destroy", module: "govuk-button" })
108
+ end
109
+
110
+ # The remove strings render here and in the JS figure template, so
111
+ # both draw from the same options (or matching defaults) — the two
112
+ # figure sources must stay string-identical.
113
+ # @param [ActiveStorage::Blob] blob
114
+ # @return [String]
115
+ def remove_button_label(blob)
116
+ if @remove_button_text
117
+ # %{filename} is the option placeholder (govuk-frontend's i18n
118
+ # convention), substituted directly — not a Ruby format token.
119
+ # rubocop:disable Style/FormatStringToken
120
+ @remove_button_text.gsub("%{filename}", blob.filename.to_s)
121
+ # rubocop:enable Style/FormatStringToken
122
+ else
123
+ attachment_translation(:remove_button, filename: blob.filename.to_s)
124
+ end
125
+ end
126
+
127
+ # @return [String]
128
+ def remove_button_content
129
+ @remove_button_content_text || attachment_translation(:remove_button_content)
130
+ end
131
+
132
+ # The current locale's translation, falling back to the gem's en
133
+ # defaults rather than a "translation missing" marker.
134
+ # @return [String]
135
+ def attachment_translation(key, **)
136
+ I18n.t(key, scope: I18N_SCOPE, default: nil, **) ||
137
+ I18n.t(key, scope: I18N_SCOPE, locale: :en, **)
138
+ end
139
+
140
+ # The representation is rendered lazily: the variant is processed
141
+ # when the browser requests it, never during the form render. If the
142
+ # blob's bytes turn out to be missing or unprocessable the preview
143
+ # simply fails to load — validating attachment content is the
144
+ # model's responsibility, not the form's.
145
+ # @param [ActiveStorage::Blob] blob
146
+ # @return [ActiveSupport::SafeBuffer,nil]
147
+ def attachment_preview_for(blob)
148
+ return unless blob.representable?
149
+
150
+ url = @builder.attachment_preview_url(
151
+ blob.representation(config.attachment_preview_representation),
152
+ )
153
+ return if url.nil?
154
+
155
+ # Setting alt to "" as the details already describe the attachment, equivalent to role="presentation"
156
+ @builder.image_tag(url, alt: "", class: "preview")
157
+ end
158
+
159
+ # The caption is a polite atomic live region: JS writes upload status
160
+ # into the status span, and the announcement reads the whole caption
161
+ # so the user hears which file the status belongs to.
162
+ # @param [ActiveStorage::Blob] blob
163
+ # @return [ActiveSupport::SafeBuffer,nil]
164
+ def attachment_caption_for(blob)
165
+ tag.figcaption(class: "caption", aria: { atomic: true, live: "polite" }) do
166
+ safe_join([
167
+ tag.span(blob.filename, id: attachment_id_for(blob, :filename), class: "filename"),
168
+ " ",
169
+ tag.span(number_to_human_size(blob.byte_size), class: "size"),
170
+ " ",
171
+ tag.span(class: "status"),
172
+ ])
173
+ end
174
+ end
175
+
176
+ # @param [ActiveStorage::Blob] blob
177
+ # @return [String,nil]
178
+ def attachment_id_for(blob, *suffixes)
179
+ return nil if @html_attributes.fetch(:skip_default_ids, false)
180
+
181
+ @builder.field_id(@attribute_name, :attachment, blob.id, *suffixes)
182
+ end
183
+
184
+ private
185
+
186
+ def persist_pending_blobs
187
+ case (change = @builder.object.attachment_changes[@attribute_name.to_s])
188
+ when ActiveStorage::Attached::Changes::CreateOne
189
+ persist_pending_change(change)
190
+ when ActiveStorage::Attached::Changes::CreateMany
191
+ change.pending_uploads.each do |subchange|
192
+ persist_pending_change(subchange)
193
+ end
194
+ end
195
+ end
196
+
197
+ def persist_pending_change(change)
198
+ change.upload
199
+ change.blob.save!
200
+ rescue ActiveStorage::Error, Errno::ENOENT => e
201
+ # no recovery available (ENOENT: tempfile vanished before render)
202
+ log_dropped_upload(e)
203
+ end
204
+
205
+ def log_dropped_upload(error)
206
+ Rails.logger.warn(
207
+ "Dropped pending attachment for " \
208
+ "#{@builder.object.class}##{@attribute_name}: #{error.class}: #{error.message}",
209
+ )
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end