decidim-core 0.32.1 → 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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
  3. data/app/cells/decidim/following_cell.rb +1 -1
  4. data/app/cells/decidim/member/show.erb +1 -1
  5. data/app/cells/decidim/nav_links/show.erb +3 -1
  6. data/app/cells/decidim/share_text_widget/modal.erb +1 -0
  7. data/app/cells/decidim/share_widget/modal.erb +1 -0
  8. data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
  9. data/app/commands/decidim/create_omniauth_registration.rb +17 -2
  10. data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
  11. data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
  12. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
  13. data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
  14. data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
  15. data/app/controllers/concerns/decidim/locale_switcher.rb +12 -10
  16. data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
  17. data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
  18. data/app/controllers/concerns/decidim/paginable.rb +7 -3
  19. data/app/controllers/concerns/decidim/payload_info.rb +2 -0
  20. data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
  21. data/app/controllers/decidim/application_controller.rb +0 -6
  22. data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
  23. data/app/controllers/decidim/components/base_controller.rb +2 -0
  24. data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
  25. data/app/controllers/decidim/favicon_controller.rb +9 -0
  26. data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
  27. data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
  28. data/app/controllers/decidim/homepage_controller.rb +4 -1
  29. data/app/controllers/decidim/likes_controller.rb +1 -1
  30. data/app/controllers/decidim/links_controller.rb +2 -0
  31. data/app/controllers/decidim/manifests_controller.rb +2 -0
  32. data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
  33. data/app/controllers/decidim/newsletters_controller.rb +4 -0
  34. data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
  35. data/app/controllers/decidim/offline_controller.rb +5 -1
  36. data/app/controllers/decidim/open_data_controller.rb +5 -1
  37. data/app/controllers/decidim/pages_controller.rb +31 -3
  38. data/app/controllers/decidim/private_downloads_controller.rb +2 -0
  39. data/app/controllers/decidim/profiles_controller.rb +19 -1
  40. data/app/controllers/decidim/qr_controller.rb +3 -1
  41. data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
  42. data/app/controllers/decidim/searches_controller.rb +2 -0
  43. data/app/controllers/decidim/short_links_controller.rb +4 -0
  44. data/app/controllers/decidim/static_map_controller.rb +2 -0
  45. data/app/controllers/decidim/timeouts_controller.rb +4 -0
  46. data/app/controllers/decidim/tos_controller.rb +2 -0
  47. data/app/controllers/decidim/upload_validations_controller.rb +4 -2
  48. data/app/controllers/decidim/user_activities_controller.rb +3 -0
  49. data/app/forms/decidim/upload_validation_form.rb +16 -10
  50. data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
  51. data/app/helpers/decidim/decidim_form_helper.rb +1 -1
  52. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  53. data/app/jobs/decidim/application_job.rb +4 -0
  54. data/app/models/decidim/authorization.rb +24 -0
  55. data/app/models/decidim/oauth_application.rb +1 -0
  56. data/app/models/decidim/user.rb +1 -0
  57. data/app/models/decidim/user_base_entity.rb +1 -1
  58. data/app/packs/src/decidim/callout.js +10 -0
  59. data/app/packs/src/decidim/callout.test.js +32 -0
  60. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +64 -0
  61. data/app/packs/src/decidim/controllers/accordion/controller.js +43 -0
  62. data/app/packs/src/decidim/controllers/autofocus/controller.js +30 -0
  63. data/app/packs/src/decidim/controllers/language_change/controller.js +21 -8
  64. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +0 -1
  65. data/app/packs/src/decidim/controllers/mention/controller.js +32 -3
  66. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +8 -5
  67. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +1 -1
  68. data/app/packs/src/decidim/datepicker/datepicker_functions.js +41 -25
  69. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  70. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +64 -0
  71. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  72. data/app/packs/src/decidim/editor/common/suggestion.js +32 -22
  73. data/app/packs/src/decidim/editor/extensions/mention/index.js +14 -3
  74. data/app/packs/src/decidim/editor/index.js +4 -0
  75. data/app/packs/src/decidim/editor/test/extensions/mention.test.js +32 -9
  76. data/app/packs/src/decidim/refactor/implementation/autofocus.js +22 -0
  77. data/app/packs/src/decidim/refactor/implementation/autofocus.test.js +171 -0
  78. data/app/packs/src/decidim/utilities/dom.js +2 -2
  79. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  80. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  81. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  82. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  83. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +11 -1
  84. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  85. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  86. data/app/packs/stylesheets/decidim/application.scss +1 -0
  87. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  88. data/app/permissions/decidim/permissions.rb +20 -0
  89. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  90. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  91. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  92. data/app/presenters/decidim/menu_item_presenter.rb +1 -5
  93. data/app/presenters/decidim/resource_presenter.rb +11 -0
  94. data/app/resolvers/decidim/meta_image_url_resolver.rb +7 -1
  95. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  96. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  97. data/app/services/decidim/svg_content_validator.rb +554 -0
  98. data/app/uploaders/decidim/application_uploader.rb +2 -2
  99. data/app/validators/uploader_image_content_validator.rb +276 -0
  100. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  101. data/app/views/decidim/messaging/conversations/_add_conversation_users.html.erb +1 -1
  102. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  103. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  104. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  105. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +3 -2
  106. data/config/initializers/rack_attack.rb +10 -0
  107. data/config/locales/bg.yml +0 -1
  108. data/config/locales/ca-IT.yml +3 -1
  109. data/config/locales/ca.yml +10 -6
  110. data/config/locales/cs.yml +15 -1
  111. data/config/locales/de.yml +99 -3
  112. data/config/locales/en.yml +5 -1
  113. data/config/locales/es-MX.yml +5 -1
  114. data/config/locales/es-PY.yml +5 -1
  115. data/config/locales/es.yml +9 -5
  116. data/config/locales/eu.yml +161 -160
  117. data/config/locales/fi-plain.yml +3 -1
  118. data/config/locales/fi.yml +3 -1
  119. data/config/locales/fr-CA.yml +5 -1
  120. data/config/locales/fr.yml +5 -1
  121. data/config/locales/gl.yml +1485 -33
  122. data/config/locales/hu.yml +0 -1
  123. data/config/locales/ja.yml +3 -1
  124. data/config/locales/pl.yml +0 -1
  125. data/config/locales/pt-BR.yml +0 -1
  126. data/config/locales/ro-RO.yml +0 -1
  127. data/config/locales/sk.yml +2 -1
  128. data/config/locales/sv.yml +68 -12
  129. data/config/locales/tr-TR.yml +0 -2
  130. data/config/routes.rb +11 -2
  131. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  132. data/decidim-core.gemspec +1 -1
  133. data/lib/decidim/api/functions/user_entity_list.rb +1 -0
  134. data/lib/decidim/api/types/user_moderation_type.rb +2 -2
  135. data/lib/decidim/content_parsers/user_parser.rb +1 -1
  136. data/lib/decidim/content_parsers.rb +0 -1
  137. data/lib/decidim/content_renderers/mention_resource_renderer.rb +18 -4
  138. data/lib/decidim/content_renderers/user_renderer.rb +15 -4
  139. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  140. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +58 -0
  141. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  142. data/lib/decidim/core/version.rb +1 -1
  143. data/lib/decidim/core.rb +9 -0
  144. data/lib/decidim/form_builder.rb +9 -1
  145. data/lib/decidim/has_upload_validations.rb +1 -0
  146. data/lib/decidim/legacy_form_builder.rb +4 -0
  147. metadata +12 -7
  148. data/lib/decidim/content_parsers/inline_images_parser.rb +0 -68
  149. 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
 
@@ -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">
@@ -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
 
@@ -1,5 +1,3 @@
1
- /* global jest */
2
-
3
1
  export const createSuggestionRenderer = (node, { itemConverter } = {}) => () => {
4
2
  let suggestion = null;
5
3
  let suggestionItems = null;
@@ -55,25 +53,25 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
55
53
  return;
56
54
  }
57
55
 
58
- const command = selectCommand;
59
- if (currentRange && !window.isTestEnvironment && typeof jest === "undefined") {
60
- // Fixes an issue that after selecting the item, the written text will be
61
- // placed after the newly added suggestion.
62
- //
63
- // NOTE: With JSDom/Jest this does not work even if we add a delay after
64
- // changing the text in the selection. This is because the range remains
65
- // the same for the `command` below which is why the underlying code is
66
- // trying to do an insertion at a position that is out of range after we
67
- // have already deleted the content.
68
- currentEditor.chain().focus().setTextSelection(currentRange).command(({ tr, dispatch }) => {
69
- if (dispatch) {
70
- tr.replaceSelectionWith(currentEditor.schema.text(" "));
71
- }
72
-
73
- return true;
74
- }).setTextSelection({ from: currentRange.from, to: currentRange.from }).run();
56
+ const item = convertItem(items[idx]);
57
+ if (currentRange) {
58
+ const docSize = currentEditor.state.doc.content.size;
59
+ const isValidRange = currentRange.from >= 0 && currentRange.to <= docSize && currentRange.from <= currentRange.to;
60
+
61
+ if (isValidRange) {
62
+ const nodeType = currentEditor.schema.nodes[items[idx].__typename === "User"
63
+ ? "mention"
64
+ : "mentionResource"];
65
+ currentEditor.chain().focus().setTextSelection(currentRange).insertContent({
66
+ type: nodeType.name,
67
+ attrs: item
68
+ }).insertContent(" ").run();
69
+ return;
70
+ }
75
71
  }
76
- command(convertItem(items[idx]));
72
+
73
+ const command = selectCommand;
74
+ command(item);
77
75
  };
78
76
 
79
77
  const showSuggestions = ({ items, clientRect }) => {
@@ -87,7 +85,7 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
87
85
  suggestion.classList.remove("hidden", "hide");
88
86
  suggestion.innerHTML = "";
89
87
  items.forEach((rawItem, idx) => {
90
- const { label, id, help } = convertItem(rawItem);
88
+ const { label, id, help, avatarUrl, displayLabel } = convertItem(rawItem);
91
89
  const suggestionItem = document.createElement("button");
92
90
  suggestionItem.type = "button";
93
91
  suggestionItem.classList.add("editor-suggestions-item");
@@ -103,7 +101,19 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
103
101
  if (help) {
104
102
  suggestionItem.disabled = true;
105
103
  }
106
- suggestionItem.textContent = label;
104
+
105
+ if (avatarUrl) {
106
+ const avatar = document.createElement("img");
107
+ avatar.classList.add("editor-suggestions-item-avatar");
108
+ avatar.src = avatarUrl;
109
+ avatar.alt = label;
110
+ suggestionItem.append(avatar);
111
+ }
112
+
113
+ const labelElement = document.createElement("span");
114
+ labelElement.classList.add("editor-suggestions-item-label");
115
+ labelElement.textContent = displayLabel || label;
116
+ suggestionItem.append(labelElement);
107
117
  suggestion.append(suggestionItem);
108
118
 
109
119
  suggestionItem.addEventListener("click", (ev) => {
@@ -29,9 +29,11 @@ const searchUsers = async (queryText) => {
29
29
  export default Mention.extend({
30
30
  addOptions() {
31
31
  const parentOptions = this.parent?.();
32
+ const searchPromptMessage = parentOptions?.searchPromptMessage || "Type to search participants";
32
33
 
33
34
  return {
34
35
  ...parentOptions,
36
+ searchPromptMessage,
35
37
  renderText({ node }) {
36
38
  // renderText is used to create the DOM representation
37
39
  const label = node.attrs.label ?? node.attrs.id;
@@ -42,16 +44,25 @@ export default Mention.extend({
42
44
  allowSpaces: true,
43
45
  items: async ({ query }) => {
44
46
  if (query.length < 2) {
45
- return [];
47
+ return [{ label: searchPromptMessage, help: true }];
46
48
  }
47
49
 
48
50
  const data = await searchUsers(query);
49
- const sorted = data.sort((user) => user.nickname.slice(1));
51
+ const sorted = data.sort((first, second) => first.nickname.localeCompare(second.nickname));
50
52
  return sorted.slice(0, 5);
51
53
  },
52
54
  render: createSuggestionRenderer(this, {
53
55
  itemConverter: (user) => {
54
- return { id: user.nickname, label: `${user.nickname} (${user.name})` }
56
+ if (user.help) {
57
+ return { label: user.label, help: true }
58
+ }
59
+
60
+ return {
61
+ id: user.nickname,
62
+ label: user.nickname,
63
+ displayLabel: `${user.nickname} (${user.name})`,
64
+ avatarUrl: user.avatarUrl
65
+ }
55
66
  }
56
67
  })
57
68
  }
@@ -87,5 +87,9 @@ export default function createEditor(container) {
87
87
  : editor.getHTML();
88
88
  });
89
89
 
90
+ editor.view.dom.addEventListener("move-cursor-to-end", () => {
91
+ editor.chain().focus("end").run();
92
+ });
93
+
90
94
  return editor;
91
95
  }