decidim-core 0.32.0 → 0.32.2

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/comments_button_cell.rb +9 -1
  3. data/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
  4. data/app/cells/decidim/data_consent/category.erb +20 -33
  5. data/app/cells/decidim/following_cell.rb +1 -1
  6. data/app/cells/decidim/footer_topics/show.erb +5 -8
  7. data/app/cells/decidim/member/show.erb +1 -1
  8. data/app/cells/decidim/nav_links/show.erb +3 -1
  9. data/app/cells/decidim/share_text_widget/modal.erb +1 -0
  10. data/app/cells/decidim/share_widget/modal.erb +1 -0
  11. data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
  12. data/app/commands/decidim/create_omniauth_registration.rb +17 -2
  13. data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
  14. data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
  15. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
  16. data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
  17. data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
  18. data/app/controllers/concerns/decidim/locale_switcher.rb +12 -10
  19. data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
  20. data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
  21. data/app/controllers/concerns/decidim/paginable.rb +7 -3
  22. data/app/controllers/concerns/decidim/payload_info.rb +2 -0
  23. data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
  24. data/app/controllers/decidim/application_controller.rb +0 -6
  25. data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
  26. data/app/controllers/decidim/components/base_controller.rb +2 -0
  27. data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
  28. data/app/controllers/decidim/favicon_controller.rb +9 -0
  29. data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
  30. data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
  31. data/app/controllers/decidim/homepage_controller.rb +4 -1
  32. data/app/controllers/decidim/likes_controller.rb +1 -1
  33. data/app/controllers/decidim/links_controller.rb +2 -0
  34. data/app/controllers/decidim/manifests_controller.rb +2 -0
  35. data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
  36. data/app/controllers/decidim/newsletters_controller.rb +4 -0
  37. data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
  38. data/app/controllers/decidim/offline_controller.rb +5 -1
  39. data/app/controllers/decidim/open_data_controller.rb +5 -1
  40. data/app/controllers/decidim/pages_controller.rb +31 -3
  41. data/app/controllers/decidim/private_downloads_controller.rb +2 -0
  42. data/app/controllers/decidim/profiles_controller.rb +19 -1
  43. data/app/controllers/decidim/qr_controller.rb +3 -1
  44. data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
  45. data/app/controllers/decidim/searches_controller.rb +2 -0
  46. data/app/controllers/decidim/short_links_controller.rb +4 -0
  47. data/app/controllers/decidim/static_map_controller.rb +2 -0
  48. data/app/controllers/decidim/timeouts_controller.rb +4 -0
  49. data/app/controllers/decidim/tos_controller.rb +2 -0
  50. data/app/controllers/decidim/upload_validations_controller.rb +4 -2
  51. data/app/controllers/decidim/user_activities_controller.rb +3 -0
  52. data/app/forms/decidim/upload_validation_form.rb +16 -10
  53. data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
  54. data/app/helpers/decidim/decidim_form_helper.rb +1 -1
  55. data/app/helpers/decidim/menu_helper.rb +0 -2
  56. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  57. data/app/jobs/decidim/application_job.rb +4 -0
  58. data/app/models/decidim/authorization.rb +24 -0
  59. data/app/models/decidim/oauth_application.rb +1 -0
  60. data/app/models/decidim/user.rb +3 -2
  61. data/app/models/decidim/user_base_entity.rb +1 -1
  62. data/app/packs/src/decidim/callout.js +10 -0
  63. data/app/packs/src/decidim/callout.test.js +32 -0
  64. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +260 -42
  65. data/app/packs/src/decidim/controllers/accordion/controller.js +136 -17
  66. data/app/packs/src/decidim/controllers/autofocus/controller.js +30 -0
  67. data/app/packs/src/decidim/controllers/dropdown/controller.js +6 -0
  68. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +28 -0
  69. data/app/packs/src/decidim/controllers/language_change/controller.js +21 -8
  70. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +0 -1
  71. data/app/packs/src/decidim/controllers/main_menu/controller.js +4 -0
  72. data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +23 -0
  73. data/app/packs/src/decidim/controllers/mention/controller.js +32 -3
  74. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +8 -5
  75. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +1 -1
  76. data/app/packs/src/decidim/controllers/password_toggler/controller.js +22 -1
  77. data/app/packs/src/decidim/controllers/password_toggler/password_toggler.test.js +11 -20
  78. data/app/packs/src/decidim/datepicker/datepicker_functions.js +41 -25
  79. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  80. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +64 -0
  81. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  82. data/app/packs/src/decidim/direct_uploads/upload_modal.js +2 -1
  83. data/app/packs/src/decidim/editor/common/suggestion.js +32 -22
  84. data/app/packs/src/decidim/editor/extensions/mention/index.js +14 -3
  85. data/app/packs/src/decidim/editor/index.js +4 -0
  86. data/app/packs/src/decidim/editor/test/extensions/mention.test.js +32 -9
  87. data/app/packs/src/decidim/map/controller.js +10 -1
  88. data/app/packs/src/decidim/refactor/implementation/autofocus.js +22 -0
  89. data/app/packs/src/decidim/refactor/implementation/autofocus.test.js +171 -0
  90. data/app/packs/src/decidim/refactor/moved/focus_guard.js +12 -12
  91. data/app/packs/src/decidim/refactor/moved/focus_guard.test.js +136 -0
  92. data/app/packs/src/decidim/utilities/dom.js +2 -2
  93. data/app/packs/stylesheets/decidim/_cards.scss +1 -1
  94. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  95. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  96. data/app/packs/stylesheets/decidim/_cookies.scss +13 -5
  97. data/app/packs/stylesheets/decidim/_documents.scss +1 -1
  98. data/app/packs/stylesheets/decidim/_footer.scss +33 -1
  99. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  100. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  101. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +12 -2
  102. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  103. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  104. data/app/packs/stylesheets/decidim/application.scss +1 -0
  105. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  106. data/app/permissions/decidim/permissions.rb +20 -0
  107. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  108. data/app/presenters/decidim/footer_menu_presenter.rb +2 -4
  109. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  110. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  111. data/app/presenters/decidim/menu_item_presenter.rb +1 -5
  112. data/app/presenters/decidim/resource_presenter.rb +11 -0
  113. data/app/resolvers/decidim/meta_image_url_resolver.rb +14 -1
  114. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  115. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  116. data/app/services/decidim/svg_content_validator.rb +554 -0
  117. data/app/uploaders/decidim/application_uploader.rb +2 -2
  118. data/app/validators/uploader_image_content_validator.rb +276 -0
  119. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  120. data/app/views/decidim/messaging/conversations/_add_conversation_users.html.erb +1 -1
  121. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  122. data/app/views/decidim/searches/_filters.html.erb +1 -1
  123. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  124. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  125. data/app/views/layouts/decidim/footer/_main.html.erb +1 -1
  126. data/app/views/layouts/decidim/footer/_main_links.html.erb +56 -28
  127. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +3 -2
  128. data/config/initializers/rack_attack.rb +10 -0
  129. data/config/locales/bg.yml +0 -1
  130. data/config/locales/ca-IT.yml +9 -1
  131. data/config/locales/ca.yml +16 -6
  132. data/config/locales/cs.yml +31 -1
  133. data/config/locales/de.yml +99 -3
  134. data/config/locales/en.yml +11 -1
  135. data/config/locales/es-MX.yml +11 -1
  136. data/config/locales/es-PY.yml +11 -1
  137. data/config/locales/es.yml +15 -5
  138. data/config/locales/eu.yml +166 -161
  139. data/config/locales/fi-plain.yml +9 -1
  140. data/config/locales/fi.yml +9 -1
  141. data/config/locales/fr-CA.yml +12 -1
  142. data/config/locales/fr.yml +12 -1
  143. data/config/locales/gl.yml +1485 -33
  144. data/config/locales/hu.yml +0 -1
  145. data/config/locales/ja.yml +28 -1
  146. data/config/locales/pl.yml +2 -1
  147. data/config/locales/pt-BR.yml +0 -1
  148. data/config/locales/ro-RO.yml +0 -1
  149. data/config/locales/sk.yml +2 -1
  150. data/config/locales/sv.yml +68 -12
  151. data/config/locales/tr-TR.yml +0 -2
  152. data/config/routes.rb +11 -2
  153. data/db/migrate/20250217192438_convert_user_groups_into_users.rb +3 -2
  154. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  155. data/decidim-core.gemspec +4 -1
  156. data/lib/decidim/api/functions/user_entity_list.rb +1 -0
  157. data/lib/decidim/api/types/user_moderation_type.rb +2 -2
  158. data/lib/decidim/content_parsers/user_parser.rb +1 -1
  159. data/lib/decidim/content_parsers.rb +0 -1
  160. data/lib/decidim/content_renderers/mention_resource_renderer.rb +18 -4
  161. data/lib/decidim/content_renderers/user_renderer.rb +15 -4
  162. data/lib/decidim/core/test/shared_examples/access_mode_restricted_participatory_spaces.rb +8 -5
  163. data/lib/decidim/core/test/shared_examples/access_mode_transparent_participatory_spaces.rb +14 -11
  164. data/lib/decidim/core/test/shared_examples/comments_examples.rb +26 -0
  165. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  166. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +58 -0
  167. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  168. data/lib/decidim/core/version.rb +1 -1
  169. data/lib/decidim/core.rb +10 -1
  170. data/lib/decidim/form_builder.rb +9 -1
  171. data/lib/decidim/has_upload_validations.rb +1 -0
  172. data/lib/decidim/legacy_form_builder.rb +4 -0
  173. data/lib/decidim/map/dynamic_map.rb +3 -1
  174. metadata +33 -7
  175. data/lib/decidim/content_parsers/inline_images_parser.rb +0 -68
  176. data/lib/tasks/upgrade/decidim_active_storage_migration_tasks.rake +0 -79
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import autofocus from "src/decidim/refactor/implementation/autofocus"
2
3
 
3
4
  /**
4
5
  * This controller is used to change the active tab when the language is changed in the admin or system panel.
@@ -10,30 +11,42 @@ export default class extends Controller {
10
11
  connect() {
11
12
  this.handleChange = this.handleChange.bind(this);
12
13
  this.element.addEventListener("change", this.handleChange);
13
- this.element.dispatchEvent(new Event("change"));
14
+ this.tabsContent = this.element.parentElement.parentElement.nextElementSibling;
15
+
16
+ this.setActiveTab(this.element.value);
14
17
  }
15
18
 
16
19
  disconnect() {
17
20
  this.element.removeEventListener("change", this.handleChange)
18
21
  }
19
22
 
20
- handleChange(event) {
21
- let targetTabPaneSelector = event.target.value;
22
- let tabsContent = event.target.parentElement.parentElement.nextElementSibling;
23
-
24
- if (!tabsContent) {
23
+ setActiveTab(targetTabPaneSelector) {
24
+ if (!this.tabsContent) {
25
25
  return;
26
26
  }
27
27
 
28
- let activeTabContent = tabsContent.querySelector(".is-active");
28
+ let activeTabContent = this.tabsContent.querySelector(".is-active");
29
29
  if (activeTabContent) {
30
30
  activeTabContent.ariaHidden = "true";
31
31
  activeTabContent.classList.remove("is-active");
32
32
  }
33
- let activePane = tabsContent.querySelector(targetTabPaneSelector);
33
+ let activePane = this.tabsContent.querySelector(targetTabPaneSelector);
34
34
  if (activePane) {
35
35
  activePane.ariaHidden = "false";
36
36
  activePane.classList.add("is-active");
37
37
  }
38
38
  }
39
+
40
+ handleChange(event) {
41
+ this.setActiveTab(event.target.value);
42
+
43
+ if (!this.tabsContent) {
44
+ return;
45
+ }
46
+
47
+ const activePane = this.tabsContent.querySelector(event.target.value);
48
+ if (activePane) {
49
+ autofocus(activePane);
50
+ }
51
+ }
39
52
  }
@@ -1,5 +1,4 @@
1
1
  /* global jest */
2
- /* eslint max-lines: ["error", 360] */
3
2
  import { Application } from "@hotwired/stimulus"
4
3
  import LanguageChangeController from "src/decidim/controllers/language_change/controller";
5
4
 
@@ -34,6 +34,10 @@ export default class extends Controller {
34
34
  this.handleCloseButtonClick = this.handleCloseButtonClick.bind(this)
35
35
  this.focusTrapHandler = this.focusTrapHandler.bind(this)
36
36
 
37
+ if (!this.menuButton.hasAttribute("aria-expanded")) {
38
+ this.menuButton.setAttribute("aria-expanded", "false");
39
+ }
40
+
37
41
  this.menuButton.addEventListener("click", this.handleButtonClick)
38
42
  this.menuContainer.addEventListener("click", this.handleContainerClick)
39
43
 
@@ -70,6 +70,29 @@ describe("MainMenuController", () => {
70
70
  expect(closeSpy).toHaveBeenCalledWith("click", controller.handleCloseButtonClick)
71
71
  })
72
72
 
73
+ it("sets aria-expanded to false when missing", async () => {
74
+ document.body.innerHTML = `
75
+ <button data-controller="main-menu" data-target="main-menu-container" data-close-button="main-menu-close">
76
+ Menu
77
+ </button>
78
+ <div id="main-menu-container" aria-hidden="true"></div>
79
+ <button id="main-menu-close">Close</button>
80
+ `
81
+
82
+ application.stop()
83
+ application = Application.start()
84
+ application.register("main-menu", MainMenuController)
85
+
86
+ const newButton = document.querySelector('[data-controller="main-menu"]')
87
+ await new Promise((resolve) => {
88
+ setTimeout(() => {
89
+ resolve(application.getControllerForElementAndIdentifier(newButton, "main-menu"))
90
+ }, 0)
91
+ })
92
+
93
+ expect(newButton.getAttribute("aria-expanded")).toBe("false")
94
+ })
95
+
73
96
  it("returns early when menu container is missing", async () => {
74
97
  document.body.innerHTML = `
75
98
  <button data-controller="main-menu" data-target="missing-menu"></button>
@@ -1,11 +1,12 @@
1
- /* eslint max-lines: ["error", 400] */
1
+ /* eslint max-lines: ["error", 450] */
2
2
 
3
3
  import { Controller } from "@hotwired/stimulus"
4
4
 
5
5
  export default class extends Controller {
6
6
  connect() {
7
7
  this.options = {
8
- noDataFoundMessage: this.element.getAttribute("data-noresults") || "No results found",
8
+ noDataFoundMessage: this.element.getAttribute("data-no_results") || "No results found",
9
+ searchPromptMessage: this.element.getAttribute("data-search_prompt") || "Type to search participants",
9
10
  debounceDelay: 250,
10
11
  menuItemLimit: 5
11
12
  };
@@ -94,6 +95,14 @@ export default class extends Controller {
94
95
  }
95
96
 
96
97
  this.currentMentionStart = trigger.start;
98
+
99
+ if (trigger.query.length < 2) {
100
+ // Invalidate any pending debounced searches
101
+ this.requestId += 1;
102
+ this.showMessage(this.options.searchPromptMessage);
103
+ return;
104
+ }
105
+
97
106
  this.performRemoteSearch(trigger.query);
98
107
  }
99
108
 
@@ -136,7 +145,7 @@ export default class extends Controller {
136
145
  }
137
146
 
138
147
  const textBeforeCursor = value.slice(0, caretPosition);
139
- const mentionMatch = textBeforeCursor.match(/(?:^|\s)@([\w.-]{2,})$/);
148
+ const mentionMatch = textBeforeCursor.match(/(?:^|\s)@([\w.-]{1,})$/);
140
149
  if (!mentionMatch) {
141
150
  return null;
142
151
  }
@@ -257,6 +266,26 @@ export default class extends Controller {
257
266
  this.suggestion.classList.remove("hidden", "hide");
258
267
  }
259
268
 
269
+ showMessage(message) {
270
+ if (!this.suggestion || !message) {
271
+ return;
272
+ }
273
+
274
+ this.suggestion.innerHTML = "";
275
+ this.isActive = false;
276
+ this.selectedIndex = -1;
277
+
278
+ const item = document.createElement("button");
279
+ item.type = "button";
280
+ item.disabled = true;
281
+ item.classList.add("editor-suggestions-item", "editor-suggestions-item-disabled");
282
+ item.textContent = message;
283
+
284
+ this.suggestion.append(item);
285
+ this.positionSuggestionMenu();
286
+ this.suggestion.classList.remove("hidden", "hide");
287
+ }
288
+
260
289
  positionSuggestionMenu() {
261
290
  if (!this.suggestion || this.currentMentionStart === null) {
262
291
  return;
@@ -26,7 +26,7 @@ describe("MentionController", () => {
26
26
 
27
27
  document.body.innerHTML = `
28
28
  <div class="mention-container">
29
- <input data-noresults="No users found" data-controller="mention" />
29
+ <input data-no_results="No users found" data-search_prompt="Type to search participants" data-controller="mention" />
30
30
  </div>
31
31
  `;
32
32
 
@@ -111,17 +111,20 @@ describe("MentionController", () => {
111
111
  expect(suggestions.classList.contains("hidden")).toBe(false);
112
112
  });
113
113
 
114
- it("does not show suggestions when fewer than two mention characters", async () => {
114
+ it("shows search prompt when first mention character is entered", async () => {
115
115
  await connectController();
116
116
 
117
117
  mockElement.value = "Hello @d";
118
118
  mockElement.setSelectionRange(mockElement.value.length, mockElement.value.length);
119
119
  mockElement.dispatchEvent(new Event("input", { bubbles: true }));
120
120
 
121
- await waitForDebounce();
122
-
123
121
  const suggestions = document.querySelector(".editor-suggestions");
124
- expect(suggestions.classList.contains("hidden")).toBe(true);
122
+ const promptItem = suggestions.querySelector(".editor-suggestions-item");
123
+
124
+ expect(promptItem).toBeInstanceOf(HTMLButtonElement);
125
+ expect(promptItem.textContent).toBe("Type to search participants");
126
+ expect(promptItem.disabled).toBe(true);
127
+ expect(suggestions.classList.contains("hidden")).toBe(false);
125
128
  });
126
129
 
127
130
  it("inserts selected mention on click", async () => {
@@ -68,7 +68,7 @@ describe("MultipleMentionsManager", () => {
68
68
  spellcheck="false"
69
69
  autocomplete="off"
70
70
  placeholder="Search..."
71
- data-noresults="No results"
71
+ data-no_results="No results"
72
72
  data-selected= "selected-users"
73
73
  contenteditable="true"
74
74
  data-direct-messages-disabled="Direct messages disabled">
@@ -55,6 +55,18 @@ export default class extends Controller {
55
55
  hide: icon("eye-off-line")
56
56
  }
57
57
 
58
+ const existingWrapper = this.input.closest(".input-group__password");
59
+ if (existingWrapper) {
60
+ const existingButton = existingWrapper.querySelector('button[type="button"]');
61
+ const existingStatusText = existingWrapper.querySelector('span.sr-only[aria-live="polite"]');
62
+ if (existingButton && existingStatusText) {
63
+ this.button = existingButton;
64
+ this.statusText = existingStatusText;
65
+ this.bindListeners();
66
+ return;
67
+ }
68
+ }
69
+
58
70
  this.init();
59
71
  }
60
72
 
@@ -81,8 +93,17 @@ export default class extends Controller {
81
93
  */
82
94
  init() {
83
95
  this.createControls();
96
+ this.bindListeners();
97
+ }
98
+
99
+ bindListeners() {
100
+ if (this.button && this.boundToggleVisibility) {
101
+ this.button.removeEventListener("click", this.boundToggleVisibility);
102
+ }
103
+ if (this.form && this.boundHidePassword) {
104
+ this.form.removeEventListener("submit", this.boundHidePassword);
105
+ }
84
106
 
85
- // Bind methods to maintain correct context
86
107
  this.boundToggleVisibility = this.toggleVisibility.bind(this);
87
108
  this.boundHidePassword = this.hidePassword.bind(this);
88
109
 
@@ -22,17 +22,7 @@ describe("PasswordToggler", () => {
22
22
  container = document.createElement("div");
23
23
  container.innerHTML = `
24
24
  <div data-controller="password-toggler" class="flex flex-row items-center gap-x-2" data-show-password="Show secret" data-hide-password="Hide Secret" data-hidden-password="Secret is hidden" data-shown-password="Secret is shown">
25
- <div class="input-group__password">
26
- <div class="input-group__password">
27
- <button type="button" aria-controls="token_162" aria-label="Show password">
28
- <svg width="0.75em" height="0.75em" role="img" aria-hidden="true">
29
- <title>eye-line</title>
30
- <use href="/decidim-packs/media/images/remixicon.symbol-e643e553623ffcd49f94.svg#ri-eye-line"></use>
31
- </svg>
32
- </button>
33
- <input type="password" id="token_162" value="AXXXXXXXX" class="w-full" autocomplete="off">
34
- </div>
35
- </div>
25
+ <input type="password" id="token_162" value="AXXXXXXXX" class="w-full" autocomplete="off">
36
26
  <div class="basis-1/4">
37
27
  <button type="button" class="button button__sm button__text-primary" data-clipboard-copy="#token_162">
38
28
  <span>Copy secret</span>
@@ -204,22 +194,23 @@ describe("PasswordToggler", () => {
204
194
  });
205
195
  });
206
196
 
207
- describe("integration with existing markup", () => {
208
- it("works with existing button structure", () => {
209
- const existingButton = passwordElement.querySelector('button[aria-controls="token_162"]');
210
- expect(existingButton).toBeTruthy();
197
+ describe("idempotency", () => {
198
+ it("does not duplicate controls on reconnect", () => {
199
+ controller.disconnect();
200
+ controller.connect();
211
201
 
212
- controller.init();
202
+ const wrappers = passwordElement.querySelectorAll(".input-group__password");
203
+ const buttons = passwordElement.querySelectorAll('button[aria-controls="token_162"]');
213
204
 
214
- // Should work alongside existing button
215
- expect(controller.button).toBeDefined();
216
- expect(controller.input.getAttribute("type")).toBe("password");
205
+ expect(wrappers.length).toBe(1);
206
+ expect(buttons.length).toBe(1);
217
207
  });
208
+ });
218
209
 
210
+ describe("integration with existing markup", () => {
219
211
  it("handles password input with existing value", () => {
220
212
  expect(controller.input.value).toBe("AXXXXXXXX");
221
213
 
222
- controller.init();
223
214
  controller.showPassword();
224
215
 
225
216
  expect(controller.input.getAttribute("type")).toBe("text");
@@ -6,11 +6,16 @@ export const adjustPickerPosition = (input, datePickerContainer, selector) => {
6
6
  if (getComputedStyle(parent).position === "static") {
7
7
  parent.style.position = "relative";
8
8
  }
9
+ const stickyFooterItem = document.querySelector(".item__edit-sticky");
10
+
11
+ const availableBottom = stickyFooterItem
12
+ ? stickyFooterItem.getBoundingClientRect().top
13
+ : window.innerHeight;
9
14
 
10
- const rect = input.getBoundingClientRect();
11
15
  const calendarHeight = datePickerContainer.offsetHeight;
16
+ const rect = input.getBoundingClientRect();
17
+ const spaceBelow = availableBottom - rect.bottom;
12
18
  const spaceAbove = rect.top;
13
- const spaceBelow = window.innerHeight - rect.bottom;
14
19
  const openBelow = spaceBelow >= calendarHeight || spaceBelow >= spaceAbove;
15
20
 
16
21
  if (openBelow) {
@@ -50,10 +55,7 @@ export const setMinute = (value) => {
50
55
  };
51
56
 
52
57
  export const formatInputDate = (date, formats) => {
53
- const dateList = date.split("-");
54
- const year = dateList[0];
55
- const month = dateList[1];
56
- const day = dateList[2];
58
+ const [year, month, day] = date.split("-");
57
59
 
58
60
  if (formats.order === "m-d-y") {
59
61
  return `${month}${formats.separator}${day}${formats.separator}${year}`;
@@ -63,10 +65,34 @@ export const formatInputDate = (date, formats) => {
63
65
  return `${day}${formats.separator}${month}${formats.separator}${year}`;
64
66
  };
65
67
 
68
+ /**
69
+ * Splits a time string into two elements: hours and minutes. Ensures a returned
70
+ * array of size 2 for any kind of strings or `null` values.
71
+ *
72
+ * Examples:
73
+ * splitTime("") => ["00", "00"]
74
+ * splitTime(null) => ["00", "00"]
75
+ * splitTime("12") => ["12", "00"]
76
+ * splitTime("12:30") => ["12", "30"]
77
+ * splitTime("2:4") => ["02", "04"]
78
+ * splitTime("12:30:45") => ["12", "30"]
79
+ *
80
+ * @param {String} value The original time string, typically hh:ss.
81
+ * @returns {Array} An array with two elements where the first element is the
82
+ * hour part and the second element is the minute part.
83
+ */
84
+ const splitTime = (value) => {
85
+ return [...(value || "").split(":"), "00"].splice(0, 2).map((part) => part.padStart(2, "0"));
86
+ };
87
+
66
88
  export const formatInputTime = (time, format, input) => {
67
- const timeList = time.split(":");
68
- let hour = timeList[0];
69
- const minute = timeList[1];
89
+ if (!time) {
90
+ return "";
91
+ } else if (time.length < 1) {
92
+ return time;
93
+ }
94
+
95
+ let [hour, minute] = splitTime(time);
70
96
 
71
97
  if (format === 12) {
72
98
  if (Number(hour) === 12) {
@@ -80,11 +106,9 @@ export const formatInputTime = (time, format, input) => {
80
106
  } else if (Number(hour) === 0) {
81
107
  hour = "12";
82
108
  }
83
-
84
- return `${hour}:${minute}`;
85
109
  };
86
110
 
87
- return time;
111
+ return `${hour}:${minute}`;
88
112
  };
89
113
 
90
114
  export const changeHourDisplay = (change, hour, format) => {
@@ -199,29 +223,21 @@ export const formatDate = (value, formats) => {
199
223
  };
200
224
 
201
225
  export const formatTime = (value, format, inputname) => {
226
+ let [hour, minute] = splitTime(value);
227
+
202
228
  if (format === 12) {
203
- const splitValue = value.split(":");
204
- let hour = splitValue[0];
205
- const minute = splitValue[1];
206
229
  if (document.getElementById(`period_am_${inputname}`).checked) {
207
- switch (hour) {
208
- case "12":
230
+ if (hour === "12") {
209
231
  hour = "00";
210
-
211
- return `${hour}:${minute}`;
212
- default:
213
- return value;
214
- };
232
+ }
215
233
  } else if (document.getElementById(`period_pm_${inputname}`).checked) {
216
234
  if (Number(hour) > 0 && Number(hour) < 12) {
217
235
  hour = `${Number(hour) + 12}`;
218
236
  };
219
-
220
- return `${hour}:${minute}`
221
237
  };
222
238
  };
223
239
 
224
- return value;
240
+ return `${hour}:${minute}`;
225
241
  };
226
242
 
227
243
  export const updateInputValue = (input, formats, time) => {
@@ -51,7 +51,7 @@ export default function generateDatePicker(input, row, formats) {
51
51
  dateColumn.appendChild(datePickerContainer);
52
52
 
53
53
  let prevDate = null;
54
- let defaultTime = input.getAttribute("default_time") || "00:00"
54
+ let defaultTime = input.dataset.defaultTime || "00:00"
55
55
 
56
56
  const datePickerDisplay = (event) => {
57
57
  if (!dateColumn.contains(event.target)) {
@@ -72,7 +72,7 @@ export default function generateDatePicker(input, row, formats) {
72
72
  if (input.type === "date") {
73
73
  input.value = `${formatDate(date.value, formats)}`;
74
74
  } else if (input.type === "datetime-local") {
75
- input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value, formats.time, input.id) || defaultTime}`;
75
+ input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value || defaultTime, formats.time, input.id)}`;
76
76
  };
77
77
  };
78
78
  });
@@ -88,7 +88,7 @@ export default function generateDatePicker(input, row, formats) {
88
88
  if (input.type === "date") {
89
89
  input.value = `${formatDate(date.value, formats)}`;
90
90
  } else if (input.type === "datetime-local") {
91
- input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value, formats.time, input.id) || defaultTime}`;
91
+ input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value || defaultTime, formats.time, input.id)}`;
92
92
  };
93
93
  };
94
94
  });
@@ -103,7 +103,7 @@ export default function generateDatePicker(input, row, formats) {
103
103
  if (input.type === "date") {
104
104
  input.value = `${pickedDate}`;
105
105
  } else if (input.type === "datetime-local") {
106
- input.value = `${pickedDate}T${formatTime(document.querySelector(`#${input.id}_time`).value, formats.time, input.id) || defaultTime}`;
106
+ input.value = `${pickedDate}T${formatTime(document.querySelector(`#${input.id}_time`).value || defaultTime, formats.time, input.id)}`;
107
107
  };
108
108
  datePickerContainer.style.display = "none";
109
109
  });
@@ -128,6 +128,37 @@ describe("adjustDatePickerPosition", () => {
128
128
 
129
129
  expect(datePickerContainer.style.right).toBe("0px");
130
130
  });
131
+
132
+ it("opens below when there is enough space above the sticky action area", () => {
133
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
134
+ top: 100,
135
+ bottom: 140
136
+ });
137
+
138
+ Reflect.defineProperty(window, "innerHeight", {
139
+ writable: true,
140
+ configurable: true,
141
+ value: 800
142
+ });
143
+
144
+ const stickyContainer = document.createElement("div");
145
+ stickyContainer.className = "item__edit-sticky";
146
+
147
+ jest.spyOn(stickyContainer, "getBoundingClientRect").mockReturnValue({
148
+ top: 500
149
+ });
150
+
151
+ document.body.appendChild(stickyContainer);
152
+
153
+ try {
154
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
155
+
156
+ expect(datePickerContainer.style.top).toBe("40px");
157
+ expect(datePickerContainer.style.bottom).toBe("");
158
+ } finally {
159
+ document.body.removeChild(stickyContainer);
160
+ }
161
+ });
131
162
  });
132
163
 
133
164
 
@@ -231,4 +262,37 @@ describe("adjustTimePickerPosition", () => {
231
262
 
232
263
  expect(timePicker.style.right).toBe("0px");
233
264
  });
265
+
266
+ it("opens above when sticky action area reduces the available space below", () => {
267
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
268
+ top: 300,
269
+ bottom: 340
270
+ });
271
+
272
+ Reflect.defineProperty(window, "innerHeight", {
273
+ writable: true,
274
+ configurable: true,
275
+ value: 600
276
+ });
277
+
278
+ const stickyContainer = document.createElement("div");
279
+ stickyContainer.className = "item__edit-sticky";
280
+
281
+ jest.spyOn(stickyContainer, "getBoundingClientRect").mockReturnValue({
282
+ top: 500
283
+ });
284
+
285
+ document.body.appendChild(stickyContainer);
286
+
287
+ adjustPickerPosition(
288
+ input,
289
+ timePicker,
290
+ ".datepicker__time-column"
291
+ );
292
+
293
+ expect(timePicker.style.top).toBe("");
294
+ expect(timePicker.style.bottom).toBe("30px");
295
+
296
+ document.body.removeChild(stickyContainer);
297
+ });
234
298
  });
@@ -49,34 +49,42 @@ describe("setMinute", () => {
49
49
  });
50
50
 
51
51
  describe("formatInputTime", () => {
52
- const setupDOM = () => {
53
- document.body.innerHTML = `
54
- <input type="radio" id="period_am_exampleInput" name="period">
55
- <input type="radio" id="period_pm_exampleInput" name="period">
56
- `;
57
- };
58
-
59
52
  describe("24-hour format", () => {
53
+ const format = 24;
54
+ const input = { id: "exampleInput" };
55
+
60
56
  it("returns the value in correct format", () => {
61
- setupDOM();
62
- const time = "15:30";
63
- const format = 24;
64
- const input = { id: "exampleInput" };
57
+ const result = formatInputTime("15:30", format, input);
65
58
 
66
- const result = formatInputTime(time, format, input);
59
+ expect(result).toBe("15:30");
60
+ });
61
+
62
+ it("returns the value in correct format if seconds are provided", () => {
63
+ const result = formatInputTime("15:30:04", format, input);
67
64
 
68
65
  expect(result).toBe("15:30");
69
66
  });
67
+
68
+ it("returns an empty string if an empty string is provided", () => {
69
+ const result = formatInputTime("", 24, { id: "exampleInput" });
70
+
71
+ expect(result).toBe("");
72
+ });
70
73
  })
71
74
 
72
75
  describe("12-hour format", () => {
73
- it("returns the value in correct format and checks the PM radio-button", () => {
74
- setupDOM();
75
- const time = "12:45";
76
- const format = 12;
77
- const input = { id: "exampleInput" };
76
+ beforeEach(() => {
77
+ document.body.innerHTML = `
78
+ <input type="radio" id="period_am_exampleInput" name="period">
79
+ <input type="radio" id="period_pm_exampleInput" name="period">
80
+ `;
81
+ });
82
+
83
+ const format = 12;
84
+ const input = { id: "exampleInput" };
78
85
 
79
- const result = formatInputTime(time, format, input);
86
+ it("returns the value in correct format and checks the PM radio-button", () => {
87
+ const result = formatInputTime("12:45", format, input);
80
88
 
81
89
  expect(result).toBe("12:45");
82
90
  expect(document.getElementById("period_pm_exampleInput").checked).toBeTruthy();
@@ -84,17 +92,24 @@ describe("formatInputTime", () => {
84
92
  });
85
93
 
86
94
  it("returns the value in correct format", () => {
87
- setupDOM();
88
- const time = "00:25";
89
- const format = 12;
90
- const input = { id: "exampleInput" };
91
-
92
- const result = formatInputTime(time, format, input);
95
+ const result = formatInputTime("00:25", format, input);
93
96
 
94
97
  expect(result).toBe("12:25");
95
98
  // We are not expecting a radio button to be selected here since in a real environment the selected button is not
96
99
  // handled by this method for values < 12
97
100
  });
101
+
102
+ it("returns the value in correct format if seconds are provided", () => {
103
+ const result = formatInputTime("00:25:04", format, input);
104
+
105
+ expect(result).toBe("12:25");
106
+ });
107
+
108
+ it("returns an empty string if an empty string is provided", () => {
109
+ const result = formatInputTime("", 24, { id: "exampleInput" });
110
+
111
+ expect(result).toBe("");
112
+ });
98
113
  })
99
114
  });
100
115
 
@@ -253,7 +253,8 @@ export default class UploadModal {
253
253
  }
254
254
 
255
255
  setProgressBar(name, value) {
256
- const item = Array.from(this.uploadItems.querySelectorAll("[data-filename]")).find((el) => el.dataset.filename === name);
256
+ const items = Array.from(this.uploadItems.querySelectorAll("[data-filename]"));
257
+ const item = items.filter((el) => el.dataset.filename === name).pop();
257
258
  if (item) {
258
259
  item.querySelector("progress").value = value;
259
260
  }