collavre 0.20.3 → 0.22.0
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/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
const browse = jest.fn()
|
|
8
|
+
const search = jest.fn()
|
|
9
|
+
|
|
10
|
+
jest.unstable_mockModule('../../lib/api/creatives', () => ({
|
|
11
|
+
default: { browse, search },
|
|
12
|
+
browse,
|
|
13
|
+
search,
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
17
|
+
const LinkCreativeController = (await import('../link_creative_controller')).default
|
|
18
|
+
|
|
19
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
20
|
+
|
|
21
|
+
const installController = async () => {
|
|
22
|
+
document.body.innerHTML = `
|
|
23
|
+
<div id="link-creative-modal" class="common-popup" style="display:none;" data-controller="link-creative"
|
|
24
|
+
data-link-creative-loading-text="Loading…"
|
|
25
|
+
data-link-creative-no-results-text="No results"
|
|
26
|
+
data-link-creative-empty-text="Empty"
|
|
27
|
+
data-link-creative-expand-text="Expand">
|
|
28
|
+
<button type="button" class="popup-close-btn" data-link-creative-target="close">×</button>
|
|
29
|
+
<input type="text" data-link-creative-target="input">
|
|
30
|
+
<ul class="common-popup-list link-creative-list" data-popup-list data-link-creative-target="list"></ul>
|
|
31
|
+
</div>`
|
|
32
|
+
|
|
33
|
+
const application = Application.start()
|
|
34
|
+
application.register('link-creative', LinkCreativeController)
|
|
35
|
+
await flush() // Stimulus connects asynchronously
|
|
36
|
+
const element = document.getElementById('link-creative-modal')
|
|
37
|
+
const controller = application.getControllerForElementAndIdentifier(element, 'link-creative')
|
|
38
|
+
return { application, element, controller }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rect = { left: 0, top: 0, bottom: 0, right: 0, width: 0, height: 0 }
|
|
42
|
+
|
|
43
|
+
describe('LinkCreativeController picker', () => {
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
// jsdom does not implement scrollIntoView (used when highlighting a node).
|
|
46
|
+
window.HTMLElement.prototype.scrollIntoView = jest.fn()
|
|
47
|
+
// jsdom does not compute layout, so offsetParent is null for every element;
|
|
48
|
+
// the picker's _visibleRows() filters on it. Treat any attached element as
|
|
49
|
+
// visible so keyboard navigation over the rendered rows is exercisable.
|
|
50
|
+
Object.defineProperty(window.HTMLElement.prototype, 'offsetParent', {
|
|
51
|
+
configurable: true,
|
|
52
|
+
get() {
|
|
53
|
+
return this.parentNode
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
document.body.innerHTML = ''
|
|
60
|
+
jest.clearAllMocks()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('renders a browsable tree on open with toggles for nodes that have children', async () => {
|
|
64
|
+
browse.mockResolvedValue([
|
|
65
|
+
{ id: 1, description: 'Root A', progress: 0, has_children: true },
|
|
66
|
+
{ id: 2, description: 'Root B', progress: 0, has_children: false },
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
const { application, controller } = await installController()
|
|
70
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
71
|
+
await flush()
|
|
72
|
+
|
|
73
|
+
expect(browse).toHaveBeenCalledWith(null)
|
|
74
|
+
const items = document.querySelectorAll('.link-tree-item')
|
|
75
|
+
expect(items).toHaveLength(2)
|
|
76
|
+
// First node is expandable, second is a leaf
|
|
77
|
+
expect(items[0].querySelector('.link-tree-toggle:not(.link-tree-toggle-empty)')).not.toBeNull()
|
|
78
|
+
expect(items[1].querySelector('.link-tree-toggle-empty')).not.toBeNull()
|
|
79
|
+
expect(items[0].querySelector('.link-tree-label').textContent).toBe('Root A')
|
|
80
|
+
|
|
81
|
+
application.stop()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('expands a node lazily, loading its children on toggle click', async () => {
|
|
85
|
+
browse
|
|
86
|
+
.mockResolvedValueOnce([{ id: 1, description: 'Root A', progress: 0, has_children: true }])
|
|
87
|
+
.mockResolvedValueOnce([{ id: 5, description: 'Child', progress: 0, has_children: false }])
|
|
88
|
+
|
|
89
|
+
const { application, controller } = await installController()
|
|
90
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
91
|
+
await flush()
|
|
92
|
+
|
|
93
|
+
const toggle = document.querySelector('.link-tree-toggle:not(.link-tree-toggle-empty)')
|
|
94
|
+
// Collapsed toggle renders the same SVG chevron as the main creative tree.
|
|
95
|
+
expect(toggle.querySelector('svg')).not.toBeNull()
|
|
96
|
+
expect(toggle.textContent).toBe('')
|
|
97
|
+
|
|
98
|
+
toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
99
|
+
await flush()
|
|
100
|
+
|
|
101
|
+
expect(browse).toHaveBeenLastCalledWith('1')
|
|
102
|
+
const child = document.querySelector('.link-tree-children .link-tree-item .link-tree-label')
|
|
103
|
+
expect(child.textContent).toBe('Child')
|
|
104
|
+
// Expanded toggle still renders an SVG chevron (icon swapped, not text).
|
|
105
|
+
expect(toggle.querySelector('svg')).not.toBeNull()
|
|
106
|
+
|
|
107
|
+
application.stop()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('does not search below the 2-character threshold', async () => {
|
|
111
|
+
browse.mockResolvedValue([])
|
|
112
|
+
const { application, controller } = await installController()
|
|
113
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
114
|
+
await flush()
|
|
115
|
+
|
|
116
|
+
controller.inputTarget.value = 'a'
|
|
117
|
+
controller.search()
|
|
118
|
+
await flush()
|
|
119
|
+
|
|
120
|
+
expect(search).not.toHaveBeenCalled()
|
|
121
|
+
|
|
122
|
+
application.stop()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('renders flat results with a clickable breadcrumb at >= 2 characters', async () => {
|
|
126
|
+
browse.mockResolvedValue([])
|
|
127
|
+
search.mockResolvedValue([
|
|
128
|
+
{
|
|
129
|
+
id: 9,
|
|
130
|
+
description: 'Deep Leaf',
|
|
131
|
+
progress: 0,
|
|
132
|
+
path: [
|
|
133
|
+
{ id: 1, description: 'Root' },
|
|
134
|
+
{ id: 3, description: 'Mid' },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
const { application, controller } = await installController()
|
|
140
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
141
|
+
await flush()
|
|
142
|
+
|
|
143
|
+
controller.inputTarget.value = 'lea'
|
|
144
|
+
controller.search()
|
|
145
|
+
await flush()
|
|
146
|
+
|
|
147
|
+
expect(search).toHaveBeenCalledWith('lea', { simple: true })
|
|
148
|
+
const result = document.querySelector('.link-result-item')
|
|
149
|
+
expect(result.querySelector('.link-result-label').textContent).toBe('Deep Leaf')
|
|
150
|
+
const crumbs = result.querySelectorAll('.link-crumb')
|
|
151
|
+
expect(Array.from(crumbs).map((c) => c.textContent)).toEqual(['Root', 'Mid'])
|
|
152
|
+
|
|
153
|
+
application.stop()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('masks restricted ancestors in the breadcrumb and keeps them non-clickable', async () => {
|
|
157
|
+
browse.mockResolvedValue([])
|
|
158
|
+
search.mockResolvedValue([
|
|
159
|
+
{
|
|
160
|
+
id: 9,
|
|
161
|
+
description: 'Deep Leaf',
|
|
162
|
+
progress: 0,
|
|
163
|
+
path: [
|
|
164
|
+
{ id: 1, description: null, restricted: true },
|
|
165
|
+
{ id: 3, description: 'Mid' },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
])
|
|
169
|
+
|
|
170
|
+
const { application, controller } = await installController()
|
|
171
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
172
|
+
await flush()
|
|
173
|
+
|
|
174
|
+
controller.inputTarget.value = 'lea'
|
|
175
|
+
controller.search()
|
|
176
|
+
await flush()
|
|
177
|
+
|
|
178
|
+
const restricted = document.querySelector('.link-crumb-restricted')
|
|
179
|
+
expect(restricted).not.toBeNull()
|
|
180
|
+
expect(restricted.tagName).toBe('SPAN') // not a button -> not clickable
|
|
181
|
+
expect(restricted.textContent).toBe('…')
|
|
182
|
+
|
|
183
|
+
application.stop()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('localizes the expand toggle aria-label from the data attribute', async () => {
|
|
187
|
+
browse.mockResolvedValue([{ id: 1, description: 'Root A', progress: 0, has_children: true }])
|
|
188
|
+
|
|
189
|
+
const { application, controller } = await installController()
|
|
190
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
191
|
+
await flush()
|
|
192
|
+
|
|
193
|
+
const toggle = document.querySelector('.link-tree-toggle:not(.link-tree-toggle-empty)')
|
|
194
|
+
expect(toggle.getAttribute('aria-label')).toBe('Expand')
|
|
195
|
+
|
|
196
|
+
application.stop()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('breadcrumb maps an origin id back to its linked shell root and expands it', async () => {
|
|
200
|
+
// Root browse returns a linked shell (id 100) whose effective origin is 1.
|
|
201
|
+
browse
|
|
202
|
+
.mockResolvedValueOnce([
|
|
203
|
+
{ id: 100, description: 'Shared', progress: 0, has_children: true, origin_id: 1 },
|
|
204
|
+
])
|
|
205
|
+
// Expanding the shell loads the origin's real children (id 3).
|
|
206
|
+
.mockResolvedValueOnce([{ id: 3, description: 'Mid', progress: 0, has_children: false }])
|
|
207
|
+
// Search match's breadcrumb carries origin ids (root crumb id 1 == shell origin).
|
|
208
|
+
search.mockResolvedValue([
|
|
209
|
+
{
|
|
210
|
+
id: 9,
|
|
211
|
+
description: 'Deep Leaf',
|
|
212
|
+
progress: 0,
|
|
213
|
+
path: [
|
|
214
|
+
{ id: 1, description: 'Shared' },
|
|
215
|
+
{ id: 3, description: 'Mid' },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
const { application, controller } = await installController()
|
|
221
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
222
|
+
await flush() // caches root nodes (the shell)
|
|
223
|
+
|
|
224
|
+
controller.inputTarget.value = 'lea'
|
|
225
|
+
controller.search()
|
|
226
|
+
await flush()
|
|
227
|
+
|
|
228
|
+
// Click the second crumb (Mid): chain = [origin id 1], target = 3.
|
|
229
|
+
const crumbs = document.querySelectorAll('.link-crumb')
|
|
230
|
+
crumbs[1].dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
231
|
+
await flush()
|
|
232
|
+
await flush()
|
|
233
|
+
|
|
234
|
+
// The shell (id 100) must have been expanded via its origin id (1), not skipped.
|
|
235
|
+
expect(browse).toHaveBeenLastCalledWith('100')
|
|
236
|
+
const mid = document.querySelector('.link-tree-children .link-tree-item[data-id="3"]')
|
|
237
|
+
expect(mid).not.toBeNull()
|
|
238
|
+
|
|
239
|
+
application.stop()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('reveal_path expands local folders to reach a nested linked shell', async () => {
|
|
243
|
+
// The shell is nested under the user's own (collapsed) folder, so root
|
|
244
|
+
// browse returns only that folder — not the shell.
|
|
245
|
+
browse
|
|
246
|
+
.mockResolvedValueOnce([
|
|
247
|
+
{ id: 50, description: 'Local Folder', progress: 0, has_children: true },
|
|
248
|
+
])
|
|
249
|
+
// Expanding the local folder reveals the shell (id 100, origin 1).
|
|
250
|
+
.mockResolvedValueOnce([
|
|
251
|
+
{ id: 100, description: 'Shared', progress: 0, has_children: true, origin_id: 1 },
|
|
252
|
+
])
|
|
253
|
+
// Expanding the shell loads the origin's real children (id 3).
|
|
254
|
+
.mockResolvedValueOnce([{ id: 3, description: 'Mid', progress: 0, has_children: false }])
|
|
255
|
+
// Origin-space breadcrumb (ids 1,3) plus the user-local reveal path to the shell.
|
|
256
|
+
search.mockResolvedValue([
|
|
257
|
+
{
|
|
258
|
+
id: 9,
|
|
259
|
+
description: 'Deep Leaf',
|
|
260
|
+
progress: 0,
|
|
261
|
+
// Keyed by origin ancestor: origin 1's shell is reached via [50, 100].
|
|
262
|
+
reveal_path: { 1: [50, 100] },
|
|
263
|
+
path: [
|
|
264
|
+
{ id: 1, description: 'Shared' },
|
|
265
|
+
{ id: 3, description: 'Mid' },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
])
|
|
269
|
+
|
|
270
|
+
const { application, controller } = await installController()
|
|
271
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
272
|
+
await flush() // caches root nodes (the local folder)
|
|
273
|
+
|
|
274
|
+
controller.inputTarget.value = 'lea'
|
|
275
|
+
controller.search()
|
|
276
|
+
await flush()
|
|
277
|
+
|
|
278
|
+
// Click the second crumb (Mid): anchor at origin 1 -> chain = [50, 100], target = 3.
|
|
279
|
+
const crumbs = document.querySelectorAll('.link-crumb')
|
|
280
|
+
crumbs[1].dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
281
|
+
for (let i = 0; i < 5; i++) await flush()
|
|
282
|
+
|
|
283
|
+
// Local folder (50) and shell (100) were both expanded to surface the hit.
|
|
284
|
+
expect(browse).toHaveBeenCalledWith('50')
|
|
285
|
+
expect(browse).toHaveBeenCalledWith('100')
|
|
286
|
+
const mid = document.querySelector('.link-tree-children .link-tree-item[data-id="3"]')
|
|
287
|
+
expect(mid).not.toBeNull()
|
|
288
|
+
|
|
289
|
+
application.stop()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('a higher ancestor crumb resolves through its own shell, not the deepest one', async () => {
|
|
293
|
+
// The user holds a shell for both origin 1 (under folder 50) and origin 3
|
|
294
|
+
// (under folder 60). Root browse returns both local folders.
|
|
295
|
+
browse
|
|
296
|
+
.mockResolvedValueOnce([
|
|
297
|
+
{ id: 50, description: 'Folder A', progress: 0, has_children: true },
|
|
298
|
+
{ id: 60, description: 'Folder D', progress: 0, has_children: true },
|
|
299
|
+
])
|
|
300
|
+
// Expanding folder A (50) reveals origin 1's shell (id 100).
|
|
301
|
+
.mockResolvedValueOnce([
|
|
302
|
+
{ id: 100, description: 'Ancestor', progress: 0, has_children: true, origin_id: 1 },
|
|
303
|
+
])
|
|
304
|
+
// Expanding that shell loads origin 1's children.
|
|
305
|
+
.mockResolvedValueOnce([{ id: 3, description: 'Descendant', progress: 0, has_children: false }])
|
|
306
|
+
// Per-origin reveal map: each origin ancestor maps to its own shell path.
|
|
307
|
+
search.mockResolvedValue([
|
|
308
|
+
{
|
|
309
|
+
id: 9,
|
|
310
|
+
description: 'Deep Leaf',
|
|
311
|
+
progress: 0,
|
|
312
|
+
reveal_path: { 1: [50, 100], 3: [60, 200] },
|
|
313
|
+
path: [
|
|
314
|
+
{ id: 1, description: 'Ancestor' },
|
|
315
|
+
{ id: 3, description: 'Descendant' },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
])
|
|
319
|
+
|
|
320
|
+
const { application, controller } = await installController()
|
|
321
|
+
controller.open(rect, jest.fn(), jest.fn())
|
|
322
|
+
await flush() // caches root nodes (both local folders)
|
|
323
|
+
|
|
324
|
+
controller.inputTarget.value = 'lea'
|
|
325
|
+
controller.search()
|
|
326
|
+
await flush()
|
|
327
|
+
|
|
328
|
+
// Click the FIRST crumb (Ancestor, id 1): must anchor at origin 1's shell
|
|
329
|
+
// (folder 50 -> shell 100), NOT the deeper origin 3 shell (folder 60).
|
|
330
|
+
const crumbs = document.querySelectorAll('.link-crumb')
|
|
331
|
+
crumbs[0].dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
332
|
+
for (let i = 0; i < 5; i++) await flush()
|
|
333
|
+
|
|
334
|
+
expect(browse).toHaveBeenCalledWith('50')
|
|
335
|
+
expect(browse).toHaveBeenCalledWith('100')
|
|
336
|
+
expect(browse).not.toHaveBeenCalledWith('60')
|
|
337
|
+
expect(browse).not.toHaveBeenCalledWith('200')
|
|
338
|
+
|
|
339
|
+
application.stop()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('selecting a tree row invokes the callback with id and label', async () => {
|
|
343
|
+
browse.mockResolvedValue([{ id: 7, description: 'Pick Me', progress: 0, has_children: false }])
|
|
344
|
+
const onSelect = jest.fn()
|
|
345
|
+
|
|
346
|
+
const { application, controller } = await installController()
|
|
347
|
+
controller.open(rect, onSelect, jest.fn())
|
|
348
|
+
await flush()
|
|
349
|
+
|
|
350
|
+
document.querySelector('.link-tree-row').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
351
|
+
await flush()
|
|
352
|
+
|
|
353
|
+
expect(onSelect).toHaveBeenCalledWith({ id: 7, label: 'Pick Me' })
|
|
354
|
+
|
|
355
|
+
application.stop()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('selecting a linked shell row emits the effective origin id', async () => {
|
|
359
|
+
// Shell row (id 100) whose effective origin is 1; selecting it must hand the
|
|
360
|
+
// origin id to consumers so a new link is based on the real shared creative,
|
|
361
|
+
// not the user's shell (which would corrupt the permission base).
|
|
362
|
+
browse.mockResolvedValue([
|
|
363
|
+
{ id: 100, description: 'Shared', progress: 0, has_children: false, origin_id: 1 },
|
|
364
|
+
])
|
|
365
|
+
const onSelect = jest.fn()
|
|
366
|
+
|
|
367
|
+
const { application, controller } = await installController()
|
|
368
|
+
controller.open(rect, onSelect, jest.fn())
|
|
369
|
+
await flush()
|
|
370
|
+
|
|
371
|
+
document.querySelector('.link-tree-row').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
372
|
+
await flush()
|
|
373
|
+
|
|
374
|
+
expect(onSelect).toHaveBeenCalledWith({ id: 1, label: 'Shared' })
|
|
375
|
+
|
|
376
|
+
application.stop()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('Tab in the initial browse state moves focus instead of linking the first row', async () => {
|
|
380
|
+
// Empty input => browse: the first root is auto-highlighted but unchosen.
|
|
381
|
+
// Tab (leaving the search field) must not link it; focus moves normally.
|
|
382
|
+
browse.mockResolvedValue([{ id: 7, description: 'Pick Me', progress: 0, has_children: false }])
|
|
383
|
+
const onSelect = jest.fn()
|
|
384
|
+
|
|
385
|
+
const { application, controller } = await installController()
|
|
386
|
+
controller.open(rect, onSelect, jest.fn())
|
|
387
|
+
await flush()
|
|
388
|
+
|
|
389
|
+
const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true, bubbles: true })
|
|
390
|
+
controller.inputTarget.dispatchEvent(tab)
|
|
391
|
+
await flush()
|
|
392
|
+
|
|
393
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
394
|
+
expect(tab.defaultPrevented).toBe(false)
|
|
395
|
+
|
|
396
|
+
application.stop()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('Tab selects the highlighted row after the user navigates the tree', async () => {
|
|
400
|
+
browse.mockResolvedValue([
|
|
401
|
+
{ id: 7, description: 'Pick Me', progress: 0, has_children: false },
|
|
402
|
+
{ id: 8, description: 'Second', progress: 0, has_children: false },
|
|
403
|
+
])
|
|
404
|
+
const onSelect = jest.fn()
|
|
405
|
+
|
|
406
|
+
const { application, controller } = await installController()
|
|
407
|
+
controller.open(rect, onSelect, jest.fn())
|
|
408
|
+
await flush()
|
|
409
|
+
|
|
410
|
+
// Explicit navigation: move the active row down to the second node.
|
|
411
|
+
controller.inputTarget.dispatchEvent(
|
|
412
|
+
new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true, bubbles: true }),
|
|
413
|
+
)
|
|
414
|
+
const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true, bubbles: true })
|
|
415
|
+
controller.inputTarget.dispatchEvent(tab)
|
|
416
|
+
await flush()
|
|
417
|
+
|
|
418
|
+
expect(onSelect).toHaveBeenCalledWith({ id: 8, label: 'Second' })
|
|
419
|
+
expect(tab.defaultPrevented).toBe(true)
|
|
420
|
+
|
|
421
|
+
application.stop()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('Tab selects the top search result without prior navigation', async () => {
|
|
425
|
+
// A typed query is an explicit choice context, so Tab selects the top result.
|
|
426
|
+
browse.mockResolvedValue([])
|
|
427
|
+
search.mockResolvedValue([{ id: 9, description: 'Found', progress: 0, path: [] }])
|
|
428
|
+
const onSelect = jest.fn()
|
|
429
|
+
|
|
430
|
+
const { application, controller } = await installController()
|
|
431
|
+
controller.open(rect, onSelect, jest.fn())
|
|
432
|
+
await flush()
|
|
433
|
+
|
|
434
|
+
controller.inputTarget.value = 'fo'
|
|
435
|
+
controller.search()
|
|
436
|
+
await flush()
|
|
437
|
+
|
|
438
|
+
const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true, bubbles: true })
|
|
439
|
+
controller.inputTarget.dispatchEvent(tab)
|
|
440
|
+
await flush()
|
|
441
|
+
|
|
442
|
+
expect(onSelect).toHaveBeenCalledWith({ id: 9, label: 'Found' })
|
|
443
|
+
expect(tab.defaultPrevented).toBe(true)
|
|
444
|
+
|
|
445
|
+
application.stop()
|
|
446
|
+
})
|
|
447
|
+
})
|
|
@@ -10,7 +10,7 @@ if (!window._streamingCommentIds) window._streamingCommentIds = new Set()
|
|
|
10
10
|
|
|
11
11
|
// Connects to data-controller="comment"
|
|
12
12
|
export default class extends Controller {
|
|
13
|
-
static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
|
|
13
|
+
static targets = ["ownerButton", "deleteButton", "approveButton", "denyButton", "actionApproveControls"]
|
|
14
14
|
|
|
15
15
|
get _commentId() {
|
|
16
16
|
return this.element.dataset.commentId
|
|
@@ -123,6 +123,15 @@ export default class extends Controller {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
const actionMarkdownElement = this.element.querySelector('.comment-action-markdown')
|
|
127
|
+
if (actionMarkdownElement && actionMarkdownElement.dataset.rendered !== 'true') {
|
|
128
|
+
const text = actionMarkdownElement.textContent || ''
|
|
129
|
+
actionMarkdownElement.innerHTML = renderCommentMarkdown(text)
|
|
130
|
+
addTableDownloadButtons(actionMarkdownElement)
|
|
131
|
+
renderMermaidDiagrams(actionMarkdownElement)
|
|
132
|
+
actionMarkdownElement.dataset.rendered = 'true'
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
// Text selection quote support
|
|
127
136
|
this.handleMouseUp = this.handleMouseUp.bind(this)
|
|
128
137
|
document.addEventListener('mouseup', this.handleMouseUp)
|
|
@@ -157,6 +166,11 @@ export default class extends Controller {
|
|
|
157
166
|
this.approveButtonTargets.forEach((button) => {
|
|
158
167
|
button.classList.remove('comment-approve-hidden')
|
|
159
168
|
})
|
|
169
|
+
// Deny button (Claude Channel permission prompts only) is gated the same
|
|
170
|
+
// way as approve.
|
|
171
|
+
this.denyButtonTargets.forEach((button) => {
|
|
172
|
+
button.classList.remove('comment-approve-hidden')
|
|
173
|
+
})
|
|
160
174
|
// Also show action block approve controls (edit action button, form)
|
|
161
175
|
this.actionApproveControlsTargets.forEach((el) => {
|
|
162
176
|
el.classList.remove('comment-approve-hidden')
|
|
@@ -121,3 +121,111 @@ describe('CommentsPresenceController', () => {
|
|
|
121
121
|
expect(close).not.toHaveBeenCalled()
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
describe('CommentsPresenceController typing-row horizontal scroll', () => {
|
|
126
|
+
let application
|
|
127
|
+
let container
|
|
128
|
+
let controller
|
|
129
|
+
let row
|
|
130
|
+
let scrollLeftValue
|
|
131
|
+
|
|
132
|
+
// jsdom has no layout engine, so scroll geometry must be stubbed. Treat the
|
|
133
|
+
// row as 100px wide with 300px of content: "at end" means scrollLeft >= 176.
|
|
134
|
+
function setGeometry({ clientWidth, scrollWidth, scrollLeft }) {
|
|
135
|
+
scrollLeftValue = scrollLeft
|
|
136
|
+
Object.defineProperty(row, 'clientWidth', { value: clientWidth, configurable: true })
|
|
137
|
+
Object.defineProperty(row, 'scrollWidth', { value: scrollWidth, configurable: true })
|
|
138
|
+
Object.defineProperty(row, 'scrollLeft', {
|
|
139
|
+
configurable: true,
|
|
140
|
+
get: () => scrollLeftValue,
|
|
141
|
+
set: (v) => { scrollLeftValue = v },
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
beforeEach(async () => {
|
|
146
|
+
document.body.dataset.currentUserId = '7'
|
|
147
|
+
global.fetch = jest.fn()
|
|
148
|
+
|
|
149
|
+
container = document.createElement('div')
|
|
150
|
+
container.innerHTML = `
|
|
151
|
+
<div id="comments-popup" data-controller="comments--presence">
|
|
152
|
+
<textarea data-comments--presence-target="textarea"></textarea>
|
|
153
|
+
<input type="checkbox" data-comments--presence-target="privateCheckbox" />
|
|
154
|
+
<div id="typing-indicator-row">
|
|
155
|
+
<div id="typing-scroll-viewport" data-comments--presence-target="scrollRow">
|
|
156
|
+
<div data-comments--presence-target="channelChips"></div>
|
|
157
|
+
<div data-comments--presence-target="typingIndicator"></div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
`
|
|
162
|
+
document.body.appendChild(container)
|
|
163
|
+
|
|
164
|
+
application = Application.start()
|
|
165
|
+
application.register('comments--presence', PresenceController)
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
167
|
+
|
|
168
|
+
const el = document.getElementById('comments-popup')
|
|
169
|
+
controller = application.getControllerForElementAndIdentifier(el, 'comments--presence')
|
|
170
|
+
controller.creativeId = '123'
|
|
171
|
+
row = el.querySelector('#typing-scroll-viewport')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
application.stop()
|
|
176
|
+
document.body.innerHTML = ''
|
|
177
|
+
delete document.body.dataset.currentUserId
|
|
178
|
+
jest.restoreAllMocks()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('auto-scrolls a new typer into view when parked at the right edge', () => {
|
|
182
|
+
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 }) // at end
|
|
183
|
+
|
|
184
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
185
|
+
|
|
186
|
+
expect(scrollLeftValue).toBe(300)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('does NOT scroll when the user has scrolled back to look at earlier items', () => {
|
|
190
|
+
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 }) // scrolled back
|
|
191
|
+
|
|
192
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
193
|
+
|
|
194
|
+
expect(scrollLeftValue).toBe(0)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('does not re-scroll on repeat typing pings from the same user (not a new item)', () => {
|
|
198
|
+
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 })
|
|
199
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
200
|
+
expect(scrollLeftValue).toBe(300)
|
|
201
|
+
|
|
202
|
+
// User scrolls back; a heartbeat ping for the same (already-shown) typer
|
|
203
|
+
// must not yank them to the end again.
|
|
204
|
+
scrollLeftValue = 0
|
|
205
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
206
|
+
expect(scrollLeftValue).toBe(0)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("always scrolls the local user's own new typing indicator into view, even when scrolled back to badges", () => {
|
|
210
|
+
// currentUserId is '7'. The user is parked at the left looking at PR/Preview
|
|
211
|
+
// badges (scrollLeft 0, not at end). When they themselves start typing they
|
|
212
|
+
// always want to see their own indicator, so stick-to-end must not suppress it.
|
|
213
|
+
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
|
|
214
|
+
|
|
215
|
+
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
|
|
216
|
+
|
|
217
|
+
expect(scrollLeftValue).toBe(300)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('does not re-scroll on repeat self typing pings (only the first appearance forces scroll)', () => {
|
|
221
|
+
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
|
|
222
|
+
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
|
|
223
|
+
expect(scrollLeftValue).toBe(300)
|
|
224
|
+
|
|
225
|
+
// A heartbeat ping for the same (already-shown) self typer while scrolled
|
|
226
|
+
// back must not yank to the end again — only the first appearance forces it.
|
|
227
|
+
scrollLeftValue = 0
|
|
228
|
+
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
|
|
229
|
+
expect(scrollLeftValue).toBe(0)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -145,6 +145,10 @@ export default class extends Controller {
|
|
|
145
145
|
onPopupOpened({ creativeId, canComment }) {
|
|
146
146
|
this.creativeId = creativeId
|
|
147
147
|
this.element.dataset.creativeId = creativeId || ''
|
|
148
|
+
// Stale topic ids from the previous creative are cleared by the popup
|
|
149
|
+
// controller BEFORE topics loadTopics() dispatches comments--topics:change,
|
|
150
|
+
// so by the time we get here, currentTopicId already reflects the new
|
|
151
|
+
// creative's restored topic. Do not re-clear it.
|
|
148
152
|
this.formTarget.style.display = canComment ? '' : 'none'
|
|
149
153
|
this.resetForm()
|
|
150
154
|
if (canComment && this.shouldAutoFocusOnOpen()) {
|
|
@@ -432,6 +432,11 @@ export default class extends Controller {
|
|
|
432
432
|
this.approveComment(target)
|
|
433
433
|
return
|
|
434
434
|
}
|
|
435
|
+
if (target.classList.contains('deny-comment-btn')) {
|
|
436
|
+
event.preventDefault()
|
|
437
|
+
this.denyComment(target)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
435
440
|
if (target.classList.contains('edit-comment-btn')) {
|
|
436
441
|
event.preventDefault()
|
|
437
442
|
this.editComment(target)
|
|
@@ -956,12 +961,22 @@ export default class extends Controller {
|
|
|
956
961
|
}
|
|
957
962
|
|
|
958
963
|
approveComment(button) {
|
|
959
|
-
|
|
964
|
+
this.decideComment(button, 'approve')
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Deny a Claude Channel tool-permission prompt. Mirrors approveComment but
|
|
968
|
+
// hits the /deny endpoint, which relays a "deny" decision to the suspended
|
|
969
|
+
// session.
|
|
970
|
+
denyComment(button) {
|
|
971
|
+
this.decideComment(button, 'deny')
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
decideComment(button, action) {
|
|
960
975
|
if (button.disabled) return
|
|
961
976
|
button.disabled = true
|
|
962
977
|
const commentId = button.getAttribute('data-comment-id')
|
|
963
978
|
const topicQuery = this.topicQueryString()
|
|
964
|
-
fetch(`/creatives/${this.creativeId}/comments/${commentId}
|
|
979
|
+
fetch(`/creatives/${this.creativeId}/comments/${commentId}/${action}${topicQuery}`, { method: 'POST', headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content } })
|
|
965
980
|
.then(r => r.ok ? r.text() : r.json().then(j => { throw new Error(j.error) }))
|
|
966
981
|
.then(html => {
|
|
967
982
|
if (!html) { button.disabled = false; return; }
|
|
@@ -1123,25 +1138,28 @@ export default class extends Controller {
|
|
|
1123
1138
|
openActionEditor(container) {
|
|
1124
1139
|
if (!container) return
|
|
1125
1140
|
const json = container.querySelector('.comment-action-json')
|
|
1141
|
+
const md = container.querySelector('.comment-action-markdown')
|
|
1126
1142
|
const form = container.querySelector('.comment-action-edit-form')
|
|
1127
1143
|
const btn = container.querySelector('.edit-comment-action-btn')
|
|
1128
1144
|
const txt = form?.querySelector('.comment-action-edit-textarea')
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1145
|
+
if (!form || !txt) return
|
|
1146
|
+
if (json) txt.value = json.textContent || ''
|
|
1147
|
+
form.style.display = 'block'
|
|
1148
|
+
if (btn) btn.style.display = 'none'
|
|
1149
|
+
if (json) json.style.display = 'none'
|
|
1150
|
+
if (md) md.style.display = 'none'
|
|
1151
|
+
txt.focus()
|
|
1136
1152
|
}
|
|
1137
1153
|
|
|
1138
1154
|
closeActionEditor(container) {
|
|
1139
1155
|
if (!container) return
|
|
1140
1156
|
const json = container.querySelector('.comment-action-json')
|
|
1157
|
+
const md = container.querySelector('.comment-action-markdown')
|
|
1141
1158
|
const form = container.querySelector('.comment-action-edit-form')
|
|
1142
1159
|
const btn = container.querySelector('.edit-comment-action-btn')
|
|
1143
1160
|
if (form) form.style.display = 'none'
|
|
1144
1161
|
if (json) json.style.display = ''
|
|
1162
|
+
if (md) md.style.display = ''
|
|
1145
1163
|
if (btn) btn.style.display = ''
|
|
1146
1164
|
}
|
|
1147
1165
|
|