decidim-comments 0.31.6 → 0.32.0.rc1

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 (85) 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 +65 -63
  18. data/app/packs/src/decidim/comments/comments.js +1 -1
  19. data/app/packs/src/decidim/comments/controllers/load_more_comments/controller.js +201 -0
  20. data/app/packs/src/decidim/comments/controllers/show_replies/controller.js +240 -0
  21. data/app/packs/stylesheets/comments.scss +63 -2
  22. data/app/queries/decidim/comments/sorted_comments.rb +77 -33
  23. data/app/views/decidim/comments/admin/shared/_availability_fields.html.erb +1 -1
  24. data/app/views/decidim/comments/comments/_load_more_comments.html.erb +22 -0
  25. data/app/views/decidim/comments/comments/create.js.erb +38 -4
  26. data/app/views/decidim/comments/comments/index.js.erb +58 -23
  27. data/app/views/decidim/comments/comments/load_more_comments.js.erb +74 -0
  28. data/app/views/decidim/comments/comments/update.js.erb +3 -13
  29. data/config/locales/ar.yml +0 -1
  30. data/config/locales/bg.yml +0 -1
  31. data/config/locales/bs-BA.yml +0 -1
  32. data/config/locales/ca-IT.yml +6 -4
  33. data/config/locales/ca.yml +6 -4
  34. data/config/locales/cs.yml +3 -6
  35. data/config/locales/de.yml +6 -4
  36. data/config/locales/el.yml +0 -1
  37. data/config/locales/en.yml +6 -3
  38. data/config/locales/es-MX.yml +6 -4
  39. data/config/locales/es-PY.yml +6 -4
  40. data/config/locales/es.yml +6 -4
  41. data/config/locales/eu.yml +14 -12
  42. data/config/locales/fi-plain.yml +6 -4
  43. data/config/locales/fi.yml +6 -4
  44. data/config/locales/fr-CA.yml +3 -4
  45. data/config/locales/fr.yml +3 -4
  46. data/config/locales/ga-IE.yml +0 -1
  47. data/config/locales/gl.yml +0 -1
  48. data/config/locales/hu.yml +0 -1
  49. data/config/locales/id-ID.yml +0 -1
  50. data/config/locales/is-IS.yml +1 -2
  51. data/config/locales/it.yml +0 -1
  52. data/config/locales/ja.yml +5 -3
  53. data/config/locales/lb.yml +0 -1
  54. data/config/locales/lt.yml +0 -1
  55. data/config/locales/lv.yml +0 -1
  56. data/config/locales/nl.yml +0 -1
  57. data/config/locales/no.yml +0 -1
  58. data/config/locales/pl.yml +1 -2
  59. data/config/locales/pt-BR.yml +6 -4
  60. data/config/locales/pt.yml +0 -1
  61. data/config/locales/ro-RO.yml +0 -5
  62. data/config/locales/ru.yml +0 -1
  63. data/config/locales/si-LK.yml +0 -1
  64. data/config/locales/sk.yml +0 -125
  65. data/config/locales/sl.yml +0 -1
  66. data/config/locales/sq-AL.yml +0 -1
  67. data/config/locales/sr-CS.yml +0 -1
  68. data/config/locales/sv.yml +0 -4
  69. data/config/locales/tr-TR.yml +0 -1
  70. data/config/locales/uk.yml +0 -1
  71. data/config/locales/zh-CN.yml +0 -1
  72. data/config/locales/zh-TW.yml +0 -1
  73. data/db/migrate/20260208201401_remove_user_group_comments.rb +13 -0
  74. data/decidim-comments.gemspec +6 -9
  75. data/lib/decidim/api/comment_mutation_type.rb +12 -4
  76. data/lib/decidim/api/commentable_interface.rb +1 -0
  77. data/lib/decidim/api/commentable_mutation_type.rb +4 -0
  78. data/lib/decidim/comments/commentable.rb +5 -0
  79. data/lib/decidim/comments/commentable_with_component.rb +9 -0
  80. data/lib/decidim/comments/engine.rb +11 -0
  81. data/lib/decidim/comments/test/factories.rb +1 -1
  82. data/lib/decidim/comments/test/shared_examples/translatable_comment.rb +1 -0
  83. data/lib/decidim/comments/version.rb +1 -1
  84. metadata +15 -14
  85. 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
@@ -115,7 +112,8 @@ describe("CommentsComponent", () => {
115
112
  data-input-emoji="true"
116
113
  name="comment[body]"
117
114
  aria-describedby="add-comment-${modelName}-${modelId}-remaining-characters_sr"
118
- data-input-emoji-initialized="true">
115
+ data-input-emoji-initialized="true"
116
+ data-tribute="true">
119
117
  </textarea>
120
118
  <span class="emoji__trigger">
121
119
  <button class="emoji__button" type="button" aria-label="Add emoji">
@@ -195,6 +193,36 @@ describe("CommentsComponent", () => {
195
193
  }
196
194
 
197
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
+
198
226
  return `
199
227
  <div id="comment_${commentId}" class="comment" data-comment-id="${commentId}">
200
228
  <div class="comment__header">
@@ -231,40 +259,42 @@ describe("CommentsComponent", () => {
231
259
  <div class="comment__content">
232
260
  <div><p>${content}</p></div>
233
261
  </div>
234
- <div data-comment-footer data-controller="accordion" role="presentation">
262
+ <div data-comment-footer data-controller="accordion" id="accordion-${commentId}" class="relative">
235
263
  <div class="comment__footer-grid">
236
- <div class="comment__actions">
237
- <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">
238
- <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>
239
- <span class="font-normal">Reply</span>
240
- <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>
241
- <span class="font-normal">Cancel reply</span>
242
- </button>
243
- </div>
244
- <div class="comment__votes">
245
- <form class="button_to" method="post" action="/comments/${commentId}/votes?weight=1" data-remote="true">
246
- <button class="button button__sm button__text-secondary js-comment__votes--up" title="I agree with this comment" type="submit">
247
- <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>
248
- <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>
249
- <span>0</span>
250
- </button>
251
- <input type="hidden" name="authenticity_token" value="knr7y99HXbKv5sdm5OlghBlFsjIX7KOnIvHZ5-vXThb87Qszlh8j_CPxdbhsiqcIPAwvofsM9zR0vWFgojq6dA" autocomplete="off">
252
- </form>
253
- <form class="button_to" method="post" action="/comments/${commentId}/votes?weight=-1" data-remote="true">
254
- <button class="button button__sm button__text-secondary js-comment__votes--down" title="I disagree with this comment" type="submit">
255
- <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>
256
- <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>
257
- <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>
258
271
  </button>
259
- <input type="hidden" name="authenticity_token" value="OOIZTq0QSU1CB6OXV1D6j337zCgOA6al6xDOmy_qlZpWdem25Eg3A84QEUnfMz0DWLJRu-Lj8ja9XHYcZgdh-A" autocomplete="off">
260
- </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>
261
291
  </div>
262
292
  </div>
263
- <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>
264
294
  ${generateCommentForm("Comment", commentId)}
265
295
  </div>
266
296
  </div>
267
- <div id="comment-${commentId}-replies">${replies}</div>
297
+ ${repliesStructure}
268
298
  </div>
269
299
  `;
270
300
  }
@@ -358,8 +388,7 @@ describe("CommentsComponent", () => {
358
388
  commentsUrl: "/comments",
359
389
  rootDepth: 0,
360
390
  order: "older",
361
- lastCommentId: 456,
362
- pollingInterval: 1000
391
+ lastCommentId: 456
363
392
  });
364
393
 
365
394
  $doc = $(document);
@@ -392,8 +421,7 @@ describe("CommentsComponent", () => {
392
421
  data: new URLSearchParams({
393
422
  "commentable_gid": "commentable-gid",
394
423
  "root_depth": 0,
395
- order: "older",
396
- after: 456
424
+ order: "older"
397
425
  }),
398
426
  success: expect.any(Function)
399
427
  });
@@ -413,30 +441,6 @@ describe("CommentsComponent", () => {
413
441
  expect($(`${selector} .add-comment textarea`).prop("disabled")).toBeFalsy();
414
442
  });
415
443
 
416
- it("starts polling for new comments", () => {
417
- jest.spyOn(window, "setTimeout");
418
- Rails.ajax.mockImplementationOnce((options) => options.success());
419
-
420
- subject.mountComponent();
421
-
422
- expect(window.setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);
423
- });
424
-
425
- it("does not disable the textarea when polling comments normally", () => {
426
- Rails.ajax.mockImplementationOnce((options) => options.success());
427
-
428
- subject.mountComponent();
429
-
430
- // Delay the success call 2s after the polling has happened to test that
431
- // the textarea is still enabled when the polling is happening.
432
- Rails.ajax.mockImplementationOnce((options) => {
433
- setTimeout(() => options.success(), 2000);
434
- });
435
- jest.advanceTimersByTime(1500);
436
-
437
- expect($(`${selector} .add-comment textarea`).prop("disabled")).toBeFalsy();
438
- });
439
-
440
444
  describe("when mounted", () => {
441
445
  beforeEach(() => {
442
446
  spyOnAddComment("on");
@@ -507,7 +511,7 @@ describe("CommentsComponent", () => {
507
511
  ).toBeTruthy();
508
512
  });
509
513
 
510
- it("disables the submit button on submit and stops polling", () => {
514
+ it("disables the submit button on submit", () => {
511
515
  jest.spyOn(window, "clearTimeout");
512
516
 
513
517
  commentText.html("This is a test comment")
@@ -517,8 +521,6 @@ describe("CommentsComponent", () => {
517
521
  expect(
518
522
  $("button[type='submit']", commentSection.commentForm).is(":disabled")
519
523
  ).toBeTruthy();
520
-
521
- expect(window.clearTimeout).toHaveBeenCalledWith(subject.pollTimeout);
522
524
  });
523
525
  });
524
526
 
@@ -99,5 +99,5 @@ const commentsInitializer = () => {
99
99
  window.addEventListener("resize", handleResize);
100
100
  };
101
101
 
102
- // Keep this on turbo load so comments are mounted after each visit
102
+ // If no jQuery is used the Tribute feature used in comments to autocomplete mentions stops working
103
103
  document.addEventListener("turbo:load", commentsInitializer);
@@ -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
+ }