decidim-core 0.30.5 → 0.30.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/announcement_cell.rb +10 -2
  3. data/app/cells/decidim/attachments_file_tab/show.erb +1 -1
  4. data/app/cells/decidim/content_blocks/highlighted_elements_with_cell_for_list_cell.rb +5 -1
  5. data/app/cells/decidim/content_blocks/participatory_space_metadata/content.erb +2 -2
  6. data/app/cells/decidim/nav_links/show.erb +2 -2
  7. data/app/cells/decidim/report_button/already_reported_modal.erb +1 -1
  8. data/app/cells/decidim/report_button/flag_modal.erb +1 -1
  9. data/app/cells/decidim/report_user_button/already_reported_modal.erb +1 -1
  10. data/app/cells/decidim/report_user_button/flag_modal.erb +1 -1
  11. data/app/cells/decidim/statistic/show.erb +4 -4
  12. data/app/cells/decidim/upload_modal/files.erb +16 -8
  13. data/app/cells/decidim/upload_modal_cell.rb +21 -5
  14. data/app/commands/decidim/multiple_attachments_methods.rb +20 -3
  15. data/app/controllers/concerns/decidim/direct_upload.rb +2 -12
  16. data/app/controllers/decidim/devise/sessions_controller.rb +7 -0
  17. data/app/helpers/concerns/decidim/flash_helper_extensions.rb +2 -2
  18. data/app/jobs/decidim/export_participatory_space_job.rb +1 -1
  19. data/app/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  20. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  21. data/app/models/decidim/attachment.rb +22 -1
  22. data/app/packs/src/decidim/a11y.js +29 -0
  23. data/app/packs/src/decidim/a11y.test.js +81 -0
  24. data/app/packs/src/decidim/confirm.js +8 -1
  25. data/app/packs/src/decidim/confirm.test.js +225 -0
  26. data/app/packs/src/decidim/direct_uploads/upload_field.js +1 -1
  27. data/app/packs/src/decidim/focus_guard.js +4 -4
  28. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  29. data/app/packs/stylesheets/decidim/_modal_update.scss +1 -1
  30. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +1 -1
  31. data/app/services/decidim/open_data_exporter.rb +1 -1
  32. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  33. data/app/views/layouts/decidim/header/_main_links_mobile_account.html.erb +1 -1
  34. data/config/initializers/devise.rb +6 -0
  35. data/config/locales/ar.yml +3 -3
  36. data/config/locales/bg.yml +0 -4
  37. data/config/locales/ca-IT.yml +12 -6
  38. data/config/locales/ca.yml +12 -6
  39. data/config/locales/cs.yml +23 -8
  40. data/config/locales/de.yml +4 -8
  41. data/config/locales/el.yml +1 -2
  42. data/config/locales/en.yml +10 -4
  43. data/config/locales/es-MX.yml +13 -7
  44. data/config/locales/es-PY.yml +13 -7
  45. data/config/locales/es.yml +15 -9
  46. data/config/locales/eu.yml +13 -10
  47. data/config/locales/fi-plain.yml +5 -4
  48. data/config/locales/fi.yml +6 -5
  49. data/config/locales/fr-CA.yml +11 -5
  50. data/config/locales/fr.yml +13 -7
  51. data/config/locales/gl.yml +0 -2
  52. data/config/locales/hu.yml +5 -9
  53. data/config/locales/id-ID.yml +0 -2
  54. data/config/locales/it.yml +1 -3
  55. data/config/locales/ja.yml +11 -8
  56. data/config/locales/lb.yml +0 -2
  57. data/config/locales/lt.yml +1 -3
  58. data/config/locales/lv.yml +0 -2
  59. data/config/locales/nl.yml +0 -2
  60. data/config/locales/no.yml +0 -2
  61. data/config/locales/pl.yml +0 -4
  62. data/config/locales/pt-BR.yml +29 -5
  63. data/config/locales/pt.yml +0 -2
  64. data/config/locales/ro-RO.yml +5 -6
  65. data/config/locales/ru.yml +0 -2
  66. data/config/locales/sk.yml +0 -4
  67. data/config/locales/sv.yml +254 -31
  68. data/config/locales/tr-TR.yml +17 -5
  69. data/config/locales/zh-CN.yml +0 -2
  70. data/config/locales/zh-TW.yml +1 -3
  71. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  72. data/lib/decidim/core/version.rb +1 -1
  73. data/lib/decidim/form_builder.rb +40 -6
  74. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  75. data/lib/decidim/searchable.rb +4 -4
  76. metadata +8 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8792833d7ba900781bcd99e714b0fc117c8bf63b1d92894a12a4d8d9eebfcab
4
- data.tar.gz: 21ae4185554af11eb516c1e7e0b6af616497331ab28234bd1c5199636fe40402
3
+ metadata.gz: 5110ff22ed67aef95bf387023ec7d18d9602a92944c82c2ab9373e786c04393c
4
+ data.tar.gz: b754a2b2ddb4a72ae07dcd384203d61454ced7f6a3a41a4f80a5bb501b3dc310
5
5
  SHA512:
6
- metadata.gz: 8d56c970e80a667e474858352900d599b48dc392d99c239e3ea732af530f457cbe0a72276b9da630e5aff999eff6585dbe8ce7a8da66346df7ee491901366d59
7
- data.tar.gz: aba95c516675ae9eff886fb93c4c29c94fede6adb809c26aa2fb2bf17fde468c7e1a7f9a8875708f13061595410f0dfaff83938ff7ff011682b8011f1e99d776
6
+ metadata.gz: 3659a2a34d2531735e1a03710329261952c724361d0661bb9cf02c7a2acc76c2516266e8030a82e4dcc40789361d67845f1421addfcbf0f1fe4e0743a1288572
7
+ data.tar.gz: 9ec948751168cd3f4a8cc2b768be6a118cd22b47aecb0712fa64a1c01d58a5cdf2b7f2f20963fdd0eec2384a41753bbaec558016f533bfab7176227df6f3cadd
@@ -68,15 +68,23 @@ module Decidim
68
68
  def clean_body
69
69
  return unless body
70
70
 
71
- Array(body).map { |paragraph| tag.p(clean(paragraph)) }.join
71
+ Array(body).map { |paragraph| clean(paragraph) }.join
72
72
  end
73
73
 
74
74
  def clean_announcement
75
+ return if announcement.is_a?(Hash) && announcement.values.all?(&:blank?)
76
+
75
77
  clean(announcement)
76
78
  end
77
79
 
78
80
  def clean(value)
79
- decidim_sanitize_admin(translated_attribute(value))
81
+ return if value.blank? || value.nil?
82
+
83
+ if value.include?("rich-text-display")
84
+ decidim_sanitize_admin(translated_attribute(value))
85
+ else
86
+ tag.p(decidim_sanitize_admin(translated_attribute(value)))
87
+ end
80
88
  end
81
89
  end
82
90
  end
@@ -1,3 +1,3 @@
1
1
  <div class="row column">
2
- <%= form.upload :file, button_class: "button button__sm button__transparent-secondary" %>
2
+ <%= form.upload :file, attachments: form.object.file.present? ? [form.object.file] : [], button_class: "button button__sm button__transparent-secondary" %>
3
3
  </div>
@@ -19,10 +19,14 @@ module Decidim
19
19
  @list_cell ||= cell(
20
20
  list_cell_path,
21
21
  published_components.one? ? published_components.first : published_components,
22
- **model.settings.to_h, see_all_path:
22
+ **model.settings.to_h, **extra_list_cell_options, see_all_path:
23
23
  )
24
24
  end
25
25
 
26
+ def extra_list_cell_options
27
+ {}
28
+ end
29
+
26
30
  def see_all_path; end
27
31
  end
28
32
  end
@@ -2,9 +2,9 @@
2
2
  <% metadata_valued_items.each do |item| %>
3
3
  <div class="participatory-space__metadata-item">
4
4
  <div class="participatory-space__metadata-item-title">
5
- <span><%= item[:title] %></span>
5
+ <p><%= item[:title] %></p>
6
6
  </div>
7
- <span><%= item[:value] %></span>
7
+ <p><%= item[:value] %></p>
8
8
  </div>
9
9
  <% end %>
10
10
  </div>
@@ -4,11 +4,11 @@
4
4
  <%= icon "arrow-down-s-line" %>
5
5
  <%= icon "arrow-up-s-line" %>
6
6
  </button>
7
- <ul id="dropdown-menu-participatory-space" class="participatory-space__nav">
7
+ <ul id="dropdown-menu-participatory-space" class="participatory-space__nav" aria-hidden="true">
8
8
  <% model.each do |item| %>
9
9
  <li role="menuitem">
10
10
  <%= link_to item[:url], class: "participatory-space__nav-item" do %>
11
- <%= item[:name] %>
11
+ <%= decidim_escape_translated(item[:name]) %>
12
12
  <%= icon "arrow-right-line" %>
13
13
  <% end %>
14
14
  </li>
@@ -1,7 +1,7 @@
1
1
  <%= decidim_modal id: modal_id, class: "flag-modal" do %>
2
2
  <div data-dialog-container>
3
3
  <%= icon "flag-line" %>
4
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
4
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
5
5
  <div>
6
6
  <div class="form__wrapper flag-modal__form">
7
7
  <p class="flag-modal__form-description"><%= t("decidim.shared.flag_modal.already_reported") %></p>
@@ -2,7 +2,7 @@
2
2
  <%= form_for report_form, builder:, url: report_path, method: :post, html: { id: nil } do |f| %>
3
3
  <div data-dialog-container>
4
4
  <%= icon "flag-line" %>
5
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
5
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
6
6
  <div>
7
7
  <div class="form__wrapper flag-modal__form">
8
8
  <p id="dialog-desc-<%= modal_id %>" class="flag-modal__form-description"><%= t("decidim.shared.flag_modal.description") %></p>
@@ -1,7 +1,7 @@
1
1
  <%= decidim_modal id: modal_id, class: "flag-user-modal" do %>
2
2
  <div data-dialog-container>
3
3
  <%= icon "flag-line" %>
4
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
4
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
5
5
  <div>
6
6
  <div class="form__wrapper flag-modal__form">
7
7
  <p class="flag-modal__form-description"><%= t("decidim.shared.flag_user_modal.already_reported") %></p>
@@ -2,7 +2,7 @@
2
2
  <%= form_for report_form, builder:, url: report_path, method: :post, html: { id: nil } do |f| %>
3
3
  <div data-dialog-container>
4
4
  <%= icon "flag-line" %>
5
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
5
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
6
6
  <div>
7
7
  <div class="form__wrapper flag-modal__form">
8
8
  <p class="flag-modal__form-description"><%= t("decidim.shared.flag_user_modal.description") %></p>
@@ -1,8 +1,8 @@
1
1
  <div class="statistic <%= stat_dom_class %>" data-statistic>
2
- <span class="statistic__title" title="<%= stat_title %>">
2
+ <p class="statistic__title" title="<%= stat_title %>">
3
3
  <%= stat_title %>
4
- </span>
5
- <span class="statistic__number">
4
+ </p>
5
+ <p class="statistic__number">
6
6
  <%= stat_number %>
7
- </span>
7
+ </p>
8
8
  </div>
@@ -1,9 +1,9 @@
1
1
  <div class="upload-modal__files-container upload-container-for-<%= attribute %> <%= with_title %>">
2
2
  <div>
3
- <%= label %>
3
+ <%= options[:paragraph] == true ? paragraph : label %>
4
4
 
5
5
  <% if options[:help_text].present? %>
6
- <span class="help-text"><%= options[:help_text] %></span>
6
+ <p class="help-text"><%= options[:help_text] %></p>
7
7
  <% end %>
8
8
 
9
9
  <%# NOTE: this block is about wrapping a default image for the avatar with the new styles,
@@ -22,24 +22,32 @@
22
22
  <div class="upload-modal__files" data-active-uploads="<%= modal_id %>">
23
23
  <% attachments.each do |attachment| %>
24
24
  <% next if [Array, Hash].any? { |klass| attachment.is_a? klass } %>
25
+ <% is_persisted_attachment = attachment.is_a?(Decidim::Attachment) && attachment.persisted? %>
26
+ <% attachment_blob = blob(attachment) %>
25
27
 
26
- <div class="attachment-details" data-attachment-id="<%= attachment.id %>" data-title="<%= title_for(attachment) %>" data-filename="<%= file_name_for(attachment) %>" data-state="uploaded">
27
- <% if file_attachment_path(attachment) && blob(attachment).image? %>
28
+ <div class="attachment-details"<% if is_persisted_attachment %> data-attachment-id="<%= attachment.id %>"<% end %> data-title="<%= title_for(attachment) %>" data-filename="<%= file_name_for(attachment) %>" data-state="uploaded" data-hidden-field="<%= attachment_blob&.signed_id %>">
29
+ <% if file_attachment_path(attachment) && attachment_blob&.image? %>
28
30
  <div><%= image_tag(file_attachment_path(attachment), alt: "") %></div>
29
31
  <% elsif uploader_default_image_path(attribute).present? %>
30
32
  <div><%= image_tag uploader_default_image_path(attribute) %></div>
31
33
  <% end %>
32
34
 
33
35
  <% if has_title? %>
34
- <span><%= title_for(attachment) %></span>
35
- <%= form.hidden_field attribute, multiple: true, value: attachment.id, id: attachment.id %>
36
+ <p><%= title_for(attachment) %></p>
36
37
  <% else %>
37
- <% if blob(attachment).image? %>
38
- <span><%= title_for(attachment) %></span>
38
+ <% if attachment_blob&.image? %>
39
+ <p><%= title_for(attachment) %></p>
39
40
  <% else %>
40
41
  <%= link_to title_for(attachment), file_attachment_path(attachment), class: "w-full break-all mb-2" %>
41
42
  <% end %>
42
43
  <% end %>
44
+ <% if attachment_blob.present? %>
45
+ <% if is_persisted_attachment %>
46
+ <%= form.hidden_field attribute, value: attachment.id, id: "hidden_#{attribute}_#{attachment.id}" %>
47
+ <% else %>
48
+ <%= form.hidden_field attribute, value: attachment_blob.signed_id, id: "hidden_#{attribute}_#{attachment_blob.id}" %>
49
+ <% end %>
50
+ <% end %>
43
51
  </div>
44
52
  <% end %>
45
53
  </div>
@@ -30,6 +30,10 @@ module Decidim
30
30
  form.send(:custom_label, attribute, options[:label], { required: required?, for: nil })
31
31
  end
32
32
 
33
+ def paragraph
34
+ form.send(:custom_paragraph, attribute, options[:label], { required: required? })
35
+ end
36
+
33
37
  def button_label
34
38
  return button_edit_label if attachments.count.positive?
35
39
 
@@ -71,13 +75,16 @@ module Decidim
71
75
  end
72
76
 
73
77
  # By default FoundationRailsHelper adds form errors next to input, but since input is in the modal
74
- # and modal is hidden by default, we need to add an additional validation field to the form.
78
+ # and modal is hidden by default, we add a hidden checkbox field to handle HTML5 validation.
75
79
  # This should only be necessary when file is required by the form.
80
+ # Note: Validation errors are now displayed in the main form area, not inside the modal.
76
81
  def input_validation_field
77
82
  object_name = form.object.present? ? "#{form.object.model_name.param_key}[#{add_attribute}_validation]" : "#{add_attribute}_validation"
78
- input = check_box_tag object_name, 1, attachments.present?, class: "reset-defaults", hidden: true, label: false, required: required?
79
- message = form.send(:abide_error_element, add_attribute) + form.send(:error_and_help_text, add_attribute)
80
- input + message
83
+ check_box_tag object_name, 1, attachments.present?, class: "reset-defaults", hidden: true, label: false, required: required?, id: validation_field_id
84
+ end
85
+
86
+ def validation_field_id
87
+ "#{attribute}_validation"
81
88
  end
82
89
 
83
90
  def explanation
@@ -121,7 +128,16 @@ module Decidim
121
128
  @attachments = begin
122
129
  attachments = options[:attachments] || form.object.send(attribute)
123
130
  attachments = Array(attachments).compact_blank
124
- attachments.map { |attachment| attachment.is_a?(String) ? ActiveStorage::Blob.find_signed(attachment) : attachment }
131
+ attachments.map do |attachment|
132
+ case attachment
133
+ when String
134
+ ActiveStorage::Blob.find_signed(attachment)
135
+ when Integer
136
+ Decidim::Attachment.find_by(id: attachment)
137
+ else
138
+ attachment
139
+ end
140
+ end.compact
125
141
  end
126
142
  end
127
143
 
@@ -41,8 +41,9 @@ module Decidim
41
41
 
42
42
  def create_attachments(first_weight: 0)
43
43
  weight = first_weight
44
- # Add the weights first to the old document
45
- @form.documents.each do |document|
44
+ # Add the weights first to the old documents
45
+ document_ids = keep_ids
46
+ Decidim::Attachment.where(id: document_ids).each do |document|
46
47
  document.update!(weight:)
47
48
  weight += 1
48
49
  end
@@ -59,7 +60,7 @@ module Decidim
59
60
  documents = include_all_attachments ? documents_attached_to.attachments.with_attached_file : documents_attached_to.documents
60
61
 
61
62
  documents.each do |document|
62
- document.destroy! if @form.documents.map(&:id).exclude? document.id
63
+ document.destroy! unless keep_ids.include?(document.id)
63
64
  end
64
65
 
65
66
  documents_attached_to.reload
@@ -98,5 +99,21 @@ module Decidim
98
99
  def blob(signed_id)
99
100
  ActiveStorage::Blob.find_signed(signed_id)
100
101
  end
102
+
103
+ def keep_ids
104
+ documents_array = Array(@form.documents)
105
+ documents_array.map do |doc|
106
+ case doc
107
+ when Decidim::Attachment
108
+ doc.id
109
+ when Integer
110
+ doc
111
+ when String
112
+ doc.match?(/\A\d+\z/) ? doc.to_i : nil
113
+ when Hash
114
+ (doc[:id] || doc["id"]).to_i
115
+ end
116
+ end.compact
117
+ end
101
118
  end
102
119
  end
@@ -9,8 +9,6 @@ module Decidim
9
9
  skip_before_action :verify_organization
10
10
 
11
11
  before_action :check_organization!,
12
- :check_authenticated!,
13
- :check_user_belongs_to_organization,
14
12
  :validate_direct_upload
15
13
  end
16
14
 
@@ -42,16 +40,6 @@ module Decidim
42
40
  head :unauthorized if current_organization.blank? && current_admin.blank?
43
41
  end
44
42
 
45
- def check_authenticated!
46
- head :unauthorized if current_user.blank? && current_admin.blank?
47
- end
48
-
49
- def check_user_belongs_to_organization
50
- return if current_admin.present?
51
-
52
- head :unauthorized unless current_organization == current_user.organization
53
- end
54
-
55
43
  def allowed_extensions
56
44
  if user_has_elevated_role?
57
45
  current_organization.settings.upload_allowed_file_extensions_admin
@@ -71,6 +59,8 @@ module Decidim
71
59
  private
72
60
 
73
61
  def user_has_elevated_role?
62
+ return false if current_user.blank? || current_organization.blank? || current_user.organization != current_organization
63
+
74
64
  [
75
65
  current_user&.admin?,
76
66
  defined?(Decidim::Assemblies::AssembliesWithUserRole) && Decidim::Assemblies::AssembliesWithUserRole.for(current_user).any?,
@@ -9,6 +9,8 @@ module Decidim
9
9
 
10
10
  before_action :check_sign_in_enabled, only: :create
11
11
 
12
+ rescue_from ActionController::InvalidAuthenticityToken, with: :redirect_to_referer_or_path
13
+
12
14
  def create
13
15
  super do |user|
14
16
  if user.admin?
@@ -44,6 +46,11 @@ module Decidim
44
46
 
45
47
  private
46
48
 
49
+ def redirect_to_referer_or_path
50
+ set_flash_message(:alert, "csrf_token", scope: "devise.failure")
51
+ redirect_back(fallback_location: root_path) && return
52
+ end
53
+
47
54
  def check_sign_in_enabled
48
55
  redirect_to new_user_session_path unless current_organization.sign_in_enabled?
49
56
  end
@@ -117,9 +117,9 @@ module Decidim
117
117
  end
118
118
 
119
119
  def message(value)
120
- return content_tag(:div, value, class: "flash__message") unless value.is_a?(Hash)
120
+ return content_tag(:p, value, class: "flash__message") unless value.is_a?(Hash)
121
121
 
122
- content_tag(:div, class: "flash__message") do
122
+ content_tag(:p, class: "flash__message") do
123
123
  concat value[:title]
124
124
  concat content_tag(:span, value[:body], class: "flash__message-body")
125
125
  end
@@ -11,7 +11,7 @@ module Decidim
11
11
  manifest.name == name.to_sym
12
12
  end
13
13
 
14
- collection = export_manifest.collection.call(participatory_space)
14
+ collection = export_manifest.collection.call(participatory_space, user)
15
15
  serializer = export_manifest.serializer
16
16
 
17
17
  export_data = Decidim::Exporters.find_exporter(format).new(collection, serializer).export
@@ -4,8 +4,14 @@ module Decidim
4
4
  # Update search indexes for each descendants of a given element
5
5
  class FindAndUpdateDescendantsJob < ApplicationJob
6
6
  queue_as :default
7
+ MAX_DEPTH = 5
8
+
9
+ def perform(element, current_depth = 0)
10
+ if current_depth >= MAX_DEPTH
11
+ Rails.logger.warn "Max depth of #{MAX_DEPTH} reached for element #{element.class.name} with id #{element.id}. Stopping recursion."
12
+ return
13
+ end
7
14
 
8
- def perform(element)
9
15
  descendants_collector = components_for(element)
10
16
  descendants_collector << element.comments.to_a if element.respond_to?(:comments)
11
17
 
@@ -14,7 +20,7 @@ module Decidim
14
20
  descendants_collector.each do |descendants|
15
21
  next if descendants.blank?
16
22
 
17
- Decidim::UpdateSearchIndexesJob.perform_later(descendants)
23
+ Decidim::UpdateSearchIndexesJob.perform_later(descendants, current_depth + 1)
18
24
  end
19
25
  end
20
26
 
@@ -4,8 +4,8 @@ module Decidim
4
4
  class UpdateSearchIndexesJob < ApplicationJob
5
5
  queue_as :default
6
6
 
7
- def perform(elements)
8
- elements.each { |element| element.try(:try_update_index_for_search_resource) }
7
+ def perform(elements, current_depth = 0)
8
+ elements.each { |element| element.try(:try_update_index_for_search_resource, current_depth) }
9
9
  end
10
10
  end
11
11
  end
@@ -9,7 +9,7 @@ module Decidim
9
9
  include Traceable
10
10
 
11
11
  before_save :set_content_type_and_size, if: :attached?
12
- before_validation :set_link_content_type_and_size, if: :link?
12
+ before_validation :set_link_content_type_and_size, if: :editable_link?
13
13
 
14
14
  translatable_fields :title, :description
15
15
  belongs_to :attachment_collection, class_name: "Decidim::AttachmentCollection", optional: true
@@ -69,6 +69,20 @@ module Decidim
69
69
  link.present?
70
70
  end
71
71
 
72
+ # Whether this attachment is a link that can be edited or not.
73
+ #
74
+ # Returns Boolean.
75
+ def editable_link?
76
+ !destroyed? && !frozen? && link?
77
+ end
78
+
79
+ # Whether this attachment has a file or not.
80
+ #
81
+ # Returns Boolean.
82
+ def file?
83
+ file.attached?
84
+ end
85
+
72
86
  # Which kind of file this is.
73
87
  #
74
88
  # Returns String.
@@ -123,5 +137,12 @@ module Decidim
123
137
  def self.log_presenter_class_for(_log)
124
138
  Decidim::AdminLog::AttachmentPresenter
125
139
  end
140
+
141
+ def can_participate?(user)
142
+ return true unless attached_to
143
+ return true unless attached_to.respond_to?(:can_participate?)
144
+
145
+ attached_to.can_participate?(user)
146
+ end
126
147
  end
127
148
  end
@@ -124,6 +124,13 @@ const createDropdown = (component) => {
124
124
  * @return {void}
125
125
  */
126
126
  const createDialog = (component) => {
127
+ const getFocusableElements = (container) => {
128
+ const selectors = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])";
129
+ return Array.from(container.querySelectorAll(selectors)).filter(
130
+ (el) => el.offsetParent !== null
131
+ );
132
+ };
133
+
127
134
  const {
128
135
  dataset: { dialog, ...attrs }
129
136
  } = component;
@@ -145,11 +152,33 @@ const createDialog = (component) => {
145
152
  backdropSelector: `[data-dialog="${dialog}"]`,
146
153
  enableAutoFocus: false,
147
154
  onOpen: (params, trigger) => {
155
+ const keyHandler = (event) => {
156
+ if (event.key !== "Tab") {
157
+ return;
158
+ }
159
+ const focusable = getFocusableElements(params);
160
+ if (focusable.length === 0) {
161
+ return;
162
+ }
163
+ if (event.shiftKey && document.activeElement === focusable[0]) {
164
+ event.preventDefault();
165
+ focusable[focusable.length - 1].focus({ preventScroll: true });
166
+ } else if (!event.shiftKey && document.activeElement === focusable[focusable.length - 1]) {
167
+ event.preventDefault();
168
+ focusable[0].focus({ preventScroll: true });
169
+ }
170
+ };
171
+ params._focusTrapHandler = keyHandler;
172
+ params.addEventListener("keydown", keyHandler);
148
173
  setFocusOnTitle(params);
149
174
  window.focusGuard.trap(params, trigger);
150
175
  params.dispatchEvent(new CustomEvent("open.dialog"));
151
176
  },
152
177
  onClose: (params) => {
178
+ if (params._focusTrapHandler) {
179
+ params.removeEventListener("keydown", params._focusTrapHandler);
180
+ Reflect.deleteProperty(params, "_focusTrapHandler");
181
+ }
153
182
  window.focusGuard.disable();
154
183
  params.dispatchEvent(new CustomEvent("close.dialog"));
155
184
  },
@@ -0,0 +1,81 @@
1
+ /* global jest */
2
+
3
+ import { createDialog } from "src/decidim/a11y"
4
+
5
+ describe("a11y dialog focus trap", () => {
6
+ const dialogHtml = `
7
+ <button id="open-btn" data-dialog-open="testDialog">Open</button>
8
+ <div id="test-dialog" data-dialog="testDialog">
9
+ <div data-dialog-container>
10
+ <h2 id="dialog-title-testDialog" tabindex="-1" data-dialog-title>Test Dialog</h2>
11
+ <a href="#">Link 1</a>
12
+ <button>Button 1</button>
13
+ <input type="text" />
14
+ <button>Button 2</button>
15
+ <a href="#">Link 2</a>
16
+ </div>
17
+ <div data-dialog-actions>
18
+ <button data-dialog-close="testDialog">Close</button>
19
+ </div>
20
+ </div>
21
+ `;
22
+
23
+ beforeEach(() => {
24
+ document.body.innerHTML = dialogHtml;
25
+ window.Decidim = {
26
+ currentDialogs: {}
27
+ };
28
+ window.focusGuard = {
29
+ trap: jest.fn(),
30
+ disable: jest.fn()
31
+ };
32
+ });
33
+
34
+ describe("keydown handler", () => {
35
+ let dialogEl = null;
36
+
37
+ beforeEach(() => {
38
+ const component = document.querySelector("[data-dialog]");
39
+ createDialog(component);
40
+ dialogEl = document.querySelector("[data-dialog='testDialog']");
41
+ // Get the dialog from Decidim.currentDialogs and open it
42
+ const dialog = window.Decidim.currentDialogs.testDialog;
43
+ dialog.open();
44
+ });
45
+
46
+ it("adds keydown handler on open", () => {
47
+ expect(dialogEl._focusTrapHandler).toBeDefined();
48
+ });
49
+
50
+ it("handles Tab key", () => {
51
+ const selectors = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])";
52
+ const tabbableElements = Array.from(dialogEl.querySelectorAll(selectors)).filter(
53
+ (el) => el.offsetParent || el.offsetParent === null
54
+ );
55
+ tabbableElements[tabbableElements.length - 1].focus();
56
+
57
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true });
58
+ const preventDefault = jest.fn();
59
+ event.preventDefault = preventDefault;
60
+
61
+ dialogEl.dispatchEvent(event);
62
+
63
+ expect(preventDefault).toHaveBeenCalled();
64
+ });
65
+ });
66
+
67
+ describe("onClose cleanup", () => {
68
+ it("removes the keydown handler on close", () => {
69
+ const component = document.querySelector("[data-dialog]");
70
+ createDialog(component);
71
+ const dialogEl = document.querySelector("[data-dialog='testDialog']");
72
+
73
+ const dialog = window.Decidim.currentDialogs.testDialog;
74
+ dialog.open();
75
+ expect(dialogEl._focusTrapHandler).toBeDefined();
76
+
77
+ dialog.close();
78
+ expect(dialogEl._focusTrapHandler).toBeUndefined();
79
+ });
80
+ });
81
+ });
@@ -162,7 +162,9 @@ export const initializeConfirm = () => {
162
162
  return handleDocumentEvent(ev, [
163
163
  Rails.linkClickSelector,
164
164
  Rails.buttonClickSelector,
165
- Rails.formInputClickSelector
165
+ Rails.formInputClickSelector,
166
+ 'button[data-confirm][type="button"]',
167
+ "form button[data-confirm]"
166
168
  ]);
167
169
  });
168
170
  document.addEventListener("change", (ev) => {
@@ -180,6 +182,11 @@ export const initializeConfirm = () => {
180
182
  $(Rails.formInputClickSelector).on("click.confirm", (ev) => {
181
183
  handleConfirm(ev, getMatchingEventTarget(ev, Rails.formInputClickSelector));
182
184
  });
185
+
186
+ // Handle button[type="button"] with data-confirm inside forms
187
+ $('button[data-confirm][type="button"]').on("click.confirm", (ev) => {
188
+ handleConfirm(ev, ev.currentTarget);
189
+ });
183
190
  });
184
191
  };
185
192