decidim-core 0.32.0.rc2 → 0.32.0.rc3

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/amendable/amend_button_card/show.erb +2 -2
  3. data/app/cells/decidim/card_metadata_cell.rb +1 -1
  4. data/app/cells/decidim/content_blocks/cta_settings_form/show.erb +1 -1
  5. data/app/cells/decidim/content_blocks/hero_settings_form/show.erb +1 -1
  6. data/app/cells/decidim/content_blocks/highlighted_content_banner_settings_form/show.erb +1 -1
  7. data/app/cells/decidim/content_blocks/participatory_space_hero_settings_form/show.erb +1 -1
  8. data/app/cells/decidim/content_blocks/static_page/section_settings_form/show.erb +3 -1
  9. data/app/cells/decidim/content_blocks/static_page/summary_settings_form/show.erb +3 -1
  10. data/app/cells/decidim/content_blocks/static_page/two_pane_section_settings_form/show.erb +4 -2
  11. data/app/cells/decidim/resource_types_filter/show.erb +2 -2
  12. data/app/controllers/concerns/decidim/devise_controllers.rb +9 -0
  13. data/app/events/decidim/amendable/amendment_base_event.rb +7 -1
  14. data/app/models/decidim/moderation.rb +1 -1
  15. data/app/models/decidim/participatory_space/member.rb +1 -1
  16. data/app/models/decidim/user_base_entity.rb +17 -2
  17. data/app/models/decidim/user_moderation.rb +1 -1
  18. data/app/packs/src/decidim/controllers/breadcrumb_truncate/breadcrumb_truncate.test.js +230 -0
  19. data/app/packs/src/decidim/controllers/breadcrumb_truncate/controller.js +172 -0
  20. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +6 -6
  21. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +23 -1
  22. data/app/packs/src/decidim/direct_uploads/upload_field.js +4 -4
  23. data/app/packs/src/decidim/direct_uploads/upload_modal.js +11 -7
  24. data/app/packs/src/decidim/index.js +2 -1
  25. data/app/packs/src/decidim/sw/sw.js +1 -1
  26. data/app/packs/src/decidim/utilities/text.js +6 -6
  27. data/app/packs/stylesheets/decidim/_floating_help.scss +1 -1
  28. data/app/packs/stylesheets/decidim/_header.scss +36 -7
  29. data/app/presenters/decidim/menu_item_presenter.rb +9 -3
  30. data/app/presenters/decidim/stats_presenter.rb +1 -1
  31. data/app/views/decidim/gamification/badges/index.html.erb +1 -1
  32. data/app/views/decidim/homepage/show.html.erb +1 -1
  33. data/app/views/decidim/last_activities/index.html.erb +1 -1
  34. data/app/views/decidim/newsletters/unsubscribe.html.erb +1 -1
  35. data/app/views/decidim/offline/show.html.erb +1 -1
  36. data/app/views/decidim/pages/index.html.erb +1 -1
  37. data/app/views/decidim/profiles/show.html.erb +1 -1
  38. data/app/views/decidim/shared/_resource_actions.html.erb +4 -4
  39. data/app/views/decidim/user_activities/index.html.erb +1 -1
  40. data/app/views/layouts/decidim/_wrapper.html.erb +2 -2
  41. data/app/views/layouts/decidim/header/_menu.html.erb +1 -1
  42. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +31 -4
  43. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile.html.erb +17 -13
  44. data/app/views/layouts/decidim/shared/_layout_center.html.erb +1 -1
  45. data/app/views/layouts/decidim/shared/_layout_item.html.erb +1 -1
  46. data/app/views/layouts/decidim/shared/_layout_two_col.html.erb +1 -1
  47. data/config/initializers/decidim_locale_aware_named_route_helper.rb +16 -0
  48. data/config/locales/ar.yml +0 -23
  49. data/config/locales/bg.yml +0 -23
  50. data/config/locales/ca-IT.yml +25 -25
  51. data/config/locales/ca.yml +25 -25
  52. data/config/locales/cs.yml +2 -25
  53. data/config/locales/de.yml +1 -33
  54. data/config/locales/el.yml +0 -21
  55. data/config/locales/en.yml +25 -25
  56. data/config/locales/es-MX.yml +25 -25
  57. data/config/locales/es-PY.yml +25 -25
  58. data/config/locales/es.yml +25 -25
  59. data/config/locales/eu.yml +51 -67
  60. data/config/locales/fi-plain.yml +27 -26
  61. data/config/locales/fi.yml +27 -26
  62. data/config/locales/fr-CA.yml +2 -22
  63. data/config/locales/fr.yml +2 -22
  64. data/config/locales/gl.yml +0 -22
  65. data/config/locales/hu.yml +0 -20
  66. data/config/locales/id-ID.yml +0 -21
  67. data/config/locales/is-IS.yml +0 -6
  68. data/config/locales/it.yml +1 -18
  69. data/config/locales/ja.yml +0 -29
  70. data/config/locales/lb.yml +0 -21
  71. data/config/locales/lt.yml +0 -22
  72. data/config/locales/lv.yml +0 -21
  73. data/config/locales/nl.yml +0 -21
  74. data/config/locales/no.yml +0 -21
  75. data/config/locales/pl.yml +0 -23
  76. data/config/locales/pt-BR.yml +0 -29
  77. data/config/locales/pt.yml +0 -21
  78. data/config/locales/ro-RO.yml +0 -22
  79. data/config/locales/ru.yml +0 -8
  80. data/config/locales/sk.yml +0 -33
  81. data/config/locales/sv.yml +6 -31
  82. data/config/locales/tr-TR.yml +0 -22
  83. data/config/locales/uk.yml +0 -6
  84. data/config/locales/zh-CN.yml +0 -19
  85. data/config/locales/zh-TW.yml +0 -22
  86. data/decidim-core.gemspec +1 -1
  87. data/lib/decidim/api/functions/user_entity_list.rb +2 -0
  88. data/lib/decidim/content_renderers/base_renderer.rb +112 -0
  89. data/lib/decidim/content_renderers/blob_renderer.rb +4 -7
  90. data/lib/decidim/content_renderers/mention_resource_renderer.rb +10 -6
  91. data/lib/decidim/content_renderers/resource_renderer.rb +16 -7
  92. data/lib/decidim/content_renderers/user_renderer.rb +11 -9
  93. data/lib/decidim/core/test/shared_examples/amendable/amendment_event_examples.rb +2 -2
  94. data/lib/decidim/core/test/shared_examples/amendable/amendment_promoted_event_examples.rb +3 -3
  95. data/lib/decidim/core/test/shared_examples/has_space_in_mcell_examples.rb +2 -1
  96. data/lib/decidim/core/test/shared_examples/resource_liked_event_examples.rb +3 -3
  97. data/lib/decidim/core/version.rb +1 -1
  98. metadata +8 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b4e8ed9025a083ab3351ad881448605343e89a1823e3616b250b9473a2c9bdb
4
- data.tar.gz: a6b96945cdd6f5722d251d41b0576bccf3ff2aae1e4aa0c51e0ab47a9c257360
3
+ metadata.gz: 5c8c0eaf7af52859f34c0b152525144104d544b91f8a5ba49173b2f25530709c
4
+ data.tar.gz: c1d8019a4006489b276e3d2ca8c5c6f194890035369ab69a7c7e358376f16fae
5
5
  SHA512:
6
- metadata.gz: 0ae197ee72cf96014bcb4ac200f53c19d0fe8d9bfffbcb32b266530a972b407a0f7dfd585b600d311830731231fb8a5ab96d740058f498120e30d41aa8aac17a
7
- data.tar.gz: a526e1c79feb561a1cd2f90e0bba221301698ebb4df70dd1911de2685c705a2de703d42755904ddd2fd2b075ba8861eafa4f806490b5f86b69e8c45e6259bf18
6
+ metadata.gz: 88f9cdca14f469d2fe61ca88c3ba56f8e44586a951be4de8a3faa3f9de1d75b982f8b0db9023052470118f8f47c4d327eecf4687a568f636a18a6c85cfacd874
7
+ data.tar.gz: de42f147191cad92bd9b25c908a4526aaa317313e44603828b59ec0f40e9360f1a9cc8e046b64ed226b9c1585cdca4b213f271380e003d43936b813ebef6160f
@@ -1,5 +1,5 @@
1
- <li role="menuitem" class="dropdown__item">
2
- <%= action_authorized_link_to :amend, new_amend_path, resource: model, data: { "redirect_url" => new_amend_path }, id: "amend-button", class: "dropdown__button" do %>
1
+ <li role="presentation" class="dropdown__item">
2
+ <%= action_authorized_link_to :amend, new_amend_path, resource: model, data: { "redirect_url" => new_amend_path }, id: "amend-button", class: "dropdown__button", role: "menuitem" do %>
3
3
  <span><%= t("button", scope: "decidim.amendments.amendable", model_name: nil) %></span>
4
4
  <%= icon "pencil-line" %>
5
5
  <% end %>
@@ -29,7 +29,7 @@ module Decidim
29
29
  return unless show_space?
30
30
 
31
31
  {
32
- text: decidim_escape_translated(participatory_space.title),
32
+ text: translated_attribute(participatory_space.title),
33
33
  icon: resource_type_icon_key(participatory_space.class),
34
34
  url: Decidim::ResourceLocatorPresenter.new(participatory_space).path
35
35
  }
@@ -4,6 +4,6 @@
4
4
  <%= settings_fields.translated :text_field, :description, label: t("decidim.content_blocks.cta_settings_form.description") %>
5
5
  <% end %>
6
6
 
7
- <% form.fields_for :images, form.object.images do |images_fields| %>
7
+ <% form.fields_for :images, content_block.images_container do |images_fields| %>
8
8
  <%= images_fields.upload :background_image, label: t("decidim.content_blocks.cta_settings_form.background_image") %>
9
9
  <% end %>
@@ -14,7 +14,7 @@
14
14
  </div>
15
15
  <% end %>
16
16
 
17
- <% form.fields_for :images, form.object.images do |images_fields| %>
17
+ <% form.fields_for :images, content_block.images_container do |images_fields| %>
18
18
  <div class="row column">
19
19
  <%= images_fields.upload :background_image, label: t(".background_image"), button_class: "button button__sm button__transparent-secondary" %>
20
20
  </div>
@@ -20,7 +20,7 @@
20
20
  </div>
21
21
  <% end %>
22
22
 
23
- <% form.fields_for :images, form.object.images do |images_fields| %>
23
+ <% form.fields_for :images, content_block.images_container do |images_fields| %>
24
24
  <div class="row column">
25
25
  <%= images_fields.upload :background_image, label: t(".background_image"), button_class: "button button__sm button__transparent-secondary" %>
26
26
  </div>
@@ -8,7 +8,7 @@
8
8
  </div>
9
9
  <% end %>
10
10
 
11
- <% form.fields_for :images, form.object.images do |images_fields| %>
11
+ <% form.fields_for :images, content_block.images_container do |images_fields| %>
12
12
  <div class="row column">
13
13
  <%= images_fields.upload :background_image, label: t("decidim.content_blocks.cta_settings_form.background_image"), button_class: "button button__sm button__transparent-secondary" %>
14
14
  </div>
@@ -1,3 +1,5 @@
1
1
  <% form.fields_for :settings, form.object.settings do |settings_fields| %>
2
- <%= settings_fields.translated :editor, :content, label: %>
2
+ <div class="row column">
3
+ <%= settings_fields.translated :editor, :content, label: %>
4
+ </div>
3
5
  <% end %>
@@ -1,3 +1,5 @@
1
1
  <% form.fields_for :settings, form.object.settings do |settings_fields| %>
2
- <%= settings_fields.translated :editor, :summary, label: %>
2
+ <div class="row column">
3
+ <%= settings_fields.translated :editor, :summary, label: %>
4
+ </div>
3
5
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <% form.fields_for :settings, form.object.settings do |settings_fields| %>
2
- <%= settings_fields.translated :editor, :left_column, label: label_left_column %>
3
- <%= settings_fields.translated :editor, :right_column, label: label_right_column %>
2
+ <div class="row column">
3
+ <%= settings_fields.translated :editor, :left_column, label: label_left_column %>
4
+ <%= settings_fields.translated :editor, :right_column, label: label_right_column %>
5
+ </div>
4
6
  <% end %>
@@ -10,8 +10,8 @@
10
10
  </button>
11
11
  <ul id="dropdown-menu-resource">
12
12
  <% resource_types.each do |resource_type| %>
13
- <li role="menuitem">
14
- <%= link_to filter_url(resource_type[0]), class: "filter#{" is-active" if filter_param == resource_type[0]}" do %>
13
+ <li role="presentation">
14
+ <%= link_to filter_url(resource_type[0]), class: "filter#{" is-active" if filter_param == resource_type[0]}", role: "menuitem" do %>
15
15
  <span class="sr-only"><%= resource_type[1] %></span>
16
16
  <%= text_with_resource_icon(*resource_type) %>
17
17
  <% end %>
@@ -41,6 +41,10 @@ module Decidim
41
41
 
42
42
  layout "layouts/decidim/application"
43
43
 
44
+ # Ensure locale is set before Devise's own prepended callbacks and reset
45
+ # after the request finishes.
46
+ prepend_around_action :set_current_locale
47
+
44
48
  # Saves the location before loading each page so we can return to the
45
49
  # right page.
46
50
  before_action :store_current_location
@@ -58,6 +62,11 @@ module Decidim
58
62
 
59
63
  store_location_for(:user, redirect_url)
60
64
  end
65
+
66
+ def set_current_locale(&)
67
+ locale = Decidim::LocaleRouterDetector.new(request, params).locale
68
+ I18n.with_locale(locale, &)
69
+ end
61
70
  end
62
71
  end
63
72
  end
@@ -3,7 +3,7 @@
3
3
  module Decidim::Amendable
4
4
  class AmendmentBaseEvent < Decidim::Events::SimpleEvent
5
5
  i18n_attributes :amendable_path, :amendable_type, :amendable_title,
6
- :emendation_path, :emendation_author_nickname, :emendation_author_path
6
+ :emendation_path, :emendation_author_nickname, :emendation_author_name, :emendation_author_path
7
7
 
8
8
  def amendable_title
9
9
  @amendable_title ||= translated_attribute(amendable_resource.title)
@@ -33,6 +33,12 @@ module Decidim::Amendable
33
33
  @emendation_author_nickname ||= emendation_author.nickname
34
34
  end
35
35
 
36
+ def emendation_author_name
37
+ return unless emendation_resource
38
+
39
+ @emendation_author_name ||= emendation_author.name
40
+ end
41
+
36
42
  def emendation_author_path
37
43
  return unless emendation_resource
38
44
 
@@ -32,7 +32,7 @@ module Decidim
32
32
  end
33
33
 
34
34
  def self.ransackable_attributes(_auth_object = nil)
35
- %w(reported_id_string reported_content created_at)
35
+ %w(reported_id_string reported_content created_at report_count)
36
36
  end
37
37
 
38
38
  def self.ransackable_associations(_auth_object = nil)
@@ -42,7 +42,7 @@ module Decidim
42
42
  def self.ransackable_attributes(auth_object = nil)
43
43
  return [] unless auth_object&.admin?
44
44
 
45
- %w(name nickname email invitation_accepted_at last_sign_in_at invitation_sent_at role)
45
+ %w(name nickname email invitation_accepted_at last_sign_in_at invitation_sent_at role published)
46
46
  end
47
47
 
48
48
  def self.ransackable_associations(_auth_object = nil)
@@ -69,12 +69,27 @@ module Decidim
69
69
  Decidim::UserBaseEntity.joins(:follows).where(decidim_follows: { user: self }).blocked.exists?
70
70
  end
71
71
 
72
+ ransacker :role do
73
+ Arel.sql(%{CASE WHEN "decidim_users"."admin" = true THEN 'admin' ELSE cast("decidim_users"."roles" as text) END})
74
+ end
75
+
76
+ ransacker :user_moderation_report_count do
77
+ query = <<~SQL.squish
78
+ (
79
+ SELECT COALESCE(MAX(decidim_user_moderations.report_count), 0)
80
+ FROM decidim_user_moderations
81
+ WHERE decidim_user_moderations.decidim_user_id = decidim_users.id
82
+ )
83
+ SQL
84
+ Arel.sql(query)
85
+ end
86
+
72
87
  def self.ransackable_attributes(auth_object = nil)
73
- base = %w(name email nickname last_sign_in_at)
88
+ base = %w(name email nickname last_sign_in_at created_at)
74
89
 
75
90
  return base unless auth_object&.admin?
76
91
 
77
- base + %w(invitation_sent_at invitation_accepted_at officialized_at)
92
+ base + %w(invitation_sent_at invitation_accepted_at officialized_at role user_moderation_report_count)
78
93
  end
79
94
 
80
95
  def self.ransackable_associations(_auth_object = nil)
@@ -19,7 +19,7 @@ module Decidim
19
19
  end
20
20
 
21
21
  def self.ransackable_attributes(_auth_object = nil)
22
- []
22
+ %w(created_at report_count)
23
23
  end
24
24
 
25
25
  def self.ransackable_associations(_auth_object = nil)
@@ -0,0 +1,230 @@
1
+ /* global jest */
2
+ import { Application } from "@hotwired/stimulus"
3
+ import BreadcrumbTruncateController from "src/decidim/controllers/breadcrumb_truncate/controller"
4
+
5
+ describe("BreadcrumbTruncateController", () => {
6
+ let application = null
7
+ let controller = null
8
+ let element = null
9
+ let textElement = null
10
+ let resizeObserverMock = null
11
+ let originalResizeObserver = null
12
+
13
+ const startController = () => new Promise((resolve) => {
14
+ setTimeout(() => {
15
+ controller = application.getControllerForElementAndIdentifier(element, "breadcrumb-truncate")
16
+ resolve()
17
+ }, 0)
18
+ })
19
+
20
+ beforeEach(() => {
21
+ originalResizeObserver = window.ResizeObserver
22
+ resizeObserverMock = {
23
+ observe: jest.fn(),
24
+ disconnect: jest.fn()
25
+ }
26
+
27
+ window.ResizeObserver = jest.fn().mockImplementation(() => resizeObserverMock)
28
+
29
+ document.body.innerHTML = `
30
+ <span data-controller="breadcrumb-truncate" class="truncate">
31
+ <span data-breadcrumb-truncate-target="text">Very long participatory space title example</span>
32
+ </span>
33
+ `
34
+
35
+ application = Application.start()
36
+ application.register("breadcrumb-truncate", BreadcrumbTruncateController)
37
+
38
+ element = document.querySelector('[data-controller="breadcrumb-truncate"]')
39
+ textElement = element.querySelector('[data-breadcrumb-truncate-target="text"]')
40
+
41
+ return startController()
42
+ })
43
+
44
+ afterEach(() => {
45
+ application.stop()
46
+ document.body.innerHTML = ""
47
+ window.ResizeObserver = originalResizeObserver
48
+ jest.restoreAllMocks()
49
+ })
50
+
51
+ it("keeps the full text when it fits", () => {
52
+ Reflect.defineProperty(element, "clientWidth", { configurable: true, value: 100 })
53
+ Reflect.defineProperty(element, "scrollWidth", { configurable: true, get: () => 80 })
54
+
55
+ controller.refresh()
56
+
57
+ expect(textElement.textContent).toBe("Very long participatory space title example")
58
+ expect(element.hasAttribute("title")).toBe(false)
59
+ })
60
+
61
+ it("truncates at the last fitting word and adds an ellipsis", () => {
62
+ Reflect.defineProperty(element, "clientWidth", { configurable: true, value: 100 })
63
+ Reflect.defineProperty(element, "scrollWidth", {
64
+ configurable: true,
65
+ get: () => (textElement.textContent.length > 16
66
+ ? 120
67
+ : 80)
68
+ })
69
+
70
+ controller.refresh()
71
+
72
+ expect(textElement.textContent).toBe("Very long...")
73
+ expect(element.getAttribute("title")).toBe("Very long participatory space title example")
74
+ })
75
+
76
+ it("falls back to character truncation for a single long word", () => {
77
+ textElement.textContent = "veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery"
78
+ controller.originalTexts = [textElement.textContent]
79
+
80
+ Reflect.defineProperty(element, "clientWidth", { configurable: true, value: 100 })
81
+ Reflect.defineProperty(element, "scrollWidth", {
82
+ configurable: true,
83
+ get: () => (textElement.textContent.length > 9
84
+ ? 120
85
+ : 80)
86
+ })
87
+
88
+ controller.refresh()
89
+
90
+ expect(textElement.textContent.endsWith("...")).toBe(true)
91
+ expect(textElement.textContent.includes(" ")).toBe(false)
92
+ expect(textElement.textContent.length).toBeGreaterThan(3)
93
+ })
94
+
95
+ it("disconnects the resize observer", () => {
96
+ controller.disconnect()
97
+
98
+ expect(resizeObserverMock.disconnect).toHaveBeenCalled()
99
+ })
100
+
101
+ it("does nothing when no text target exists", async () => {
102
+ document.body.innerHTML = "<nav data-controller=\"breadcrumb-truncate\"></nav>"
103
+
104
+ application.stop()
105
+ application = Application.start()
106
+ application.register("breadcrumb-truncate", BreadcrumbTruncateController)
107
+
108
+ element = document.querySelector('[data-controller="breadcrumb-truncate"]')
109
+
110
+ await startController()
111
+
112
+ expect(() => controller.refresh()).not.toThrow()
113
+ })
114
+
115
+ it("prioritizes truncating the widest item in group mode", async () => {
116
+ document.body.innerHTML = `
117
+ <nav data-controller="breadcrumb-truncate">
118
+ <span data-breadcrumb-truncate-target="item">
119
+ <span data-breadcrumb-truncate-target="text">Very long participatory space title example</span>
120
+ </span>
121
+ <span data-breadcrumb-truncate-target="item">
122
+ <span data-breadcrumb-truncate-target="text">Proposals</span>
123
+ </span>
124
+ </nav>
125
+ `
126
+
127
+ application.stop()
128
+ application = Application.start()
129
+ application.register("breadcrumb-truncate", BreadcrumbTruncateController)
130
+
131
+ element = document.querySelector('[data-controller="breadcrumb-truncate"]')
132
+ const itemElements = element.querySelectorAll('[data-breadcrumb-truncate-target="item"]')
133
+ const textElements = element.querySelectorAll('[data-breadcrumb-truncate-target="text"]')
134
+ const initialWidths = [150, 50]
135
+
136
+ Reflect.defineProperty(element, "clientWidth", { configurable: true, value: 100 })
137
+ Reflect.defineProperty(itemElements[0], "clientWidth", {
138
+ configurable: true,
139
+ get: () => Number.parseInt(itemElements[0].style.maxWidth || `${initialWidths[0]}`, 10)
140
+ })
141
+ Reflect.defineProperty(itemElements[1], "clientWidth", {
142
+ configurable: true,
143
+ get: () => Number.parseInt(itemElements[1].style.maxWidth || `${initialWidths[1]}`, 10)
144
+ })
145
+ Reflect.defineProperty(element, "scrollWidth", {
146
+ configurable: true,
147
+ get: () => Array.from(itemElements).reduce((sum, item) => sum + item.clientWidth, 0)
148
+ })
149
+ Reflect.defineProperty(itemElements[0], "scrollWidth", {
150
+ configurable: true,
151
+ get: () => (textElements[0].textContent.length > 16
152
+ ? 120
153
+ : 80)
154
+ })
155
+ Reflect.defineProperty(itemElements[1], "scrollWidth", {
156
+ configurable: true,
157
+ get: () => 50
158
+ })
159
+
160
+ await startController()
161
+ controller.refresh()
162
+
163
+ expect(textElements[0].textContent).not.toBe("Very long participatory space title example")
164
+ expect(textElements[0].textContent.endsWith("...")).toBe(true)
165
+ expect(textElements[1].textContent).toBe("Proposals")
166
+ })
167
+
168
+ it("preserves shorter deep breadcrumb labels while shrinking the longest one first", async () => {
169
+ document.body.innerHTML = `
170
+ <nav data-controller="breadcrumb-truncate">
171
+ <span data-breadcrumb-truncate-target="item">
172
+ <span data-breadcrumb-truncate-target="text">This is a very long title for a participatory process for a test</span>
173
+ </span>
174
+ <span data-breadcrumb-truncate-target="item">
175
+ <span data-breadcrumb-truncate-target="text">Debates</span>
176
+ </span>
177
+ <span data-breadcrumb-truncate-target="item">
178
+ <span data-breadcrumb-truncate-target="text">Debate made by a participant</span>
179
+ </span>
180
+ </nav>
181
+ `
182
+
183
+ application.stop()
184
+ application = Application.start()
185
+ application.register("breadcrumb-truncate", BreadcrumbTruncateController)
186
+
187
+ element = document.querySelector('[data-controller="breadcrumb-truncate"]')
188
+ const itemElements = element.querySelectorAll('[data-breadcrumb-truncate-target="item"]')
189
+ const textElements = element.querySelectorAll('[data-breadcrumb-truncate-target="text"]')
190
+
191
+ Reflect.defineProperty(element, "clientWidth", { configurable: true, value: 220 })
192
+
193
+ const initialWidths = [180, 56, 104]
194
+
195
+ itemElements.forEach((item, index) => {
196
+ Reflect.defineProperty(item, "clientWidth", {
197
+ configurable: true,
198
+ get: () => Number.parseInt(item.style.maxWidth || `${initialWidths[index]}`, 10)
199
+ })
200
+ })
201
+ Reflect.defineProperty(element, "scrollWidth", {
202
+ configurable: true,
203
+ get: () => Array.from(itemElements).reduce((sum, item) => sum + item.clientWidth, 0)
204
+ })
205
+
206
+ Reflect.defineProperty(itemElements[0], "scrollWidth", {
207
+ configurable: true,
208
+ get: () => (textElements[0].textContent.length > 18
209
+ ? 160
210
+ : 80)
211
+ })
212
+ Reflect.defineProperty(itemElements[1], "scrollWidth", {
213
+ configurable: true,
214
+ get: () => 56
215
+ })
216
+ Reflect.defineProperty(itemElements[2], "scrollWidth", {
217
+ configurable: true,
218
+ get: () => (textElements[2].textContent.length > 20
219
+ ? 120
220
+ : 96)
221
+ })
222
+
223
+ await startController()
224
+ controller.refresh()
225
+
226
+ expect(textElements[0].textContent.endsWith("...")).toBe(true)
227
+ expect(textElements[1].textContent).toBe("Debates")
228
+ expect(textElements[2].textContent).toBe("Debate made by a participant")
229
+ })
230
+ })
@@ -0,0 +1,172 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const ELLIPSIS = "..."
4
+ const PREFERRED_MIN_WIDTH = 96
5
+ const ABSOLUTE_MIN_WIDTH = 24
6
+
7
+ export default class extends Controller {
8
+ static get targets() {
9
+ return ["item", "text"]
10
+ }
11
+
12
+ connect() {
13
+ this.originalTexts = this.textTargets.map((target) => target.textContent.trim())
14
+ this.refresh = this.refresh.bind(this)
15
+
16
+ this.resizeObserver = new ResizeObserver(() => this.refresh())
17
+ this.resizeObserver.observe(this.element)
18
+ window.addEventListener("resize", this.refresh)
19
+
20
+ this.refresh()
21
+ }
22
+
23
+ disconnect() {
24
+ this.resizeObserver?.disconnect()
25
+ window.removeEventListener("resize", this.refresh)
26
+ }
27
+
28
+ refresh() {
29
+ if (!this.hasTextTarget) {
30
+ return
31
+ }
32
+
33
+ if (this.hasItemTarget) {
34
+ this.refreshGroup()
35
+ return
36
+ }
37
+
38
+ this.truncateItem(this.element, this.textTarget, this.originalTexts[0])
39
+ }
40
+
41
+ refreshGroup() {
42
+ this.itemTargets.forEach((item, index) => this.resetItem(item, this.textTargets[index], this.originalTexts[index]))
43
+
44
+ if (this.element.clientWidth <= 0) {
45
+ return
46
+ }
47
+
48
+ if (!this.isOverflowing(this.element)) {
49
+ return
50
+ }
51
+
52
+ const candidates = this.itemTargets.map((item, index) => ({
53
+ item,
54
+ text: this.textTargets[index],
55
+ originalText: this.originalTexts[index],
56
+ width: item.scrollWidth
57
+ })).sort((left, right) => right.width - left.width)
58
+
59
+ candidates.forEach((candidate) => {
60
+ if (!this.isOverflowing(this.element)) {
61
+ return
62
+ }
63
+
64
+ this.shrinkCandidate(candidate, PREFERRED_MIN_WIDTH)
65
+
66
+ if (this.isOverflowing(this.element)) {
67
+ this.shrinkCandidate(candidate, ABSOLUTE_MIN_WIDTH)
68
+ }
69
+ })
70
+ }
71
+
72
+ shrinkCandidate(candidate, minimumWidth) {
73
+ const overflow = this.element.scrollWidth - this.element.clientWidth
74
+
75
+ if (overflow <= 0) {
76
+ return
77
+ }
78
+
79
+ const minWidth = Math.min(candidate.width, minimumWidth)
80
+ const shrinkableWidth = candidate.width - minWidth
81
+
82
+ if (shrinkableWidth <= 0) {
83
+ return
84
+ }
85
+
86
+ const reduction = Math.min(overflow, shrinkableWidth)
87
+ const nextWidth = candidate.width - reduction
88
+
89
+ candidate.item.style.flex = `0 1 ${nextWidth}px`
90
+ candidate.item.style.maxWidth = `${nextWidth}px`
91
+ candidate.item.style.minWidth = "0"
92
+
93
+ this.truncateItem(candidate.item, candidate.text, candidate.originalText)
94
+ candidate.width = nextWidth
95
+ }
96
+
97
+ resetItem(item, text, originalText) {
98
+ item.style.removeProperty("flex")
99
+ item.style.removeProperty("max-width")
100
+ item.style.removeProperty("min-width")
101
+ item.removeAttribute("title")
102
+ text.textContent = originalText
103
+ }
104
+
105
+ truncateItem(item, text, originalText) {
106
+ if (item.clientWidth <= 0) {
107
+ return
108
+ }
109
+
110
+ text.textContent = originalText
111
+
112
+ if (!this.isOverflowing(item)) {
113
+ item.removeAttribute("title")
114
+ return
115
+ }
116
+
117
+ const words = originalText.split(/\s+/).filter(Boolean)
118
+ let visibleText = ""
119
+
120
+ for (let index = 0; index < words.length; index += 1) {
121
+ let nextText = words[index]
122
+
123
+ if (visibleText) {
124
+ nextText = `${visibleText} ${words[index]}`
125
+ }
126
+
127
+ text.textContent = `${nextText}${ELLIPSIS}`
128
+
129
+ if (this.isOverflowing(item)) {
130
+ break
131
+ }
132
+
133
+ visibleText = nextText
134
+ }
135
+
136
+ if (!visibleText) {
137
+ visibleText = this.truncateSingleWord(item, text, words[0] || "")
138
+ }
139
+
140
+ if (visibleText === originalText) {
141
+ text.textContent = visibleText
142
+ } else {
143
+ text.textContent = `${visibleText}${ELLIPSIS}`
144
+ }
145
+
146
+ item.setAttribute("title", originalText)
147
+ }
148
+
149
+ truncateSingleWord(item, text, word) {
150
+ let visibleText = ""
151
+
152
+ for (let index = 0; index < word.length; index += 1) {
153
+ text.textContent = `${visibleText}${word[index]}${ELLIPSIS}`
154
+
155
+ if (this.isOverflowing(item)) {
156
+ break
157
+ }
158
+
159
+ visibleText += word[index]
160
+ }
161
+
162
+ if (!visibleText && word) {
163
+ return word[0]
164
+ }
165
+
166
+ return visibleText
167
+ }
168
+
169
+ isOverflowing(element) {
170
+ return element.scrollWidth > element.clientWidth
171
+ }
172
+ }
@@ -419,13 +419,13 @@ class FormValidator {
419
419
  return true;
420
420
  }
421
421
 
422
- checkboxGroup.forEach((checkboxElement) => {
423
- if (isValid) {
424
- this.removeInputErrorClasses(checkboxElement);
425
- } else {
422
+ if (isValid) {
423
+ this.removeCheckboxGroupErrorClasses(groupName);
424
+ } else {
425
+ checkboxGroup.forEach((checkboxElement) => {
426
426
  this.addInputErrorClasses(checkboxElement, ["required"]);
427
- }
428
- });
427
+ });
428
+ }
429
429
 
430
430
  return isValid;
431
431
  }
@@ -1,4 +1,4 @@
1
- /* eslint max-lines: ["error", 690] */
1
+ /* eslint max-lines: ["error", 712] */
2
2
  /* global jest */
3
3
  /**
4
4
  * @jest-environment jsdom
@@ -253,6 +253,28 @@ describe("FormValidator", () => {
253
253
  const isValidTwo = validatorInstance.validateCheckboxGroup("multiCheckboxes");
254
254
  expect(isValidTwo).toBe(true);
255
255
  });
256
+
257
+ it("should remove error classes once for the group, not per checkbox", () => {
258
+ formElement.innerHTML = `
259
+ <fieldset>
260
+ <input type="checkbox" id="cb-1" name="largeGroup" value="1" required>
261
+ <input type="checkbox" id="cb-2" name="largeGroup" value="2" required>
262
+ <input type="checkbox" id="cb-3" name="largeGroup" value="3" required>
263
+ </fieldset>
264
+ <button type="submit">Submit</button>
265
+ `;
266
+ validatorInstance = new FormValidator(formElement);
267
+ formElement.querySelector("#cb-1").checked = true;
268
+
269
+ const removeGroupSpy = jest.spyOn(validatorInstance, "removeCheckboxGroupErrorClasses");
270
+ const removeInputSpy = jest.spyOn(validatorInstance, "removeInputErrorClasses");
271
+
272
+ const isValid = validatorInstance.validateCheckboxGroup("largeGroup");
273
+
274
+ expect(isValid).toBe(true);
275
+ expect(removeGroupSpy).toHaveBeenCalledTimes(1);
276
+ expect(removeInputSpy).not.toHaveBeenCalled();
277
+ });
256
278
  });
257
279
 
258
280
  describe("Custom Validators", () => {