decidim-comments 0.31.5 → 0.32.0.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +18 -16
  3. data/app/cells/decidim/comments/comment/deletion_data.erb +3 -7
  4. data/app/cells/decidim/comments/comment/replies.erb +23 -3
  5. data/app/cells/decidim/comments/comment/show.erb +5 -19
  6. data/app/cells/decidim/comments/comment_cell.rb +10 -6
  7. data/app/cells/decidim/comments/comment_form/show.erb +1 -1
  8. data/app/cells/decidim/comments/comments/inline.erb +13 -10
  9. data/app/cells/decidim/comments/comments_cell.rb +7 -2
  10. data/app/cells/decidim/comments/two_columns_comments/column.erb +7 -2
  11. data/app/cells/decidim/comments/two_columns_comments/show.erb +7 -4
  12. data/app/cells/decidim/comments/two_columns_comments_cell.rb +30 -31
  13. data/app/controllers/decidim/comments/comments_controller.rb +32 -7
  14. data/app/models/decidim/comments/seed.rb +17 -7
  15. data/app/packs/entrypoints/decidim_comments.js +6 -0
  16. data/app/packs/src/decidim/comments/comments.component.js +1 -43
  17. data/app/packs/src/decidim/comments/comments.component.test.js +63 -62
  18. data/app/packs/src/decidim/comments/controllers/load_more_comments/controller.js +201 -0
  19. data/app/packs/src/decidim/comments/controllers/show_replies/controller.js +240 -0
  20. data/app/packs/stylesheets/comments.scss +62 -1
  21. data/app/queries/decidim/comments/sorted_comments.rb +77 -33
  22. data/app/views/decidim/comments/comments/_load_more_comments.html.erb +22 -0
  23. data/app/views/decidim/comments/comments/create.js.erb +38 -4
  24. data/app/views/decidim/comments/comments/index.js.erb +58 -23
  25. data/app/views/decidim/comments/comments/load_more_comments.js.erb +74 -0
  26. data/app/views/decidim/comments/comments/update.js.erb +3 -13
  27. data/config/locales/ca-IT.yml +6 -3
  28. data/config/locales/ca.yml +6 -3
  29. data/config/locales/cs.yml +3 -5
  30. data/config/locales/de.yml +6 -3
  31. data/config/locales/en.yml +6 -3
  32. data/config/locales/es-MX.yml +6 -3
  33. data/config/locales/es-PY.yml +6 -3
  34. data/config/locales/es.yml +6 -3
  35. data/config/locales/eu.yml +6 -3
  36. data/config/locales/fi-plain.yml +6 -3
  37. data/config/locales/fi.yml +6 -3
  38. data/config/locales/fr-CA.yml +3 -3
  39. data/config/locales/fr.yml +3 -3
  40. data/config/locales/ja.yml +5 -2
  41. data/config/locales/pt-BR.yml +6 -3
  42. data/config/locales/ro-RO.yml +0 -4
  43. data/config/locales/sk.yml +8 -5
  44. data/config/locales/sv.yml +0 -3
  45. data/db/migrate/20260208201401_remove_user_group_comments.rb +13 -0
  46. data/decidim-comments.gemspec +6 -9
  47. data/lib/decidim/api/comment_mutation_type.rb +12 -4
  48. data/lib/decidim/api/commentable_interface.rb +1 -0
  49. data/lib/decidim/api/commentable_mutation_type.rb +4 -0
  50. data/lib/decidim/comments/commentable.rb +5 -0
  51. data/lib/decidim/comments/commentable_with_component.rb +9 -0
  52. data/lib/decidim/comments/test/factories.rb +1 -1
  53. data/lib/decidim/comments/test/shared_examples/translatable_comment.rb +1 -0
  54. data/lib/decidim/comments/version.rb +1 -1
  55. metadata +15 -14
  56. data/app/resolvers/decidim/comments/vote_comment_resolver.rb +0 -24
@@ -10,14 +10,11 @@ window.$ = jest.fn().mockImplementation((...args) => $(...args));
10
10
  window.$.ajax = jest.fn().mockImplementation((...args) => $.ajax(...args));
11
11
  window.$.extend = jest.fn().mockImplementation((...args) => $.extend(...args));
12
12
 
13
- // Rails.ajax is used by the fetching/polling of the comments
13
+ // Rails.ajax is used by the fetching of the comments
14
14
  import Rails from "@rails/ujs";
15
15
  jest.mock("@rails/ujs");
16
16
  window.Rails = Rails;
17
17
 
18
- // Fake timers for testing polling
19
- jest.useFakeTimers();
20
-
21
18
  import Configuration from "src/decidim/refactor/implementation/configuration";
22
19
  // Component is loaded with require because using import loads it before $ has been mocked
23
20
  // so tests are not able to check the spied behaviours
@@ -196,6 +193,36 @@ describe("CommentsComponent", () => {
196
193
  }
197
194
 
198
195
  const generateSingleComment = (commentId, content, replies = "") => {
196
+ const hasReplies = replies.trim().length > 0;
197
+ const repliesStructure = hasReplies
198
+ ? `
199
+ <div data-controller="show-replies"
200
+ data-show-replies-url-value="/comments"
201
+ data-show-replies-comment-gid-value="comment-gid-${commentId}"
202
+ data-show-replies-order-value="older"
203
+ data-show-replies-loaded-value="false">
204
+ <div class="show-replies-button">
205
+ <button class="button button__xs button__text-secondary"
206
+ data-action="click->show-replies#toggle"
207
+ data-show-replies-target="button"
208
+ aria-expanded="false"
209
+ aria-controls="comment-${commentId}-replies"
210
+ id="comment-${commentId}-replies-trigger">
211
+ <span class="font-normal">1 reply</span>
212
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-arrow-down-s-line" tabindex="-1"></use></svg>
213
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-arrow-up-s-line" tabindex="-1"></use></svg>
214
+ </button>
215
+ <span data-show-replies-target="spinner" class="ml-2 hidden">
216
+ <svg width="1em" height="1em" role="img" aria-hidden="true" class="animate-spin fill-secondary"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-loader-3-line" tabindex="-1"></use></svg>
217
+ </span>
218
+ </div>
219
+ <div id="comment-${commentId}-replies"
220
+ data-show-replies-target="container"
221
+ class="comment-reply hidden">${replies}</div>
222
+ </div>
223
+ `
224
+ : `<div id="comment-${commentId}-replies" class="comment-reply"></div>`;
225
+
199
226
  return `
200
227
  <div id="comment_${commentId}" class="comment" data-comment-id="${commentId}">
201
228
  <div class="comment__header">
@@ -232,40 +259,42 @@ describe("CommentsComponent", () => {
232
259
  <div class="comment__content">
233
260
  <div><p>${content}</p></div>
234
261
  </div>
235
- <div data-comment-footer data-controller="accordion" role="presentation">
262
+ <div data-comment-footer data-controller="accordion" id="accordion-${commentId}" class="relative">
236
263
  <div class="comment__footer-grid">
237
- <div class="comment__actions">
238
- <button class="button button__sm button__text-secondary" data-controls="panel-comment${commentId}-reply" role="button" tabindex="0" aria-controls="panel-comment${commentId}-reply" aria-expanded="false" aria-disabled="false">
239
- <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-chat-1-line" tabindex="-1"></use></svg>
240
- <span class="font-normal">Reply</span>
241
- <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-close-circle-line" tabindex="-1"></use></svg>
242
- <span class="font-normal">Cancel reply</span>
243
- </button>
244
- </div>
245
- <div class="comment__votes">
246
- <form class="button_to" method="post" action="/comments/${commentId}/votes?weight=1" data-remote="true">
247
- <button class="button button__sm button__text-secondary js-comment__votes--up" title="I agree with this comment" type="submit">
248
- <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-up-line" tabindex="-1"></use></svg>
249
- <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-up-fill" tabindex="-1"></use></svg>
250
- <span>0</span>
251
- </button>
252
- <input type="hidden" name="authenticity_token" value="knr7y99HXbKv5sdm5OlghBlFsjIX7KOnIvHZ5-vXThb87Qszlh8j_CPxdbhsiqcIPAwvofsM9zR0vWFgojq6dA" autocomplete="off">
253
- </form>
254
- <form class="button_to" method="post" action="/comments/${commentId}/votes?weight=-1" data-remote="true">
255
- <button class="button button__sm button__text-secondary js-comment__votes--down" title="I disagree with this comment" type="submit">
256
- <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-down-line" tabindex="-1"></use></svg>
257
- <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-down-fill" tabindex="-1"></use></svg>
258
- <span>0</span>
264
+ <div class="comment__votes-actions">
265
+ <div class="comment__actions">
266
+ <button class="button button__sm button__text-secondary" data-controls="panel-${commentId}-reply" id="panel-${commentId}-reply-trigger">
267
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-chat-1-line" tabindex="-1"></use></svg>
268
+ <span class="font-normal">Reply</span>
269
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-close-circle-line" tabindex="-1"></use></svg>
270
+ <span class="font-normal">Cancel reply</span>
259
271
  </button>
260
- <input type="hidden" name="authenticity_token" value="OOIZTq0QSU1CB6OXV1D6j337zCgOA6al6xDOmy_qlZpWdem25Eg3A84QEUnfMz0DWLJRu-Lj8ja9XHYcZgdh-A" autocomplete="off">
261
- </form>
272
+ </div>
273
+ <div class="comment__votes">
274
+ <form class="button_to" method="post" action="/comments/${commentId}/votes?weight=1" data-remote="true">
275
+ <button class="button button__sm button__text-secondary js-comment__votes--up" title="I agree with this comment" type="submit">
276
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-up-line" tabindex="-1"></use></svg>
277
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-up-fill" tabindex="-1"></use></svg>
278
+ <span>0</span>
279
+ </button>
280
+ <input type="hidden" name="authenticity_token" value="knr7y99HXbKv5sdm5OlghBlFsjIX7KOnIvHZ5-vXThb87Qszlh8j_CPxdbhsiqcIPAwvofsM9zR0vWFgojq6dA" autocomplete="off">
281
+ </form>
282
+ <form class="button_to" method="post" action="/comments/${commentId}/votes?weight=-1" data-remote="true">
283
+ <button class="button button__sm button__text-secondary js-comment__votes--down" title="I disagree with this comment" type="submit">
284
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-down-line" tabindex="-1"></use></svg>
285
+ <svg width="1em" height="1em" role="img" aria-hidden="true"><use href="/decidim-packs/media/images/remixicon.symbol-5540ed538fb6bd400d2a.svg#ri-thumb-down-fill" tabindex="-1"></use></svg>
286
+ <span>0</span>
287
+ </button>
288
+ <input type="hidden" name="authenticity_token" value="OOIZTq0QSU1CB6OXV1D6j337zCgOA6al6xDOmy_qlZpWdem25Eg3A84QEUnfMz0DWLJRu-Lj8ja9XHYcZgdh-A" autocomplete="off">
289
+ </form>
290
+ </div>
262
291
  </div>
263
292
  </div>
264
- <div id="panel-comment${commentId}-reply" class="add-comment" role="region" tabindex="-1" aria-labelledby="" aria-hidden="true">
293
+ <div id="panel-${commentId}-reply" class="add-comment" data-additional-reply>
265
294
  ${generateCommentForm("Comment", commentId)}
266
295
  </div>
267
296
  </div>
268
- <div id="comment-${commentId}-replies">${replies}</div>
297
+ ${repliesStructure}
269
298
  </div>
270
299
  `;
271
300
  }
@@ -359,8 +388,7 @@ describe("CommentsComponent", () => {
359
388
  commentsUrl: "/comments",
360
389
  rootDepth: 0,
361
390
  order: "older",
362
- lastCommentId: 456,
363
- pollingInterval: 1000
391
+ lastCommentId: 456
364
392
  });
365
393
 
366
394
  $doc = $(document);
@@ -393,8 +421,7 @@ describe("CommentsComponent", () => {
393
421
  data: new URLSearchParams({
394
422
  "commentable_gid": "commentable-gid",
395
423
  "root_depth": 0,
396
- order: "older",
397
- after: 456
424
+ order: "older"
398
425
  }),
399
426
  success: expect.any(Function)
400
427
  });
@@ -414,30 +441,6 @@ describe("CommentsComponent", () => {
414
441
  expect($(`${selector} .add-comment textarea`).prop("disabled")).toBeFalsy();
415
442
  });
416
443
 
417
- it("starts polling for new comments", () => {
418
- jest.spyOn(window, "setTimeout");
419
- Rails.ajax.mockImplementationOnce((options) => options.success());
420
-
421
- subject.mountComponent();
422
-
423
- expect(window.setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);
424
- });
425
-
426
- it("does not disable the textarea when polling comments normally", () => {
427
- Rails.ajax.mockImplementationOnce((options) => options.success());
428
-
429
- subject.mountComponent();
430
-
431
- // Delay the success call 2s after the polling has happened to test that
432
- // the textarea is still enabled when the polling is happening.
433
- Rails.ajax.mockImplementationOnce((options) => {
434
- setTimeout(() => options.success(), 2000);
435
- });
436
- jest.advanceTimersByTime(1500);
437
-
438
- expect($(`${selector} .add-comment textarea`).prop("disabled")).toBeFalsy();
439
- });
440
-
441
444
  describe("when mounted", () => {
442
445
  beforeEach(() => {
443
446
  spyOnAddComment("on");
@@ -508,7 +511,7 @@ describe("CommentsComponent", () => {
508
511
  ).toBeTruthy();
509
512
  });
510
513
 
511
- it("disables the submit button on submit and stops polling", () => {
514
+ it("disables the submit button on submit", () => {
512
515
  jest.spyOn(window, "clearTimeout");
513
516
 
514
517
  commentText.html("This is a test comment")
@@ -518,8 +521,6 @@ describe("CommentsComponent", () => {
518
521
  expect(
519
522
  $("button[type='submit']", commentSection.commentForm).is(":disabled")
520
523
  ).toBeTruthy();
521
-
522
- expect(window.clearTimeout).toHaveBeenCalledWith(subject.pollTimeout);
523
524
  });
524
525
  });
525
526
 
@@ -0,0 +1,201 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Load More Comments Stimulus Controller
5
+ *
6
+ * Handles paginated loading of additional comments via AJAX requests.
7
+ * When the user clicks the "Load more" button, it fetches the next page of comments
8
+ * from the server and appends them to the existing comment list.
9
+ *
10
+ * Usage:
11
+ * <div data-controller="load-more-comments"
12
+ * data-load-more-comments-url-value="/comments"
13
+ * data-load-more-comments-commentable-gid-value="gid://app/Model/1"
14
+ * data-load-more-comments-order-value="older"
15
+ * data-load-more-comments-offset-value="20"
16
+ * data-load-more-comments-per-page-value="20"
17
+ * data-load-more-comments-alignment-value="1">
18
+ * <button data-load-more-comments-target="button"
19
+ * data-action="click->load-more-comments#loadMore">Load more</button>
20
+ * <span data-load-more-comments-target="spinner" class="hidden">Loading...</span>
21
+ * </div>
22
+ */
23
+ export default class extends Controller {
24
+ static get values() {
25
+ return {
26
+ url: String,
27
+ commentableGid: String,
28
+ order: String,
29
+ offset: Number,
30
+ perPage: Number,
31
+ alignment: Number
32
+ }
33
+ }
34
+
35
+ static get targets() {
36
+ return ["button", "spinner"]
37
+ }
38
+
39
+ connect() {
40
+ this.loading = false;
41
+ }
42
+
43
+ /**
44
+ * Load more comments when the button is clicked
45
+ * @param {Event} event - The click event from the button
46
+ * @returns {void}
47
+ */
48
+ async loadMore(event) {
49
+ event.preventDefault();
50
+
51
+ if (this.loading) {
52
+ return;
53
+ }
54
+
55
+ this.loading = true;
56
+ this.showLoadingState();
57
+
58
+ try {
59
+ const url = this.buildUrl();
60
+ const response = await this.makeRequest(url);
61
+
62
+ if (response.ok) {
63
+ const script = await response.text();
64
+ this.executeScript(script);
65
+ } else {
66
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
67
+ }
68
+ } catch (error) {
69
+ this.handleError(error);
70
+ } finally {
71
+ this.loading = false;
72
+ this.hideLoadingState();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Build the URL with query parameters for the AJAX request
78
+ * @private
79
+ * @returns {string} The URL with query parameters
80
+ */
81
+ buildUrl() {
82
+ const locale = document.documentElement.getAttribute("lang") || "en";
83
+ const params = new URLSearchParams({
84
+ "commentable_gid": this.commentableGidValue,
85
+ "order": this.orderValue,
86
+ "offset": this.offsetValue,
87
+ "locale": locale,
88
+ "load_more": 1
89
+ });
90
+
91
+ if (this.hasAlignmentValue && typeof this.alignmentValue !== "undefined") {
92
+ params.append("alignment", this.alignmentValue);
93
+ }
94
+
95
+ const separator = this.urlValue.includes("?")
96
+ ? "&"
97
+ : "?";
98
+ return `${this.urlValue}${separator}${params.toString()}`;
99
+ }
100
+
101
+ /**
102
+ * Make the HTTP request using fetch
103
+ * @private
104
+ * @param {string} url - The URL to request
105
+ * @returns {Promise<Response>} The fetch response
106
+ */
107
+ async makeRequest(url) {
108
+ const csrfToken = this.getCSRFToken();
109
+
110
+ return fetch(url, {
111
+ method: "GET",
112
+ headers: {
113
+ "Accept": "text/javascript",
114
+ "X-Requested-With": "XMLHttpRequest",
115
+ ...(csrfToken && { "X-CSRF-Token": csrfToken })
116
+ },
117
+ credentials: "same-origin"
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Get CSRF token from meta tag
123
+ * @private
124
+ * @returns {string|null} The CSRF token or null if not found
125
+ */
126
+ getCSRFToken() {
127
+ const tokenElement = document.querySelector('meta[name="csrf-token"]');
128
+ return tokenElement
129
+ ? tokenElement.getAttribute("content")
130
+ : null;
131
+ }
132
+
133
+ /**
134
+ * Execute the JavaScript response from the server
135
+ * @private
136
+ * @param {string} script - The JavaScript code to execute
137
+ * @returns {void}
138
+ */
139
+ executeScript(script) {
140
+ const scriptElement = document.createElement("script");
141
+ scriptElement.textContent = script;
142
+ document.body.appendChild(scriptElement);
143
+ document.body.removeChild(scriptElement);
144
+ }
145
+
146
+ /**
147
+ * Show loading state on the button
148
+ * @private
149
+ * @returns {void}
150
+ */
151
+ showLoadingState() {
152
+ if (this.hasButtonTarget) {
153
+ this.buttonTarget.disabled = true;
154
+ this.buttonTarget.classList.add("loading");
155
+ }
156
+ if (this.hasSpinnerTarget) {
157
+ this.spinnerTarget.classList.remove("hidden");
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Hide loading state on the button
163
+ * @private
164
+ * @returns {void}
165
+ */
166
+ hideLoadingState() {
167
+ if (this.hasButtonTarget) {
168
+ this.buttonTarget.disabled = false;
169
+ this.buttonTarget.classList.remove("loading");
170
+ }
171
+ if (this.hasSpinnerTarget) {
172
+ this.spinnerTarget.classList.add("hidden");
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Handle error response
178
+ * @private
179
+ * @param {Error} error - The error that occurred
180
+ * @returns {void}
181
+ */
182
+ handleError(error) {
183
+ console.error("Error loading more comments:", error);
184
+
185
+ this.dispatch("error", {
186
+ detail: {
187
+ error: error.message,
188
+ element: this.element
189
+ }
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Hide the load more button when no more comments are available
195
+ * @public
196
+ * @returns {void}
197
+ */
198
+ hideButton() {
199
+ this.element.remove();
200
+ }
201
+ }
@@ -0,0 +1,240 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Show Replies Stimulus Controller
5
+ *
6
+ * Handles lazy loading and toggling visibility of comment replies.
7
+ * On first click, it fetches replies via AJAX. Subsequent clicks toggle visibility.
8
+ *
9
+ * Usage:
10
+ * <div data-controller="show-replies"
11
+ * data-show-replies-url-value="/comments"
12
+ * data-show-replies-comment-gid-value="gid://app/Comment/1"
13
+ * data-show-replies-order-value="older"
14
+ * data-show-replies-loaded-value="false">
15
+ * <button data-show-replies-target="button"
16
+ * data-action="click->show-replies#toggle">Show replies</button>
17
+ * <span data-show-replies-target="spinner" class="hidden">Loading...</span>
18
+ * <div data-show-replies-target="container" class="hidden"></div>
19
+ * </div>
20
+ */
21
+ export default class extends Controller {
22
+ static get values() {
23
+ return {
24
+ url: String,
25
+ commentGid: String,
26
+ order: String,
27
+ loaded: Boolean
28
+ }
29
+ }
30
+
31
+ static get targets() {
32
+ return ["container", "button", "spinner"]
33
+ }
34
+
35
+ connect() {
36
+ this.loading = false;
37
+ }
38
+
39
+ /**
40
+ * Toggle replies visibility - loads on first click, then toggles
41
+ * @param {Event} event - The click event from the button
42
+ * @returns {void}
43
+ */
44
+ async toggle(event) {
45
+ event.preventDefault();
46
+
47
+ if (this.loading) {
48
+ return;
49
+ }
50
+
51
+ if (this.loadedValue) {
52
+ // Already loaded - just toggle visibility
53
+ this.toggleVisibility();
54
+ } else {
55
+ // First time - load replies via AJAX
56
+ await this.loadReplies();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Load replies via AJAX
62
+ * @private
63
+ * @returns {Promise<void>} A promise that resolves when replies are loaded
64
+ */
65
+ async loadReplies() {
66
+ this.loading = true;
67
+ this.showLoadingState();
68
+
69
+ try {
70
+ const url = this.buildUrl();
71
+ const response = await this.makeRequest(url);
72
+
73
+ if (response.ok) {
74
+ const script = await response.text();
75
+ this.executeScript(script);
76
+ this.loadedValue = true;
77
+ this.showReplies();
78
+ } else {
79
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
80
+ }
81
+ } catch (error) {
82
+ this.handleError(error);
83
+ } finally {
84
+ this.loading = false;
85
+ this.hideLoadingState();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Build the URL with query parameters for the AJAX request
91
+ * @private
92
+ * @returns {string} The URL with query parameters
93
+ */
94
+ buildUrl() {
95
+ const locale = document.documentElement.getAttribute("lang") || "en";
96
+ const params = new URLSearchParams({
97
+ "commentable_gid": this.commentGidValue,
98
+ "order": this.orderValue,
99
+ "offset": 0,
100
+ "locale": locale,
101
+ "load_more": 1
102
+ });
103
+
104
+ const separator = this.urlValue.includes("?")
105
+ ? "&"
106
+ : "?";
107
+ return `${this.urlValue}${separator}${params.toString()}`;
108
+ }
109
+
110
+ /**
111
+ * Make the HTTP request using fetch
112
+ * @private
113
+ * @param {string} url - The URL to request
114
+ * @returns {Promise<Response>} The fetch response
115
+ */
116
+ async makeRequest(url) {
117
+ const csrfToken = this.getCSRFToken();
118
+
119
+ return fetch(url, {
120
+ method: "GET",
121
+ headers: {
122
+ "Accept": "text/javascript",
123
+ "X-Requested-With": "XMLHttpRequest",
124
+ ...(csrfToken && { "X-CSRF-Token": csrfToken })
125
+ },
126
+ credentials: "same-origin"
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Get CSRF token from meta tag
132
+ * @private
133
+ * @returns {string|null} The CSRF token or null if not found
134
+ */
135
+ getCSRFToken() {
136
+ const tokenElement = document.querySelector('meta[name="csrf-token"]');
137
+ return tokenElement
138
+ ? tokenElement.getAttribute("content")
139
+ : null;
140
+ }
141
+
142
+ /**
143
+ * Execute the JavaScript response from the server
144
+ * @private
145
+ * @param {string} script - The JavaScript code to execute
146
+ * @returns {void}
147
+ */
148
+ executeScript(script) {
149
+ const scriptElement = document.createElement("script");
150
+ scriptElement.textContent = script;
151
+ document.body.appendChild(scriptElement);
152
+ document.body.removeChild(scriptElement);
153
+ }
154
+
155
+ /**
156
+ * Show loading state
157
+ * @private
158
+ * @returns {void}
159
+ */
160
+ showLoadingState() {
161
+ if (this.hasSpinnerTarget) {
162
+ this.spinnerTarget.classList.remove("hidden");
163
+ }
164
+ if (this.hasButtonTarget) {
165
+ this.buttonTarget.disabled = true;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Hide loading state
171
+ * @private
172
+ * @returns {void}
173
+ */
174
+ hideLoadingState() {
175
+ if (this.hasSpinnerTarget) {
176
+ this.spinnerTarget.classList.add("hidden");
177
+ }
178
+ if (this.hasButtonTarget) {
179
+ this.buttonTarget.disabled = false;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Show replies (after loading or toggling)
185
+ * @private
186
+ * @returns {void}
187
+ */
188
+ showReplies() {
189
+ if (this.hasContainerTarget) {
190
+ this.containerTarget.classList.remove("hidden");
191
+ }
192
+ if (this.hasButtonTarget) {
193
+ this.buttonTarget.setAttribute("aria-expanded", "true");
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Hide replies
199
+ * @private
200
+ * @returns {void}
201
+ */
202
+ hideReplies() {
203
+ if (this.hasContainerTarget) {
204
+ this.containerTarget.classList.add("hidden");
205
+ }
206
+ if (this.hasButtonTarget) {
207
+ this.buttonTarget.setAttribute("aria-expanded", "false");
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Toggle visibility of replies
213
+ * @private
214
+ * @returns {void}
215
+ */
216
+ toggleVisibility() {
217
+ if (this.hasContainerTarget && this.containerTarget.classList.contains("hidden")) {
218
+ this.showReplies();
219
+ } else {
220
+ this.hideReplies();
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Handle error response
226
+ * @private
227
+ * @param {Error} error - The error that occurred
228
+ * @returns {void}
229
+ */
230
+ handleError(error) {
231
+ console.error("Error loading replies:", error);
232
+
233
+ this.dispatch("error", {
234
+ detail: {
235
+ error: error.message,
236
+ element: this.element
237
+ }
238
+ });
239
+ }
240
+ }