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.
- 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 +65 -63
- data/app/packs/src/decidim/comments/comments.js +1 -1
- 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 +63 -2
- data/app/queries/decidim/comments/sorted_comments.rb +77 -33
- data/app/views/decidim/comments/admin/shared/_availability_fields.html.erb +1 -1
- 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/ar.yml +0 -1
- data/config/locales/bg.yml +0 -1
- data/config/locales/bs-BA.yml +0 -1
- data/config/locales/ca-IT.yml +6 -4
- data/config/locales/ca.yml +6 -4
- data/config/locales/cs.yml +3 -6
- data/config/locales/de.yml +6 -4
- data/config/locales/el.yml +0 -1
- data/config/locales/en.yml +6 -3
- data/config/locales/es-MX.yml +6 -4
- data/config/locales/es-PY.yml +6 -4
- data/config/locales/es.yml +6 -4
- data/config/locales/eu.yml +14 -12
- data/config/locales/fi-plain.yml +6 -4
- data/config/locales/fi.yml +6 -4
- data/config/locales/fr-CA.yml +3 -4
- data/config/locales/fr.yml +3 -4
- data/config/locales/ga-IE.yml +0 -1
- data/config/locales/gl.yml +0 -1
- data/config/locales/hu.yml +0 -1
- data/config/locales/id-ID.yml +0 -1
- data/config/locales/is-IS.yml +1 -2
- data/config/locales/it.yml +0 -1
- data/config/locales/ja.yml +5 -3
- data/config/locales/lb.yml +0 -1
- data/config/locales/lt.yml +0 -1
- data/config/locales/lv.yml +0 -1
- data/config/locales/nl.yml +0 -1
- data/config/locales/no.yml +0 -1
- data/config/locales/pl.yml +1 -2
- data/config/locales/pt-BR.yml +6 -4
- data/config/locales/pt.yml +0 -1
- data/config/locales/ro-RO.yml +0 -5
- data/config/locales/ru.yml +0 -1
- data/config/locales/si-LK.yml +0 -1
- data/config/locales/sk.yml +0 -125
- data/config/locales/sl.yml +0 -1
- data/config/locales/sq-AL.yml +0 -1
- data/config/locales/sr-CS.yml +0 -1
- data/config/locales/sv.yml +0 -4
- data/config/locales/tr-TR.yml +0 -1
- data/config/locales/uk.yml +0 -1
- data/config/locales/zh-CN.yml +0 -1
- data/config/locales/zh-TW.yml +0 -1
- 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/engine.rb +11 -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
|
|
@@ -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"
|
|
262
|
+
<div data-comment-footer data-controller="accordion" id="accordion-${commentId}" class="relative">
|
|
235
263
|
<div class="comment__footer-grid">
|
|
236
|
-
<div class="
|
|
237
|
-
<
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
293
|
+
<div id="panel-${commentId}-reply" class="add-comment" data-additional-reply>
|
|
264
294
|
${generateCommentForm("Comment", commentId)}
|
|
265
295
|
</div>
|
|
266
296
|
</div>
|
|
267
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
+
}
|