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.
- checksums.yaml +4 -4
- data/README.md +141 -15
- data/app/assets/builds/katalyst/govuk/formbuilder.css +77 -0
- data/app/assets/builds/katalyst/govuk/formbuilder.js +573 -10
- data/app/assets/builds/katalyst/govuk/formbuilder.min.js +8 -8
- data/app/assets/builds/katalyst/govuk/formbuilder.min.js.map +1 -1
- data/app/assets/stylesheets/katalyst/govuk/components/_index.scss +1 -0
- data/app/assets/stylesheets/katalyst/govuk/components/attachment/_index.scss +3 -0
- data/app/assets/stylesheets/katalyst/govuk/components/attachment/_mixin.scss +89 -0
- data/app/helpers/katalyst/govuk/form_builder/builder.rb +174 -6
- data/app/helpers/katalyst/govuk/form_builder/elements/attachment.rb +80 -0
- data/app/helpers/katalyst/govuk/form_builder/frontend.rb +15 -3
- data/app/helpers/katalyst/govuk/form_builder/traits/attachment.rb +215 -0
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +16 -0
- data/lib/katalyst/govuk/form_builder/config.rb +20 -0
- data/lib/katalyst/govuk/form_builder/engine.rb +1 -1
- metadata +20 -1
|
@@ -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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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"
|