decidim-core 0.25.0 → 0.26.0.rc2

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 (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
+ });