decidim-friendly_signup 0.4.2 → 0.4.3

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.
@@ -0,0 +1,915 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "foundation_rails_helper/form_builder"
4
+
5
+ module Decidim
6
+ # This custom FormBuilder adds fields needed to deal with translatable fields,
7
+ # following the conventions set on `Decidim::TranslatableAttributes`.
8
+ class FormBuilder < FoundationRailsHelper::FormBuilder
9
+ include ActionView::Context
10
+ include Decidim::TranslatableAttributes
11
+ include Decidim::Map::Autocomplete::FormBuilder
12
+
13
+ # Public: generates a check boxes input from a collection and adds help
14
+ # text and errors.
15
+ #
16
+ # attribute - the name of the field
17
+ # collection - the collection from which we will render the check boxes
18
+ # value_attribute - a Symbol or a Proc defining how to find the value
19
+ # attribute
20
+ # text_attribute - a Symbol or a Proc defining how to find the text
21
+ # attribute
22
+ # options - a Hash with options
23
+ # html_options - a Hash with options
24
+ #
25
+ # Renders a collection of check boxes.
26
+ # rubocop:disable Metrics/ParameterLists
27
+ def collection_check_boxes(attribute, collection, value_attribute, text_attribute, options = {}, html_options = {})
28
+ super + error_and_help_text(attribute, options)
29
+ end
30
+ # rubocop:enable Metrics/ParameterLists
31
+
32
+ # Public: generates a radio buttons input from a collection and adds help
33
+ # text and errors.
34
+ #
35
+ # attribute - the name of the field
36
+ # collection - the collection from which we will render the radio buttons
37
+ # value_attribute - a Symbol or a Proc defining how to find the value attribute
38
+ # text_attribute - a Symbol or a Proc defining how to find the text attribute
39
+ # options - a Hash with options
40
+ # html_options - a Hash with options
41
+ #
42
+ # Renders a collection of radio buttons.
43
+ # rubocop:disable Metrics/ParameterLists
44
+ def collection_radio_buttons(attribute, collection, value_attribute, text_attribute, options = {}, html_options = {})
45
+ super + error_and_help_text(attribute, options)
46
+ end
47
+ # rubocop:enable Metrics/ParameterLists
48
+
49
+ def create_language_selector(locales, tabs_id, name)
50
+ if Decidim.available_locales.count > 4
51
+ language_selector_select(locales, tabs_id, name)
52
+ else
53
+ language_tabs(locales, tabs_id, name)
54
+ end
55
+ end
56
+
57
+ # Public: Generates an form field for each locale.
58
+ #
59
+ # type - The form field's type, like `text_area` or `text_input`
60
+ # name - The name of the field
61
+ # options - The set of options to send to the field
62
+ #
63
+ # Renders form fields for each locale.
64
+ def translated(type, name, options = {})
65
+ return translated_one_locale(type, name, locales.first, options.merge(label: (options[:label] || label_for(name)))) if locales.count == 1
66
+
67
+ tabs_id = sanitize_tabs_selector(options[:tabs_id] || "#{object_name}-#{name}-tabs")
68
+
69
+ label_tabs = content_tag(:div, class: "label--tabs") do
70
+ field_label = label_i18n(name, options[:label] || label_for(name))
71
+
72
+ language_selector = "".html_safe
73
+ language_selector = create_language_selector(locales, tabs_id, name) if options[:label] != false
74
+
75
+ safe_join [field_label, language_selector]
76
+ end
77
+
78
+ hashtaggable = options.delete(:hashtaggable)
79
+ tabs_content = content_tag(:div, class: "tabs-content", data: { tabs_content: tabs_id }) do
80
+ locales.each_with_index.inject("".html_safe) do |string, (locale, index)|
81
+ tab_content_id = "#{tabs_id}-#{name}-panel-#{index}"
82
+ string + content_tag(:div, class: tab_element_class_for("panel", index), id: tab_content_id) do
83
+ if hashtaggable
84
+ hashtaggable_text_field(type, name, locale, options.merge(label: false))
85
+ elsif type.to_sym == :editor
86
+ send(type, name_with_locale(name, locale), options.merge(label: false, hashtaggable: hashtaggable))
87
+ else
88
+ send(type, name_with_locale(name, locale), options.merge(label: false))
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ safe_join [label_tabs, tabs_content]
95
+ end
96
+
97
+ def translated_one_locale(type, name, locale, options = {})
98
+ return hashtaggable_text_field(type, name, locale, options) if options.delete(:hashtaggable)
99
+
100
+ send(
101
+ type,
102
+ "#{name}_#{locale.to_s.gsub("-", "__")}",
103
+ options.merge(label: options[:label] || label_for(name))
104
+ )
105
+ end
106
+
107
+ # Public: Generates a field for hashtaggable type.
108
+ # type - The form field's type, like `text_area` or `text_input`
109
+ # name - The name of the field
110
+ # handlers - The social handlers to be created
111
+ # options - The set of options to send to the field
112
+ #
113
+ # Renders form fields for each locale.
114
+ def hashtaggable_text_field(type, name, locale, options = {})
115
+ options[:hashtaggable] = true if type.to_sym == :editor
116
+
117
+ content_tag(:div, class: "hashtags__container") do
118
+ if options[:value]
119
+ send(type, name_with_locale(name, locale), options.merge(label: options[:label], value: options[:value][locale]))
120
+ else
121
+ send(type, name_with_locale(name, locale), options.merge(label: options[:label]))
122
+ end
123
+ end
124
+ end
125
+
126
+ # Public: Generates an form field for each social.
127
+ #
128
+ # type - The form field's type, like `text_area` or `text_input`
129
+ # name - The name of the field
130
+ # handlers - The social handlers to be created
131
+ # options - The set of options to send to the field
132
+ #
133
+ # Renders form fields for each locale.
134
+ def social_field(type, name, handlers, options = {})
135
+ tabs_id = sanitize_tabs_selector(options[:tabs_id] || "#{object_name}-#{name}-tabs")
136
+
137
+ label_tabs = content_tag(:div, class: "label--tabs") do
138
+ field_label = label_i18n(name, options[:label] || label_for(name))
139
+
140
+ tabs_panels = "".html_safe
141
+ if options[:label] != false
142
+ tabs_panels = content_tag(:ul, class: "tabs tabs--lang", id: tabs_id, data: { tabs: true }) do
143
+ handlers.each_with_index.inject("".html_safe) do |string, (handler, index)|
144
+ string + content_tag(:li, class: tab_element_class_for("title", index)) do
145
+ title = I18n.t(".#{handler}", scope: "activemodel.attributes.#{object_name}")
146
+ tab_content_id = sanitize_tabs_selector "#{tabs_id}-#{name}-panel-#{index}"
147
+ content_tag(:a, title, href: "##{tab_content_id}")
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ safe_join [field_label, tabs_panels]
154
+ end
155
+
156
+ tabs_content = content_tag(:div, class: "tabs-content", data: { tabs_content: tabs_id }) do
157
+ handlers.each_with_index.inject("".html_safe) do |string, (handler, index)|
158
+ tab_content_id = sanitize_tabs_selector "#{tabs_id}-#{name}-panel-#{index}"
159
+ string + content_tag(:div, class: tab_element_class_for("panel", index), id: tab_content_id) do
160
+ send(type, "#{handler}_handler", options.merge(label: false))
161
+ end
162
+ end
163
+ end
164
+
165
+ safe_join [label_tabs, tabs_content]
166
+ end
167
+
168
+ # Public: generates a hidden field and a container for WYSIWYG editor
169
+ #
170
+ # name - The name of the field
171
+ # options - The set of options to send to the field
172
+ # :label - The Boolean value to create or not the input label (optional) (default: true)
173
+ # :toolbar - The String value to configure WYSIWYG toolbar. It should be 'basic' or
174
+ # or 'full' (optional) (default: 'basic')
175
+ # :lines - The Integer to indicate how many lines should editor have (optional) (default: 10)
176
+ # :disabled - Whether the editor should be disabled
177
+ # :editor_images - Allow attached images (optional) (default: false)
178
+ #
179
+ # Renders a container with both hidden field and editor container
180
+ def editor(name, options = {})
181
+ options[:disabled] ||= false
182
+ toolbar = options.delete(:toolbar) || "basic"
183
+ lines = options.delete(:lines) || 10
184
+ label_text = options[:label].to_s
185
+ label_text = label_for(name) if label_text.blank?
186
+ options.delete(:required)
187
+ hashtaggable = options.delete(:hashtaggable)
188
+ hidden_options = extract_validations(name, options).merge(options)
189
+
190
+ content_tag(:div, class: "editor #{"hashtags__container" if hashtaggable}") do
191
+ template = ""
192
+ template += label(name, label_text + required_for_attribute(name)) if options.fetch(:label, true)
193
+ template += hidden_field(name, hidden_options)
194
+ template += content_tag(:div, nil, class: "editor-container #{"js-hashtags" if hashtaggable}", data: {
195
+ toolbar: toolbar,
196
+ disabled: options[:disabled]
197
+ }.merge(editor_images_options(options)), style: "height: #{lines}rem")
198
+ template += error_for(name, options) if error?(name)
199
+ template.html_safe
200
+ end
201
+ end
202
+
203
+ # Public: Generates a select field with the categories. Only leaf categories can be set as selected.
204
+ #
205
+ # name - The name of the field (usually category_id)
206
+ # collection - A collection of categories.
207
+ # options - An optional Hash with options:
208
+ # - prompt - An optional String with the text to display as prompt.
209
+ # - disable_parents - A Boolean to disable parent categories. Defaults to `true`.
210
+ # html_options - HTML options for the select
211
+ #
212
+ # Returns a String.
213
+ def categories_select(name, collection, options = {}, html_options = {})
214
+ options = {
215
+ disable_parents: true
216
+ }.merge(options)
217
+
218
+ disable_parents = options[:disable_parents]
219
+
220
+ selected = object.send(name)
221
+ selected = selected.first if selected.is_a?(Array) && selected.length > 1
222
+ categories = categories_for_select(collection)
223
+ disabled = if disable_parents
224
+ disabled_categories_for(collection)
225
+ else
226
+ []
227
+ end
228
+
229
+ select(name, @template.options_for_select(categories, selected: selected, disabled: disabled), options, html_options)
230
+ end
231
+
232
+ # Public: Generates a select field for areas.
233
+ #
234
+ # name - The name of the field (usually area_id)
235
+ # collection - A collection of areas or area_types.
236
+ # If it's areas, we sort the selectable options alphabetically.
237
+ #
238
+ # Returns a String.
239
+ def areas_select(name, collection, options = {}, html_options = {})
240
+ selectables = if collection.first.is_a?(Decidim::Area)
241
+ assemblies = collection
242
+ .map { |a| [a.name[I18n.locale.to_s], a.id] }
243
+ .sort_by { |arr| arr[0] }
244
+
245
+ @template.options_for_select(
246
+ assemblies,
247
+ selected: options[:selected]
248
+ )
249
+ else
250
+ @template.option_groups_from_collection_for_select(
251
+ collection,
252
+ :areas,
253
+ :translated_name,
254
+ :id,
255
+ :translated_name,
256
+ selected: options[:selected]
257
+ )
258
+ end
259
+
260
+ select(name, selectables, options, html_options)
261
+ end
262
+
263
+ # Public: Generates a select field for resource types.
264
+ #
265
+ # name - The name of the field (usually resource_type)
266
+ # collection - A collection of resource types.
267
+ # The options are sorted alphabetically.
268
+ #
269
+ # Returns a String.
270
+ def resources_select(name, collection, options = {})
271
+ resources =
272
+ collection
273
+ .map { |r| [I18n.t(r.split("::").last.underscore, scope: "decidim.components.component_order_selector.order"), r] }
274
+ .sort
275
+
276
+ select(name, @template.options_for_select(resources, selected: options[:selected]), options)
277
+ end
278
+
279
+ # Public: Generates a picker field for scope selection.
280
+ #
281
+ # attribute - The name of the field (usually scope_id)
282
+ # options - An optional Hash with options:
283
+ # - multiple - Multiple mode, to allow multiple scopes selection.
284
+ # - label - Show label?
285
+ # - checkboxes_on_top - Show checked picker values on top (default) or below the picker prompt (only for multiple pickers)
286
+ # - namespace - prepend a custom name to the html element's DOM id.
287
+ #
288
+ # Also it should receive a block that returns a Hash with :url and :text for each selected scope (and for null scope for prompt)
289
+ #
290
+ # Returns a String.
291
+ def scopes_picker(attribute, options = {})
292
+ id = if self.options.has_key?(:namespace)
293
+ "#{self.options[:namespace]}_#{sanitize_for_dom_selector(@object_name)}"
294
+ else
295
+ "#{sanitize_for_dom_selector(@object_name)}_#{attribute}"
296
+ end
297
+
298
+ picker_options = {
299
+ id: id,
300
+ class: "picker-#{options[:multiple] ? "multiple" : "single"}",
301
+ name: "#{@object_name}[#{attribute}]"
302
+ }
303
+
304
+ picker_options[:class] += " is-invalid-input" if error?(attribute)
305
+
306
+ prompt_params = yield(nil)
307
+ scopes = selected_scopes(attribute).map { |scope| [scope, yield(scope)] }
308
+ template = ""
309
+ template += "<label>#{label_for(attribute) + required_for_attribute(attribute)}</label>" unless options[:label] == false
310
+ template += @template.render("decidim/scopes/scopes_picker_input",
311
+ picker_options: picker_options,
312
+ prompt_params: prompt_params,
313
+ scopes: scopes,
314
+ values_on_top: !options[:multiple] || options[:checkboxes_on_top])
315
+ template += error_and_help_text(attribute, options)
316
+ template.html_safe
317
+ end
318
+
319
+ # Public: Generates a picker field for selection (either simple or multiselect).
320
+ #
321
+ # attribute - The name of the object's attribute.
322
+ # options - A Hash with options:
323
+ # - multiple: Multiple mode, to allow selection of multiple items.
324
+ # - label: Show label?
325
+ # - name: (optional) The name attribute of the input elements.
326
+ # prompt_params - Hash with options:
327
+ # - url: The url where the ajax endpoint that will fill the content of the selector popup (the prompt).
328
+ # - text: Text in the button to open the Data Picker selector.
329
+ #
330
+ # Also it should receive a block that returns a Hash with :url and :text for each selected scope
331
+ #
332
+ # Returns an html String.
333
+ def data_picker(attribute, options = {}, prompt_params = {})
334
+ picker_options = {
335
+ id: "#{@object_name}_#{attribute}",
336
+ class: "picker-#{options[:multiple] ? "multiple" : "single"}",
337
+ name: options[:name] || "#{@object_name}[#{attribute}]"
338
+ }
339
+ picker_options[:class] += " is-invalid-input" if error?(attribute)
340
+ picker_options[:class] += " picker-autosort" if options[:autosort]
341
+
342
+ items = object.send(attribute).collect { |item| [item, yield(item)] }
343
+
344
+ template = ""
345
+ template += label(attribute, label_for(attribute) + required_for_attribute(attribute)) unless options[:label] == false
346
+ template += @template.render("decidim/widgets/data_picker", picker_options: picker_options, prompt_params: prompt_params, items: items)
347
+ template += error_and_help_text(attribute, options)
348
+ template.html_safe
349
+ end
350
+
351
+ # Public: Override so checkboxes are rendered before the label.
352
+ def check_box(attribute, options = {}, checked_value = "1", unchecked_value = "0")
353
+ custom_label(attribute, options[:label], options[:label_options], field_before_label: true) do
354
+ options.delete(:label)
355
+ options.delete(:label_options)
356
+ @template.check_box(@object_name, attribute, objectify_options(options), checked_value, unchecked_value)
357
+ end + error_and_help_text(attribute, options)
358
+ end
359
+
360
+ # Public: Override so the date fields are rendered using foundation
361
+ # datepicker library
362
+ def date_field(attribute, options = {})
363
+ value = object.send(attribute)
364
+ data = { datepicker: "" }
365
+ data[:startdate] = I18n.l(value, format: :decidim_short) if value.present? && value.is_a?(Date)
366
+ datepicker_format = ruby_format_to_datepicker(I18n.t("date.formats.decidim_short"))
367
+ data[:"date-format"] = datepicker_format
368
+
369
+ template = text_field(
370
+ attribute,
371
+ options.merge(data: data)
372
+ )
373
+ help_text = I18n.t("decidim.datepicker.help_text", datepicker_format: datepicker_format)
374
+ template += error_and_help_text(attribute, options.merge(help_text: help_text))
375
+ template.html_safe
376
+ end
377
+
378
+ # Public: Generates a timepicker field using foundation
379
+ # datepicker library
380
+ def datetime_field(attribute, options = {})
381
+ value = object.send(attribute)
382
+ data = { datepicker: "", timepicker: "" }
383
+ data[:startdate] = I18n.l(value, format: :decidim_short) if value.present? && value.is_a?(ActiveSupport::TimeWithZone)
384
+ datepicker_format = ruby_format_to_datepicker(I18n.t("time.formats.decidim_short"))
385
+ data[:"date-format"] = datepicker_format
386
+
387
+ template = text_field(
388
+ attribute,
389
+ options.merge(data: data)
390
+ )
391
+ help_text = I18n.t("decidim.datepicker.help_text", datepicker_format: datepicker_format)
392
+ template += content_tag(:span, help_text, class: "help-text")
393
+ template.html_safe
394
+ end
395
+
396
+ # Public: Generates a file upload field and sets the form as multipart.
397
+ # If the file is an image it displays the default image if present or the current one.
398
+ # By default it also generates a checkbox to delete the file. This checkbox can
399
+ # be hidden if `options[:optional]` is passed as `false`.
400
+ #
401
+ # attribute - The String name of the attribute to buidl the field.
402
+ # options - A Hash with options to build the field.
403
+ # * optional: Whether the file can be optional or not.
404
+ # rubocop:disable Metrics/CyclomaticComplexity
405
+ # rubocop:disable Metrics/PerceivedComplexity
406
+ def upload(attribute, options = {})
407
+ self.multipart = true
408
+ options[:optional] = options[:optional].nil? ? true : options[:optional]
409
+ label_text = options[:label] || label_for(attribute)
410
+ alt_text = label_text
411
+
412
+ file = object.send attribute
413
+ template = ""
414
+ template += label(attribute, label_text + required_for_attribute(attribute))
415
+ template += upload_help(attribute, options)
416
+ template += @template.file_field @object_name, attribute
417
+
418
+ template += extension_allowlist_help(options[:extension_allowlist]) if options[:extension_allowlist].present?
419
+ template += image_dimensions_help(options[:dimensions_info]) if options[:dimensions_info].present?
420
+
421
+ default_image_path = uploader_default_image_path(attribute)
422
+ file_path = file_attachment_path(file)
423
+
424
+ if file_path.present? && file.attachment.image? || default_image_path.present?
425
+ if file_path.present?
426
+ template += @template.content_tag :label, I18n.t("current_image", scope: "decidim.forms")
427
+ template += @template.link_to @template.image_tag(file_path, alt: alt_text), file_path, target: "_blank", rel: "noopener"
428
+ else
429
+ template += @template.content_tag :label, I18n.t("default_image", scope: "decidim.forms")
430
+ template += @template.link_to @template.image_tag(default_image_path, alt: alt_text), default_image_path, target: "_blank", rel: "noopener"
431
+ end
432
+ elsif file_path.present?
433
+ template += @template.label_tag I18n.t("current_file", scope: "decidim.forms")
434
+ template += @template.link_to file.filename, file_path, target: "_blank", rel: "noopener"
435
+ end
436
+
437
+ if file_path.present? && options[:optional]
438
+ template += content_tag :div, class: "field" do
439
+ safe_join([
440
+ @template.check_box(@object_name, "remove_#{attribute}"),
441
+ label("remove_#{attribute}", I18n.t("remove_this_file", scope: "decidim.forms"))
442
+ ])
443
+ end
444
+ end
445
+
446
+ if object.errors[attribute].any?
447
+ template += content_tag :p, class: "is-invalid-label" do
448
+ safe_join object.errors[attribute], "<br/>".html_safe
449
+ end
450
+ end
451
+
452
+ template.html_safe
453
+ end
454
+ # rubocop:enable Metrics/CyclomaticComplexity
455
+ # rubocop:enable Metrics/PerceivedComplexity
456
+
457
+ def upload_help(attribute, options = {})
458
+ humanizer = FileValidatorHumanizer.new(object, attribute)
459
+
460
+ help_scope = begin
461
+ if options[:help_i18n_scope].present?
462
+ options[:help_i18n_scope]
463
+ elsif humanizer.uploader.is_a?(Decidim::ImageUploader)
464
+ "decidim.forms.file_help.image"
465
+ else
466
+ "decidim.forms.file_help.file"
467
+ end
468
+ end
469
+
470
+ help_messages = begin
471
+ if options[:help_i18n_messages].present?
472
+ Array(options[:help_i18n_messages])
473
+ else
474
+ %w(message_1 message_2)
475
+ end
476
+ end
477
+
478
+ content_tag(:div, class: "help-text") do
479
+ inner = "<p>#{I18n.t("explanation", scope: help_scope)}</p>".html_safe
480
+ inner + content_tag(:ul) do
481
+ messages = help_messages.each.map { |msg| I18n.t(msg, scope: help_scope) }
482
+ messages += humanizer.messages
483
+
484
+ messages.map { |msg| content_tag(:li, msg) }.join("\n").html_safe
485
+ end.html_safe
486
+ end
487
+ end
488
+
489
+ # Public: Returns the translated name for the given attribute.
490
+ #
491
+ # attribute - The String name of the attribute to return the name.
492
+ def label_for(attribute)
493
+ if object.class.respond_to?(:human_attribute_name)
494
+ object.class.human_attribute_name(attribute)
495
+ else
496
+ attribute.to_s.humanize
497
+ end
498
+ end
499
+
500
+ def form_field_for(attribute, options = {})
501
+ if attribute == :body
502
+ text_area(attribute, options.merge(rows: 10))
503
+ else
504
+ text_field(attribute, options)
505
+ end
506
+ end
507
+
508
+ # Discard the pattern attribute which is not allowed for textarea elements.
509
+ def text_area(attribute, options = {})
510
+ field(attribute, options) do |opts|
511
+ opts.delete(:pattern)
512
+ @template.send(
513
+ :text_area,
514
+ @object_name,
515
+ attribute,
516
+ objectify_options(opts)
517
+ )
518
+ end
519
+ end
520
+
521
+ private
522
+
523
+ # Private: Override from FoundationRailsHelper in order to render
524
+ # inputs inside the label and to automatically inject validations
525
+ # from the object.
526
+ #
527
+ # attribute - The String name of the attribute to buidl the field.
528
+ # options - A Hash with options to build the field.
529
+ # html_options - An optional Hash with options to pass to the html element.
530
+ #
531
+ # Returns a String
532
+ def field(attribute, options, html_options = nil, &block)
533
+ label = options.delete(:label)
534
+ label_options = options.delete(:label_options)
535
+ custom_label(attribute, label, label_options) do
536
+ field_with_validations(attribute, options, html_options, &block)
537
+ end
538
+ end
539
+
540
+ # Private: Builds a form field and detects validations from
541
+ # the form object.
542
+ #
543
+ # attribute - The String name of the attribute to build the field.
544
+ # options - A Hash with options to build the field.
545
+ # html_options - An optional Hash with options to pass to the html element.
546
+ #
547
+ # Returns a String.
548
+ def field_with_validations(attribute, options, html_options)
549
+ class_options = html_options || options
550
+
551
+ if error?(attribute)
552
+ class_options[:class] = class_options[:class].to_s
553
+ class_options[:class] += " is-invalid-input"
554
+ end
555
+
556
+ help_text = options.delete(:help_text)
557
+ prefix = options.delete(:prefix)
558
+ postfix = options.delete(:postfix)
559
+
560
+ class_options = extract_validations(attribute, options).merge(class_options)
561
+
562
+ content = yield(class_options)
563
+ content += abide_error_element(attribute) if class_options[:pattern] || class_options[:required]
564
+ content = content.html_safe
565
+
566
+ html = wrap_prefix_and_postfix(content, prefix, postfix)
567
+ html + error_and_help_text(attribute, options.merge(help_text: help_text))
568
+ end
569
+
570
+ # rubocop: disable Metrics/CyclomaticComplexity
571
+ # rubocop: disable Metrics/PerceivedComplexity
572
+
573
+ # Private: Builds a Hash of options to be injected at the HTML output as
574
+ # HTML5 validations.
575
+ #
576
+ # attribute - The String name of the attribute to extract the validations.
577
+ # options - A Hash of options to extract validations.
578
+ #
579
+ # Returns a Hash.
580
+ def extract_validations(attribute, options)
581
+ min_length = options.delete(:minlength) || length_for_attribute(attribute, :minimum) || 0
582
+ max_length = options.delete(:maxlength) || length_for_attribute(attribute, :maximum)
583
+
584
+ validation_options = {}
585
+ validation_options[:pattern] = "^(.|[\n\r]){#{min_length},#{max_length}}$" if min_length.to_i.positive? || max_length.to_i.positive?
586
+ validation_options[:required] = options[:required] || attribute_required?(attribute)
587
+ validation_options[:minlength] ||= min_length if min_length.to_i.positive?
588
+ validation_options[:maxlength] ||= max_length if max_length.to_i.positive?
589
+ validation_options
590
+ end
591
+ # rubocop: enable Metrics/CyclomaticComplexity
592
+ # rubocop: enable Metrics/PerceivedComplexity
593
+
594
+ # Private: Tries to find if an attribute is required in the form object.
595
+ #
596
+ # Returns Boolean.
597
+ def attribute_required?(attribute)
598
+ validator = find_validator(attribute, ActiveModel::Validations::PresenceValidator) ||
599
+ find_validator(attribute, TranslatablePresenceValidator)
600
+
601
+ return unless validator
602
+
603
+ # Check if the if condition is present and it evaluates to true
604
+ if_condition = validator.options[:if]
605
+ validator_if_condition = if_condition.nil? ||
606
+ (string_or_symbol?(if_condition) ? object.send(if_condition) : if_condition.call(object))
607
+
608
+ # Check if the unless condition is present and it evaluates to false
609
+ unless_condition = validator.options[:unless]
610
+ validator_unless_condition = unless_condition.nil? ||
611
+ (string_or_symbol?(unless_condition) ? !object.send(unless_condition) : !unless_condition.call(object))
612
+
613
+ validator_if_condition && validator_unless_condition
614
+ end
615
+
616
+ def string_or_symbol?(obj)
617
+ obj.is_a?(String) || obj.is_a?(Symbol)
618
+ end
619
+
620
+ # Private: Tries to find a length validator in the form object.
621
+ #
622
+ # attribute - The attribute to look for the validations.
623
+ # type - A Symbol for the type of length to fetch. Currently only :minimum & :maximum are supported.
624
+ #
625
+ # Returns an Integer or Nil.
626
+ def length_for_attribute(attribute, type)
627
+ length_validator = find_validator(attribute, ActiveModel::Validations::LengthValidator)
628
+ return length_validator.options[type] if length_validator
629
+
630
+ length_validator = find_validator(attribute, ProposalLengthValidator) if Object.const_defined?("ProposalLengthValidator")
631
+ if length_validator
632
+ length = length_validator.options[type]
633
+ return length.call(object) if length.respond_to?(:call)
634
+
635
+ return length
636
+ end
637
+
638
+ nil
639
+ end
640
+
641
+ # Private: Finds a validator.
642
+ #
643
+ # attribute - The attribute to validate.
644
+ # klass - The Class of the validator to find.
645
+ #
646
+ # Returns a klass object.
647
+ def find_validator(attribute, klass)
648
+ return unless object.respond_to?(:_validators)
649
+
650
+ object._validators[attribute.to_sym].find { |validator| validator.class == klass }
651
+ end
652
+
653
+ # Private: Override method from FoundationRailsHelper to render the text of the
654
+ # label before the input, instead of after.
655
+ #
656
+ # attribute - The String name of the attribute we're build the label.
657
+ # text - The String text to use as label.
658
+ # options - A Hash to build the label.
659
+ #
660
+ # Returns a String.
661
+ # rubocop:disable Metrics/CyclomaticComplexity
662
+ # rubocop:disable Metrics/PerceivedComplexity
663
+ def custom_label(attribute, text, options, field_before_label: false, show_required: true)
664
+ return block_given? ? yield.html_safe : "".html_safe if text == false
665
+
666
+ text = default_label_text(object, attribute) if text.nil? || text == true
667
+ text += required_for_attribute(attribute) if show_required
668
+
669
+ text = if field_before_label && block_given?
670
+ safe_join([yield, text.html_safe])
671
+ elsif block_given?
672
+ safe_join([text.html_safe, yield])
673
+ end
674
+
675
+ label(attribute, text, options || {})
676
+ end
677
+ # rubocop:enable Metrics/PerceivedComplexity
678
+ # rubocop:enable Metrics/CyclomaticComplexity
679
+
680
+ # Private: Builds a span to be shown when there's a validation error in a field.
681
+ # It looks for the text that will be the content in a similar way `human_attribute_name`
682
+ # does it.
683
+ #
684
+ # attribute - The name of the attribute of the field.
685
+ #
686
+ # Returns a String.
687
+ def abide_error_element(attribute)
688
+ defaults = []
689
+ defaults << :"decidim.forms.errors.#{object.class.model_name.i18n_key}.#{attribute}"
690
+ defaults << :"decidim.forms.errors.#{attribute}"
691
+ defaults << :"forms.errors.#{attribute}"
692
+ defaults << :"decidim.forms.errors.error"
693
+
694
+ options = { count: 1, default: defaults }
695
+
696
+ text = I18n.t(defaults.shift, **options)
697
+ content_tag(:span, text, class: "form-error")
698
+ end
699
+
700
+ def categories_for_select(scope)
701
+ sorted_main_categories = scope.first_class.includes(:subcategories).sort_by do |category|
702
+ [category.weight, translated_attribute(category.name, category.participatory_space.organization)]
703
+ end
704
+
705
+ sorted_main_categories.flat_map do |category|
706
+ parent = [[translated_attribute(category.name, category.participatory_space.organization), category.id]]
707
+
708
+ sorted_subcategories = category.subcategories.sort_by do |subcategory|
709
+ [subcategory.weight, translated_attribute(subcategory.name, subcategory.participatory_space.organization)]
710
+ end
711
+
712
+ sorted_subcategories.each do |subcategory|
713
+ parent << ["- #{translated_attribute(subcategory.name, subcategory.participatory_space.organization)}", subcategory.id]
714
+ end
715
+
716
+ parent
717
+ end
718
+ end
719
+
720
+ def disabled_categories_for(scope)
721
+ scope.first_class.joins(:subcategories).pluck(:id)
722
+ end
723
+
724
+ def tab_element_class_for(type, index)
725
+ element_class = "tabs-#{type}"
726
+ element_class += " is-active" if index.zero?
727
+ element_class
728
+ end
729
+
730
+ def locales
731
+ @locales ||= if @template.respond_to?(:available_locales)
732
+ Set.new([@template.current_locale] + @template.available_locales).to_a
733
+ else
734
+ Decidim.available_locales
735
+ end
736
+ end
737
+
738
+ def name_with_locale(name, locale)
739
+ "#{name}_#{locale.to_s.gsub("-", "__")}"
740
+ end
741
+
742
+ def label_i18n(attribute, text = nil, options = {})
743
+ errored = error?(attribute) || locales.any? { |locale| error?(name_with_locale(attribute, locale)) }
744
+
745
+ if errored
746
+ options[:class] ||= ""
747
+ options[:class] += " is-invalid-label"
748
+ end
749
+ text += required_for_attribute(attribute)
750
+
751
+ label(attribute, (text || "").html_safe, options)
752
+ end
753
+
754
+ # Private: Returns default url for attribute when uploader is an
755
+ # image and has defined a default url
756
+ def uploader_default_image_path(attribute)
757
+ uploader = FileValidatorHumanizer.new(object, attribute).uploader
758
+ return if uploader.blank?
759
+ return unless uploader.is_a?(Decidim::ImageUploader)
760
+
761
+ uploader.try(:default_url)
762
+ end
763
+
764
+ # Private: Returns blob path when file is attached
765
+ def file_attachment_path(file)
766
+ return unless file && file.try(:attached?)
767
+
768
+ Rails.application.routes.url_helpers.rails_blob_url(file.blob, only_path: true)
769
+ end
770
+
771
+ def required_for_attribute(attribute)
772
+ if attribute_required?(attribute) || (attribute == :password && object.class.name == "Decidim::RegistrationForm")
773
+ visible_title = content_tag(:span, "*", "aria-hidden": true)
774
+ screenreader_title = content_tag(
775
+ :span,
776
+ I18n.t("required", scope: "forms"),
777
+ class: "show-for-sr"
778
+ )
779
+ return content_tag(
780
+ :span,
781
+ visible_title + screenreader_title,
782
+ title: I18n.t("required", scope: "forms"),
783
+ data: { tooltip: true, disable_hover: false, keep_on_hover: true },
784
+ class: "label-required"
785
+ ).html_safe
786
+ end
787
+ "".html_safe
788
+ end
789
+
790
+ # Private: Returns an array of scopes related to object attribute
791
+ def selected_scopes(attribute)
792
+ selected = object.send(attribute) || []
793
+ selected = selected.values if selected.is_a?(Hash)
794
+ selected = [selected] unless selected.is_a?(Array)
795
+ selected = Decidim::Scope.where(id: selected.map(&:to_i)) unless selected.first.is_a?(Decidim::Scope)
796
+ selected
797
+ end
798
+
799
+ # Private: Returns the help text and error tags at the end of the field.
800
+ # Modified to change the tag to a valid HTML tag inside the <label> element.
801
+ def error_and_help_text(attribute, options = {})
802
+ html = ""
803
+ html += content_tag(:span, options[:help_text], class: "help-text") if options[:help_text]
804
+ html += error_for(attribute, options) || ""
805
+ html.html_safe
806
+ end
807
+
808
+ def ruby_format_to_datepicker(ruby_date_format)
809
+ ruby_date_format.gsub("%d", "dd").gsub("%m", "mm").gsub("%Y", "yyyy").gsub("%H", "hh").gsub("%M", "ii")
810
+ end
811
+
812
+ def sanitize_tabs_selector(id)
813
+ id.tr("[", "-").tr("]", "-")
814
+ end
815
+
816
+ def sanitize_for_dom_selector(name)
817
+ name.to_s.parameterize.underscore
818
+ end
819
+
820
+ def extension_allowlist_help(extension_allowlist)
821
+ content_tag :p, class: "extensions-help help-text" do
822
+ safe_join([
823
+ content_tag(:span, I18n.t("extension_allowlist", scope: "decidim.forms.files")),
824
+ " ",
825
+ safe_join(extension_allowlist.map { |ext| content_tag(:b, ext) }, ", ")
826
+ ])
827
+ end
828
+ end
829
+
830
+ def image_dimensions_help(dimensions_info)
831
+ content_tag :p, class: "image-dimensions-help help-text" do
832
+ safe_join([
833
+ content_tag(:span, I18n.t("dimensions_info", scope: "decidim.forms.images")),
834
+ " ",
835
+ content_tag(:span) do
836
+ safe_join(dimensions_info.map do |_version, info|
837
+ processor = @template.content_tag(:span, I18n.t("processors.#{info[:processor]}", scope: "decidim.forms.images"))
838
+ dimensions = @template.content_tag(:b, I18n.t("dimensions", scope: "decidim.forms.images", width: info[:dimensions].first, height: info[:dimensions].last))
839
+ safe_join([processor, " ", dimensions, ". ".html_safe])
840
+ end)
841
+ end
842
+ ])
843
+ end
844
+ end
845
+
846
+ # Private: Creates a tag from the given options for the field prefix and
847
+ # suffix. Overridden from Foundation Rails helper to make the generated HTML
848
+ # valid since these elements are printed within <label> elements and <div>'s
849
+ # are not allowed there.
850
+ def tag_from_options(name, options)
851
+ return "".html_safe unless options && options[:value].present?
852
+
853
+ content_tag(:span,
854
+ content_tag(:span, options[:value], class: name),
855
+ class: column_classes(options).to_s)
856
+ end
857
+
858
+ # Private: Wraps the prefix and postfix for the field. Overridden from
859
+ # Foundation Rails helper to make the generated HTML valid since these
860
+ # elements are printed within <label> elements and <div>'s are not allowed
861
+ # there.
862
+ def wrap_prefix_and_postfix(block, prefix_options, postfix_options)
863
+ prefix = tag_from_options("prefix", prefix_options)
864
+ postfix = tag_from_options("postfix", postfix_options)
865
+
866
+ input_size = calculate_input_size(prefix_options, postfix_options)
867
+ klass = column_classes(input_size.marshal_dump).to_s
868
+ input = content_tag(:span, block, class: klass)
869
+
870
+ return block unless input_size.changed?
871
+
872
+ content_tag(:span, prefix + input + postfix, class: "row collapse")
873
+ end
874
+
875
+ def language_selector_select(locales, tabs_id, name)
876
+ content_tag(:div) do
877
+ content_tag(:select, id: tabs_id, class: "language-change") do
878
+ locales.each_with_index.inject("".html_safe) do |string, (locale, index)|
879
+ title = if error?(name_with_locale(name, locale))
880
+ I18n.with_locale(locale) { I18n.t("name_with_error", scope: "locale") }
881
+ else
882
+ I18n.with_locale(locale) { I18n.t("name", scope: "locale") }
883
+ end
884
+ tab_content_id = sanitize_tabs_selector "#{tabs_id}-#{name}-panel-#{index}"
885
+ string + content_tag(:option, title, value: "##{tab_content_id}")
886
+ end
887
+ end
888
+ end
889
+ end
890
+
891
+ def language_tabs(locales, tabs_id, name)
892
+ content_tag(:ul, class: "tabs tabs--lang", id: tabs_id, data: { tabs: true }) do
893
+ locales.each_with_index.inject("".html_safe) do |string, (locale, index)|
894
+ string + content_tag(:li, class: tab_element_class_for("title", index)) do
895
+ title = I18n.with_locale(locale) { I18n.t("name", scope: "locale") }
896
+ element_class = nil
897
+ element_class = "is-tab-error" if error?(name_with_locale(name, locale))
898
+ tab_content_id = sanitize_tabs_selector "#{tabs_id}-#{name}-panel-#{index}"
899
+ content_tag(:a, title, href: "##{tab_content_id}", class: element_class)
900
+ end
901
+ end
902
+ end
903
+ end
904
+
905
+ def editor_images_options(options)
906
+ return {} unless options[:editor_images]
907
+
908
+ {
909
+ editor_images: true,
910
+ upload_images_path: Decidim::Core::Engine.routes.url_helpers.editor_images_path,
911
+ drag_and_drop_help_text: I18n.t("drag_and_drop_help", scope: "decidim.editor_images")
912
+ }
913
+ end
914
+ end
915
+ end