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
|
@@ -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
|
+
})
|
data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// The add-topic button used to be rendered as the last child of the horizontally
|
|
8
|
+
// scrolling topic strip, so it drifted off-screen once enough topics existed.
|
|
9
|
+
// It now lives outside the strip, left of the topic-list button, and renderTopics
|
|
10
|
+
// only toggles it. These tests pin that it stays out of the strip, that the
|
|
11
|
+
// permission gate still applies, and that a re-render no longer wipes a name
|
|
12
|
+
// being typed.
|
|
13
|
+
jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
|
|
14
|
+
confirmDialog: jest.fn(),
|
|
15
|
+
alertDialog: jest.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
19
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
20
|
+
|
|
21
|
+
const TOPICS = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `topic-${i + 1}` }))
|
|
22
|
+
|
|
23
|
+
describe('TopicsController create-button placement', () => {
|
|
24
|
+
let application
|
|
25
|
+
let container
|
|
26
|
+
let controller
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
container.innerHTML = `
|
|
31
|
+
<div id="topics" data-controller="comments--topics">
|
|
32
|
+
<div class="comment-topics-bar">
|
|
33
|
+
<div data-comments--topics-target="list" class="comment-topics-list"
|
|
34
|
+
data-new-topic-placeholder="New Topic"></div>
|
|
35
|
+
<span class="topic-creation-container"
|
|
36
|
+
data-comments--topics-target="creationContainer" hidden></span>
|
|
37
|
+
<button type="button" class="topic-list-btn"></button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
`
|
|
41
|
+
document.body.appendChild(container)
|
|
42
|
+
|
|
43
|
+
application = Application.start()
|
|
44
|
+
application.register('comments--topics', TopicsController)
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
47
|
+
const element = document.getElementById('topics')
|
|
48
|
+
controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
|
|
49
|
+
controller.creativeIdValue = '42'
|
|
50
|
+
controller.loadTopics = jest.fn()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
document.body.innerHTML = ''
|
|
56
|
+
application.stop()
|
|
57
|
+
jest.clearAllMocks()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('renders the create button outside the scrolling strip, left of the topic-list button', () => {
|
|
61
|
+
controller.renderTopics(TOPICS, true, true)
|
|
62
|
+
|
|
63
|
+
expect(controller.listTarget.querySelector('.add-topic-btn')).toBeNull()
|
|
64
|
+
|
|
65
|
+
const button = document.querySelector('.add-topic-btn')
|
|
66
|
+
expect(button).not.toBeNull()
|
|
67
|
+
|
|
68
|
+
const creation = button.closest('.topic-creation-container')
|
|
69
|
+
expect(creation.hidden).toBe(false)
|
|
70
|
+
expect(creation.previousElementSibling).toBe(controller.listTarget)
|
|
71
|
+
expect(creation.nextElementSibling.classList.contains('topic-list-btn')).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('hides and empties the container when the user cannot create topics', () => {
|
|
75
|
+
controller.renderTopics(TOPICS, true, true)
|
|
76
|
+
controller.renderTopics(TOPICS, false, false)
|
|
77
|
+
|
|
78
|
+
const creation = controller.creationContainerTarget
|
|
79
|
+
expect(creation.hidden).toBe(true)
|
|
80
|
+
expect(creation.innerHTML).toBe('')
|
|
81
|
+
expect(document.querySelector('.add-topic-btn')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('re-shows the button when permission is regained', () => {
|
|
85
|
+
controller.renderTopics(TOPICS, false, false)
|
|
86
|
+
controller.renderTopics(TOPICS, true, true)
|
|
87
|
+
|
|
88
|
+
expect(controller.creationContainerTarget.hidden).toBe(false)
|
|
89
|
+
expect(document.querySelector('.add-topic-btn')).not.toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// A topic broadcast used to re-render the whole strip and destroy the open input.
|
|
93
|
+
test('preserves an in-progress topic name across a re-render', () => {
|
|
94
|
+
controller.renderTopics(TOPICS, true, true)
|
|
95
|
+
controller.showInput({ preventDefault() {} })
|
|
96
|
+
|
|
97
|
+
const input = controller.creationContainerTarget.querySelector('.topic-input')
|
|
98
|
+
input.value = 'half-typed'
|
|
99
|
+
|
|
100
|
+
controller.renderTopics([...TOPICS, { id: 99, name: 'from-broadcast' }], true, true)
|
|
101
|
+
|
|
102
|
+
expect(controller.creationContainerTarget.querySelector('.topic-input')).toBe(input)
|
|
103
|
+
expect(input.value).toBe('half-typed')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// createTopic keeps `creating` set across its loadTopics() await, so the
|
|
107
|
+
// re-render it triggers must not mistake the submitted input for live typing.
|
|
108
|
+
test('restores the add button after a successful creation', async () => {
|
|
109
|
+
controller.renderTopics(TOPICS, true, true)
|
|
110
|
+
controller.showInput({ preventDefault() {} })
|
|
111
|
+
|
|
112
|
+
const input = controller.creationContainerTarget.querySelector('.topic-input')
|
|
113
|
+
input.value = 'shipped'
|
|
114
|
+
|
|
115
|
+
document.head.innerHTML = '<meta name="csrf-token" content="tok">'
|
|
116
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
117
|
+
ok: true,
|
|
118
|
+
json: async () => ({ id: 99, name: 'shipped' }),
|
|
119
|
+
})
|
|
120
|
+
controller.flushSaveLastTopic = jest.fn()
|
|
121
|
+
controller.loadTopics = jest.fn(async () => {
|
|
122
|
+
controller.renderTopics([...TOPICS, { id: 99, name: 'shipped' }], true, true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await controller.createTopic('shipped')
|
|
126
|
+
|
|
127
|
+
expect(controller.creationContainerTarget.querySelector('.topic-input')).toBeNull()
|
|
128
|
+
expect(document.querySelector('.add-topic-btn')).not.toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Alt+Left/Right chat navigation switches creatives without blurring the input,
|
|
132
|
+
// so a preserved draft would otherwise be posted to the wrong creative.
|
|
133
|
+
test('drops an in-progress topic name when the creative changes', () => {
|
|
134
|
+
controller.renderTopics(TOPICS, true, true)
|
|
135
|
+
controller.showInput({ preventDefault() {} })
|
|
136
|
+
controller.creationContainerTarget.querySelector('.topic-input').value = 'for-42'
|
|
137
|
+
|
|
138
|
+
controller.creativeIdValue = '77'
|
|
139
|
+
controller.renderTopics(TOPICS, true, true)
|
|
140
|
+
|
|
141
|
+
expect(controller.creationContainerTarget.querySelector('.topic-input')).toBeNull()
|
|
142
|
+
expect(document.querySelector('.add-topic-btn')).not.toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// The container is static markup, but rendering must not throw if a caller
|
|
146
|
+
// mounts the controller on a stripped-down strip.
|
|
147
|
+
test('no-ops when the container is absent', () => {
|
|
148
|
+
controller.creationContainerTarget.remove()
|
|
149
|
+
|
|
150
|
+
expect(() => controller.renderTopics(TOPICS, true, true)).not.toThrow()
|
|
151
|
+
expect(() => controller.showInput({ preventDefault() {} })).not.toThrow()
|
|
152
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="12"]')).not.toBeNull()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// setTopicPrimaryAgent used to drop the PATCH response on the floor and wait for
|
|
8
|
+
// the WebSocket broadcast to render the avatar, and it swallowed failures into
|
|
9
|
+
// console.error. These tests pin both: the avatar renders from the response with
|
|
10
|
+
// no broadcast at all, and failures reach the user.
|
|
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 } = await import('../../../lib/utils/dialog')
|
|
19
|
+
|
|
20
|
+
const AGENT = {
|
|
21
|
+
id: 4,
|
|
22
|
+
name: 'GitHub PR Analyzer',
|
|
23
|
+
avatar_url: '/avatar.png',
|
|
24
|
+
default_avatar: false,
|
|
25
|
+
initial: 'G',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('TopicsController#setTopicPrimaryAgent', () => {
|
|
29
|
+
let application
|
|
30
|
+
let container
|
|
31
|
+
let controller
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
container = document.createElement('div')
|
|
35
|
+
container.innerHTML = `
|
|
36
|
+
<div id="topics" data-controller="comments--topics"
|
|
37
|
+
data-topic-set-agent-error="에이전트를 지정할 수 없습니다.">
|
|
38
|
+
<div data-comments--topics-target="list"></div>
|
|
39
|
+
</div>
|
|
40
|
+
`
|
|
41
|
+
document.body.appendChild(container)
|
|
42
|
+
|
|
43
|
+
application = Application.start()
|
|
44
|
+
application.register('comments--topics', TopicsController)
|
|
45
|
+
|
|
46
|
+
const meta = document.createElement('meta')
|
|
47
|
+
meta.name = 'csrf-token'
|
|
48
|
+
meta.content = 'test-csrf'
|
|
49
|
+
document.head.appendChild(meta)
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
52
|
+
const element = document.getElementById('topics')
|
|
53
|
+
controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
|
|
54
|
+
controller.creativeIdValue = '42'
|
|
55
|
+
controller.loadTopics = jest.fn()
|
|
56
|
+
controller.topics = [{ id: 1, name: 'Main' }]
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
document.body.innerHTML = ''
|
|
62
|
+
document.head.innerHTML = ''
|
|
63
|
+
application.stop()
|
|
64
|
+
jest.clearAllMocks()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('renders the agent avatar from the PATCH response without any broadcast', async () => {
|
|
68
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
69
|
+
ok: true,
|
|
70
|
+
json: async () => ({ success: true, topic: { id: 1, name: 'Main', primary_agent: AGENT } }),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
74
|
+
|
|
75
|
+
const avatar = controller.listTarget.querySelector('.topic-agent-avatar')
|
|
76
|
+
expect(avatar).not.toBeNull()
|
|
77
|
+
expect(avatar.getAttribute('src')).toBe('/avatar.png')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('surfaces the server error when the agent is rejected (422)', async () => {
|
|
81
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
82
|
+
ok: false,
|
|
83
|
+
json: async () => ({ error: 'Selected user is not an AI agent' }),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
87
|
+
|
|
88
|
+
expect(alertDialog).toHaveBeenCalledWith('Selected user is not an AI agent')
|
|
89
|
+
expect(controller.listTarget.querySelector('.topic-agent-avatar')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// The fallback path has no server-supplied (already localized) error to show,
|
|
93
|
+
// so it must use the localized string handed down by the ERB partial rather
|
|
94
|
+
// than an English literal.
|
|
95
|
+
test('surfaces the localized fallback when the response body is not JSON', async () => {
|
|
96
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
97
|
+
ok: false,
|
|
98
|
+
json: async () => { throw new SyntaxError('Unexpected token <') },
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
102
|
+
|
|
103
|
+
expect(alertDialog).toHaveBeenCalledWith('에이전트를 지정할 수 없습니다.')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('surfaces the localized fallback when the request itself fails', async () => {
|
|
107
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
108
|
+
|
|
109
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
110
|
+
|
|
111
|
+
expect(alertDialog).toHaveBeenCalledWith('에이전트를 지정할 수 없습니다.')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// A successful response with no topic payload must not throw; the broadcast
|
|
115
|
+
// remains the fallback path in that case.
|
|
116
|
+
test('is a no-op when a successful response carries no topic', async () => {
|
|
117
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
118
|
+
ok: true,
|
|
119
|
+
json: async () => ({ success: true }),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
123
|
+
|
|
124
|
+
expect(alertDialog).not.toHaveBeenCalled()
|
|
125
|
+
expect(controller.listTarget.querySelector('.topic-agent-avatar')).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('does not issue a request when the controller has no creative', async () => {
|
|
129
|
+
controller.creativeIdValue = ''
|
|
130
|
+
global.fetch = jest.fn()
|
|
131
|
+
|
|
132
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
133
|
+
|
|
134
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
135
|
+
expect(alertDialog).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('falls back to English when the partial supplied no localized string', async () => {
|
|
139
|
+
delete document.getElementById('topics').dataset.topicSetAgentError
|
|
140
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
141
|
+
|
|
142
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
143
|
+
|
|
144
|
+
expect(alertDialog).toHaveBeenCalledWith('Unable to assign the agent to this topic.')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
import TopicsController from '../topics_controller'
|
|
7
|
+
|
|
8
|
+
describe('TopicsController#openTopicListPopup', () => {
|
|
9
|
+
let application, controller
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
global.requestAnimationFrame = (fn) => { fn(); return 0 }
|
|
13
|
+
document.body.innerHTML = `
|
|
14
|
+
<div id="comments-popup" data-controller="comments--topics"
|
|
15
|
+
data-topic-main-text="All Messages"
|
|
16
|
+
data-topic-search-placeholder-text="Search topics...">
|
|
17
|
+
<div data-comments--topics-target="list"></div>
|
|
18
|
+
</div>
|
|
19
|
+
`
|
|
20
|
+
application = Application.start()
|
|
21
|
+
application.register('comments--topics', TopicsController)
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
23
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
24
|
+
document.getElementById('comments-popup'), 'comments--topics'
|
|
25
|
+
)
|
|
26
|
+
controller.topics = [{ id: 2, name: 'Alpha' }]
|
|
27
|
+
controller.archivedTopics = [{ id: 3, name: 'Zeta' }]
|
|
28
|
+
controller.mainTopicId = null
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
document.body.innerHTML = ''
|
|
34
|
+
application.stop()
|
|
35
|
+
jest.clearAllMocks()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('creates the topic-list modal with the required targets', () => {
|
|
39
|
+
const btn = document.createElement('button')
|
|
40
|
+
document.body.appendChild(btn)
|
|
41
|
+
controller.openTopicListPopup({ currentTarget: btn })
|
|
42
|
+
|
|
43
|
+
const modal = document.getElementById('topic-list-modal')
|
|
44
|
+
expect(modal).not.toBeNull()
|
|
45
|
+
expect(modal.dataset.controller).toBe('topic-list')
|
|
46
|
+
expect(modal.querySelector('[data-topic-list-target="input"]')).not.toBeNull()
|
|
47
|
+
expect(modal.querySelector('[data-topic-list-target="list"]')).not.toBeNull()
|
|
48
|
+
expect(modal.querySelector('[data-topic-list-target="close"]')).not.toBeNull()
|
|
49
|
+
expect(modal.querySelector('input').placeholder).toBe('Search topics...')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('appends the modal inside the chat box so it is caged within it', () => {
|
|
53
|
+
const btn = document.createElement('button')
|
|
54
|
+
document.body.appendChild(btn)
|
|
55
|
+
controller.openTopicListPopup({ currentTarget: btn })
|
|
56
|
+
|
|
57
|
+
const modal = document.getElementById('topic-list-modal')
|
|
58
|
+
// Bounded to the chat popup, not body-level.
|
|
59
|
+
expect(modal.parentElement).toBe(document.getElementById('comments-popup'))
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -884,6 +884,9 @@ export default class extends Controller {
|
|
|
884
884
|
if (quote.id === store.activeId) {
|
|
885
885
|
const commentEl = document.querySelector(`[data-comment-id="${quote.commentId}"]`)
|
|
886
886
|
if (commentEl) {
|
|
887
|
+
// Programmatic list scroll — drop the prev-message anchor so the next
|
|
888
|
+
// previous-message click resolves from the quoted comment now in view.
|
|
889
|
+
this.listController?.notifyProgrammaticScroll()
|
|
887
890
|
commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
888
891
|
commentEl.classList.add('comment-highlight')
|
|
889
892
|
setTimeout(() => commentEl.classList.remove('comment-highlight'), 2000)
|