collavre 0.20.2 → 0.21.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 +83 -0
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/creatives_controller.rb +50 -6
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/passwords_controller.rb +1 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/topics_controller.rb +21 -30
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/controllers/comment_controller.js +9 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +10 -7
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +83 -1
- 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/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- 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/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/creative/describable.rb +65 -3
- data/app/models/collavre/creative.rb +2 -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/topic.rb +3 -25
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- 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/tools/creative_attach_files_service.rb +96 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
- 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/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 +54 -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 +6 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
- 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/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 +11 -0
- data/config/locales/channels.ko.yml +11 -0
- data/config/locales/comments.en.yml +2 -0
- data/config/locales/comments.ko.yml +2 -0
- data/config/locales/creatives.en.yml +9 -0
- data/config/locales/creatives.ko.yml +8 -0
- data/config/locales/integrations.en.yml +44 -0
- data/config/locales/integrations.ko.yml +44 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +18 -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/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +51 -0
- data/lib/collavre/integration_settings/key_definition.rb +29 -0
- data/lib/collavre/integration_settings/registry.rb +55 -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 +52 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
jest.unstable_mockModule('../../../creatives/tree_renderer', () => ({
|
|
8
|
+
renderCreativeTree: jest.fn(),
|
|
9
|
+
dispatchCreativeTreeUpdated: jest.fn(),
|
|
10
|
+
applyRowProperties: jest.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
|
|
14
|
+
parseEmojis: jest.fn(() => ['✨']),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
18
|
+
const TreeController = (await import('../tree_controller')).default
|
|
19
|
+
|
|
20
|
+
const TRANSIENT_RETRY_DELAYS = [200, 600]
|
|
21
|
+
|
|
22
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
23
|
+
|
|
24
|
+
const collectRetryDelays = (spy) =>
|
|
25
|
+
spy.mock.calls
|
|
26
|
+
.map((args) => args[1])
|
|
27
|
+
.filter((delay) => TRANSIENT_RETRY_DELAYS.includes(delay))
|
|
28
|
+
|
|
29
|
+
const installController = () => {
|
|
30
|
+
const container = document.createElement('div')
|
|
31
|
+
container.setAttribute('data-controller', 'creatives--tree')
|
|
32
|
+
container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
|
|
33
|
+
document.body.appendChild(container)
|
|
34
|
+
|
|
35
|
+
const application = Application.start()
|
|
36
|
+
application.register('creatives--tree', TreeController)
|
|
37
|
+
|
|
38
|
+
return { container, application }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('CreativesTreeController retry on transient network errors', () => {
|
|
42
|
+
let originalFetch
|
|
43
|
+
let setTimeoutSpy
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
originalFetch = global.fetch
|
|
47
|
+
setTimeoutSpy = jest.spyOn(global, 'setTimeout')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
setTimeoutSpy.mockRestore()
|
|
52
|
+
global.fetch = originalFetch
|
|
53
|
+
document.body.innerHTML = ''
|
|
54
|
+
jest.restoreAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('schedules 200ms then 600ms backoff retry on TypeError "Failed to fetch"', async () => {
|
|
58
|
+
global.fetch = jest
|
|
59
|
+
.fn()
|
|
60
|
+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
61
|
+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
62
|
+
.mockResolvedValueOnce({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: async () => ({ creatives: [] }),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const { application } = installController()
|
|
68
|
+
// Allow time for the 200ms + 600ms retries to fire
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
70
|
+
|
|
71
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
|
|
72
|
+
|
|
73
|
+
application.stop()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('does NOT schedule retry on HTTP error responses', async () => {
|
|
77
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
78
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 500,
|
|
81
|
+
json: async () => ({}),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const { application } = installController()
|
|
85
|
+
await flush()
|
|
86
|
+
await flush()
|
|
87
|
+
await flush()
|
|
88
|
+
|
|
89
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
|
|
90
|
+
|
|
91
|
+
application.stop()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('does NOT schedule retry on AbortError', async () => {
|
|
95
|
+
const abortErr = new Error('aborted')
|
|
96
|
+
abortErr.name = 'AbortError'
|
|
97
|
+
global.fetch = jest.fn().mockRejectedValue(abortErr)
|
|
98
|
+
|
|
99
|
+
const { application } = installController()
|
|
100
|
+
await flush()
|
|
101
|
+
await flush()
|
|
102
|
+
|
|
103
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
|
|
104
|
+
|
|
105
|
+
application.stop()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('gives up after exactly 2 retries on persistent transient errors', async () => {
|
|
109
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
110
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
111
|
+
|
|
112
|
+
const { application } = installController()
|
|
113
|
+
// Allow time for both retries to complete (200 + 600 = 800ms)
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
115
|
+
|
|
116
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
|
|
117
|
+
|
|
118
|
+
application.stop()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -24,20 +24,41 @@ export default class extends Controller {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Defer ActionCable subscribe until tree finishes loading. Opening the
|
|
28
|
+
// WebSocket while the tree fetch is in flight triggers ERR_NETWORK_CHANGED
|
|
29
|
+
// in Chromium because the browser tears down the in-flight HTTP connection.
|
|
30
|
+
// Exception: Turbo cache restores leave data-loaded="true" so tree_controller
|
|
31
|
+
// skips load() and never dispatches creative-tree:updated — subscribe now.
|
|
32
|
+
const subscribeForRoot = () => {
|
|
33
|
+
if (this.rootIdValue > 0) {
|
|
34
|
+
this.subscribe()
|
|
35
|
+
} else {
|
|
36
|
+
this.inferAndSubscribe()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
29
39
|
|
|
30
|
-
if (this.
|
|
31
|
-
|
|
40
|
+
if (this.element.dataset.loaded === 'true') {
|
|
41
|
+
subscribeForRoot()
|
|
32
42
|
} else {
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
this._handleTreeUpdated = () => {
|
|
44
|
+
this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
|
|
45
|
+
this._handleTreeUpdated = null
|
|
46
|
+
subscribeForRoot()
|
|
47
|
+
}
|
|
48
|
+
this.element.addEventListener('creative-tree:updated', this._handleTreeUpdated)
|
|
35
49
|
}
|
|
50
|
+
|
|
51
|
+
document.addEventListener('creative-editing:start', this.handleEditStart)
|
|
52
|
+
document.addEventListener('creative-editing:stop', this.handleEditStop)
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
disconnect() {
|
|
39
56
|
document.removeEventListener('creative-editing:start', this.handleEditStart)
|
|
40
57
|
document.removeEventListener('creative-editing:stop', this.handleEditStop)
|
|
58
|
+
if (this._handleTreeUpdated) {
|
|
59
|
+
this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
|
|
60
|
+
this._handleTreeUpdated = null
|
|
61
|
+
}
|
|
41
62
|
|
|
42
63
|
if (this.subscription) {
|
|
43
64
|
this.subscription.cleanup()
|
|
@@ -50,9 +71,9 @@ export default class extends Controller {
|
|
|
50
71
|
this.subscription.cleanup()
|
|
51
72
|
this.subscription = null
|
|
52
73
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
// Skip re-subscribing here — connect() defers the first subscribe until
|
|
75
|
+
// creative-tree:updated fires so the WebSocket handshake doesn't race
|
|
76
|
+
// the tree's HTTP fetch.
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
inferAndSubscribe() {
|
|
@@ -2,6 +2,8 @@ import { Controller } from '@hotwired/stimulus'
|
|
|
2
2
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
|
|
3
3
|
import { parseEmojis } from '../../utils/emoji_parser'
|
|
4
4
|
|
|
5
|
+
const TREE_RETRY_DELAYS_MS = [200, 600]
|
|
6
|
+
|
|
5
7
|
export default class extends Controller {
|
|
6
8
|
static values = {
|
|
7
9
|
url: String,
|
|
@@ -49,6 +51,10 @@ export default class extends Controller {
|
|
|
49
51
|
this.abortController.abort()
|
|
50
52
|
this.abortController = null
|
|
51
53
|
}
|
|
54
|
+
if (this._retryTimer) {
|
|
55
|
+
clearTimeout(this._retryTimer)
|
|
56
|
+
this._retryTimer = null
|
|
57
|
+
}
|
|
52
58
|
window.removeEventListener('resize', this.handleResize)
|
|
53
59
|
this.element.removeEventListener('creative-tree:updated', this.handleTreeUpdated)
|
|
54
60
|
document.removeEventListener('creative-editing:start', this._handleEditStart)
|
|
@@ -138,8 +144,12 @@ export default class extends Controller {
|
|
|
138
144
|
this.abortController.abort()
|
|
139
145
|
}
|
|
140
146
|
this.abortController = new AbortController()
|
|
147
|
+
this._retryCount = 0
|
|
141
148
|
this.showLoadingIndicator()
|
|
149
|
+
this._fetchTree()
|
|
150
|
+
}
|
|
142
151
|
|
|
152
|
+
_fetchTree() {
|
|
143
153
|
fetch(this.urlValue, {
|
|
144
154
|
headers: { Accept: 'application/json' },
|
|
145
155
|
signal: this.abortController.signal,
|
|
@@ -154,12 +164,25 @@ export default class extends Controller {
|
|
|
154
164
|
})
|
|
155
165
|
.catch((error) => {
|
|
156
166
|
if (error.name === 'AbortError') return
|
|
167
|
+
// Transient network failures (ERR_NETWORK_CHANGED, offline blips, VPN
|
|
168
|
+
// toggles) surface as TypeError "Failed to fetch". Retry briefly so a
|
|
169
|
+
// momentary network event doesn't leave the user with an empty tree.
|
|
170
|
+
if (this._isTransientNetworkError(error) && this._retryCount < TREE_RETRY_DELAYS_MS.length) {
|
|
171
|
+
const delay = TREE_RETRY_DELAYS_MS[this._retryCount]
|
|
172
|
+
this._retryCount += 1
|
|
173
|
+
this._retryTimer = setTimeout(() => this._fetchTree(), delay)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
157
176
|
console.error(error)
|
|
158
177
|
this.hideLoadingIndicator()
|
|
159
178
|
this.showEmptyState()
|
|
160
179
|
})
|
|
161
180
|
}
|
|
162
181
|
|
|
182
|
+
_isTransientNetworkError(error) {
|
|
183
|
+
return error instanceof TypeError && /fetch|network/i.test(error.message || '')
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
renderData(data) {
|
|
164
187
|
const nodes = Array.isArray(data?.creatives) ? data.creatives : []
|
|
165
188
|
|
|
@@ -33,6 +33,7 @@ import CommentBadgeController from "./comment_badge_controller"
|
|
|
33
33
|
import ShareModalController from "./share_modal_controller"
|
|
34
34
|
import ImageLightboxController from "./image_lightbox_controller"
|
|
35
35
|
import SearchPopupController from "./search_popup_controller"
|
|
36
|
+
import LandingVideoController from "./landing_video_controller"
|
|
36
37
|
|
|
37
38
|
// Export all controllers
|
|
38
39
|
export {
|
|
@@ -67,7 +68,8 @@ export {
|
|
|
67
68
|
ShareModalController,
|
|
68
69
|
ImageLightboxController,
|
|
69
70
|
SearchPopupController,
|
|
70
|
-
CommentBadgeController
|
|
71
|
+
CommentBadgeController,
|
|
72
|
+
LandingVideoController
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
// Registration function for use with a Stimulus application
|
|
@@ -105,4 +107,5 @@ export function registerControllers(application) {
|
|
|
105
107
|
application.register("image-lightbox", ImageLightboxController)
|
|
106
108
|
application.register("search-popup", SearchPopupController)
|
|
107
109
|
application.register("comment-badge", CommentBadgeController)
|
|
110
|
+
application.register("landing-video", LandingVideoController)
|
|
108
111
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["video", "progressBar", "progressFill", "toggle", "icon"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this._raf = null
|
|
8
|
+
this._updateProgress = this._updateProgress.bind(this)
|
|
9
|
+
this.videoTarget.addEventListener("play", () => this._startProgress())
|
|
10
|
+
this.videoTarget.addEventListener("pause", () => this._stopProgress())
|
|
11
|
+
this.videoTarget.addEventListener("ended", () => this._stopProgress())
|
|
12
|
+
if (!this.videoTarget.paused) this._startProgress()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
disconnect() {
|
|
16
|
+
this._stopProgress()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
togglePlay() {
|
|
20
|
+
if (this.videoTarget.paused) {
|
|
21
|
+
this.videoTarget.play()
|
|
22
|
+
this.iconTarget.textContent = "❚❚"
|
|
23
|
+
} else {
|
|
24
|
+
this.videoTarget.pause()
|
|
25
|
+
this.iconTarget.textContent = "▶"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_startProgress() {
|
|
30
|
+
this._stopProgress()
|
|
31
|
+
this._tick()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_stopProgress() {
|
|
35
|
+
if (this._raf) {
|
|
36
|
+
cancelAnimationFrame(this._raf)
|
|
37
|
+
this._raf = null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_tick() {
|
|
42
|
+
this._updateProgress()
|
|
43
|
+
this._raf = requestAnimationFrame(() => this._tick())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_updateProgress() {
|
|
47
|
+
const v = this.videoTarget
|
|
48
|
+
if (v.duration) {
|
|
49
|
+
const pct = (v.currentTime / v.duration) * 100
|
|
50
|
+
this.progressFillTarget.style.width = `${pct}%`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -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();
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import CommandArgsForm from '../command_args_form'
|
|
5
|
+
|
|
6
|
+
const baseCommand = (overrides = {}) => ({
|
|
7
|
+
name: 'foo_tool',
|
|
8
|
+
label: '/foo_tool',
|
|
9
|
+
input_schema: [
|
|
10
|
+
{ name: 'creative_id', type: 'string', required: false },
|
|
11
|
+
{ name: 'topic_id', type: 'string', required: false },
|
|
12
|
+
{ name: 'note', type: 'string', required: false }
|
|
13
|
+
],
|
|
14
|
+
...overrides
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const fieldFor = (paramName) =>
|
|
18
|
+
document.querySelector(`[data-param-name="${paramName}"]`)
|
|
19
|
+
|
|
20
|
+
describe('CommandArgsForm context auto-fill', () => {
|
|
21
|
+
let container
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
container = document.createElement('div')
|
|
25
|
+
document.body.appendChild(container)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = ''
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('fills creative_id and topic_id from contextValuesFn', () => {
|
|
33
|
+
const form = new CommandArgsForm({
|
|
34
|
+
container,
|
|
35
|
+
contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
form.show(baseCommand())
|
|
39
|
+
|
|
40
|
+
expect(fieldFor('creative_id').value).toBe('12508')
|
|
41
|
+
expect(fieldFor('topic_id').value).toBe('8455')
|
|
42
|
+
expect(fieldFor('note').value).toBe('')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('explicit default_value beats context auto-fill', () => {
|
|
46
|
+
const form = new CommandArgsForm({
|
|
47
|
+
container,
|
|
48
|
+
contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
form.show(baseCommand({
|
|
52
|
+
input_schema: [
|
|
53
|
+
{ name: 'creative_id', type: 'string', required: false, default_value: '999' },
|
|
54
|
+
{ name: 'topic_id', type: 'string', required: false }
|
|
55
|
+
]
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
expect(fieldFor('creative_id').value).toBe('999')
|
|
59
|
+
expect(fieldFor('topic_id').value).toBe('8455')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('does not fill unrelated params even if names overlap', () => {
|
|
63
|
+
const form = new CommandArgsForm({
|
|
64
|
+
container,
|
|
65
|
+
contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455', note: 'ctx' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
form.show(baseCommand())
|
|
69
|
+
|
|
70
|
+
expect(fieldFor('note').value).toBe('')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('missing context values leave fields empty', () => {
|
|
74
|
+
const form = new CommandArgsForm({
|
|
75
|
+
container,
|
|
76
|
+
contextValuesFn: () => ({ creative_id: null, topic_id: '' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
form.show(baseCommand())
|
|
80
|
+
|
|
81
|
+
expect(fieldFor('creative_id').value).toBe('')
|
|
82
|
+
expect(fieldFor('topic_id').value).toBe('')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('contextValuesFn throwing does not break form', () => {
|
|
86
|
+
const form = new CommandArgsForm({
|
|
87
|
+
container,
|
|
88
|
+
contextValuesFn: () => { throw new Error('boom') }
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(() => form.show(baseCommand())).not.toThrow()
|
|
92
|
+
expect(fieldFor('creative_id').value).toBe('')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('no contextValuesFn provided behaves like before', () => {
|
|
96
|
+
const form = new CommandArgsForm({ container })
|
|
97
|
+
|
|
98
|
+
form.show(baseCommand())
|
|
99
|
+
|
|
100
|
+
expect(fieldFor('creative_id').value).toBe('')
|
|
101
|
+
expect(fieldFor('topic_id').value).toBe('')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { isHtmlEmpty } from '../html_content_empty';
|
|
5
|
+
|
|
6
|
+
describe('isHtmlEmpty', () => {
|
|
7
|
+
test('returns true for null/empty string', () => {
|
|
8
|
+
expect(isHtmlEmpty('')).toBe(true);
|
|
9
|
+
expect(isHtmlEmpty(null)).toBe(true);
|
|
10
|
+
expect(isHtmlEmpty(undefined)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('returns true for whitespace-only HTML', () => {
|
|
14
|
+
expect(isHtmlEmpty('<p> </p>')).toBe(true);
|
|
15
|
+
expect(isHtmlEmpty('<div><br></div>')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('returns false when there is text content', () => {
|
|
19
|
+
expect(isHtmlEmpty('<p>hello</p>')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns false for image-only HTML', () => {
|
|
23
|
+
expect(isHtmlEmpty('<p><img src="/blob/abc.png"></p>')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns false for action-text-attachment-only HTML', () => {
|
|
27
|
+
expect(isHtmlEmpty(
|
|
28
|
+
'<action-text-attachment sgid="abc" url="/blob" filename="file.png"></action-text-attachment>'
|
|
29
|
+
)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns false for figure.attachment-only HTML (trix-style)', () => {
|
|
33
|
+
expect(isHtmlEmpty(
|
|
34
|
+
'<figure class="attachment attachment--file" data-trix-attachment=\'{"sgid":"abc"}\'></figure>'
|
|
35
|
+
)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('returns false for [data-trix-attachment]-only HTML', () => {
|
|
39
|
+
expect(isHtmlEmpty('<div data-trix-attachment=\'{"sgid":"x"}\'></div>')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { reconcileMarkdownSource } from '../markdown_source_reconcile';
|
|
5
|
+
|
|
6
|
+
const DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
7
|
+
const BLOB_PATH = '/rails/active_storage/blobs/redirect/abc123def/image.png';
|
|
8
|
+
const DATA_URI_2 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
9
|
+
const BLOB_PATH_2 = '/rails/active_storage/blobs/redirect/xyz789/two.gif';
|
|
10
|
+
|
|
11
|
+
describe('reconcileMarkdownSource', () => {
|
|
12
|
+
test('returns rewritten value when current still equals saved', () => {
|
|
13
|
+
const saved = `Hello  world`;
|
|
14
|
+
const rewritten = `Hello  world`;
|
|
15
|
+
expect(reconcileMarkdownSource(saved, rewritten, saved)).toBe(rewritten);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('returns null when no rewrites happened', () => {
|
|
19
|
+
expect(reconcileMarkdownSource('a', 'a', 'a')).toBe(null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns null when saved has no data URI and current diverged from saved', () => {
|
|
23
|
+
expect(reconcileMarkdownSource('saved', 'rewritten', 'user typed something else')).toBe(null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('merges substitutions when user typed AFTER the data URI mid-save', () => {
|
|
27
|
+
const saved = ``;
|
|
28
|
+
const rewritten = ``;
|
|
29
|
+
const current = `\n\nNew paragraph user typed during save.`;
|
|
30
|
+
const expected = `\n\nNew paragraph user typed during save.`;
|
|
31
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('merges substitutions when user typed BEFORE the data URI mid-save', () => {
|
|
35
|
+
const saved = `start  end`;
|
|
36
|
+
const rewritten = `start  end`;
|
|
37
|
+
const current = `inserted at top\n\nstart  end`;
|
|
38
|
+
const expected = `inserted at top\n\nstart  end`;
|
|
39
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('handles multiple data URIs in a single save', () => {
|
|
43
|
+
const saved = `A  B  C`;
|
|
44
|
+
const rewritten = `A  B  C`;
|
|
45
|
+
const current = `A  B  C\n\nextra text`;
|
|
46
|
+
const expected = `A  B  C\n\nextra text`;
|
|
47
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('handles reference-style data URI definitions', () => {
|
|
51
|
+
const saved = `![pic][p]\n\n[p]: ${DATA_URI}`;
|
|
52
|
+
const rewritten = `![pic][p]\n\n[p]: ${BLOB_PATH}`;
|
|
53
|
+
const current = `![pic][p]\n\nuser typed more\n\n[p]: ${DATA_URI}`;
|
|
54
|
+
const expected = `![pic][p]\n\nuser typed more\n\n[p]: ${BLOB_PATH}`;
|
|
55
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns null when user removed the data URI before response', () => {
|
|
59
|
+
const saved = ``;
|
|
60
|
+
const rewritten = ``;
|
|
61
|
+
const current = 'user wiped everything';
|
|
62
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('returns null on non-string inputs', () => {
|
|
66
|
+
expect(reconcileMarkdownSource(null, 'a', 'a')).toBe(null);
|
|
67
|
+
expect(reconcileMarkdownSource('a', null, 'a')).toBe(null);
|
|
68
|
+
expect(reconcileMarkdownSource('a', 'a', null)).toBe(null);
|
|
69
|
+
});
|
|
70
|
+
});
|