katalyst-govuk-formbuilder 1.30.1 → 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.
@@ -1,3 +1,4 @@
1
+ @use "attachment";
1
2
  @use "govuk-frontend/dist/govuk/components/character-count";
2
3
  @use "govuk-frontend/dist/govuk/components/checkboxes";
3
4
  @use "combobox";
@@ -0,0 +1,3 @@
1
+ @use "mixin";
2
+
3
+ @include mixin.styles;
@@ -0,0 +1,89 @@
1
+ @use "govuk-frontend/dist/govuk/base";
2
+ @use "govuk-frontend/dist/govuk/core/typography";
3
+
4
+ $attachment-background-colour: base.govuk-colour("black", $variant: "tint-95");
5
+ $attachment-border-width: 2px;
6
+
7
+ @mixin styles {
8
+ :where(.govuk-attachment) {
9
+ display: grid;
10
+ grid-template-areas: "preview caption actions";
11
+ grid-template-columns: auto 1fr auto;
12
+ margin: 0;
13
+ padding: base.govuk-spacing(3) base.govuk-spacing(3);
14
+ grid-gap: base.govuk-spacing(2);
15
+ background-color: $attachment-background-colour;
16
+ border: $attachment-border-width solid
17
+ base.govuk-functional-colour("border");
18
+
19
+ .preview {
20
+ grid-area: preview;
21
+ max-width: 4rem;
22
+ aspect-ratio: 1/1;
23
+ object-fit: cover;
24
+ }
25
+
26
+ .caption {
27
+ display: flex;
28
+ flex-direction: column;
29
+ grid-area: caption;
30
+ @include base.govuk-font($size: 16, $line-height: 1.25);
31
+ }
32
+
33
+ .filename {
34
+ @include base.govuk-typography-weight-bold;
35
+ }
36
+
37
+ .size {
38
+ color: base.govuk-functional-colour(secondary-text);
39
+ }
40
+
41
+ .actions {
42
+ grid-area: actions;
43
+ }
44
+
45
+ // Buttons require JavaScript
46
+ .actions button {
47
+ display: none;
48
+ }
49
+
50
+ &[data-state="upload-successful"] {
51
+ .status {
52
+ color: base.govuk-functional-colour("success");
53
+ }
54
+ }
55
+
56
+ &[data-state="upload-failed"] {
57
+ border-color: base.govuk-functional-colour("error");
58
+
59
+ .status {
60
+ color: base.govuk-functional-colour("error");
61
+ }
62
+ }
63
+ }
64
+
65
+ .govuk-attachment {
66
+ margin-bottom: base.govuk-spacing(2);
67
+ }
68
+
69
+ // With JavaScript running the button is the figure's only interactive
70
+ // control; the select still carries the submitted value but leaves the
71
+ // display, tab order, and accessibility tree.
72
+ .govuk-frontend-supported :where(.govuk-attachment) {
73
+ .actions select {
74
+ display: none;
75
+ }
76
+
77
+ .actions button {
78
+ display: revert;
79
+ }
80
+ }
81
+
82
+ // Hide duplicate inputs when multiple is not enabled, this allows reverting
83
+ // but the value from the last select will overwrite the others in save.
84
+ .govuk-file-upload-wrapper:has(input[type="file"]:not([multiple])) {
85
+ .govuk-attachment:has(+ .govuk-attachment) {
86
+ display: none;
87
+ }
88
+ }
89
+ }
@@ -7,6 +7,9 @@ module Katalyst
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
+ # Delegate image_tag for attachment previews
11
+ delegate :image_tag, to: :@template
12
+
10
13
  # Overwrite GOVUK default to set small to true
11
14
  # @see GOVUKDesignSystemFormBuilder::Builder#govuk_collection_radio_buttons
12
15
  def govuk_collection_radio_buttons(attribute_name, collection, value_method, text_method = nil,
@@ -248,6 +251,136 @@ module Katalyst
248
251
  ).html
249
252
  end
250
253
 
254
+ # Generates an input of type +file+ with active storage and preview support.
255
+ #
256
+ # @param attribute_name [Symbol] The name of the attribute
257
+ # @option label text [String] the label text
258
+ # @option label tag [Symbol,String] the label's wrapper tag, intended to allow labels to act as page headings
259
+ # @option label size [String] the size of the label font, can be +xl+, +l+, +m+, +s+ or nil
260
+ # @option label hidden [Boolean] control the visability of the label. Hidden labels will stil be read by
261
+ # screenreaders
262
+ # @option label kwargs [Hash] additional arguments are applied as attributes on the +label+ element
263
+ # @param caption [Hash] configures or sets the caption content which is inserted above the label
264
+ # @option caption text [String] the caption text
265
+ # @option caption size [String] the size of the caption, can be +xl+, +l+ or +m+. Defaults to +m+
266
+ # @option caption kwargs [Hash] additional arguments are applied as attributes on the caption +span+ element
267
+ # @param hint [Hash,Proc] The content of the hint. No hint will be added if 'text' is left +nil+. When a
268
+ # +Proc+ is supplied the hint will be wrapped in a +div+ instead of a +span+
269
+ # @option hint text [String] the hint text
270
+ # @option hint kwargs [Hash] additional arguments are applied as attributes to the hint
271
+ # @option kwargs [Hash] kwargs additional arguments are applied as attributes to the +input+ element
272
+ # @param form_group [Hash] configures the form group
273
+ # @option form_group kwargs [Hash] additional attributes added to the form group
274
+ # @param before_input [String,Proc] the content injected before the input. No content will be added if left
275
+ # +nil+
276
+ # @param after_input [String,Proc] the content injected after the input. No content will be added if left
277
+ # +nil+
278
+ # @param choose_files_button_text [String] The text of the button that opens the file picker. Default is
279
+ # "Choose file". If javascript is not provided, this option will be ignored.
280
+ # @param drop_instruction_text [String] The text informing users they can drop files. Default is
281
+ # "or drop file". If javascript is not provided, this option will be ignored.
282
+ # @param multiple_files_chosen_text [Hash] The text displayed when multiple files have been chosen by the
283
+ # user. The component will replace the %{count} placeholder with the number of files selected. This uses
284
+ # the govuk-frontend pluralisation rules. If javascript is not provided, this option will be ignored.
285
+ # @param multiple_files_chosen_one_text [String] The text displayed when JavaScript is enabled and one file
286
+ # has been chosen by the user. The component will replace the %{count} placeholder with the number of files
287
+ # selected. This can also be set by passing a hash with key +one:+ to +multiple_files_chosen_text+.
288
+ # @param multiple_files_chosen_other_text [String] The text displayed when JavaScript is enabled and multiple
289
+ # files have been chosen by the user. The component will replace the %{count} placeholder with the number of
290
+ # files selected. This can also be set by passing a hash with key +other:+ to +multiple_files_chosen_text+.
291
+ # @param no_file_chosen_text [String] The text displayed when no file has been chosen by the user. Default is
292
+ # "No file chosen". If javascript is not provided, this option will be ignored.
293
+ # @param entered_drop_zone_text [String] The text announced by assistive technology when user drags files and
294
+ # enters the drop zone. Default is "Entered drop zone". If javascript is not provided, this option will be
295
+ # ignored.
296
+ # @param left_drop_zone_text [String] The text announced by assistive technology when user drags files and
297
+ # leaves the drop zone without dropping. Default is "Left drop zone". If javascript is not provided, this
298
+ # option will be ignored.
299
+ # @param upload_succeeded_text [String] The status shown in a figure's caption when its direct upload
300
+ # completes. Default is "Uploaded successfully". If javascript is not provided, this option will be ignored.
301
+ # @param upload_failed_text [String] The status shown in a figure's caption when its direct upload fails.
302
+ # Default is "Upload failed — try again". If javascript is not provided, this option will be ignored.
303
+ # @param retry_button_text [String] The label of the retry control offered on a failed upload. Default is
304
+ # "Try again". If javascript is not provided, this option will be ignored.
305
+ # @param file_removed_text [String] The text announced by assistive technology when a figure is removed. The
306
+ # component will replace the %{filename} placeholder with the removed file's name. Default is
307
+ # "%{filename} removed". If javascript is not provided, this option will be ignored.
308
+ # @param remove_button_text [String] The accessible name of each figure's remove control and the text of its
309
+ # no-JavaScript remove option. The component will replace the %{filename} placeholder with the figure's
310
+ # file name. Default is "Remove %{filename}".
311
+ # @param remove_button_content_text [String] The visible content of each figure's remove button. Default is
312
+ # "Remove".
313
+ # @param & [Block] arbitrary HTML that will be rendered between the hint and the input
314
+ #
315
+ # @example A photo upload field with file type specifier and injected content
316
+ # = f.govuk_attachment_field :photo, label: { text: 'Upload your photo' }, accept: 'image/*' do
317
+ #
318
+ # p.govuk-inset-text
319
+ # | Explicit images will result in account termination
320
+ #
321
+ # @example A CV upload field with label as a proc
322
+ # = f.govuk_attachment_field :cv, label: -> { tag.h3('Upload your CV') }
323
+ #
324
+ # @see https://design-system.service.gov.uk/components/file-upload/ GOV.UK file upload
325
+ # @see https://design-system.service.gov.uk/styles/typography/#headings-with-captions Headings with captions
326
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file MDN documentation for file upload
327
+ def govuk_attachment_field(
328
+ attribute_name,
329
+ label: {},
330
+ caption: {},
331
+ hint: {},
332
+ form_group: {},
333
+ before_input: nil,
334
+ after_input: nil,
335
+ choose_files_button_text: nil,
336
+ drop_instruction_text: nil,
337
+ multiple_files_chosen_text: nil,
338
+ multiple_files_chosen_one_text: nil,
339
+ multiple_files_chosen_other_text: nil,
340
+ no_file_chosen_text: nil,
341
+ entered_drop_zone_text: nil,
342
+ left_drop_zone_text: nil,
343
+ upload_succeeded_text: nil,
344
+ upload_failed_text: nil,
345
+ retry_button_text: nil,
346
+ file_removed_text: nil,
347
+ remove_button_text: nil,
348
+ remove_button_content_text: nil,
349
+ direct_upload: true,
350
+ direct_upload_url: (self.direct_upload_url if direct_upload),
351
+ **,
352
+ &
353
+ )
354
+ Elements::Attachment.new(
355
+ self,
356
+ object_name,
357
+ attribute_name,
358
+ label:,
359
+ caption:,
360
+ hint:,
361
+ form_group:,
362
+ before_input:,
363
+ after_input:,
364
+ direct_upload_url:,
365
+ choose_files_button_text:,
366
+ drop_instruction_text:,
367
+ multiple_files_chosen_text:,
368
+ multiple_files_chosen_one_text:,
369
+ multiple_files_chosen_other_text:,
370
+ no_file_chosen_text:,
371
+ entered_drop_zone_text:,
372
+ left_drop_zone_text:,
373
+ upload_succeeded_text:,
374
+ upload_failed_text:,
375
+ retry_button_text:,
376
+ file_removed_text:,
377
+ remove_button_text:,
378
+ remove_button_content_text:,
379
+ **,
380
+ &
381
+ ).html
382
+ end
383
+
251
384
  # Generates a file input element for uploading documents.
252
385
  #
253
386
  # @example A upload field with label as a proc
@@ -261,9 +394,15 @@ module Katalyst
261
394
  mime_types: config.document_mime_types,
262
395
  **,
263
396
  &)
264
- Elements::Document.new(
265
- self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
266
- ).html
397
+ if config.use_legacy_file_fields
398
+ Elements::Document.new(
399
+ self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
400
+ ).html
401
+ else
402
+ govuk_attachment_field(
403
+ attribute_name, label:, caption:, hint:, form_group:, accept: mime_types&.join(","), **, &
404
+ )
405
+ end
267
406
  end
268
407
 
269
408
  # Generates a file input element with a preview for uploading images.
@@ -309,9 +448,15 @@ module Katalyst
309
448
  mime_types: config.image_mime_types,
310
449
  **,
311
450
  &)
312
- Elements::Image.new(
313
- self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
314
- ).html
451
+ if config.use_legacy_file_fields
452
+ Elements::Image.new(
453
+ self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
454
+ ).html
455
+ else
456
+ govuk_attachment_field(
457
+ attribute_name, label:, caption:, hint:, form_group:, accept: mime_types&.join(","), **, &
458
+ )
459
+ end
315
460
  end
316
461
 
317
462
  # Keep track of whether we are inside a fieldset
@@ -320,8 +465,31 @@ module Katalyst
320
465
  @fieldset_context ||= []
321
466
  end
322
467
 
468
+ # URL for an attachment preview. ActiveStorage's representation route
469
+ # lives in the application's route set, so engine-mounted forms
470
+ # resolve it through main_app. Returns nil when no route is
471
+ # available, in which case the figure renders without a preview.
472
+ #
473
+ # @param [ActiveStorage::Variant,ActiveStorage::VariantWithRecord,ActiveStorage::Preview] representation
474
+ # @return [String,nil]
475
+ def attachment_preview_url(representation)
476
+ if @template.respond_to?(:rails_representation_path)
477
+ @template.rails_representation_path(representation)
478
+ elsif @template.respond_to?(:main_app) && @template.main_app.respond_to?(:rails_representation_path)
479
+ @template.main_app.rails_representation_path(representation)
480
+ end
481
+ end
482
+
323
483
  private
324
484
 
485
+ def direct_upload_url
486
+ if @template.respond_to?(:rails_direct_uploads_url)
487
+ @template.rails_direct_uploads_url
488
+ elsif @template.respond_to?(:main_app) && @template.main_app.respond_to?(:rails_direct_uploads_url)
489
+ @template.main_app.rails_direct_uploads_url
490
+ end
491
+ end
492
+
325
493
  def enum_values(attribute_name)
326
494
  object.class.defined_enums[attribute_name.to_s].keys
327
495
  end
@@ -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
@@ -4,18 +4,30 @@ module Katalyst
4
4
  module GOVUK
5
5
  module FormBuilder
6
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.
7
12
  # rubocop:disable Rails/OutputSafety
8
13
  def govuk_formbuilder_init
9
14
  tag.script type: "module", nonce: request.content_security_policy_nonce do
10
15
  <<~JS.html_safe
11
- document.body.classList.toggle("js-enabled", true);
12
- document.body.classList.toggle("govuk-frontend-supported", ('noModule' in HTMLScriptElement.prototype));
13
16
  import {initAll} from "@katalyst/govuk-formbuilder";
14
- initAll();
17
+ initAll(#{govuk_formbuilder_init_options});
15
18
  JS
16
19
  end
17
20
  end
18
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
19
31
  end
20
32
  end
21
33
  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
data/config/importmap.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  pin "@katalyst/govuk-formbuilder", to: "katalyst/govuk/formbuilder.js"
4
+ pin "@rails/activestorage", to: "activestorage.esm.js"
4
5
 
5
6
  pin_all_from Katalyst::GOVUK::FormBuilder::Engine.root.join("app/assets/javascripts"),
6
7
  # preload in tests so that we don't start clicking before controllers load
@@ -0,0 +1,16 @@
1
+ # The attachment field's string defaults — the canonical vocabulary.
2
+ # The JS bundle carries a mirror of this table as its offline defaults
3
+ # (app/javascript/katalyst/govuk/config.js); keep the two in step.
4
+ # Strings that differ from these defaults — a translation, or a host
5
+ # app's override in any locale, en included — render onto the field's
6
+ # data-i18n.* attributes, where the JS enhancement reads them.
7
+ en:
8
+ katalyst:
9
+ govuk:
10
+ attachment:
11
+ upload_succeeded: "Uploaded successfully"
12
+ upload_failed: "Upload failed — try again"
13
+ retry_button: "Try again"
14
+ file_removed: "%{filename} removed"
15
+ remove_button: "Remove %{filename}"
16
+ remove_button_content: "Remove"