decidim-core 0.32.0.rc3 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/author_cell.rb +0 -4
  3. data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
  4. data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
  5. data/app/commands/decidim/destroy_account.rb +12 -1
  6. data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
  7. data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
  8. data/app/mailers/decidim/delete_user_mailer.rb +14 -0
  9. data/app/mailers/decidim/participants_account_mailer.rb +0 -16
  10. data/app/packs/src/decidim/controllers/main_menu/controller.js +33 -0
  11. data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +77 -0
  12. data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
  13. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
  14. data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
  15. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
  16. data/app/packs/src/decidim/editor/common/suggestion.js +3 -1
  17. data/app/packs/src/decidim/editor/extensions/indent/index.js +9 -0
  18. data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
  19. data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
  20. data/app/packs/src/decidim/index.js +2 -2
  21. data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
  22. data/app/packs/stylesheets/decidim/_dropdown.scss +1 -1
  23. data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
  24. data/app/packs/stylesheets/decidim/_header.scss +12 -8
  25. data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
  26. data/app/packs/stylesheets/decidim/application.scss +2 -0
  27. data/app/packs/stylesheets/decidim/editor.scss +2 -33
  28. data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
  29. data/app/uploaders/decidim/image_uploader.rb +1 -1
  30. data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
  31. data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
  32. data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
  33. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile.html.erb +2 -1
  34. data/config/locales/ar.yml +0 -2
  35. data/config/locales/bg.yml +0 -2
  36. data/config/locales/ca-IT.yml +21 -10
  37. data/config/locales/ca.yml +21 -10
  38. data/config/locales/cs.yml +10 -9
  39. data/config/locales/de.yml +4 -13
  40. data/config/locales/el.yml +0 -1
  41. data/config/locales/en.yml +20 -9
  42. data/config/locales/es-MX.yml +20 -9
  43. data/config/locales/es-PY.yml +20 -9
  44. data/config/locales/es.yml +20 -9
  45. data/config/locales/eu.yml +36 -9
  46. data/config/locales/fi-plain.yml +19 -8
  47. data/config/locales/fi.yml +19 -8
  48. data/config/locales/fr-CA.yml +23 -9
  49. data/config/locales/fr.yml +23 -9
  50. data/config/locales/hu.yml +0 -2
  51. data/config/locales/it.yml +0 -2
  52. data/config/locales/ja.yml +59 -19
  53. data/config/locales/lb.yml +0 -2
  54. data/config/locales/lt.yml +0 -2
  55. data/config/locales/nl.yml +0 -2
  56. data/config/locales/no.yml +0 -2
  57. data/config/locales/pl.yml +2 -4
  58. data/config/locales/pt-BR.yml +2 -11
  59. data/config/locales/pt.yml +0 -2
  60. data/config/locales/ro-RO.yml +1 -10
  61. data/config/locales/sk.yml +1 -10
  62. data/config/locales/sv.yml +0 -9
  63. data/config/locales/tr-TR.yml +0 -2
  64. data/config/locales/zh-CN.yml +0 -2
  65. data/config/locales/zh-TW.yml +0 -2
  66. data/decidim-core.gemspec +1 -1
  67. data/lib/decidim/attachment_attributes.rb +58 -9
  68. data/lib/decidim/command.rb +1 -1
  69. data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
  70. data/lib/decidim/core/engine.rb +8 -0
  71. data/lib/decidim/core/test/factories.rb +3 -0
  72. data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
  73. data/lib/decidim/core/test/shared_examples/comments_examples.rb +6 -6
  74. data/lib/decidim/core/version.rb +1 -1
  75. data/lib/decidim/map/autocomplete.rb +4 -3
  76. data/lib/decidim/searchable.rb +5 -0
  77. data/lib/decidim/view_model.rb +1 -1
  78. data/lib/tasks/decidim_mailers_tasks.rake +31 -9
  79. metadata +10 -9
  80. data/app/commands/decidim/gallery_methods.rb +0 -107
  81. data/app/packs/src/decidim/vendor/tribute.js +0 -1890
  82. data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
  83. data/app/views/decidim/participants_account_mailer/removal_notification.html.erb +0 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c8c0eaf7af52859f34c0b152525144104d544b91f8a5ba49173b2f25530709c
4
- data.tar.gz: c1d8019a4006489b276e3d2ca8c5c6f194890035369ab69a7c7e358376f16fae
3
+ metadata.gz: 50d3683adf392d4b4b2900c05d390051d922759af5aca3a012a0e2d6b1db501a
4
+ data.tar.gz: 6c44f0977cb804af860603ede5ad244515e6cc92d57071cfe594f687ee1c2a96
5
5
  SHA512:
6
- metadata.gz: 88f9cdca14f469d2fe61ca88c3ba56f8e44586a951be4de8a3faa3f9de1d75b982f8b0db9023052470118f8f47c4d327eecf4687a568f636a18a6c85cfacd874
7
- data.tar.gz: de42f147191cad92bd9b25c908a4526aaa317313e44603828b59ec0f40e9360f1a9cc8e046b64ed226b9c1585cdca4b213f271380e003d43936b813ebef6160f
6
+ metadata.gz: '097e493989f0f1cdc7d260ba099d36810cb0f85266165a122a39aab7a7befe35560b6efa701c7672541c9016c06cacf455521a9e927fcc1e791bc9caec2d8bcf'
7
+ data.tar.gz: f9df0bd52556860de3d48910823fa559a6a0614eb6aaf7cd335c7e9362659a0ccdcc6be27f84f90d20da6e08f8fb6d6935132bafca33ffaf8d2053f25a015461
@@ -31,10 +31,6 @@ module Decidim
31
31
  render unless current_user == model
32
32
  end
33
33
 
34
- def perform_caching?
35
- true
36
- end
37
-
38
34
  def raw_model
39
35
  model.try(:__getobj__) || model
40
36
  end
@@ -32,7 +32,7 @@ module Decidim
32
32
  private
33
33
 
34
34
  def render?
35
- required_keys = [:title, :short_description, :action_button_title, :action_button_subtitle, :action_button_url]
35
+ required_keys = [:title, :action_button_title, :action_button_subtitle, :action_button_url]
36
36
  required_keys.all? { |key| model.settings.public_send(key).present? }
37
37
  end
38
38
  end
@@ -28,7 +28,7 @@ module Decidim
28
28
  private
29
29
 
30
30
  def cache_hash
31
- [I18n.locale, highlighted_spaces.map(&:cache_key_with_version)].join(Decidim.cache_key_separator)
31
+ [I18n.locale, model.cache_key_with_version, highlighted_spaces.map(&:cache_key_with_version)].join(Decidim.cache_key_separator)
32
32
  end
33
33
 
34
34
  def section_class
@@ -15,7 +15,7 @@ module Decidim
15
15
  def call
16
16
  return broadcast(:invalid) unless @form.valid?
17
17
 
18
- Decidim::User.transaction do
18
+ with_events(with_transaction: true) do
19
19
  destroy_user_account!
20
20
  destroy_user_identities
21
21
  destroy_follows
@@ -110,5 +110,16 @@ module Decidim
110
110
  space_manifest.invoke_on_destroy_account(current_user)
111
111
  end
112
112
  end
113
+
114
+ # We use memoization in this particular email, as we want to have the data available before the actual anonymization
115
+ def event_arguments
116
+ @event_arguments ||= {
117
+ user_id: current_user.id,
118
+ user_email: current_user.email,
119
+ user_name: current_user.name,
120
+ locale: current_user.locale,
121
+ organization: current_user.organization
122
+ }
123
+ end
113
124
  end
114
125
  end
@@ -5,16 +5,16 @@ module Decidim
5
5
  private
6
6
 
7
7
  def build_attachments
8
- @documents = []
9
- @form.add_documents.compact_blank.each do |attachment|
8
+ @attachments = []
9
+ @form.add_attachments.compact_blank.each do |attachment|
10
10
  if attachment.is_a?(Hash) && attachment.has_key?(:id)
11
11
  update_attachment_title_for(attachment)
12
12
  next
13
13
  end
14
14
 
15
- @documents << Attachment.new(
15
+ @attachments << Attachment.new(
16
16
  title: title_for(attachment),
17
- attached_to: @attached_to || documents_attached_to,
17
+ attached_to: @attached_to || attachments_attached_to,
18
18
  file: signed_id_for(attachment),
19
19
  content_type: content_type_for(attachment)
20
20
  )
@@ -26,11 +26,11 @@ module Decidim
26
26
  end
27
27
 
28
28
  def attachments_invalid?
29
- @documents.each do |document|
30
- next if document.valid? || !document.errors.has_key?(:file)
29
+ @attachments.each do |attachment|
30
+ next if attachment.valid? || !attachment.errors.has_key?(:file)
31
31
 
32
- document.errors[:file].each do |error|
33
- @form.errors.add(:add_documents, error)
32
+ attachment.errors[:file].each do |error|
33
+ @form.errors.add(:add_attachments, error)
34
34
  end
35
35
 
36
36
  return true
@@ -41,37 +41,38 @@ 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 documents
45
- document_ids = keep_ids
46
- Decidim::Attachment.where(id: document_ids).each do |document|
47
- document.update!(weight:)
44
+ # Add the weights first to the old attachments
45
+ attachment_ids = keep_ids
46
+ Decidim::Attachment.where(id: attachment_ids).each do |attachment|
47
+ attachment.update!(weight:)
48
48
  weight += 1
49
49
  end
50
- @documents.map! do |document|
51
- document.weight = weight
52
- document.attached_to = documents_attached_to
53
- document.save!
50
+ @attachments.map! do |attachment|
51
+ attachment.weight = weight
52
+ attachment.attached_to = attachments_attached_to
53
+ attachment.save!
54
54
  weight += 1
55
- @form.documents << document
55
+ @form.attachments << attachment
56
56
  end
57
57
  end
58
58
 
59
- def document_cleanup!(include_all_attachments: false)
60
- documents = include_all_attachments ? documents_attached_to.attachments.with_attached_file : documents_attached_to.documents
59
+ def attachment_cleanup!(include_all_attachments: false)
60
+ attachments = include_all_attachments ? attachments_attached_to.attachments.with_attached_file : attachments_attached_to.attachments
61
61
 
62
- documents.each do |document|
63
- document.destroy! unless keep_ids.include?(document.id)
62
+ attachments.each do |attachment|
63
+ attachment.destroy! unless keep_ids.include?(attachment.id)
64
64
  end
65
65
 
66
- documents_attached_to.reload
67
- documents_attached_to.instance_variable_set(:@documents, nil)
66
+ attachments_attached_to.reload
67
+ attachments_attached_to.instance_variable_set(:@attachments, nil)
68
+ attachments_attached_to.instance_variable_set(:@photos, nil)
68
69
  end
69
70
 
70
71
  def process_attachments?
71
- @form.add_documents.any?
72
+ @form.add_attachments.any?
72
73
  end
73
74
 
74
- def documents_attached_to
75
+ def attachments_attached_to
75
76
  return @attached_to if @attached_to.present?
76
77
  return form.current_organization if form.respond_to?(:current_organization)
77
78
 
@@ -101,8 +102,8 @@ module Decidim
101
102
  end
102
103
 
103
104
  def keep_ids
104
- documents_array = Array(@form.documents)
105
- documents_array.map do |doc|
105
+ attachments_array = Array(@form.attachments)
106
+ attachments_array.map do |doc|
106
107
  case doc
107
108
  when Decidim::Attachment
108
109
  doc.id
@@ -31,13 +31,6 @@ module Decidim
31
31
  end
32
32
 
33
33
  def process_remove_user(user)
34
- email = user.email
35
- name = user.name
36
- locale = user.locale
37
- organization = user.organization
38
-
39
- ParticipantsAccountMailer.removal_notification(email, name, locale, organization).deliver_later
40
-
41
34
  Decidim::DestroyAccount.call(
42
35
  Decidim::DeleteAccountForm.from_params(
43
36
  delete_reason: I18n.t("decidim.account.destroy.inactive_account_removal_reason", inactivity_period: Decidim.delete_inactive_users_after_days)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ class DeleteUserMailer < ApplicationMailer
5
+ # This email is being sent when a user deletes his own account, or when the user was inactive for too long.
6
+ def delete(user_email:, user_name:, locale:, organization:)
7
+ I18n.with_locale(locale) do
8
+ @user_name = user_name
9
+ @organization = organization
10
+ mail(to: user_email, subject: I18n.t("decidim.delete_user_mailer.subject"))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -18,21 +18,5 @@ module Decidim
18
18
  mail(to: user.email, subject:)
19
19
  end
20
20
  end
21
-
22
- # Notify user about account removal due to inactivity
23
- def removal_notification(email, name, locale, organization)
24
- @email = email
25
- @user_name = name
26
- @organization = organization
27
-
28
- I18n.with_locale(locale) do
29
- subject = I18n.t(
30
- "decidim.participants_account_mailer.removal_notification.subject",
31
- organization_name: organization_name(@organization)
32
- )
33
-
34
- mail(to: email, subject:)
35
- end
36
- end
37
21
  end
38
22
  end
@@ -2,6 +2,14 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  const OPEN_DELAY_MS = 50
4
4
 
5
+ const FOCUSABLE_SELECTORS = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])"
6
+
7
+ const getFocusableElements = (container) => {
8
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter(
9
+ (el) => el.offsetParent !== null
10
+ );
11
+ }
12
+
5
13
  /**
6
14
  * Main menu dropdown controller and traps page scroll while the menu is open.
7
15
  *
@@ -24,6 +32,7 @@ export default class extends Controller {
24
32
  this.handleButtonClick = this.handleButtonClick.bind(this)
25
33
  this.handleKeydown = this.handleKeydown.bind(this)
26
34
  this.handleCloseButtonClick = this.handleCloseButtonClick.bind(this)
35
+ this.focusTrapHandler = this.focusTrapHandler.bind(this)
27
36
 
28
37
  this.menuButton.addEventListener("click", this.handleButtonClick)
29
38
  this.menuContainer.addEventListener("click", this.handleContainerClick)
@@ -96,6 +105,23 @@ export default class extends Controller {
96
105
  return this.menuContainer.getAttribute("aria-hidden") === "true"
97
106
  }
98
107
 
108
+ focusTrapHandler(event) {
109
+ if (event.key !== "Tab") {
110
+ return;
111
+ }
112
+ const focusable = getFocusableElements(this.menuContainer);
113
+ if (focusable.length === 0) {
114
+ return;
115
+ }
116
+ if (event.shiftKey && document.activeElement === focusable[0]) {
117
+ event.preventDefault();
118
+ focusable[focusable.length - 1].focus({ preventScroll: true });
119
+ } else if (!event.shiftKey && document.activeElement === focusable[focusable.length - 1]) {
120
+ event.preventDefault();
121
+ focusable[0].focus({ preventScroll: true });
122
+ }
123
+ }
124
+
99
125
  openMenu() {
100
126
  if (typeof this.previousBodyOverflow === "undefined") {
101
127
  this.previousBodyOverflow = document.body.style.overflow;
@@ -104,9 +130,16 @@ export default class extends Controller {
104
130
  this.element.setAttribute("aria-expanded", "true")
105
131
  this.menuContainer.setAttribute("aria-hidden", "false")
106
132
  this.menuContainer.setAttribute("aria-modal", "true")
133
+ this.menuContainer.addEventListener("keydown", this.focusTrapHandler)
134
+
135
+ const focusable = getFocusableElements(this.menuContainer);
136
+ if (focusable.length > 0) {
137
+ focusable[0].focus({ preventScroll: true });
138
+ }
107
139
  }
108
140
 
109
141
  closeMenu() {
142
+ this.menuContainer.removeEventListener("keydown", this.focusTrapHandler)
110
143
  document.body.style.overflow = this.previousBodyOverflow ?? ""
111
144
  this.element.setAttribute("aria-expanded", "false")
112
145
  this.menuContainer.setAttribute("aria-hidden", "true")
@@ -20,6 +20,8 @@ describe("MainMenuController", () => {
20
20
  Menu
21
21
  </button>
22
22
  <div id="main-menu-container" aria-hidden="true">
23
+ <a href="/link">Link</a>
24
+ <button>Action</button>
23
25
  <div id="main-menu-item"></div>
24
26
  </div>
25
27
  <button id="main-menu-close">Close</button>
@@ -165,6 +167,81 @@ describe("MainMenuController", () => {
165
167
  })
166
168
  })
167
169
 
170
+ describe("focusTrapHandler", () => {
171
+ beforeEach(() => {
172
+ jest.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(menuContainer)
173
+ controller.openMenu()
174
+ })
175
+
176
+ afterEach(() => {
177
+ controller.closeMenu()
178
+ })
179
+
180
+ it("focuses the first focusable element when menu opens", () => {
181
+ const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
182
+ const firstEl = focusable[0]
183
+ expect(document.activeElement).toBe(firstEl)
184
+ })
185
+
186
+ it("cycles focus to first element when Tab is pressed on last focusable element", () => {
187
+ const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
188
+ expect(focusable.length).toBeGreaterThan(1)
189
+ const lastEl = focusable[focusable.length - 1]
190
+ const firstEl = focusable[0]
191
+
192
+ lastEl.focus()
193
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
194
+ jest.spyOn(event, "preventDefault")
195
+ jest.spyOn(firstEl, "focus")
196
+
197
+ menuContainer.dispatchEvent(event)
198
+
199
+ expect(event.preventDefault).toHaveBeenCalled()
200
+ expect(firstEl.focus).toHaveBeenCalledWith({ preventScroll: true })
201
+ })
202
+
203
+ it("cycles focus to last element when Shift+Tab is pressed on first focusable element", () => {
204
+ const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
205
+ expect(focusable.length).toBeGreaterThan(1)
206
+ const firstEl = focusable[0]
207
+ const lastEl = focusable[focusable.length - 1]
208
+
209
+ firstEl.focus()
210
+ const event = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })
211
+ jest.spyOn(event, "preventDefault")
212
+ jest.spyOn(lastEl, "focus")
213
+
214
+ menuContainer.dispatchEvent(event)
215
+
216
+ expect(event.preventDefault).toHaveBeenCalled()
217
+ expect(lastEl.focus).toHaveBeenCalledWith({ preventScroll: true })
218
+ })
219
+
220
+ it("does nothing when a non-Tab key is pressed", () => {
221
+ const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
222
+ jest.spyOn(event, "preventDefault")
223
+
224
+ menuContainer.dispatchEvent(event)
225
+
226
+ expect(event.preventDefault).not.toHaveBeenCalled()
227
+ })
228
+
229
+ it("does nothing when there are no focusable elements", () => {
230
+ controller.closeMenu()
231
+ menuContainer.innerHTML = ""
232
+ controller.openMenu()
233
+
234
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
235
+ expect(() => menuContainer.dispatchEvent(event)).not.toThrow()
236
+ })
237
+
238
+ it("removes the keydown handler when menu is closed", () => {
239
+ const spy = jest.spyOn(menuContainer, "removeEventListener")
240
+ controller.closeMenu()
241
+ expect(spy).toHaveBeenCalledWith("keydown", controller.focusTrapHandler)
242
+ })
243
+ })
244
+
168
245
  describe("disconnect", () => {
169
246
  it("removes listeners and closes the menu if open", () => {
170
247
  const buttonSpy = jest.spyOn(menuButton, "removeEventListener")