decidim-core 0.25.0 → 0.26.0.rc2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of decidim-core might be problematic. Click here for more details.

Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/activity_cell.rb +2 -1
  3. data/app/cells/decidim/author/flag_user.erb +1 -1
  4. data/app/cells/decidim/author/profile_inline.erb +1 -1
  5. data/app/cells/decidim/author/withdraw.erb +2 -2
  6. data/app/cells/decidim/author_cell.rb +32 -0
  7. data/app/cells/decidim/card_m_cell.rb +1 -1
  8. data/app/cells/decidim/content_blocks/cta_cell.rb +1 -1
  9. data/app/cells/decidim/content_blocks/hero_cell.rb +1 -1
  10. data/app/cells/decidim/content_blocks/highlighted_content_banner/show.erb +1 -1
  11. data/app/cells/decidim/content_blocks/how_to_participate/show.erb +1 -1
  12. data/app/cells/decidim/content_blocks/last_activity_cell.rb +1 -1
  13. data/app/cells/decidim/content_blocks/stats_cell.rb +12 -0
  14. data/app/cells/decidim/endorsers_list_cell.rb +3 -1
  15. data/app/cells/decidim/flag_modal/flag_user.erb +2 -2
  16. data/app/cells/decidim/flag_modal/show.erb +2 -2
  17. data/app/cells/decidim/flag_modal_cell.rb +10 -0
  18. data/app/cells/decidim/following/show.erb +17 -8
  19. data/app/cells/decidim/following_cell.rb +6 -2
  20. data/app/cells/decidim/notification/show.erb +31 -0
  21. data/app/cells/decidim/notification_cell.rb +20 -0
  22. data/app/cells/decidim/notifications/show.erb +1 -24
  23. data/app/cells/decidim/notifications_cell.rb +0 -1
  24. data/app/cells/decidim/user_conversation/conversation_header.erb +1 -1
  25. data/app/cells/decidim/user_conversation/show.erb +4 -2
  26. data/app/cells/decidim/user_conversations/conversation_item.erb +1 -1
  27. data/app/commands/decidim/create_editor_image.rb +41 -0
  28. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +15 -0
  29. data/app/controllers/concerns/decidim/safe_redirect.rb +14 -3
  30. data/app/controllers/decidim/application_controller.rb +1 -0
  31. data/app/controllers/decidim/cookie_policy_controller.rb +2 -0
  32. data/app/controllers/decidim/editor_images_controller.rb +47 -0
  33. data/app/controllers/decidim/user_activities_controller.rb +2 -1
  34. data/app/forms/decidim/editor_image_form.rb +16 -0
  35. data/app/helpers/decidim/amendments_helper.rb +1 -1
  36. data/app/helpers/decidim/application_helper.rb +2 -2
  37. data/app/helpers/decidim/messaging/conversation_helper.rb +32 -3
  38. data/app/helpers/decidim/resource_versions_helper.rb +1 -1
  39. data/app/helpers/decidim/sanitize_helper.rb +65 -0
  40. data/app/models/decidim/editor_image.rb +14 -0
  41. data/app/models/decidim/messaging/conversation.rb +9 -0
  42. data/app/models/decidim/participatory_space_private_user.rb +16 -0
  43. data/app/models/decidim/user.rb +3 -9
  44. data/app/models/decidim/user_base_entity.rb +24 -13
  45. data/app/models/decidim/user_group.rb +40 -0
  46. data/app/packs/entrypoints/decidim_core.js +1 -0
  47. data/app/packs/src/decidim/dialog_mode.js +143 -0
  48. data/app/packs/src/decidim/dialog_mode.test.js +168 -0
  49. data/app/packs/src/decidim/editor.js +56 -14
  50. data/app/packs/src/decidim/form_attachments.js +5 -0
  51. data/app/packs/src/decidim/geocoding/attach_input.js +11 -2
  52. data/app/packs/src/decidim/index.js +4 -0
  53. data/app/packs/src/decidim/input_emoji.js +10 -1
  54. data/app/packs/src/decidim/vendor/image-resize.min.js +3 -0
  55. data/app/packs/src/decidim/vendor/image-upload.min.js +8 -0
  56. data/app/packs/stylesheets/decidim/extras/_extras.scss +0 -1
  57. data/app/packs/stylesheets/decidim/extras/_quill.scss +7 -0
  58. data/app/packs/stylesheets/decidim/modules/_buttons.scss +11 -4
  59. data/app/packs/stylesheets/decidim/modules/_cards.scss +4 -0
  60. data/app/packs/stylesheets/decidim/modules/_layout.scss +1 -1
  61. data/app/packs/stylesheets/decidim/modules/_timeline.scss +1 -1
  62. data/app/presenters/decidim/nil_presenter.rb +2 -2
  63. data/app/presenters/decidim/notification_presenter.rb +25 -0
  64. data/app/presenters/decidim/official_author_presenter.rb +1 -1
  65. data/app/presenters/decidim/validation_errors_presenter.rb +27 -0
  66. data/app/queries/decidim/similar_emendations.rb +1 -1
  67. data/app/resolvers/decidim/core/metric_resolver.rb +1 -1
  68. data/app/services/decidim/activity_search.rb +2 -2
  69. data/app/services/decidim/email_notification_generator.rb +4 -1
  70. data/app/services/decidim/html_truncation.rb +130 -0
  71. data/app/services/decidim/open_data_exporter.rb +29 -5
  72. data/app/services/decidim/resource_search.rb +1 -1
  73. data/app/uploaders/decidim/editor_image_uploader.rb +6 -0
  74. data/app/validators/password_validator.rb +123 -0
  75. data/app/views/decidim/account/_password_fields.html.erb +1 -1
  76. data/app/views/decidim/devise/passwords/edit.html.erb +1 -1
  77. data/app/views/decidim/devise/registrations/new.html.erb +1 -1
  78. data/app/views/decidim/devise/shared/_omniauth_buttons_mini.html.erb +6 -4
  79. data/app/views/decidim/messaging/conversations/_conversation.html.erb +9 -3
  80. data/app/views/decidim/messaging/conversations/_messages.html.erb +8 -2
  81. data/app/views/decidim/messaging/conversations/_show.html.erb +10 -12
  82. data/app/views/decidim/messaging/conversations/show.html.erb +4 -2
  83. data/app/views/decidim/newsletters/show.html.erb +1 -1
  84. data/app/views/decidim/notification_mailer/event_received.html.erb +17 -0
  85. data/app/views/decidim/pages/_tabbed.html.erb +1 -1
  86. data/app/views/decidim/searches/_filters_small_view.html.erb +3 -3
  87. data/app/views/decidim/shared/_login_modal.html.erb +5 -5
  88. data/app/views/decidim/shared/_orders.html.erb +1 -1
  89. data/app/views/decidim/shared/_results_per_page.html.erb +1 -1
  90. data/app/views/decidim/shared/participatory_space_filters/_filters_small_view.html.erb +3 -3
  91. data/app/views/layouts/decidim/_application.html.erb +1 -12
  92. data/app/views/layouts/decidim/_head.html.erb +4 -0
  93. data/app/views/layouts/decidim/_language_chooser.html.erb +1 -1
  94. data/app/views/layouts/decidim/_meta_tags_config.html.erb +11 -0
  95. data/app/views/layouts/decidim/_wrapper.html.erb +1 -1
  96. data/config/brakeman.ignore +149 -0
  97. data/config/initializers/devise.rb +1 -1
  98. data/config/initializers/rack_attack.rb +23 -21
  99. data/config/locales/ar.yml +8 -0
  100. data/config/locales/ca.yml +43 -0
  101. data/config/locales/cs.yml +61 -0
  102. data/config/locales/en.yml +47 -0
  103. data/config/locales/es-MX.yml +42 -0
  104. data/config/locales/es-PY.yml +42 -0
  105. data/config/locales/es.yml +42 -0
  106. data/config/locales/eu.yml +27 -12
  107. data/config/locales/fi-plain.yml +42 -0
  108. data/config/locales/fi.yml +42 -0
  109. data/config/locales/fr-CA.yml +43 -0
  110. data/config/locales/fr.yml +75 -28
  111. data/config/locales/gl.yml +6 -0
  112. data/config/locales/it.yml +11 -0
  113. data/config/locales/ja.yml +85 -49
  114. data/config/locales/lb-LU.yml +1354 -0
  115. data/config/locales/lb.yml +1 -1
  116. data/config/locales/nl.yml +51 -0
  117. data/config/locales/pl.yml +5 -5
  118. data/config/locales/pt-BR.yml +1 -1
  119. data/config/locales/ro-RO.yml +275 -252
  120. data/config/locales/sv.yml +45 -2
  121. data/config/locales/val-ES.yml +1 -0
  122. data/config/routes.rb +2 -0
  123. data/db/migrate/20210730112319_create_decidim_editor_images.rb +12 -0
  124. data/db/migrate/20211126183540_add_timestamps_to_content_blocks.rb +14 -0
  125. data/db/seeds.rb +16 -14
  126. data/lib/decidim/api/functions/user_entity_finder.rb +2 -1
  127. data/lib/decidim/api/functions/user_entity_list.rb +3 -1
  128. data/lib/decidim/api/input_sorts/component_input_sort.rb +1 -1
  129. data/lib/decidim/common_passwords.rb +56 -0
  130. data/lib/decidim/content_parsers/inline_images_parser.rb +68 -0
  131. data/lib/decidim/content_parsers.rb +1 -0
  132. data/lib/decidim/content_renderers/link_renderer.rb +85 -1
  133. data/lib/decidim/content_renderers/user_group_renderer.rb +1 -1
  134. data/lib/decidim/content_renderers/user_renderer.rb +1 -1
  135. data/lib/decidim/core/engine.rb +7 -12
  136. data/lib/decidim/core/test/factories.rb +7 -1
  137. data/lib/decidim/core/test/shared_examples/translated_event_examples.rb +131 -0
  138. data/lib/decidim/core/test.rb +1 -0
  139. data/lib/decidim/core/version.rb +1 -1
  140. data/lib/decidim/core.rb +33 -5
  141. data/lib/decidim/db/common-passwords.txt +128420 -0
  142. data/lib/decidim/etherpad/pad.rb +48 -0
  143. data/lib/decidim/etherpad.rb +7 -0
  144. data/lib/decidim/events/base_event.rb +18 -0
  145. data/lib/decidim/events/machine_translated_event.rb +36 -0
  146. data/lib/decidim/events/user_group_event.rb +1 -3
  147. data/lib/decidim/events.rb +1 -0
  148. data/lib/decidim/exporters/csv.rb +7 -7
  149. data/lib/decidim/faker/localized.rb +15 -6
  150. data/lib/decidim/form_builder.rb +14 -4
  151. data/lib/decidim/has_attachments.rb +11 -4
  152. data/lib/decidim/has_component.rb +4 -0
  153. data/lib/decidim/importers/import_manifest.rb +103 -3
  154. data/lib/decidim/organization_settings.rb +1 -1
  155. data/lib/decidim/paddable.rb +1 -9
  156. data/lib/decidim/participable.rb +5 -0
  157. data/lib/decidim/resourceable.rb +2 -9
  158. data/lib/decidim/searchable.rb +2 -2
  159. data/lib/decidim/settings_manifest.rb +2 -0
  160. data/lib/decidim/translatable_attributes.rb +6 -6
  161. data/lib/decidim/view_model.rb +10 -0
  162. data/lib/tasks/decidim_active_storage_migration_tasks.rake +68 -0
  163. data/lib/tasks/decidim_webpacker_tasks.rake +4 -10
  164. metadata +70 -78
  165. data/app/packs/stylesheets/decidim/extras/_social_icons_mini.scss +0 -11
@@ -3,14 +3,43 @@
3
3
  module Decidim
4
4
  module Messaging
5
5
  module ConversationHelper
6
+ def conversation_name_for(users)
7
+ return content_tag(:span, t("decidim.profile.deleted"), class: "label label--small label--basic") if users.first.deleted?
8
+
9
+ content_tag = content_tag(:strong, users.first.name)
10
+ content_tag << tag.br
11
+ content_tag << content_tag(:span, "@#{users.first.nickname}", class: "muted")
12
+ content_tag
13
+ end
14
+
15
+ def conversation_label_for(participants)
16
+ return t("title", scope: "decidim.messaging.conversations.show", usernames: username_list(participants)) unless participants.count == 1
17
+
18
+ chat_with_user = if participants.first.deleted?
19
+ t("decidim.profile.deleted")
20
+ else
21
+ "#{participants.first.name} (@#{participants.first.nickname})"
22
+ end
23
+
24
+ "#{t("chat_with", scope: "decidim.messaging.conversations.show")} #{chat_with_user}"
25
+ end
26
+
6
27
  #
7
28
  # Generates a visualization of users for listing conversations threads
8
29
  #
9
30
  def username_list(users, shorten: false)
10
- return users.pluck(:name).join(", ") unless shorten
11
- return users.pluck(:name).join(", ") unless users.count > 3
31
+ content_tags = []
32
+ first_users = shorten ? users.first(3) : users
33
+ deleted_user_tag = content_tag(:span, t("decidim.profile.deleted"), class: "label label--small label--basic")
34
+ first_users.each do |u|
35
+ content_tags.push(u.deleted? ? deleted_user_tag : content_tag(:strong, u.name))
36
+ end
37
+
38
+ return content_tags.join(", ") unless shorten
39
+ return content_tags.join(", ") unless users.count > 3
12
40
 
13
- "#{users.first(3).pluck(:name).join(", ")} + #{users.count - 3}"
41
+ content_tags.push(content_tag(:strong, " + #{users.count - 3}"))
42
+ content_tags.join(", ")
14
43
  end
15
44
 
16
45
  #
@@ -13,7 +13,7 @@ module Decidim
13
13
  #
14
14
  # Returns a String.
15
15
  def resource_version(resource, options = {})
16
- return unless resource.respond_to?(:versions) && resource.versions.present?
16
+ return unless resource.respond_to?(:versions) && resource.versions_count.positive?
17
17
 
18
18
  html = []
19
19
  html << resource_version_number(resource.versions_count)
@@ -5,6 +5,7 @@ module Decidim
5
5
  module SanitizeHelper
6
6
  def self.included(base)
7
7
  base.include ActionView::Helpers::SanitizeHelper
8
+ base.include ActionView::Helpers::TagHelper
8
9
  end
9
10
 
10
11
  # Public: It sanitizes a user-inputted string with the
@@ -30,6 +31,10 @@ module Decidim
30
31
  end
31
32
  end
32
33
 
34
+ def decidim_sanitize_editor(html, options = {})
35
+ content_tag(:div, decidim_sanitize(html, options), class: %w(ql-editor ql-reset-decidim))
36
+ end
37
+
33
38
  def decidim_html_escape(text)
34
39
  ERB::Util.unwrapped_html_escape(text.to_str)
35
40
  end
@@ -37,5 +42,65 @@ module Decidim
37
42
  def decidim_url_escape(text)
38
43
  decidim_html_escape(text).sub(/^javascript:/, "")
39
44
  end
45
+
46
+ private
47
+
48
+ # Maintains the paragraphs and lists separations with their bullet points and
49
+ # list numberings where appropriate.
50
+ #
51
+ # Returns a String.
52
+ def sanitize_text(text)
53
+ add_line_feeds(sanitize_ordered_lists(sanitize_unordered_lists(text)))
54
+ end
55
+
56
+ def sanitize_unordered_lists(text)
57
+ text.gsub(%r{(?=.*</ul>)(?!.*?<li>.*?</ol>.*?</ul>)<li>}) { |li| "#{li}• " }
58
+ end
59
+
60
+ def sanitize_ordered_lists(text)
61
+ i = 0
62
+
63
+ text.gsub(%r{(?=.*</ol>)(?!.*?<li>.*?</ul>.*?</ol>)<li>}) do |li|
64
+ i += 1
65
+
66
+ li + "#{i}. "
67
+ end
68
+ end
69
+
70
+ def add_line_feeds_to_paragraphs(text)
71
+ text.gsub("</p>") { |p| "#{p}\n\n" }
72
+ end
73
+
74
+ def add_line_feeds_to_list_items(text)
75
+ text.gsub("</li>") { |li| "#{li}\n" }
76
+ end
77
+
78
+ # Adds line feeds after the paragraph and list item closing tags.
79
+ #
80
+ # Returns a String.
81
+ def add_line_feeds(text)
82
+ add_line_feeds_to_paragraphs(add_line_feeds_to_list_items(text))
83
+ end
84
+
85
+ def content_handle_locale(body, all_locales, extras, links, strip_tags)
86
+ handle_locales(body, all_locales) do |content|
87
+ content = strip_tags(sanitize_text(content)) if strip_tags
88
+
89
+ renderer = Decidim::ContentRenderers::HashtagRenderer.new(content)
90
+ content = renderer.render(links: links, extras: extras).html_safe
91
+
92
+ content = Decidim::ContentRenderers::LinkRenderer.new(content).render if links
93
+ content
94
+ end
95
+ end
96
+
97
+ def render_sanitized_content(resource, method)
98
+ content = present(resource).send(method, links: true, strip_tags: !safe_content?)
99
+ content = simple_format(content, {}, sanitize: false)
100
+
101
+ return content unless safe_content?
102
+
103
+ decidim_sanitize_editor(content)
104
+ end
40
105
  end
41
106
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ # Images attached to editors.
5
+ class EditorImage < ApplicationRecord
6
+ include Decidim::HasUploadValidations
7
+
8
+ belongs_to :author, foreign_key: :decidim_author_id, class_name: "Decidim::User"
9
+ belongs_to :organization, foreign_key: :decidim_organization_id, class_name: "Decidim::Organization"
10
+
11
+ has_one_attached :file
12
+ validates_upload :file, uploader: Decidim::EditorImageUploader
13
+ end
14
+ end
@@ -131,6 +131,15 @@ module Decidim
131
131
  blocked.blank?
132
132
  end
133
133
 
134
+ #
135
+ # Given a user, returns if ALL the interlocutors have their accounts deleted
136
+ #
137
+ # @return Boolean
138
+ #
139
+ def with_deleted_users?(user)
140
+ interlocutors(user).all?(&:deleted?)
141
+ end
142
+
134
143
  #
135
144
  # Given a user, returns if the user is participating in the conversation
136
145
  # for groups being part of a conversation all their admin member are accepted
@@ -22,6 +22,22 @@ module Decidim
22
22
  Decidim::AdminLog::ParticipatorySpacePrivateUserPresenter
23
23
  end
24
24
 
25
+ ransacker :name do
26
+ Arel.sql(%{("decidim_users"."name")::text})
27
+ end
28
+
29
+ ransacker :email do
30
+ Arel.sql(%{("decidim_users"."email")::text})
31
+ end
32
+
33
+ ransacker :invitation_sent_at do
34
+ Arel.sql(%{("invitation_sent_at")::text})
35
+ end
36
+
37
+ ransacker :invitation_accepted_at do
38
+ Arel.sql(%{("invitation_accepted_at")::text})
39
+ end
40
+
25
41
  private
26
42
 
27
43
  # Private: check if the participatory space and the user have the same organization
@@ -63,17 +63,11 @@ module Decidim
63
63
  scope :officialized, -> { where.not(officialized_at: nil) }
64
64
  scope :not_officialized, -> { where(officialized_at: nil) }
65
65
 
66
- scope :confirmed, -> { where.not(confirmed_at: nil) }
67
- scope :not_confirmed, -> { where(confirmed_at: nil) }
68
-
69
- scope :blocked, -> { where(blocked: true) }
70
- scope :not_blocked, -> { where(blocked: false) }
71
-
72
66
  scope :interested_in_scopes, lambda { |scope_ids|
73
67
  actual_ids = scope_ids.select(&:presence)
74
68
  if actual_ids.count.positive?
75
69
  ids = actual_ids.map(&:to_i).join(",")
76
- where("extended_data->'interested_scopes' @> ANY('{#{ids}}')")
70
+ where(Arel.sql("extended_data->'interested_scopes' @> ANY('{#{ids}}')").to_s)
77
71
  else
78
72
  # Do not apply the scope filter when there are scope ids available. Note
79
73
  # that the active record scope must always return an active record
@@ -92,8 +86,8 @@ module Decidim
92
86
  A: :name,
93
87
  datetime: :created_at
94
88
  },
95
- index_on_create: ->(user) { !user.deleted? },
96
- index_on_update: ->(user) { !user.deleted? })
89
+ index_on_create: ->(user) { !(user.deleted? || user.blocked?) },
90
+ index_on_update: ->(user) { !(user.deleted? || user.blocked?) })
97
91
 
98
92
  before_save :ensure_encrypted_password
99
93
 
@@ -25,26 +25,37 @@ module Decidim
25
25
 
26
26
  validates :name, format: { with: REGEXP_NAME }
27
27
 
28
- # Public: Returns a collection with all the entities this user is following.
28
+ scope :confirmed, -> { where.not(confirmed_at: nil) }
29
+ scope :not_confirmed, -> { where(confirmed_at: nil) }
30
+
31
+ scope :blocked, -> { where(blocked: true) }
32
+ scope :not_blocked, -> { where(blocked: false) }
33
+
34
+ # Public: Returns a collection with all the public entities this user is following.
29
35
  #
30
36
  # This can't be done as with a `has_many :following, through: :following_follows`
31
37
  # since it's a polymorphic relation and Rails doesn't know how to load it. With
32
38
  # this implementation we only query the database once for each kind of following.
33
39
  #
34
40
  # Returns an Array of Decidim::Followable
35
- def following
36
- @following ||= begin
37
- followings = following_follows.pluck(:decidim_followable_type, :decidim_followable_id)
38
- grouped_followings = followings.each_with_object({}) do |(type, following_id), all|
39
- all[type] ||= []
40
- all[type] << following_id
41
- all
42
- end
43
-
44
- grouped_followings.flat_map do |type, ids|
45
- type.constantize.where(id: ids)
46
- end
41
+ def public_followings
42
+ @public_followings ||= following_follows.select("array_agg(decidim_followable_id)")
43
+ .group(:decidim_followable_type)
44
+ .pluck(:decidim_followable_type, "array_agg(decidim_followable_id)")
45
+ .to_h
46
+ .flat_map do |type, ids|
47
+ only_public(type.constantize, ids)
47
48
  end
48
49
  end
50
+
51
+ private
52
+
53
+ def only_public(klass, ids)
54
+ scope = klass.where(id: ids)
55
+ scope = scope.public_spaces if klass.try(:participatory_space?)
56
+ scope = scope.includes(:component) if klass.try(:has_component?)
57
+ scope = scope.filter(&:visible?) if klass.method_defined?(:visible?)
58
+ scope
59
+ end
49
60
  end
50
61
  end
@@ -144,6 +144,46 @@ module Decidim
144
144
  @unread_messages_count_for[user.id] ||= Decidim::Messaging::Conversation.user_collection(self).unread_messages_by(user).count
145
145
  end
146
146
 
147
+ def self.state_eq(value)
148
+ send(value.to_sym) if %w(all pending rejected verified).include?(value)
149
+ end
150
+
151
+ def self.ransackable_scopes(_auth = nil)
152
+ [:state_eq]
153
+ end
154
+
155
+ scope :sort_by_users_count_asc, lambda {
156
+ order("users_count ASC NULLS FIRST")
157
+ }
158
+
159
+ scope :sort_by_users_count_desc, lambda {
160
+ order("users_count DESC NULLS LAST")
161
+ }
162
+
163
+ def self.sort_by_document_number_asc
164
+ order(Arel.sql("extended_data->>'document_number' ASC"))
165
+ end
166
+
167
+ def self.sort_by_document_number_desc
168
+ order(Arel.sql("extended_data->>'document_number' DESC"))
169
+ end
170
+
171
+ def self.sort_by_phone_asc
172
+ order(Arel.sql("extended_data->>'phone' ASC"))
173
+ end
174
+
175
+ def self.sort_by_phone_desc
176
+ order(Arel.sql("extended_data->>'phone' DESC"))
177
+ end
178
+
179
+ def self.sort_by_state_asc
180
+ order(Arel.sql("extended_data->>'rejected_at' ASC, extended_data->>'verified_at' ASC, deleted_at ASC"))
181
+ end
182
+
183
+ def self.sort_by_state_desc
184
+ order(Arel.sql("extended_data->>'rejected_at' DESC, extended_data->>'verified_at' DESC, deleted_at DESC"))
185
+ end
186
+
147
187
  private
148
188
 
149
189
  # Private: Checks if the state user group is correct.
@@ -31,6 +31,7 @@ import "src/decidim/account_form"
31
31
  import "src/decidim/data_picker"
32
32
  import "src/decidim/dropdowns_menus"
33
33
  import "src/decidim/append_redirect_url_to_modals"
34
+ import "src/decidim/form_attachments"
34
35
  import "src/decidim/form_validator"
35
36
  import "src/decidim/ajax_modals"
36
37
  import "src/decidim/conferences"
@@ -0,0 +1,143 @@
1
+ const focusGuardClass = "focusguard";
2
+ const focusableNodes = ["A", "IFRAME", "OBJECT", "EMBED"];
3
+ const focusableDisableableNodes = ["BUTTON", "INPUT", "TEXTAREA", "SELECT"];
4
+
5
+ const isFocusGuard = (element) => {
6
+ return element.classList.contains(focusGuardClass);
7
+ }
8
+
9
+ const isFocusable = (element) => {
10
+ if (focusableNodes.indexOf(element.nodeName) > -1) {
11
+ return true;
12
+ }
13
+ if (focusableDisableableNodes.indexOf(element.nodeName) > -1 || element.getAttribute("contenteditable")) {
14
+ if (element.getAttribute("disabled")) {
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+
20
+ const tabindex = parseInt(element.getAttribute("tabindex"), 10);
21
+ if (!isNaN(tabindex) && tabindex >= 0) {
22
+ return true;
23
+ }
24
+
25
+ return false;
26
+ }
27
+
28
+ const createFocusGuard = (position) => {
29
+ return $(`<div class="${focusGuardClass}" data-position="${position}" tabindex="0" aria-hidden="true"></div>`);
30
+ };
31
+
32
+ const handleContainerFocus = ($container, $guard) => {
33
+ const $reveal = $(".reveal:visible:last", $container);
34
+ if ($reveal.length > 0) {
35
+ handleContainerFocus($reveal, $guard);
36
+ return;
37
+ }
38
+
39
+ const $nodes = $("*:visible", $container);
40
+ let $target = null;
41
+
42
+ if ($guard.data("position") === "start") {
43
+ // Focus at the start guard, so focus the first focusable element after that
44
+ for (let ind = 0; ind < $nodes.length; ind += 1) {
45
+ if (!isFocusGuard($nodes[ind]) && isFocusable($nodes[ind])) {
46
+ $target = $($nodes[ind]);
47
+ break;
48
+ }
49
+ }
50
+ } else {
51
+ // Focus at the end guard, so focus the first focusable element after that
52
+ for (let ind = $nodes.length - 1; ind >= 0; ind -= 1) {
53
+ if (!isFocusGuard($nodes[ind]) && isFocusable($nodes[ind])) {
54
+ $target = $($nodes[ind]);
55
+ break;
56
+ }
57
+ }
58
+ }
59
+
60
+ if ($target) {
61
+ $target.trigger("focus");
62
+ } else {
63
+ // If no focusable element was found, blur the guard focus
64
+ $guard.blur();
65
+ }
66
+ };
67
+
68
+ /**
69
+ * A method to enable the dialog mode for the given dialog(s).
70
+ *
71
+ * This should be called when the dialog is opened. It implements two things for
72
+ * the dialog:
73
+ * 1. It places the focus to the title element making sure the screen reader
74
+ * focuses in the correct position of the document. Otherwise some screen
75
+ * readers continue reading outside of the document.
76
+ * 2. Document "tab guards" that force the keyboard focus within the modal when
77
+ * the user is using keyboard or keyboard emulating devices for browsing the
78
+ * document.
79
+ *
80
+ * The "tab guards" are added at the top and bottom of the document to keep the
81
+ * user's focus within the dialog if they accidentally or intentionally place
82
+ * the focus outside of the document, e.g. in different window or in the browser
83
+ * address bar. They guard the focus on both sides of the document returning
84
+ * focus back to the first or last focusable element within the dialog.
85
+ *
86
+ * @param {jQuery} $dialogs The jQuery element(s) to apply the mode for.
87
+ * @return {Void} Nothing
88
+ */
89
+ export default ($dialogs) => {
90
+ $dialogs.each((_i, dialog) => {
91
+ const $dialog = $(dialog);
92
+
93
+ const $container = $("body");
94
+ const $title = $(".reveal__title:first", $dialog);
95
+
96
+ if ($title.length > 0) {
97
+ // Focus on the title to make the screen reader to start reading the
98
+ // content within the modal.
99
+ $title.attr("tabindex", $title.attr("tabindex") || -1);
100
+ $title.trigger("focus");
101
+ }
102
+
103
+ // Once the final modal closes, remove the focus guards from the container
104
+ $dialog.off("closed.zf.reveal.focusguard").on("closed.zf.reveal.focusguard", () => {
105
+ $dialog.off("closed.zf.reveal.focusguard");
106
+
107
+ // After the last dialog is closed, the tab guards should be removed.
108
+ // Note that there may be multiple dialogs open on top of each other at
109
+ // the same time.
110
+ if ($(".reveal:visible", $container).length < 1) {
111
+ $(`> .${focusGuardClass}`, $container).remove();
112
+ }
113
+ });
114
+
115
+ // Check if the guards already exists due to some other dialog
116
+ const $guards = $(`> .${focusGuardClass}`, $container);
117
+ if ($guards.length > 0) {
118
+ // Make sure the guards are the first and last element as there have
119
+ // been changes in the DOM.
120
+ $guards.each((_j, guard) => {
121
+ const $guard = $(guard);
122
+ if ($guard.data("position") === "start") {
123
+ $container.prepend($guard);
124
+ } else {
125
+ $container.append($guard);
126
+ }
127
+ });
128
+
129
+ return;
130
+ }
131
+
132
+ // Add guards at the start and end of the document and attach their focus
133
+ // listeners
134
+ const $startGuard = createFocusGuard("start");
135
+ const $endGuard = createFocusGuard("end");
136
+
137
+ $container.prepend($startGuard);
138
+ $container.append($endGuard);
139
+
140
+ $startGuard.on("focus", () => handleContainerFocus($container, $startGuard));
141
+ $endGuard.on("focus", () => handleContainerFocus($container, $endGuard));
142
+ });
143
+ };
@@ -0,0 +1,168 @@
1
+ /* global jest, global */
2
+
3
+ // Mock jQuery because the visibility indicator works differently within jest.
4
+ // This fixes jQuery reporting $(".element").is(":visible") correctly during the
5
+ // tests and within foundation, too.
6
+ jest.mock("jquery", () => {
7
+ const jq = jest.requireActual("jquery");
8
+
9
+ jq.expr.pseudos.visible = (elem) => {
10
+ const display = global.window.getComputedStyle(elem).display;
11
+ return ["inline", "block", "inline-block"].includes(display);
12
+ };
13
+
14
+ return jq;
15
+ });
16
+
17
+ import $ from "jquery"; // eslint-disable-line id-length
18
+ import "foundation-sites";
19
+
20
+ import dialogMode from "./dialog_mode.js";
21
+
22
+ describe("dialogMode", () => {
23
+ const content = `
24
+ <div class="reveal" id="test-modal" data-reveal aria-modal="true" aria-labelledby="test-modal-label">
25
+ <div class="reveal__header">
26
+ <h3 id="test-modal-label" class="reveal__title">Testing modal</h3>
27
+ <button class="close-button" data-close aria-label="Close window"
28
+ type="button">
29
+ <span aria-hidden="true">&times;</span>
30
+ </button>
31
+ </div>
32
+ <div class="row">
33
+ <div class="columns medium-4 medium-centered">
34
+ <p>Here is some content within the modal.</p>
35
+ <button type="button" id="test-modal-button">Button at the bottom</button>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="reveal" id="test-modal-2" data-reveal aria-modal="true" aria-labelledby="test-modal-2-label">
41
+ <div class="reveal__header">
42
+ <h3 id="test-modal-2-label" class="reveal__title">Other testing modal</h3>
43
+ <button class="close-button" data-close aria-label="Close window"
44
+ type="button">
45
+ <span aria-hidden="true">&times;</span>
46
+ </button>
47
+ </div>
48
+ <div class="row">
49
+ <div class="columns medium-4 medium-centered">
50
+ <p>Here is some content within the other modal.</p>
51
+ <button type="button" id="test-modal-2-button">Button at the bottom</button>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ `;
56
+
57
+ beforeEach(() => {
58
+ $("body").html(content);
59
+
60
+ $(document).foundation();
61
+
62
+ // Make sure all reveals are hidden by default so that their visibility is
63
+ // correctly reported always.
64
+ $(".reveal").css("display", "none");
65
+
66
+ $(document).on("open.zf.reveal", (ev) => {
67
+ dialogMode($(ev.target));
68
+ });
69
+ });
70
+
71
+ it("focuses the title", () => {
72
+ $("#test-modal").foundation("open");
73
+
74
+ const $focused = $(document.activeElement);
75
+ expect($focused.is($("#test-modal-label"))).toBe(true);
76
+ });
77
+
78
+ it("adds the tab guads on both sides of the document", () => {
79
+ $("#test-modal").foundation("open");
80
+
81
+ const $first = $("body *:first");
82
+ const $last = $("body *:last");
83
+
84
+ expect($first[0].outerHTML).toEqual(
85
+ '<div class="focusguard" data-position="start" tabindex="0" aria-hidden="true"></div>'
86
+ );
87
+ expect($last[0].outerHTML).toEqual(
88
+ '<div class="focusguard" data-position="end" tabindex="0" aria-hidden="true"></div>'
89
+ );
90
+ });
91
+
92
+ it("removes the tab guards when the modal is closed", () => {
93
+ const $modal = $("#test-modal");
94
+ $modal.foundation("open");
95
+ $modal.foundation("close");
96
+
97
+ expect($(".focusguard").length).toEqual(0);
98
+ });
99
+
100
+ it("focuses the first focusable element when the start tab guard gets focus", () => {
101
+ const $modal = $("#test-modal");
102
+ $modal.foundation("open");
103
+
104
+ $(".focusguard[data-position='start']").trigger("focus");
105
+
106
+ const $focused = $(document.activeElement);
107
+ expect($focused.is($("#test-modal .close-button"))).toBe(true);
108
+ });
109
+
110
+ it("focuses the last focusable element when the end tab guard gets focus", () => {
111
+ const $modal = $("#test-modal");
112
+ $modal.foundation("open");
113
+
114
+ $(".focusguard[data-position='end']").trigger("focus");
115
+
116
+ const $focused = $(document.activeElement);
117
+ expect($focused.is($("#test-modal-button"))).toBe(true);
118
+ });
119
+
120
+ describe("when multiple modals are opened", () => {
121
+ it("adds the tab guads only once", () => {
122
+ $("#test-modal").foundation("open");
123
+ $("#test-modal-2").foundation("open");
124
+
125
+ expect($(".focusguard[data-position='start']").length).toEqual(1);
126
+ expect($(".focusguard[data-position='end']").length).toEqual(1);
127
+ });
128
+
129
+ it("does not remove the tab guards when modal is closed but there is still another modal open", () => {
130
+ $("#test-modal").foundation("open");
131
+ $("#test-modal-2").foundation("open");
132
+ $("#test-modal-2").foundation("close");
133
+
134
+ expect($(".focusguard[data-position='start']").length).toEqual(1);
135
+ expect($(".focusguard[data-position='end']").length).toEqual(1);
136
+ });
137
+
138
+ it("removes the tab guards when the last modal is closed", () => {
139
+ $("#test-modal").foundation("open");
140
+ $("#test-modal-2").foundation("open");
141
+ $("#test-modal-2").foundation("close");
142
+ $("#test-modal").foundation("close");
143
+
144
+ expect($(".focusguard").length).toEqual(0);
145
+ });
146
+
147
+ describe("within the active modal", () => {
148
+ beforeEach(() => {
149
+ $("#test-modal").foundation("open");
150
+ $("#test-modal-2").foundation("open");
151
+ });
152
+
153
+ it("focuses the first focusable element when the start tab guard gets focus", () => {
154
+ $(".focusguard[data-position='start']").trigger("focus");
155
+
156
+ const $focused = $(document.activeElement);
157
+ expect($focused.is($("#test-modal-2 .close-button"))).toBe(true);
158
+ });
159
+
160
+ it("focuses the last focusable element when the end tab guard gets focus", () => {
161
+ $(".focusguard[data-position='end']").trigger("focus");
162
+
163
+ const $focused = $(document.activeElement);
164
+ expect($focused.is($("#test-modal-2-button"))).toBe(true);
165
+ });
166
+ });
167
+ });
168
+ });