collavre 0.23.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/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +45 -17
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- 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/__tests__/topics_controller_topic_list.test.js +61 -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 +115 -21
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +19 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +7 -0
- data/config/locales/comments.ko.yml +7 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- metadata +48 -1
|
@@ -6,16 +6,19 @@ import {
|
|
|
6
6
|
ORDERED_LIST,
|
|
7
7
|
CODE
|
|
8
8
|
} from "@lexical/markdown"
|
|
9
|
+
import { registerCodeFenceShortcut } from "../../lib/lexical/code_fence_shortcut"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Markdown-style shortcuts for the creative inline editor:
|
|
12
13
|
*
|
|
13
14
|
* - "* " / "- " / "+ " at line start → unordered list
|
|
14
15
|
* - "1. " (any number) at line start → ordered list
|
|
15
|
-
* - "```"
|
|
16
|
+
* - "```" (optionally "```lang") on its own line → code block
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* The list transformers fire on text change. The built-in CODE transformer
|
|
19
|
+
* needs "``` " (trailing space); registerCodeFenceShortcut adds the Enter path,
|
|
20
|
+
* converting a "```" (or "```lang") line to a code block when Enter is pressed —
|
|
21
|
+
* so the language can be typed before the fence commits.
|
|
19
22
|
*/
|
|
20
23
|
const CREATIVE_MARKDOWN_TRANSFORMERS = [
|
|
21
24
|
UNORDERED_LIST,
|
|
@@ -27,7 +30,15 @@ export default function MarkdownShortcutsPlugin() {
|
|
|
27
30
|
const [editor] = useLexicalComposerContext()
|
|
28
31
|
|
|
29
32
|
useEffect(() => {
|
|
30
|
-
|
|
33
|
+
const unregisterShortcuts = registerMarkdownShortcuts(
|
|
34
|
+
editor,
|
|
35
|
+
CREATIVE_MARKDOWN_TRANSFORMERS
|
|
36
|
+
)
|
|
37
|
+
const unregisterFence = registerCodeFenceShortcut(editor)
|
|
38
|
+
return () => {
|
|
39
|
+
unregisterShortcuts()
|
|
40
|
+
unregisterFence()
|
|
41
|
+
}
|
|
31
42
|
}, [editor])
|
|
32
43
|
|
|
33
44
|
return null
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
|
|
7
|
+
const { default: SearchPopupController } = await import('../search_popup_controller')
|
|
8
|
+
|
|
9
|
+
describe('SearchPopupController Ctrl+K shortcut', () => {
|
|
10
|
+
let application
|
|
11
|
+
let container
|
|
12
|
+
let popup
|
|
13
|
+
|
|
14
|
+
function pressCtrlK(target) {
|
|
15
|
+
const event = new KeyboardEvent('keydown', {
|
|
16
|
+
key: 'k',
|
|
17
|
+
ctrlKey: true,
|
|
18
|
+
bubbles: true,
|
|
19
|
+
cancelable: true
|
|
20
|
+
})
|
|
21
|
+
target.dispatchEvent(event)
|
|
22
|
+
return event
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
document.body.innerHTML = ''
|
|
27
|
+
|
|
28
|
+
container = document.createElement('div')
|
|
29
|
+
container.innerHTML = `
|
|
30
|
+
<div data-controller="search-popup">
|
|
31
|
+
<input type="text" data-search-popup-target="input" />
|
|
32
|
+
<div data-search-popup-target="popup"></div>
|
|
33
|
+
</div>
|
|
34
|
+
`
|
|
35
|
+
document.body.appendChild(container)
|
|
36
|
+
|
|
37
|
+
application = Application.start()
|
|
38
|
+
application.register('search-popup', SearchPopupController)
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
40
|
+
|
|
41
|
+
popup = container.querySelector('[data-search-popup-target="popup"]')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
application?.stop()
|
|
46
|
+
document.body.innerHTML = ''
|
|
47
|
+
jest.restoreAllMocks()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('opens the popup on Ctrl+K when focus is not in an editable element', () => {
|
|
51
|
+
const event = pressCtrlK(document.body)
|
|
52
|
+
|
|
53
|
+
expect(popup.classList.contains('open')).toBe(true)
|
|
54
|
+
expect(event.defaultPrevented).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('does not open the popup when Ctrl+K is pressed inside a text input', () => {
|
|
58
|
+
const input = document.createElement('input')
|
|
59
|
+
input.type = 'text'
|
|
60
|
+
document.body.appendChild(input)
|
|
61
|
+
input.focus()
|
|
62
|
+
|
|
63
|
+
const event = pressCtrlK(input)
|
|
64
|
+
|
|
65
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
66
|
+
expect(event.defaultPrevented).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('does not open the popup when Ctrl+K is pressed inside a textarea', () => {
|
|
70
|
+
const textarea = document.createElement('textarea')
|
|
71
|
+
document.body.appendChild(textarea)
|
|
72
|
+
textarea.focus()
|
|
73
|
+
|
|
74
|
+
const event = pressCtrlK(textarea)
|
|
75
|
+
|
|
76
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
77
|
+
expect(event.defaultPrevented).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('does not open the popup when Ctrl+K is pressed inside a contenteditable element', () => {
|
|
81
|
+
const editable = document.createElement('div')
|
|
82
|
+
editable.setAttribute('contenteditable', 'true')
|
|
83
|
+
document.body.appendChild(editable)
|
|
84
|
+
editable.focus()
|
|
85
|
+
|
|
86
|
+
const event = pressCtrlK(editable)
|
|
87
|
+
|
|
88
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
89
|
+
expect(event.defaultPrevented).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('does not open the popup when Ctrl+K is pressed inside the inline Lexical editor', () => {
|
|
93
|
+
const lexicalRoot = document.createElement('div')
|
|
94
|
+
lexicalRoot.setAttribute('data-lexical-editor-root', '')
|
|
95
|
+
const inner = document.createElement('p')
|
|
96
|
+
lexicalRoot.appendChild(inner)
|
|
97
|
+
document.body.appendChild(lexicalRoot)
|
|
98
|
+
|
|
99
|
+
const event = pressCtrlK(inner)
|
|
100
|
+
|
|
101
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
102
|
+
expect(event.defaultPrevented).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
import TopicListController from '../topic_list_controller'
|
|
7
|
+
|
|
8
|
+
describe('TopicListController', () => {
|
|
9
|
+
let application, controller
|
|
10
|
+
|
|
11
|
+
const mount = () => {
|
|
12
|
+
document.body.innerHTML = `
|
|
13
|
+
<div id="topic-list-modal" class="common-popup" data-controller="topic-list">
|
|
14
|
+
<button data-topic-list-target="close">×</button>
|
|
15
|
+
<input data-topic-list-target="input">
|
|
16
|
+
<ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
|
|
17
|
+
</div>
|
|
18
|
+
`
|
|
19
|
+
application = Application.start()
|
|
20
|
+
application.register('topic-list', TopicListController)
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
22
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
23
|
+
document.getElementById('topic-list-modal'), 'topic-list'
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RECT = { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }
|
|
29
|
+
const DATA = {
|
|
30
|
+
topics: [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }],
|
|
31
|
+
archivedTopics: [{ id: 3, name: 'Zeta' }],
|
|
32
|
+
mainTopicId: '1',
|
|
33
|
+
allMessagesLabel: 'All Messages'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const items = () => Array.from(document.querySelectorAll('#topic-list-modal li.common-popup-item'))
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
global.requestAnimationFrame = (fn) => { fn(); return 0 }
|
|
40
|
+
return mount()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
document.body.innerHTML = ''
|
|
45
|
+
application.stop()
|
|
46
|
+
jest.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('builds items in bar order: main, others, All Messages, archived', () => {
|
|
50
|
+
controller.openForTopics(DATA, RECT, () => {})
|
|
51
|
+
const labels = items().map((li) => li.textContent.trim())
|
|
52
|
+
expect(labels).toEqual(['#Main', '#Alpha', '📋 All Messages', '#Zeta'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('marks archived items with the distinguishing class', () => {
|
|
56
|
+
controller.openForTopics(DATA, RECT, () => {})
|
|
57
|
+
const archived = items().filter((li) => li.querySelector('.topic-list-item--archived'))
|
|
58
|
+
expect(archived).toHaveLength(1)
|
|
59
|
+
expect(archived[0].textContent).toContain('#Zeta')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('filters by label substring and adds NO create option', () => {
|
|
63
|
+
controller.openForTopics(DATA, RECT, () => {})
|
|
64
|
+
controller.inputTarget.value = 'alpha'
|
|
65
|
+
controller._onInput()
|
|
66
|
+
const labels = items().map((li) => li.textContent.trim())
|
|
67
|
+
expect(labels).toEqual(['#Alpha'])
|
|
68
|
+
|
|
69
|
+
controller.inputTarget.value = 'brandnew'
|
|
70
|
+
controller._onInput()
|
|
71
|
+
expect(items()).toHaveLength(0) // no "create and move" pseudo-item
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('select invokes the callback with the item and closes', () => {
|
|
75
|
+
const cb = jest.fn()
|
|
76
|
+
controller.openForTopics(DATA, RECT, cb)
|
|
77
|
+
const item = { id: 2, label: '#Alpha', archived: false }
|
|
78
|
+
controller.select(item)
|
|
79
|
+
expect(cb).toHaveBeenCalledWith(item)
|
|
80
|
+
expect(controller.popup.isOpen()).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -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
|
+
})
|