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.
- checksums.yaml +4 -4
- data/README.md +18 -16
- data/app/cells/decidim/comments/comment/deletion_data.erb +3 -7
- data/app/cells/decidim/comments/comment/replies.erb +23 -3
- data/app/cells/decidim/comments/comment/show.erb +5 -19
- data/app/cells/decidim/comments/comment_cell.rb +10 -6
- data/app/cells/decidim/comments/comment_form/show.erb +1 -1
- data/app/cells/decidim/comments/comments/inline.erb +13 -10
- data/app/cells/decidim/comments/comments_cell.rb +7 -2
- data/app/cells/decidim/comments/two_columns_comments/column.erb +7 -2
- data/app/cells/decidim/comments/two_columns_comments/show.erb +7 -4
- data/app/cells/decidim/comments/two_columns_comments_cell.rb +30 -31
- data/app/controllers/decidim/comments/comments_controller.rb +32 -7
- data/app/models/decidim/comments/seed.rb +17 -7
- data/app/packs/entrypoints/decidim_comments.js +6 -0
- data/app/packs/src/decidim/comments/comments.component.js +1 -43
- data/app/packs/src/decidim/comments/comments.component.test.js +63 -62
- data/app/packs/src/decidim/comments/controllers/load_more_comments/controller.js +201 -0
- data/app/packs/src/decidim/comments/controllers/show_replies/controller.js +240 -0
- data/app/packs/stylesheets/comments.scss +62 -1
- data/app/queries/decidim/comments/sorted_comments.rb +77 -33
- data/app/views/decidim/comments/comments/_load_more_comments.html.erb +22 -0
- data/app/views/decidim/comments/comments/create.js.erb +38 -4
- data/app/views/decidim/comments/comments/index.js.erb +58 -23
- data/app/views/decidim/comments/comments/load_more_comments.js.erb +74 -0
- data/app/views/decidim/comments/comments/update.js.erb +3 -13
- data/config/locales/ca-IT.yml +6 -3
- data/config/locales/ca.yml +6 -3
- data/config/locales/cs.yml +3 -5
- data/config/locales/de.yml +6 -3
- data/config/locales/en.yml +6 -3
- data/config/locales/es-MX.yml +6 -3
- data/config/locales/es-PY.yml +6 -3
- data/config/locales/es.yml +6 -3
- data/config/locales/eu.yml +6 -3
- data/config/locales/fi-plain.yml +6 -3
- data/config/locales/fi.yml +6 -3
- data/config/locales/fr-CA.yml +3 -3
- data/config/locales/fr.yml +3 -3
- data/config/locales/ja.yml +5 -2
- data/config/locales/pt-BR.yml +6 -3
- data/config/locales/ro-RO.yml +0 -4
- data/config/locales/sk.yml +8 -5
- data/config/locales/sv.yml +0 -3
- data/db/migrate/20260208201401_remove_user_group_comments.rb +13 -0
- data/decidim-comments.gemspec +6 -9
- data/lib/decidim/api/comment_mutation_type.rb +12 -4
- data/lib/decidim/api/commentable_interface.rb +1 -0
- data/lib/decidim/api/commentable_mutation_type.rb +4 -0
- data/lib/decidim/comments/commentable.rb +5 -0
- data/lib/decidim/comments/commentable_with_component.rb +9 -0
- data/lib/decidim/comments/test/factories.rb +1 -1
- data/lib/decidim/comments/test/shared_examples/translatable_comment.rb +1 -0
- data/lib/decidim/comments/version.rb +1 -1
- metadata +15 -14
- 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
|
|
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"
|
|
262
|
+
<div data-comment-footer data-controller="accordion" id="accordion-${commentId}" class="relative">
|
|
236
263
|
<div class="comment__footer-grid">
|
|
237
|
-
<div class="
|
|
238
|
-
<
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
|
293
|
+
<div id="panel-${commentId}-reply" class="add-comment" data-additional-reply>
|
|
265
294
|
${generateCommentForm("Comment", commentId)}
|
|
266
295
|
</div>
|
|
267
296
|
</div>
|
|
268
|
-
|
|
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
|
|
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
|
+
}
|