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
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import CommonPopupController from './common_popup_controller'
|
|
2
2
|
import creativesApi from '../lib/api/creatives'
|
|
3
3
|
|
|
4
|
+
// Minimum characters before a text search fires. Below this the popup shows the
|
|
5
|
+
// browsable mini-tree instead (empty input => tree, >= MIN_QUERY chars => search).
|
|
6
|
+
const MIN_QUERY = 2
|
|
7
|
+
|
|
8
|
+
// Chevron icons matched to the main creative tree (creative_tree_row.js#_toggleIcon)
|
|
9
|
+
// so the mini-tree expand/collapse affordance is visually identical.
|
|
10
|
+
const CHEVRON_COLLAPSED =
|
|
11
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6L15 12L9 18"/></svg>'
|
|
12
|
+
const CHEVRON_EXPANDED =
|
|
13
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9L12 15L18 9"/></svg>'
|
|
14
|
+
|
|
4
15
|
export default class extends CommonPopupController {
|
|
5
16
|
static targets = ['input', 'list', 'close']
|
|
6
17
|
|
|
@@ -8,6 +19,13 @@ export default class extends CommonPopupController {
|
|
|
8
19
|
super.connect()
|
|
9
20
|
this._debounceTimer = null
|
|
10
21
|
this._searchToken = 0
|
|
22
|
+
this._mode = 'tree'
|
|
23
|
+
this._rootNodes = null
|
|
24
|
+
this._activeEl = null
|
|
25
|
+
// Whether the user has explicitly navigated the active row (arrow keys).
|
|
26
|
+
// Gates Tab-to-select so Tab still moves focus out of the initial browse
|
|
27
|
+
// state, where the first row is auto-highlighted but unchosen.
|
|
28
|
+
this._navigated = false
|
|
11
29
|
this.inputTarget.addEventListener('input', this._debouncedSearch.bind(this))
|
|
12
30
|
this.inputTarget.addEventListener('keydown', this.handleInputKeydown.bind(this))
|
|
13
31
|
this.closeTarget.addEventListener('click', () => this.close())
|
|
@@ -17,72 +35,427 @@ export default class extends CommonPopupController {
|
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
disconnect() {
|
|
20
|
-
|
|
21
|
-
clearTimeout(this._debounceTimer)
|
|
22
|
-
this._debounceTimer = null
|
|
23
|
-
}
|
|
38
|
+
this._clearDebounce()
|
|
24
39
|
super.disconnect()
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
open(anchorRect, onSelectCallback, onCloseCallback) {
|
|
28
43
|
this.onSelectCallback = onSelectCallback
|
|
29
44
|
this.onCloseCallback = onCloseCallback
|
|
30
|
-
this.
|
|
45
|
+
this._mode = 'tree'
|
|
46
|
+
this._rootNodes = null
|
|
47
|
+
this._activeEl = null
|
|
31
48
|
this.inputTarget.value = ''
|
|
49
|
+
// Clear any CommonPopup item state; we render our own DOM into the list.
|
|
50
|
+
this.popup.setItems([])
|
|
32
51
|
super.open(anchorRect)
|
|
33
52
|
|
|
34
53
|
requestAnimationFrame(() => {
|
|
35
54
|
this.inputTarget.focus()
|
|
36
55
|
})
|
|
56
|
+
|
|
57
|
+
this._showTree()
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
close() {
|
|
61
|
+
this._clearDebounce()
|
|
62
|
+
super.close()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_clearDebounce() {
|
|
40
66
|
if (this._debounceTimer) {
|
|
41
67
|
clearTimeout(this._debounceTimer)
|
|
42
68
|
this._debounceTimer = null
|
|
43
69
|
}
|
|
44
|
-
// super.close() calls popup.hide(), which triggers dispatchClose,
|
|
45
|
-
// where we now handle the callback. So we just need to call super.close().
|
|
46
|
-
super.close()
|
|
47
70
|
}
|
|
48
71
|
|
|
49
72
|
handleInputKeydown(event) {
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// Special handling for this specific popup
|
|
73
|
+
// CommonPopup's key handling is item-list based and we don't populate it,
|
|
74
|
+
// so it is a no-op here. We drive navigation over our own rendered rows.
|
|
54
75
|
if (event.key === 'Escape') {
|
|
55
76
|
this.close()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (event.key === 'ArrowDown') {
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
this._moveActive(1)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (event.key === 'ArrowUp') {
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
this._moveActive(-1)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Enter is a deliberate select key: always activate the highlighted row.
|
|
93
|
+
if (event.key === 'Enter' && this._activeEl) {
|
|
94
|
+
event.preventDefault()
|
|
95
|
+
this._activateRow(this._activeEl)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Tab-to-select only when the choice is explicit — an actual search result
|
|
100
|
+
// or after the user has navigated the tree. In the initial browse state the
|
|
101
|
+
// first row is auto-highlighted but unchosen, so let Tab move focus instead
|
|
102
|
+
// of linking a creative the user never picked.
|
|
103
|
+
if (event.key === 'Tab' && this._activeEl && (this._mode === 'search' || this._navigated)) {
|
|
104
|
+
event.preventDefault()
|
|
105
|
+
this._activateRow(this._activeEl)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (this._mode === 'tree' && this._activeEl) {
|
|
110
|
+
if (event.key === 'ArrowRight') {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
this._navigated = true
|
|
113
|
+
this._expandNode(this._activeEl.closest('.link-tree-item'))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (event.key === 'ArrowLeft') {
|
|
117
|
+
event.preventDefault()
|
|
118
|
+
this._navigated = true
|
|
119
|
+
this._collapseNode(this._activeEl.closest('.link-tree-item'))
|
|
120
|
+
}
|
|
56
121
|
}
|
|
57
122
|
}
|
|
58
123
|
|
|
59
124
|
_debouncedSearch() {
|
|
60
|
-
|
|
125
|
+
this._clearDebounce()
|
|
61
126
|
this._debounceTimer = setTimeout(() => this.search(), 300)
|
|
62
127
|
}
|
|
63
128
|
|
|
64
129
|
search() {
|
|
65
130
|
const query = this.inputTarget.value.trim()
|
|
66
|
-
if (query.length <
|
|
131
|
+
if (query.length < MIN_QUERY) {
|
|
67
132
|
this._searchToken++
|
|
68
|
-
this.
|
|
133
|
+
this._showTree()
|
|
69
134
|
return
|
|
70
135
|
}
|
|
71
136
|
|
|
137
|
+
this._mode = 'search'
|
|
72
138
|
const token = ++this._searchToken
|
|
73
139
|
creativesApi.search(query, { simple: true })
|
|
74
140
|
.then((results) => {
|
|
75
141
|
// Discard stale responses if input changed since this request
|
|
76
142
|
if (token !== this._searchToken) return
|
|
143
|
+
this._renderSearchResults(Array.isArray(results) ? results : [])
|
|
144
|
+
})
|
|
145
|
+
.catch(() => {
|
|
146
|
+
if (token === this._searchToken) this._renderSearchResults([])
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Tree (browse) mode --------------------------------------------------
|
|
151
|
+
|
|
152
|
+
_showTree() {
|
|
153
|
+
this._mode = 'tree'
|
|
154
|
+
// Entering a fresh browse state (open, or query cleared back to browse):
|
|
155
|
+
// the auto-highlighted first row is unchosen until the user navigates.
|
|
156
|
+
this._navigated = false
|
|
157
|
+
if (this._rootNodes) {
|
|
158
|
+
this._renderTree(this._rootNodes)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this._renderMessage(this._text('loadingText'))
|
|
163
|
+
const token = ++this._searchToken
|
|
164
|
+
creativesApi.browse(null)
|
|
165
|
+
.then((nodes) => {
|
|
166
|
+
if (token !== this._searchToken) return
|
|
167
|
+
this._rootNodes = Array.isArray(nodes) ? nodes : []
|
|
168
|
+
this._renderTree(this._rootNodes)
|
|
169
|
+
})
|
|
170
|
+
.catch(() => {
|
|
171
|
+
if (token === this._searchToken) this._renderMessage(this._text('emptyText'))
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_renderTree(nodes) {
|
|
176
|
+
this.listTarget.innerHTML = ''
|
|
177
|
+
if (!nodes || nodes.length === 0) {
|
|
178
|
+
this._renderMessage(this._text('emptyText'))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
nodes.forEach((node) => this.listTarget.appendChild(this._buildTreeItem(node, 0)))
|
|
182
|
+
this._resetActive()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_buildTreeItem(node, level) {
|
|
186
|
+
const li = document.createElement('li')
|
|
187
|
+
li.className = 'link-tree-item'
|
|
188
|
+
li.dataset.id = String(node.id)
|
|
189
|
+
li.dataset.level = String(level)
|
|
190
|
+
li.dataset.loaded = '0'
|
|
191
|
+
// Linked-creative shells render under the user's shell id, but search
|
|
192
|
+
// breadcrumbs carry the effective origin id. Record the origin id so
|
|
193
|
+
// _findItem can resolve a breadcrumb's root crumb back to this shell.
|
|
194
|
+
if (node.origin_id) li.dataset.originId = String(node.origin_id)
|
|
195
|
+
|
|
196
|
+
const row = document.createElement('div')
|
|
197
|
+
row.className = 'link-tree-row'
|
|
198
|
+
row.setAttribute('data-pick-row', '')
|
|
199
|
+
row.dataset.id = String(node.id)
|
|
200
|
+
row.style.paddingLeft = `${level * 1.1 + 0.5}em`
|
|
201
|
+
|
|
202
|
+
const toggle = document.createElement('button')
|
|
203
|
+
toggle.type = 'button'
|
|
204
|
+
toggle.className = 'link-tree-toggle'
|
|
205
|
+
if (node.has_children) {
|
|
206
|
+
toggle.innerHTML = CHEVRON_COLLAPSED
|
|
207
|
+
toggle.setAttribute('aria-label', this._text('expandText'))
|
|
208
|
+
toggle.addEventListener('mousedown', (e) => e.preventDefault())
|
|
209
|
+
toggle.addEventListener('click', (e) => {
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
e.stopPropagation()
|
|
212
|
+
this._toggleNode(li)
|
|
213
|
+
})
|
|
214
|
+
} else {
|
|
215
|
+
toggle.className = 'link-tree-toggle link-tree-toggle-empty'
|
|
216
|
+
toggle.tabIndex = -1
|
|
217
|
+
toggle.setAttribute('aria-hidden', 'true')
|
|
218
|
+
}
|
|
219
|
+
row.appendChild(toggle)
|
|
220
|
+
|
|
221
|
+
const label = document.createElement('span')
|
|
222
|
+
label.className = 'link-tree-label'
|
|
223
|
+
label.textContent = node.description || ''
|
|
224
|
+
row.appendChild(label)
|
|
225
|
+
|
|
226
|
+
row.addEventListener('mouseenter', () => this._setActive(row))
|
|
227
|
+
row.addEventListener('mousedown', (e) => e.preventDefault())
|
|
228
|
+
row.addEventListener('click', () => this._activateRow(row))
|
|
229
|
+
|
|
230
|
+
li.appendChild(row)
|
|
231
|
+
|
|
232
|
+
if (node.has_children) {
|
|
233
|
+
const childrenUl = document.createElement('ul')
|
|
234
|
+
childrenUl.className = 'link-tree-children'
|
|
235
|
+
childrenUl.hidden = true
|
|
236
|
+
li.appendChild(childrenUl)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return li
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_toggleNode(li) {
|
|
243
|
+
if (!li) return
|
|
244
|
+
if (li.classList.contains('expanded')) {
|
|
245
|
+
this._collapseNode(li)
|
|
246
|
+
} else {
|
|
247
|
+
this._expandNode(li)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_collapseNode(li) {
|
|
252
|
+
if (!li || !li.classList.contains('expanded')) return
|
|
253
|
+
li.classList.remove('expanded')
|
|
254
|
+
const childrenUl = li.querySelector(':scope > .link-tree-children')
|
|
255
|
+
if (childrenUl) childrenUl.hidden = true
|
|
256
|
+
const toggle = li.querySelector(':scope > .link-tree-row > .link-tree-toggle')
|
|
257
|
+
if (toggle && !toggle.classList.contains('link-tree-toggle-empty')) toggle.innerHTML = CHEVRON_COLLAPSED
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Returns a promise that resolves once the node is expanded (children loaded
|
|
261
|
+
// and visible). Used both by user interaction and breadcrumb navigation.
|
|
262
|
+
_expandNode(li) {
|
|
263
|
+
if (!li) return Promise.resolve()
|
|
264
|
+
const childrenUl = li.querySelector(':scope > .link-tree-children')
|
|
265
|
+
if (!childrenUl) return Promise.resolve() // leaf
|
|
266
|
+
|
|
267
|
+
const toggle = li.querySelector(':scope > .link-tree-row > .link-tree-toggle')
|
|
268
|
+
li.classList.add('expanded')
|
|
269
|
+
childrenUl.hidden = false
|
|
270
|
+
if (toggle && !toggle.classList.contains('link-tree-toggle-empty')) toggle.innerHTML = CHEVRON_EXPANDED
|
|
271
|
+
|
|
272
|
+
if (li.dataset.loaded === '1') return Promise.resolve()
|
|
273
|
+
|
|
274
|
+
const level = parseInt(li.dataset.level, 10) + 1
|
|
275
|
+
li.dataset.loaded = '1'
|
|
276
|
+
childrenUl.innerHTML = `<li class="link-tree-loading">${this._escape(this._text('loadingText'))}</li>`
|
|
77
277
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
278
|
+
return creativesApi.browse(li.dataset.id)
|
|
279
|
+
.then((nodes) => {
|
|
280
|
+
childrenUl.innerHTML = ''
|
|
281
|
+
const list = Array.isArray(nodes) ? nodes : []
|
|
282
|
+
if (list.length === 0) {
|
|
283
|
+
childrenUl.innerHTML = `<li class="link-tree-empty">${this._escape(this._text('emptyText'))}</li>`
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
list.forEach((child) => childrenUl.appendChild(this._buildTreeItem(child, level)))
|
|
82
287
|
})
|
|
83
288
|
.catch(() => {
|
|
84
|
-
|
|
289
|
+
li.dataset.loaded = '0'
|
|
290
|
+
childrenUl.innerHTML = ''
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Search (flat + breadcrumb) mode -------------------------------------
|
|
295
|
+
|
|
296
|
+
_renderSearchResults(results) {
|
|
297
|
+
this.listTarget.innerHTML = ''
|
|
298
|
+
if (!results || results.length === 0) {
|
|
299
|
+
this._renderMessage(this._text('noResultsText'))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
results.forEach((result) => {
|
|
304
|
+
const li = document.createElement('li')
|
|
305
|
+
li.className = 'link-result-item'
|
|
306
|
+
li.setAttribute('data-pick-row', '')
|
|
307
|
+
li.dataset.id = String(result.id)
|
|
308
|
+
|
|
309
|
+
const label = document.createElement('div')
|
|
310
|
+
label.className = 'link-result-label'
|
|
311
|
+
label.textContent = result.description || ''
|
|
312
|
+
li.appendChild(label)
|
|
313
|
+
|
|
314
|
+
if (Array.isArray(result.path) && result.path.length > 0) {
|
|
315
|
+
li.appendChild(this._buildBreadcrumb(result))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
li.addEventListener('mouseenter', () => this._setActive(li))
|
|
319
|
+
li.addEventListener('mousedown', (e) => e.preventDefault())
|
|
320
|
+
li.addEventListener('click', () => this._activateRow(li))
|
|
321
|
+
|
|
322
|
+
this.listTarget.appendChild(li)
|
|
323
|
+
})
|
|
324
|
+
this._resetActive()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_buildBreadcrumb(result) {
|
|
328
|
+
const nav = document.createElement('div')
|
|
329
|
+
nav.className = 'link-result-path'
|
|
330
|
+
|
|
331
|
+
result.path.forEach((crumb, index) => {
|
|
332
|
+
if (index > 0) {
|
|
333
|
+
const sep = document.createElement('span')
|
|
334
|
+
sep.className = 'link-crumb-sep'
|
|
335
|
+
sep.textContent = '›'
|
|
336
|
+
nav.appendChild(sep)
|
|
337
|
+
}
|
|
338
|
+
// Ancestors the user cannot read are kept (to preserve depth) but
|
|
339
|
+
// masked and non-navigable — clicking them would reveal nothing.
|
|
340
|
+
if (crumb.restricted) {
|
|
341
|
+
const masked = document.createElement('span')
|
|
342
|
+
masked.className = 'link-crumb link-crumb-restricted'
|
|
343
|
+
masked.textContent = '…'
|
|
344
|
+
nav.appendChild(masked)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const btn = document.createElement('button')
|
|
349
|
+
btn.type = 'button'
|
|
350
|
+
btn.className = 'link-crumb'
|
|
351
|
+
btn.dataset.id = String(crumb.id)
|
|
352
|
+
btn.textContent = crumb.description || ''
|
|
353
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault())
|
|
354
|
+
btn.addEventListener('click', (e) => {
|
|
355
|
+
e.preventDefault()
|
|
356
|
+
e.stopPropagation()
|
|
357
|
+
this._navigateToCrumb(result, index)
|
|
85
358
|
})
|
|
359
|
+
nav.appendChild(btn)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
return nav
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Clicking a breadcrumb segment switches to the tree, expands the path down
|
|
366
|
+
// to that ancestor, and highlights it — turning search into a jump-to-place.
|
|
367
|
+
_navigateToCrumb(result, crumbIndex) {
|
|
368
|
+
// When the hit lives under a linked shell nested in the user's own tree,
|
|
369
|
+
// the origin-space crumbs omit the local folders above the shell. The
|
|
370
|
+
// server-supplied reveal_path maps each origin ancestor id to the local
|
|
371
|
+
// path ([localFolder..., shellId]) that surfaces *that* ancestor's shell.
|
|
372
|
+
// Anchor at the entry at/above the clicked crumb (nearest first) so a
|
|
373
|
+
// higher ancestor crumb resolves through its own shell, not a deeper one;
|
|
374
|
+
// origins between the anchoring shell and the target render as the shell's
|
|
375
|
+
// descendants, while origins above the anchor are not in the user's tree.
|
|
376
|
+
const path = result.path
|
|
377
|
+
const revealMap =
|
|
378
|
+
result.reveal_path && typeof result.reveal_path === 'object' && !Array.isArray(result.reveal_path)
|
|
379
|
+
? result.reveal_path
|
|
380
|
+
: null
|
|
381
|
+
let localPrefix = []
|
|
382
|
+
let anchorIndex = -1
|
|
383
|
+
if (revealMap) {
|
|
384
|
+
for (let i = crumbIndex; i >= 0; i--) {
|
|
385
|
+
const entry = revealMap[String(path[i].id)]
|
|
386
|
+
if (entry) {
|
|
387
|
+
localPrefix = entry.map(String)
|
|
388
|
+
anchorIndex = i
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const originChain = path.slice(anchorIndex + 1, crumbIndex).map((p) => String(p.id))
|
|
394
|
+
const chain = localPrefix.concat(originChain)
|
|
395
|
+
const targetId = String(path[crumbIndex].id)
|
|
396
|
+
|
|
397
|
+
this.inputTarget.value = ''
|
|
398
|
+
this._searchToken++
|
|
399
|
+
this._mode = 'tree'
|
|
400
|
+
|
|
401
|
+
const reveal = () => this._expandChain(chain).then(() => this._highlight(targetId))
|
|
402
|
+
|
|
403
|
+
if (this._rootNodes) {
|
|
404
|
+
this._renderTree(this._rootNodes)
|
|
405
|
+
reveal()
|
|
406
|
+
} else {
|
|
407
|
+
creativesApi.browse(null)
|
|
408
|
+
.then((nodes) => {
|
|
409
|
+
this._rootNodes = Array.isArray(nodes) ? nodes : []
|
|
410
|
+
this._renderTree(this._rootNodes)
|
|
411
|
+
return reveal()
|
|
412
|
+
})
|
|
413
|
+
.catch(() => this._renderMessage(this._text('emptyText')))
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_expandChain(ids) {
|
|
418
|
+
return ids.reduce((promise, id) => {
|
|
419
|
+
return promise.then(() => {
|
|
420
|
+
const li = this._findItem(id)
|
|
421
|
+
return li ? this._expandNode(li) : null
|
|
422
|
+
})
|
|
423
|
+
}, Promise.resolve())
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_highlight(id) {
|
|
427
|
+
const li = this._findItem(id)
|
|
428
|
+
if (!li) return
|
|
429
|
+
const row = li.querySelector(':scope > .link-tree-row')
|
|
430
|
+
if (row) {
|
|
431
|
+
this._setActive(row)
|
|
432
|
+
row.scrollIntoView({ block: 'nearest' })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
_findItem(id) {
|
|
437
|
+
// Match the rendered node id first; fall back to the effective origin id
|
|
438
|
+
// so a breadcrumb's root crumb (origin id) resolves to its linked shell.
|
|
439
|
+
return (
|
|
440
|
+
this.listTarget.querySelector(`.link-tree-item[data-id="${id}"]`) ||
|
|
441
|
+
this.listTarget.querySelector(`.link-tree-item[data-origin-id="${id}"]`)
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- Selection & keyboard navigation -------------------------------------
|
|
446
|
+
|
|
447
|
+
_activateRow(row) {
|
|
448
|
+
if (!row || !row.hasAttribute('data-pick-row')) return
|
|
449
|
+
// For a linked-creative shell row, emit the effective origin id, not the
|
|
450
|
+
// shell id: consumers use the selected id as the new link's origin, and
|
|
451
|
+
// linking to the shell (rather than the real shared creative) would make
|
|
452
|
+
// PermissionChecker treat the shell as the permission base. Flat search
|
|
453
|
+
// rows already carry the origin creative's id, so they pass through.
|
|
454
|
+
const item = row.closest('.link-tree-item')
|
|
455
|
+
const id = Number((item && item.dataset.originId) || row.dataset.id)
|
|
456
|
+
const labelEl = row.querySelector('.link-tree-label, .link-result-label')
|
|
457
|
+
const label = labelEl ? labelEl.textContent : ''
|
|
458
|
+
this.select({ id, label })
|
|
86
459
|
}
|
|
87
460
|
|
|
88
461
|
// Override select to invoke callback
|
|
@@ -93,15 +466,64 @@ export default class extends CommonPopupController {
|
|
|
93
466
|
this.close()
|
|
94
467
|
}
|
|
95
468
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
469
|
+
_visibleRows() {
|
|
470
|
+
return Array.from(this.listTarget.querySelectorAll('[data-pick-row]'))
|
|
471
|
+
.filter((el) => el.offsetParent !== null)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
_resetActive() {
|
|
475
|
+
const rows = this._visibleRows()
|
|
476
|
+
this._setActive(rows[0] || null)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_setActive(row) {
|
|
480
|
+
if (this._activeEl === row) return
|
|
481
|
+
if (this._activeEl) this._activeEl.classList.remove('active')
|
|
482
|
+
this._activeEl = row
|
|
483
|
+
if (row) row.classList.add('active')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_moveActive(delta) {
|
|
487
|
+
const rows = this._visibleRows()
|
|
488
|
+
if (rows.length === 0) return
|
|
489
|
+
this._navigated = true
|
|
490
|
+
const current = rows.indexOf(this._activeEl)
|
|
491
|
+
let next = current + delta
|
|
492
|
+
if (next < 0) next = rows.length - 1
|
|
493
|
+
if (next >= rows.length) next = 0
|
|
494
|
+
const row = rows[next]
|
|
495
|
+
this._setActive(row)
|
|
496
|
+
row.scrollIntoView({ block: 'nearest' })
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- Helpers -------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
_renderMessage(text) {
|
|
502
|
+
this.listTarget.innerHTML = ''
|
|
503
|
+
const li = document.createElement('li')
|
|
504
|
+
li.className = 'link-popup-message'
|
|
505
|
+
li.textContent = text
|
|
506
|
+
this.listTarget.appendChild(li)
|
|
507
|
+
this._activeEl = null
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
_text(key) {
|
|
511
|
+
const map = {
|
|
512
|
+
loadingText: this.element.dataset.linkCreativeLoadingText,
|
|
513
|
+
noResultsText: this.element.dataset.linkCreativeNoResultsText,
|
|
514
|
+
emptyText: this.element.dataset.linkCreativeEmptyText,
|
|
515
|
+
expandText: this.element.dataset.linkCreativeExpandText,
|
|
516
|
+
}
|
|
517
|
+
return map[key] || ''
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
_escape(text) {
|
|
521
|
+
return String(text || '')
|
|
522
|
+
.replace(/&/g, '&')
|
|
523
|
+
.replace(/</g, '<')
|
|
524
|
+
.replace(/>/g, '>')
|
|
525
|
+
.replace(/"/g, '"')
|
|
526
|
+
.replace(/'/g, ''')
|
|
105
527
|
}
|
|
106
528
|
|
|
107
529
|
dispatchClose(reason) {
|
|
@@ -163,6 +163,12 @@ function applyRowProperties(row, node) {
|
|
|
163
163
|
if (Object.prototype.hasOwnProperty.call(inlinePayload, 'origin_id')) {
|
|
164
164
|
setDatasetValue(row, 'originId', inlinePayload.origin_id ?? '')
|
|
165
165
|
}
|
|
166
|
+
if (Object.prototype.hasOwnProperty.call(inlinePayload, 'content_type')) {
|
|
167
|
+
setDatasetValue(row, 'contentType', inlinePayload.content_type ?? '')
|
|
168
|
+
}
|
|
169
|
+
if (Object.prototype.hasOwnProperty.call(inlinePayload, 'markdown_source')) {
|
|
170
|
+
setDatasetValue(row, 'markdownSource', inlinePayload.markdown_source ?? '')
|
|
171
|
+
}
|
|
166
172
|
|
|
167
173
|
if (dirty && typeof row.requestUpdate === 'function') {
|
|
168
174
|
// Before Lit re-renders, sync progressHtml from current DOM.
|
|
@@ -122,6 +122,33 @@ describe('ApiQueueManager', () => {
|
|
|
122
122
|
expect(options.body.has('file')).toBe(true);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('should pass parsed JSON response data to onSuccess callback', async () => {
|
|
126
|
+
// Restore processQueue for this test
|
|
127
|
+
apiQueue.processQueue.mockRestore();
|
|
128
|
+
|
|
129
|
+
const callback = jest.fn();
|
|
130
|
+
|
|
131
|
+
// Mock successful response with JSON body containing markdown_source rewrite
|
|
132
|
+
const rewrittenSource = '';
|
|
133
|
+
mockCsrfFetch.mockResolvedValue({
|
|
134
|
+
ok: true,
|
|
135
|
+
text: async () => JSON.stringify({ id: 42, markdown_source: rewrittenSource })
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const item = {
|
|
139
|
+
path: '/creatives/42',
|
|
140
|
+
method: 'PATCH',
|
|
141
|
+
onSuccess: callback,
|
|
142
|
+
retries: 0
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
apiQueue.queue = [item];
|
|
146
|
+
await apiQueue.processQueue();
|
|
147
|
+
|
|
148
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(callback).toHaveBeenCalledWith({ id: 42, markdown_source: rewrittenSource });
|
|
150
|
+
});
|
|
151
|
+
|
|
125
152
|
test('should dispatch event on permanent failure', async () => {
|
|
126
153
|
// Restore processQueue for this test
|
|
127
154
|
apiQueue.processQueue.mockRestore();
|
|
@@ -18,6 +18,18 @@ export function loadChildren(url) {
|
|
|
18
18
|
return csrfFetch(url, { headers: JSON_HEADERS }).then((response) => response.json())
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Browse the creative tree for the picker popup. Returns the lightweight
|
|
22
|
+
// simple payload ([{ id, description, progress, has_children }]) for the
|
|
23
|
+
// children of `parentId`, or the roots when `parentId` is null/undefined.
|
|
24
|
+
export function browse(parentId) {
|
|
25
|
+
const params = new URLSearchParams({ simple: 'true' })
|
|
26
|
+
if (parentId != null) params.set('id', parentId)
|
|
27
|
+
|
|
28
|
+
return csrfFetch(`/creatives.json?${params.toString()}`, {
|
|
29
|
+
headers: JSON_HEADERS,
|
|
30
|
+
}).then((response) => response.json())
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
export function search(query, { simple = false } = {}) {
|
|
22
34
|
const params = new URLSearchParams()
|
|
23
35
|
if (query != null) params.set('search', query)
|
|
@@ -92,6 +104,7 @@ const creativesApi = {
|
|
|
92
104
|
get,
|
|
93
105
|
parentSuggestions,
|
|
94
106
|
loadChildren,
|
|
107
|
+
browse,
|
|
95
108
|
search,
|
|
96
109
|
save,
|
|
97
110
|
linkExisting,
|
|
@@ -174,11 +174,11 @@ class ApiQueueManager {
|
|
|
174
174
|
// Merge new callback with existing callbacks
|
|
175
175
|
let mergedCallback = null
|
|
176
176
|
if (existingCallbacks.length > 0 || request.onSuccess) {
|
|
177
|
-
mergedCallback = () => {
|
|
177
|
+
mergedCallback = (responseData) => {
|
|
178
178
|
// Run all existing callbacks first
|
|
179
179
|
existingCallbacks.forEach(cb => {
|
|
180
180
|
try {
|
|
181
|
-
cb()
|
|
181
|
+
cb(responseData)
|
|
182
182
|
} catch (error) {
|
|
183
183
|
console.error('Merged callback failed:', error)
|
|
184
184
|
}
|
|
@@ -186,7 +186,7 @@ class ApiQueueManager {
|
|
|
186
186
|
// Then run the new callback
|
|
187
187
|
if (typeof request.onSuccess === 'function') {
|
|
188
188
|
try {
|
|
189
|
-
request.onSuccess()
|
|
189
|
+
request.onSuccess(responseData)
|
|
190
190
|
} catch (error) {
|
|
191
191
|
console.error('New callback failed:', error)
|
|
192
192
|
}
|
|
@@ -229,8 +229,20 @@ class ApiQueueManager {
|
|
|
229
229
|
while (this.queue.length > 0) {
|
|
230
230
|
const item = this.queue[0]
|
|
231
231
|
|
|
232
|
+
let responseData = null
|
|
232
233
|
try {
|
|
233
|
-
await this.executeRequest(item)
|
|
234
|
+
const response = await this.executeRequest(item)
|
|
235
|
+
// Parse JSON body so callbacks can react to server-side rewrites
|
|
236
|
+
// (e.g. markdown_source data: URIs → blob paths). Best-effort: empty
|
|
237
|
+
// or non-JSON bodies leave responseData null.
|
|
238
|
+
if (response && typeof response.text === 'function') {
|
|
239
|
+
try {
|
|
240
|
+
const text = await response.text()
|
|
241
|
+
responseData = text ? JSON.parse(text) : null
|
|
242
|
+
} catch (_parseError) {
|
|
243
|
+
responseData = null
|
|
244
|
+
}
|
|
245
|
+
}
|
|
234
246
|
// Success - handle cleanup actions
|
|
235
247
|
|
|
236
248
|
// Dispatch event for attachment cleanup if needed
|
|
@@ -243,7 +255,7 @@ class ApiQueueManager {
|
|
|
243
255
|
// Call onSuccess callback if provided (for non-serializable actions)
|
|
244
256
|
if (typeof item.onSuccess === 'function') {
|
|
245
257
|
try {
|
|
246
|
-
item.onSuccess()
|
|
258
|
+
item.onSuccess(responseData)
|
|
247
259
|
} catch (callbackError) {
|
|
248
260
|
console.error('onSuccess callback failed:', callbackError)
|
|
249
261
|
}
|