collavre 0.24.0 → 0.24.1
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/app/assets/stylesheets/collavre/comments_popup.css +5 -17
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +26 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +230 -0
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +178 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +146 -0
- data/app/javascript/controllers/comments/form_controller.js +3 -0
- data/app/javascript/controllers/comments/list_controller.js +42 -21
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +68 -21
- data/app/views/collavre/comments/_comments_popup.html.erb +8 -0
- data/config/locales/comments.en.yml +6 -0
- data/config/locales/comments.ko.yml +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '080f4e1eadfbbb142d360670a3477f3c19f191d586ea47200b95e6566b63d64d'
|
|
4
|
+
data.tar.gz: 593a65708b18711b54283e4390af4b7b39267a0e20f9a98b2d3df3c3aede0a5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57e481a2c4284f2f705cd363f7417c6803d47af951a8e37bea75ef45366142442aec2231f572b2e021319d85283fba78ce613ae96df4e536e16188a5873fe8f3
|
|
7
|
+
data.tar.gz: aefdf3ea1327707b3e230089d5fe8787b428b92ba86d80ba32d244037be9aad82f5c1f36de99ccff779ec323210a1394bfc076e623d99ff46c44645f9bfc8330
|
|
@@ -1452,23 +1452,6 @@ body.chat-fullscreen {
|
|
|
1452
1452
|
fill-opacity: 0.2;
|
|
1453
1453
|
}
|
|
1454
1454
|
|
|
1455
|
-
.comment-topics-list {
|
|
1456
|
-
display: flex;
|
|
1457
|
-
flex-wrap: nowrap;
|
|
1458
|
-
align-items: center;
|
|
1459
|
-
gap: 0.5em;
|
|
1460
|
-
padding: var(--space-1) 0;
|
|
1461
|
-
overflow-x: auto;
|
|
1462
|
-
overflow-y: hidden;
|
|
1463
|
-
scrollbar-width: none; /* Firefox */
|
|
1464
|
-
-ms-overflow-style: none; /* IE/Edge */
|
|
1465
|
-
min-height: 36px; /* Prevent height collapse during scroll */
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
.comment-topics-list::-webkit-scrollbar {
|
|
1469
|
-
display: none; /* Chrome/Safari */
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
1455
|
.topic-tag {
|
|
1473
1456
|
display: inline-flex;
|
|
1474
1457
|
align-items: center;
|
|
@@ -1651,6 +1634,11 @@ body.chat-fullscreen {
|
|
|
1651
1634
|
flex-shrink: 0;
|
|
1652
1635
|
}
|
|
1653
1636
|
|
|
1637
|
+
/* Class rule above outranks the UA [hidden] rule, so restate it. */
|
|
1638
|
+
.topic-creation-container[hidden] {
|
|
1639
|
+
display: none;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1654
1642
|
.add-topic-btn {
|
|
1655
1643
|
display: inline-flex;
|
|
1656
1644
|
align-items: center;
|
|
@@ -343,4 +343,30 @@ describe('FormController - Review Quote Chips', () => {
|
|
|
343
343
|
expect(controller.textareaTarget.value).toBe('fb2')
|
|
344
344
|
})
|
|
345
345
|
})
|
|
346
|
+
|
|
347
|
+
describe('active chip re-click - programmatic scroll', () => {
|
|
348
|
+
// Re-clicking the active chip scrolls its quoted comment into view. That is a
|
|
349
|
+
// programmatic list scroll, so it must drop the prev-message anchor the same way
|
|
350
|
+
// scrollToBottom/highlightComment do — otherwise a stale anchor inside the corridor
|
|
351
|
+
// survives and the next previous-message click skips the comment now in view.
|
|
352
|
+
test('re-clicking the active chip notifies the list of a programmatic scroll', () => {
|
|
353
|
+
const commentEl = document.createElement('div')
|
|
354
|
+
commentEl.setAttribute('data-comment-id', '1')
|
|
355
|
+
commentEl.scrollIntoView = jest.fn() // jsdom has no scrollIntoView
|
|
356
|
+
document.body.appendChild(commentEl)
|
|
357
|
+
|
|
358
|
+
const notifyProgrammaticScroll = jest.fn()
|
|
359
|
+
jest
|
|
360
|
+
.spyOn(controller, 'listController', 'get')
|
|
361
|
+
.mockReturnValue({ notifyProgrammaticScroll })
|
|
362
|
+
|
|
363
|
+
controller.appendReviewQuote(1, 'first') // freshly appended quote is active
|
|
364
|
+
container.querySelector('.review-quote-chip-text').click()
|
|
365
|
+
|
|
366
|
+
expect(commentEl.scrollIntoView).toHaveBeenCalled()
|
|
367
|
+
expect(notifyProgrammaticScroll).toHaveBeenCalled()
|
|
368
|
+
|
|
369
|
+
commentEl.remove()
|
|
370
|
+
})
|
|
371
|
+
})
|
|
346
372
|
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Covers the previous-message wiring in list_controller: anchor lifecycle across
|
|
7
|
+
* connect/disconnect, list reloads, and the scroll action itself.
|
|
8
|
+
*
|
|
9
|
+
* jsdom has no layout engine, so item geometry is stubbed. `scrollTo` records
|
|
10
|
+
* the request without moving `scrollTop` — that models the smooth-scroll
|
|
11
|
+
* animation still being in flight, which is the state the original bug needed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { jest } from '@jest/globals'
|
|
15
|
+
import ListController from '../list_controller'
|
|
16
|
+
|
|
17
|
+
const ITEM_HEIGHT = 100
|
|
18
|
+
|
|
19
|
+
function buildController({ itemCount = 5 } = {}) {
|
|
20
|
+
const state = { scrollTop: 0 }
|
|
21
|
+
|
|
22
|
+
const element = document.createElement('div')
|
|
23
|
+
const list = document.createElement('div')
|
|
24
|
+
element.appendChild(list)
|
|
25
|
+
document.body.appendChild(element)
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < itemCount; i++) {
|
|
28
|
+
const item = document.createElement('div')
|
|
29
|
+
item.className = 'comment-item'
|
|
30
|
+
item.dataset.commentId = `c${i}`
|
|
31
|
+
Object.defineProperty(item, 'offsetTop', { get: () => i * ITEM_HEIGHT })
|
|
32
|
+
item.getBoundingClientRect = () => ({ top: i * ITEM_HEIGHT - state.scrollTop })
|
|
33
|
+
list.appendChild(item)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
list.getBoundingClientRect = () => ({ top: 0 })
|
|
37
|
+
Object.defineProperty(list, 'offsetTop', { get: () => 0 })
|
|
38
|
+
list.scrollTo = jest.fn()
|
|
39
|
+
|
|
40
|
+
// Stand the controller up without a Stimulus application: `element` is a
|
|
41
|
+
// getter on Controller.prototype, so it has to be shadowed rather than set.
|
|
42
|
+
const controller = Object.create(ListController.prototype)
|
|
43
|
+
Object.defineProperty(controller, 'element', { value: element })
|
|
44
|
+
Object.defineProperty(controller, 'listTarget', { value: list })
|
|
45
|
+
controller.connect()
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
controller,
|
|
49
|
+
list,
|
|
50
|
+
element,
|
|
51
|
+
// Apply the last requested scroll, i.e. let the smooth animation finish.
|
|
52
|
+
settle() {
|
|
53
|
+
const call = list.scrollTo.mock.calls.at(-1)
|
|
54
|
+
if (call) state.scrollTop = call[0].top
|
|
55
|
+
},
|
|
56
|
+
// Move the list without going through the button, as a user gesture would.
|
|
57
|
+
scrollTo(top) {
|
|
58
|
+
state.scrollTop = top
|
|
59
|
+
},
|
|
60
|
+
lastScrollTop() {
|
|
61
|
+
return list.scrollTo.mock.calls.at(-1)?.[0].top
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('list_controller previous-message navigation', () => {
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
document.body.innerHTML = ''
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('connect installs a navigator', () => {
|
|
72
|
+
const { controller } = buildController()
|
|
73
|
+
expect(controller.prevMsgNavigator).toBeTruthy()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('walks backwards one message per click when settled', () => {
|
|
77
|
+
const h = buildController()
|
|
78
|
+
h.scrollTo(4 * ITEM_HEIGHT) // parked on the last item
|
|
79
|
+
|
|
80
|
+
h.controller.scrollToPreviousMessage()
|
|
81
|
+
expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
|
|
82
|
+
h.settle()
|
|
83
|
+
|
|
84
|
+
h.controller.scrollToPreviousMessage()
|
|
85
|
+
expect(h.lastScrollTop()).toBe(2 * ITEM_HEIGHT)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('keeps stepping back when clicked mid-animation', () => {
|
|
89
|
+
const h = buildController()
|
|
90
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
91
|
+
|
|
92
|
+
// Three clicks with no settle between them: the list never reaches the
|
|
93
|
+
// requested offset, which is exactly when the old geometry-only code
|
|
94
|
+
// bounced back to the item it had just left.
|
|
95
|
+
h.controller.scrollToPreviousMessage()
|
|
96
|
+
expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
|
|
97
|
+
h.controller.scrollToPreviousMessage()
|
|
98
|
+
expect(h.lastScrollTop()).toBe(2 * ITEM_HEIGHT)
|
|
99
|
+
h.controller.scrollToPreviousMessage()
|
|
100
|
+
expect(h.lastScrollTop()).toBe(1 * ITEM_HEIGHT)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('releases stick-to-bottom so incoming comments do not yank the view', () => {
|
|
104
|
+
const h = buildController()
|
|
105
|
+
h.controller.stickToBottom = true
|
|
106
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
107
|
+
|
|
108
|
+
h.controller.scrollToPreviousMessage()
|
|
109
|
+
|
|
110
|
+
expect(h.controller.stickToBottom).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('does nothing when the list is empty', () => {
|
|
114
|
+
const h = buildController({ itemCount: 0 })
|
|
115
|
+
|
|
116
|
+
h.controller.scrollToPreviousMessage()
|
|
117
|
+
|
|
118
|
+
expect(h.list.scrollTo).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('loads older comments once past the oldest message', () => {
|
|
122
|
+
const h = buildController()
|
|
123
|
+
h.controller.allOlderLoaded = false
|
|
124
|
+
h.controller.loadOlderComments = jest.fn()
|
|
125
|
+
|
|
126
|
+
h.controller.scrollToPreviousMessage() // lands on the oldest item
|
|
127
|
+
h.settle()
|
|
128
|
+
h.controller.scrollToPreviousMessage() // nothing older in the DOM
|
|
129
|
+
|
|
130
|
+
expect(h.controller.loadOlderComments).toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('stops at the oldest message when everything is already loaded', () => {
|
|
134
|
+
const h = buildController()
|
|
135
|
+
h.controller.allOlderLoaded = true
|
|
136
|
+
h.controller.loadOlderComments = jest.fn()
|
|
137
|
+
|
|
138
|
+
h.controller.scrollToPreviousMessage()
|
|
139
|
+
h.settle()
|
|
140
|
+
const before = h.list.scrollTo.mock.calls.length
|
|
141
|
+
h.controller.scrollToPreviousMessage()
|
|
142
|
+
|
|
143
|
+
expect(h.controller.loadOlderComments).not.toHaveBeenCalled()
|
|
144
|
+
expect(h.list.scrollTo.mock.calls.length).toBe(before)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe.each(['wheel', 'touchstart', 'keydown', 'pointerdown'])(
|
|
148
|
+
'user input via %s',
|
|
149
|
+
(eventName) => {
|
|
150
|
+
test('drops the anchor so the next click resolves from where the user landed', () => {
|
|
151
|
+
const h = buildController()
|
|
152
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
153
|
+
|
|
154
|
+
h.controller.scrollToPreviousMessage() // anchor = c3
|
|
155
|
+
h.settle()
|
|
156
|
+
|
|
157
|
+
// User scrolls back down to the bottom themselves.
|
|
158
|
+
h.list.dispatchEvent(new Event(eventName))
|
|
159
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
160
|
+
|
|
161
|
+
h.controller.scrollToPreviousMessage()
|
|
162
|
+
|
|
163
|
+
// Resolved from geometry (c4 at top -> c3), not from the stale anchor.
|
|
164
|
+
expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
test('a programmatic jump to the bottom drops the anchor', () => {
|
|
170
|
+
// form_controller (after sending) and the fullscreen handlers call
|
|
171
|
+
// scrollToBottom() with no user gesture. A small jump can leave the anchor
|
|
172
|
+
// inside the corridor, where geometry cannot tell it from our own in-flight
|
|
173
|
+
// scroll, so the next click would step off the stale anchor (c3 -> c2)
|
|
174
|
+
// instead of resolving from where the list actually is (c4 -> c3).
|
|
175
|
+
const h = buildController()
|
|
176
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
177
|
+
|
|
178
|
+
h.controller.scrollToPreviousMessage() // anchor = c3, in flight
|
|
179
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
|
|
180
|
+
|
|
181
|
+
h.controller.scrollToBottom()
|
|
182
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
|
|
183
|
+
|
|
184
|
+
h.controller.scrollToPreviousMessage()
|
|
185
|
+
expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT) // c3 from geometry, not c2
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('a permalink highlight jump drops the anchor', () => {
|
|
189
|
+
const h = buildController()
|
|
190
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
191
|
+
|
|
192
|
+
h.controller.scrollToPreviousMessage() // anchor = c3
|
|
193
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
|
|
194
|
+
|
|
195
|
+
// highlightComment looks the target up by DOM id and scrolls it into view
|
|
196
|
+
// (jsdom has no scrollIntoView, so stub it).
|
|
197
|
+
const target = h.list.querySelector('[data-comment-id="c1"]')
|
|
198
|
+
target.id = 'comment_c1'
|
|
199
|
+
target.scrollIntoView = () => {}
|
|
200
|
+
h.controller.highlightComment('c1')
|
|
201
|
+
|
|
202
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('disconnect unhooks the user-input listeners', () => {
|
|
206
|
+
const h = buildController()
|
|
207
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
208
|
+
h.controller.scrollToPreviousMessage() // anchor = c3
|
|
209
|
+
h.settle()
|
|
210
|
+
|
|
211
|
+
h.controller.disconnect()
|
|
212
|
+
h.list.dispatchEvent(new Event('wheel'))
|
|
213
|
+
|
|
214
|
+
// Anchor survived because the listener is gone.
|
|
215
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('reloading the list drops the anchor', () => {
|
|
219
|
+
const h = buildController()
|
|
220
|
+
h.scrollTo(4 * ITEM_HEIGHT)
|
|
221
|
+
h.controller.scrollToPreviousMessage()
|
|
222
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
|
|
223
|
+
|
|
224
|
+
h.controller.creativeId = '1'
|
|
225
|
+
h.controller.fetchComments = jest.fn(() => new Promise(() => {}))
|
|
226
|
+
h.controller.loadInitialComments()
|
|
227
|
+
|
|
228
|
+
expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import PrevMessageNavigator from '../prev_message_navigator'
|
|
6
|
+
|
|
7
|
+
// Items are ordered oldest -> newest, matching DOM order of `.comment-item`.
|
|
8
|
+
// `top` is the item's viewport-relative top, as measured by the controller.
|
|
9
|
+
function items(...tops) {
|
|
10
|
+
return tops.map((top, i) => ({ id: String(i), top }))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('PrevMessageNavigator', () => {
|
|
14
|
+
const VIEWPORT_TOP = 100
|
|
15
|
+
|
|
16
|
+
let nav
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
nav = new PrevMessageNavigator()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('without an anchor (geometry fallback)', () => {
|
|
23
|
+
test('steps to the previous item when the current one is flush with the top', () => {
|
|
24
|
+
// item 2 is exactly at the viewport top -> previous message is item 1
|
|
25
|
+
expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('scrolls the partially visible item to the top first', () => {
|
|
29
|
+
// item 2 is cut off above the fold -> bring item 2 fully into view
|
|
30
|
+
expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('starts from the last item when everything is scrolled above the fold', () => {
|
|
34
|
+
expect(nav.resolveTargetIndex(items(-300, -200, -100), VIEWPORT_TOP)).toBe(2)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('returns -1 at the oldest item so the caller can load older comments', () => {
|
|
38
|
+
expect(nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)).toBe(-1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('returns -1 for an empty list', () => {
|
|
42
|
+
expect(nav.resolveTargetIndex([], VIEWPORT_TOP)).toBe(-1)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('with an anchor (rapid clicks)', () => {
|
|
47
|
+
test('keeps stepping backwards while the smooth scroll is still in flight', () => {
|
|
48
|
+
// First click: item 2 is flush with the top, so we head for item 1.
|
|
49
|
+
expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
|
|
50
|
+
nav.commit('1', -100)
|
|
51
|
+
|
|
52
|
+
// Second click lands mid-animation: item 1 has not reached the top yet, so
|
|
53
|
+
// geometry alone would send us back down to item 1. The anchor must win.
|
|
54
|
+
expect(nav.resolveTargetIndex(items(-150, -50, 150, 350), VIEWPORT_TOP)).toBe(0)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('keeps stepping when the first click started from a partially visible item', () => {
|
|
58
|
+
// First click from between messages: item 2 is cut off *below* the top, so
|
|
59
|
+
// we scroll it up into place and the anchor starts below the viewport top.
|
|
60
|
+
expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
|
|
61
|
+
nav.commit('2', 130)
|
|
62
|
+
|
|
63
|
+
// Second click mid-animation: item 2 is on its way down to the top. The
|
|
64
|
+
// anchor must still win, otherwise we re-target item 2 and stall.
|
|
65
|
+
expect(nav.resolveTargetIndex(items(-215, -115, 115, 315), VIEWPORT_TOP)).toBe(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('survives older comments being prepended (anchor is id based)', () => {
|
|
69
|
+
// Prepending shifts scrollTop but leaves the anchor where it is on screen,
|
|
70
|
+
// so it is still on its way from -300 up to the viewport top.
|
|
71
|
+
nav.commit('c5', -300)
|
|
72
|
+
const prepended = [
|
|
73
|
+
{ id: 'c3', top: -500 },
|
|
74
|
+
{ id: 'c4', top: -300 },
|
|
75
|
+
{ id: 'c5', top: -100 },
|
|
76
|
+
]
|
|
77
|
+
expect(nav.resolveTargetIndex(prepended, VIEWPORT_TOP)).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('falls back to geometry when the anchored comment is gone', () => {
|
|
81
|
+
nav.commit('deleted-comment', -100)
|
|
82
|
+
expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('returns -1 when the anchor is already the oldest item', () => {
|
|
86
|
+
nav.commit('0', -100)
|
|
87
|
+
expect(nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)).toBe(-1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('ignores an anchor the list has scrolled away from', () => {
|
|
91
|
+
// A programmatic jump (new message arriving, permalink highlight) moves the
|
|
92
|
+
// list without any user gesture, so nothing drops the anchor. It is now far
|
|
93
|
+
// above where it started, which our own animation can never produce - that
|
|
94
|
+
// only ever walks the anchor down towards the viewport top.
|
|
95
|
+
nav.commit('0', -100)
|
|
96
|
+
expect(nav.resolveTargetIndex(items(-900, -700, 100, 300), VIEWPORT_TOP)).toBe(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('a stale anchor stops wedging the button at the oldest message', () => {
|
|
100
|
+
// Regression: anchored on the oldest item, every later click returned -1
|
|
101
|
+
// forever once there was nothing more to load, so the button went dead.
|
|
102
|
+
nav.commit('0', -100)
|
|
103
|
+
nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)
|
|
104
|
+
|
|
105
|
+
// The list is jumped back to the bottom; clicking must resume stepping.
|
|
106
|
+
expect(nav.resolveTargetIndex(items(-900, -700, 100, 300), VIEWPORT_TOP)).toBe(1)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('anchor invalidation', () => {
|
|
111
|
+
test('drops the anchor when the user moves the list', () => {
|
|
112
|
+
nav.commit('1', -100)
|
|
113
|
+
nav.notifyUserInput()
|
|
114
|
+
expect(nav.anchorId).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('reset clears the anchor', () => {
|
|
118
|
+
nav.commit('1', -100)
|
|
119
|
+
nav.reset()
|
|
120
|
+
expect(nav.anchorId).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('after a user scroll, geometry drives the next click again', () => {
|
|
124
|
+
nav.commit('1', -100)
|
|
125
|
+
nav.notifyUserInput()
|
|
126
|
+
expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// Every topic mutation used to alert a hardcoded English literal on failure.
|
|
8
|
+
// These tests pin that each failure path reaches the user through the localized
|
|
9
|
+
// string handed down by the ERB partial, and that the English literals survive
|
|
10
|
+
// only as the last-resort fallback when the partial supplied nothing.
|
|
11
|
+
jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
|
|
12
|
+
confirmDialog: jest.fn(),
|
|
13
|
+
alertDialog: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
17
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
18
|
+
const { alertDialog, confirmDialog } = await import('../../../lib/utils/dialog')
|
|
19
|
+
|
|
20
|
+
// Mirrors the data attributes emitted by _comments_popup.html.erb.
|
|
21
|
+
const LOCALIZED = {
|
|
22
|
+
topicSetAgentError: '에이전트를 지정할 수 없습니다.',
|
|
23
|
+
topicCreateError: '토픽을 만들 수 없습니다.',
|
|
24
|
+
topicUpdateError: '토픽을 수정할 수 없습니다.',
|
|
25
|
+
topicDeleteError: '토픽을 삭제할 수 없습니다.',
|
|
26
|
+
topicArchiveError: '토픽을 보관할 수 없습니다.',
|
|
27
|
+
topicRestoreError: '토픽을 복원할 수 없습니다.',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENGLISH_FALLBACK = {
|
|
31
|
+
set_agent_error: 'Unable to assign the agent to this topic.',
|
|
32
|
+
create_error: 'Unable to create the topic.',
|
|
33
|
+
update_error: 'Unable to update the topic.',
|
|
34
|
+
delete_error: 'Unable to delete the topic.',
|
|
35
|
+
archive_error: 'Unable to archive the topic.',
|
|
36
|
+
restore_error: 'Unable to restore the topic.',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('TopicsController topic action failures', () => {
|
|
40
|
+
let application
|
|
41
|
+
let controller
|
|
42
|
+
let element
|
|
43
|
+
|
|
44
|
+
const mount = async (dataset = LOCALIZED) => {
|
|
45
|
+
const container = document.createElement('div')
|
|
46
|
+
container.innerHTML = `
|
|
47
|
+
<div id="topics" data-controller="comments--topics">
|
|
48
|
+
<div data-comments--topics-target="list"></div>
|
|
49
|
+
</div>
|
|
50
|
+
`
|
|
51
|
+
document.body.appendChild(container)
|
|
52
|
+
|
|
53
|
+
element = document.getElementById('topics')
|
|
54
|
+
Object.assign(element.dataset, dataset)
|
|
55
|
+
|
|
56
|
+
application = Application.start()
|
|
57
|
+
application.register('comments--topics', TopicsController)
|
|
58
|
+
|
|
59
|
+
const meta = document.createElement('meta')
|
|
60
|
+
meta.name = 'csrf-token'
|
|
61
|
+
meta.content = 'test-csrf'
|
|
62
|
+
document.head.appendChild(meta)
|
|
63
|
+
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
65
|
+
controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
|
|
66
|
+
controller.creativeIdValue = '42'
|
|
67
|
+
controller.loadTopics = jest.fn()
|
|
68
|
+
controller.renderTopics = jest.fn()
|
|
69
|
+
controller.restoreSelection = jest.fn()
|
|
70
|
+
controller.flushSaveLastTopic = jest.fn()
|
|
71
|
+
controller.topics = [{ id: 1, name: 'Main' }]
|
|
72
|
+
return controller
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// deleteTopic/archiveTopic/unarchiveTopic read the id off event.currentTarget.
|
|
76
|
+
const eventFor = (id) => ({
|
|
77
|
+
stopPropagation: jest.fn(),
|
|
78
|
+
currentTarget: { dataset: { id: String(id) } },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const failingFetch = () => jest.fn().mockResolvedValue({ ok: false, json: async () => ({}) })
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
document.body.innerHTML = ''
|
|
85
|
+
document.head.innerHTML = ''
|
|
86
|
+
application.stop()
|
|
87
|
+
jest.clearAllMocks()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('with localized strings from the partial', () => {
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
await mount()
|
|
93
|
+
confirmDialog.mockResolvedValue(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('deleteTopic surfaces the localized delete error', async () => {
|
|
97
|
+
global.fetch = failingFetch()
|
|
98
|
+
|
|
99
|
+
await controller.deleteTopic(eventFor(1))
|
|
100
|
+
|
|
101
|
+
expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicDeleteError)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('archiveTopic surfaces the localized archive error', async () => {
|
|
105
|
+
global.fetch = failingFetch()
|
|
106
|
+
|
|
107
|
+
await controller.archiveTopic(eventFor(1))
|
|
108
|
+
|
|
109
|
+
expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicArchiveError)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('unarchiveTopic surfaces the localized restore error', async () => {
|
|
113
|
+
global.fetch = failingFetch()
|
|
114
|
+
|
|
115
|
+
await controller.unarchiveTopic(eventFor(1))
|
|
116
|
+
|
|
117
|
+
expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicRestoreError)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// updateTopic additionally reloads to discard the optimistic rename.
|
|
121
|
+
test('updateTopic surfaces the localized update error and reloads', async () => {
|
|
122
|
+
global.fetch = failingFetch()
|
|
123
|
+
|
|
124
|
+
await controller.updateTopic('1', 'Renamed')
|
|
125
|
+
|
|
126
|
+
expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicUpdateError)
|
|
127
|
+
expect(controller.loadTopics).toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('createTopic surfaces the localized create error', async () => {
|
|
131
|
+
global.fetch = failingFetch()
|
|
132
|
+
|
|
133
|
+
await controller.createTopic('New topic')
|
|
134
|
+
|
|
135
|
+
expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicCreateError)
|
|
136
|
+
// The `finally` block must clear the guard even on the failure path,
|
|
137
|
+
// otherwise blur handling stays wedged after one failed create.
|
|
138
|
+
expect(controller.creating).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('without localized strings (controller mounted bare)', () => {
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
await mount({})
|
|
145
|
+
confirmDialog.mockResolvedValue(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test.each(Object.entries(ENGLISH_FALLBACK))(
|
|
149
|
+
'_i18n("%s") falls back to the English literal',
|
|
150
|
+
(key, expected) => {
|
|
151
|
+
expect(controller._i18n(key)).toBe(expected)
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// An unknown key must not render as `undefined` in a dialog; returning the
|
|
156
|
+
// key itself keeps the failure legible.
|
|
157
|
+
test('_i18n returns the key itself when it is unknown', () => {
|
|
158
|
+
expect(controller._i18n('no_such_key')).toBe('no_such_key')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('_i18n with localized strings', () => {
|
|
163
|
+
beforeEach(async () => {
|
|
164
|
+
await mount()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test.each([
|
|
168
|
+
['set_agent_error', LOCALIZED.topicSetAgentError],
|
|
169
|
+
['create_error', LOCALIZED.topicCreateError],
|
|
170
|
+
['update_error', LOCALIZED.topicUpdateError],
|
|
171
|
+
['delete_error', LOCALIZED.topicDeleteError],
|
|
172
|
+
['archive_error', LOCALIZED.topicArchiveError],
|
|
173
|
+
['restore_error', LOCALIZED.topicRestoreError],
|
|
174
|
+
])('_i18n("%s") prefers the value from the partial', (key, expected) => {
|
|
175
|
+
expect(controller._i18n(key)).toBe(expected)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
})
|