collavre 0.22.0 → 0.23.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/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -14,7 +14,6 @@ describe('CommentsPresenceController', () => {
|
|
|
14
14
|
beforeEach(async () => {
|
|
15
15
|
document.body.dataset.currentUserId = '7'
|
|
16
16
|
global.fetch = jest.fn()
|
|
17
|
-
global.alert = jest.fn()
|
|
18
17
|
|
|
19
18
|
container = document.createElement('div')
|
|
20
19
|
container.innerHTML = `
|
|
@@ -91,7 +90,9 @@ describe('CommentsPresenceController', () => {
|
|
|
91
90
|
|
|
92
91
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
93
92
|
|
|
94
|
-
|
|
93
|
+
// alertDialog renders an in-app modal (replacing native alert for the
|
|
94
|
+
// Tauri webview); assert the message surfaced there instead.
|
|
95
|
+
expect(document.querySelector('.confirm-dialog-message')?.textContent).toBe('No permission')
|
|
95
96
|
expect(close).toHaveBeenCalled()
|
|
96
97
|
})
|
|
97
98
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// deleteTopic awaits confirmDialog. The native click dispatch completes during
|
|
8
|
+
// that await, which resets event.currentTarget to null (DOM spec). Mock the
|
|
9
|
+
// dialog so the test controls when it resolves and can null currentTarget in
|
|
10
|
+
// between — reproducing the regression where the topic id was read after await.
|
|
11
|
+
jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
|
|
12
|
+
confirmDialog: jest.fn(),
|
|
13
|
+
alertDialog: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
17
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
18
|
+
const { confirmDialog } = await import('../../../lib/utils/dialog')
|
|
19
|
+
|
|
20
|
+
describe('TopicsController#deleteTopic', () => {
|
|
21
|
+
let application
|
|
22
|
+
let container
|
|
23
|
+
let controller
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
container = document.createElement('div')
|
|
27
|
+
container.innerHTML = `
|
|
28
|
+
<div id="topics" data-controller="comments--topics">
|
|
29
|
+
<div data-comments--topics-target="list"></div>
|
|
30
|
+
</div>
|
|
31
|
+
`
|
|
32
|
+
document.body.appendChild(container)
|
|
33
|
+
|
|
34
|
+
application = Application.start()
|
|
35
|
+
application.register('comments--topics', TopicsController)
|
|
36
|
+
|
|
37
|
+
const meta = document.createElement('meta')
|
|
38
|
+
meta.name = 'csrf-token'
|
|
39
|
+
meta.content = 'test-csrf'
|
|
40
|
+
document.head.appendChild(meta)
|
|
41
|
+
|
|
42
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
43
|
+
const element = document.getElementById('topics')
|
|
44
|
+
controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
|
|
45
|
+
controller.creativeIdValue = '42'
|
|
46
|
+
controller.loadTopics = jest.fn()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
document.body.innerHTML = ''
|
|
52
|
+
document.head.innerHTML = ''
|
|
53
|
+
application.stop()
|
|
54
|
+
jest.clearAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('issues DELETE with the topic id even when currentTarget is nulled after the await', async () => {
|
|
58
|
+
const fetchMock = jest.fn().mockResolvedValue({ ok: true })
|
|
59
|
+
global.fetch = fetchMock
|
|
60
|
+
|
|
61
|
+
// Simulate the event whose currentTarget is reset to null once the click
|
|
62
|
+
// dispatch finishes (which happens while confirmDialog is awaited).
|
|
63
|
+
const button = document.createElement('button')
|
|
64
|
+
button.dataset.id = '99'
|
|
65
|
+
const event = { stopPropagation: jest.fn(), currentTarget: button }
|
|
66
|
+
|
|
67
|
+
confirmDialog.mockImplementation(() => {
|
|
68
|
+
event.currentTarget = null // dispatch completed -> currentTarget reset
|
|
69
|
+
return Promise.resolve(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await controller.deleteTopic(event)
|
|
73
|
+
|
|
74
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
75
|
+
'/creatives/42/topics/99',
|
|
76
|
+
expect.objectContaining({ method: 'DELETE' })
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('does not issue DELETE when the user cancels the confirm dialog', async () => {
|
|
81
|
+
const fetchMock = jest.fn()
|
|
82
|
+
global.fetch = fetchMock
|
|
83
|
+
|
|
84
|
+
const button = document.createElement('button')
|
|
85
|
+
button.dataset.id = '99'
|
|
86
|
+
const event = { stopPropagation: jest.fn(), currentTarget: button }
|
|
87
|
+
|
|
88
|
+
confirmDialog.mockResolvedValue(false)
|
|
89
|
+
|
|
90
|
+
await controller.deleteTopic(event)
|
|
91
|
+
|
|
92
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -3,6 +3,16 @@ import { renderMarkdownInContainer } from '../../lib/utils/markdown'
|
|
|
3
3
|
import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
|
|
4
4
|
import { refreshCsrfToken } from '../../lib/api/csrf_fetch'
|
|
5
5
|
import ReviewQuotesStore from './review_quotes_store'
|
|
6
|
+
import { alertDialog } from '../../lib/utils/dialog'
|
|
7
|
+
|
|
8
|
+
// In-flight comment sends, keyed by creative id. This lives at module scope —
|
|
9
|
+
// not on the controller instance — so the duplicate-submit guard survives a
|
|
10
|
+
// Stimulus reconnect (Turbo morph / re-render) mid-send. The instance-only
|
|
11
|
+
// `this.sending` flag is reset by connect() and cannot be relied on alone:
|
|
12
|
+
// a reconnect while a slow request is in flight would re-enable sending and let
|
|
13
|
+
// an impatient second Enter submit the same comment twice.
|
|
14
|
+
const inFlightSends = new Set()
|
|
15
|
+
const sendKeyFor = (creativeId) => `creative:${creativeId}`
|
|
6
16
|
|
|
7
17
|
export default class extends Controller {
|
|
8
18
|
static targets = [
|
|
@@ -255,7 +265,9 @@ export default class extends Controller {
|
|
|
255
265
|
const hasText = this.textareaTarget.value.trim().length > 0
|
|
256
266
|
const hasQuotes = !store.isEmpty
|
|
257
267
|
const hasImages = this.currentImageFiles().length > 0
|
|
258
|
-
|
|
268
|
+
const sendKey = sendKeyFor(this.creativeId)
|
|
269
|
+
if (this.sending || inFlightSends.has(sendKey) || (!hasText && !hasQuotes && !hasImages) || !this.creativeId) return
|
|
270
|
+
inFlightSends.add(sendKey)
|
|
259
271
|
this.sending = true
|
|
260
272
|
this.setSendingState(true)
|
|
261
273
|
this.presenceController?.stoppedTyping()
|
|
@@ -358,9 +370,10 @@ export default class extends Controller {
|
|
|
358
370
|
this._renderReviewQuoteChips()
|
|
359
371
|
this._updateSubmitButton()
|
|
360
372
|
}
|
|
361
|
-
|
|
373
|
+
alertDialog(error?.message || 'Failed to submit comment')
|
|
362
374
|
})
|
|
363
375
|
.finally(() => {
|
|
376
|
+
inFlightSends.delete(sendKey)
|
|
364
377
|
this._hasRetried = false
|
|
365
378
|
this.setSendingState(false)
|
|
366
379
|
})
|
|
@@ -403,7 +416,7 @@ export default class extends Controller {
|
|
|
403
416
|
|
|
404
417
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
405
418
|
if (!SpeechRecognition) {
|
|
406
|
-
|
|
419
|
+
alertDialog(this.element.dataset.speechUnavailableText || 'Speech recognition not supported')
|
|
407
420
|
return false
|
|
408
421
|
}
|
|
409
422
|
|
|
@@ -743,7 +756,9 @@ export default class extends Controller {
|
|
|
743
756
|
|
|
744
757
|
// Send a single question quote immediately as a standalone comment.
|
|
745
758
|
_sendQuestionQuote(quote) {
|
|
746
|
-
|
|
759
|
+
const sendKey = sendKeyFor(this.creativeId)
|
|
760
|
+
if (this.sending || inFlightSends.has(sendKey) || !this.creativeId) return
|
|
761
|
+
inFlightSends.add(sendKey)
|
|
747
762
|
|
|
748
763
|
const store = this._reviewStore
|
|
749
764
|
const content = store.buildQuestionContent(quote)
|
|
@@ -809,9 +824,10 @@ export default class extends Controller {
|
|
|
809
824
|
}
|
|
810
825
|
})
|
|
811
826
|
.catch((error) => {
|
|
812
|
-
|
|
827
|
+
alertDialog(error?.message || 'Failed to send question')
|
|
813
828
|
})
|
|
814
829
|
.finally(() => {
|
|
830
|
+
inFlightSends.delete(sendKey)
|
|
815
831
|
this._hasRetried = false
|
|
816
832
|
this.sending = false
|
|
817
833
|
})
|
|
@@ -5,6 +5,7 @@ import { renderMarkdownInContainer } from '../../lib/utils/markdown'
|
|
|
5
5
|
import creativesApi from '../../lib/api/creatives'
|
|
6
6
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
|
|
7
7
|
import { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
|
|
8
|
+
import { alertDialog, confirmDialog } from '../../lib/utils/dialog'
|
|
8
9
|
// CommonPopup is now used via TopicSearchController (Stimulus)
|
|
9
10
|
|
|
10
11
|
export default class extends Controller {
|
|
@@ -595,7 +596,7 @@ export default class extends Controller {
|
|
|
595
596
|
async deleteSelectedComments() {
|
|
596
597
|
if (this.selection.size === 0) return
|
|
597
598
|
const confirmText = this.element.dataset.batchDeleteConfirmText || 'Are you sure you want to delete the selected messages?'
|
|
598
|
-
if (!
|
|
599
|
+
if (!(await confirmDialog(confirmText, { danger: true }))) return
|
|
599
600
|
|
|
600
601
|
const commentIds = Array.from(this.selection)
|
|
601
602
|
try {
|
|
@@ -615,18 +616,18 @@ export default class extends Controller {
|
|
|
615
616
|
this.clearSelection()
|
|
616
617
|
} else {
|
|
617
618
|
const data = await response.json().catch(() => ({}))
|
|
618
|
-
|
|
619
|
+
alertDialog(data.error || 'Failed to delete comments')
|
|
619
620
|
}
|
|
620
621
|
} catch (error) {
|
|
621
622
|
console.error('Error deleting comments:', error)
|
|
622
|
-
|
|
623
|
+
alertDialog('Failed to delete comments')
|
|
623
624
|
}
|
|
624
625
|
}
|
|
625
626
|
|
|
626
627
|
async mergeSelectedComments() {
|
|
627
628
|
if (this.selection.size < 2) return
|
|
628
629
|
const confirmText = this.element.dataset.mergeConfirmText || 'Merge the selected messages into one?'
|
|
629
|
-
if (!
|
|
630
|
+
if (!(await confirmDialog(confirmText, { danger: true }))) return
|
|
630
631
|
|
|
631
632
|
const commentIds = Array.from(this.selection)
|
|
632
633
|
try {
|
|
@@ -643,11 +644,11 @@ export default class extends Controller {
|
|
|
643
644
|
// Job is async — the merged comment will update via broadcast
|
|
644
645
|
} else {
|
|
645
646
|
const data = await response.json().catch(() => ({}))
|
|
646
|
-
|
|
647
|
+
alertDialog(data.error || 'Failed to merge comments')
|
|
647
648
|
}
|
|
648
649
|
} catch (error) {
|
|
649
650
|
console.error('Error merging comments:', error)
|
|
650
|
-
|
|
651
|
+
alertDialog('Failed to merge comments')
|
|
651
652
|
}
|
|
652
653
|
}
|
|
653
654
|
|
|
@@ -676,11 +677,11 @@ export default class extends Controller {
|
|
|
676
677
|
}
|
|
677
678
|
} else {
|
|
678
679
|
const data = await response.json().catch(() => ({}))
|
|
679
|
-
|
|
680
|
+
alertDialog(data.error || 'Failed to branch comments')
|
|
680
681
|
}
|
|
681
682
|
} catch (error) {
|
|
682
683
|
console.error('Error branching comments:', error)
|
|
683
|
-
|
|
684
|
+
alertDialog('Failed to branch comments')
|
|
684
685
|
}
|
|
685
686
|
}
|
|
686
687
|
|
|
@@ -808,11 +809,11 @@ export default class extends Controller {
|
|
|
808
809
|
this.loadInitialComments()
|
|
809
810
|
} else {
|
|
810
811
|
const data = await response.json()
|
|
811
|
-
|
|
812
|
+
alertDialog(data.error || 'Failed to move comments')
|
|
812
813
|
}
|
|
813
814
|
} catch (error) {
|
|
814
815
|
console.error('Error moving comments to topic:', error)
|
|
815
|
-
|
|
816
|
+
alertDialog('Failed to move comments')
|
|
816
817
|
}
|
|
817
818
|
}
|
|
818
819
|
|
|
@@ -902,8 +903,8 @@ export default class extends Controller {
|
|
|
902
903
|
|
|
903
904
|
// API Methods
|
|
904
905
|
|
|
905
|
-
deleteComment(button) {
|
|
906
|
-
if (!
|
|
906
|
+
async deleteComment(button) {
|
|
907
|
+
if (!(await confirmDialog(this.element.dataset.deleteConfirmText, { danger: true }))) return
|
|
907
908
|
const commentId = button.getAttribute('data-comment-id')
|
|
908
909
|
fetch(`/creatives/${this.creativeId}/comments/${commentId}`, {
|
|
909
910
|
method: 'DELETE',
|
|
@@ -919,9 +920,9 @@ export default class extends Controller {
|
|
|
919
920
|
})
|
|
920
921
|
}
|
|
921
922
|
|
|
922
|
-
convertComment(button) {
|
|
923
|
+
async convertComment(button) {
|
|
923
924
|
// ... (Existing logic) ...
|
|
924
|
-
if (!
|
|
925
|
+
if (!(await confirmDialog(this.element.dataset.convertConfirmText))) return
|
|
925
926
|
const commentId = button.getAttribute('data-comment-id')
|
|
926
927
|
fetch(`/creatives/${this.creativeId}/comments/${commentId}/convert`, {
|
|
927
928
|
method: 'POST',
|
|
@@ -983,7 +984,7 @@ export default class extends Controller {
|
|
|
983
984
|
const existing = document.getElementById(`comment_${commentId}`)
|
|
984
985
|
if (existing) existing.outerHTML = html
|
|
985
986
|
})
|
|
986
|
-
.catch(e => {
|
|
987
|
+
.catch(e => { alertDialog(e.message); button.disabled = false; })
|
|
987
988
|
}
|
|
988
989
|
|
|
989
990
|
editComment(button) {
|
|
@@ -1013,7 +1014,7 @@ export default class extends Controller {
|
|
|
1013
1014
|
})
|
|
1014
1015
|
.catch((error) => {
|
|
1015
1016
|
console.error(error)
|
|
1016
|
-
|
|
1017
|
+
alertDialog(this.element.dataset.updateErrorText || 'Failed to update action')
|
|
1017
1018
|
})
|
|
1018
1019
|
.finally(() => { if (submitButton) submitButton.disabled = false })
|
|
1019
1020
|
}
|
|
@@ -1022,7 +1023,7 @@ export default class extends Controller {
|
|
|
1022
1023
|
openMoveModal(event) {
|
|
1023
1024
|
if (this.movingComments) return
|
|
1024
1025
|
if (this.selection.size === 0) {
|
|
1025
|
-
|
|
1026
|
+
alertDialog(this.element.dataset.moveNoSelectionText || "No Selection")
|
|
1026
1027
|
return
|
|
1027
1028
|
}
|
|
1028
1029
|
this.movingComments = true
|
|
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
|
|
2
2
|
import { createSubscription } from '../../services/cable'
|
|
3
3
|
import TouchDragHandler from '../../lib/touch_drag'
|
|
4
4
|
import csrfFetch from '../../lib/api/csrf_fetch'
|
|
5
|
+
import { alertDialog } from '../../lib/utils/dialog'
|
|
5
6
|
|
|
6
7
|
const TYPING_TIMEOUT = 3000
|
|
7
8
|
const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
|
|
@@ -156,7 +157,7 @@ export default class extends Controller {
|
|
|
156
157
|
this.renderTypingIndicator()
|
|
157
158
|
|
|
158
159
|
if (closeOnForbidden) {
|
|
159
|
-
|
|
160
|
+
alertDialog(error.message)
|
|
160
161
|
this.popupController?.close()
|
|
161
162
|
}
|
|
162
163
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { createSubscription } from "../../services/cable"
|
|
3
3
|
import { fetchNextTopicName, createTopicWithComments, saveLastTopic } from "../../lib/api/topics"
|
|
4
|
+
import { alertDialog, confirmDialog } from "../../lib/utils/dialog"
|
|
4
5
|
|
|
5
6
|
const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
|
|
6
7
|
const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
|
|
@@ -377,10 +378,15 @@ export default class extends Controller {
|
|
|
377
378
|
|
|
378
379
|
async deleteTopic(event) {
|
|
379
380
|
event.stopPropagation()
|
|
381
|
+
// Capture the topic id BEFORE awaiting the dialog: once the click
|
|
382
|
+
// dispatch completes, event.currentTarget is reset to null, so reading
|
|
383
|
+
// it after `await` throws. confirmDialog made this handler async, which
|
|
384
|
+
// exposed the latent stale-currentTarget hazard (the old sync confirm()
|
|
385
|
+
// never yielded the event loop).
|
|
386
|
+
const topicId = event.currentTarget.dataset.id
|
|
380
387
|
const confirmText = this.listTarget.dataset.confirmDeleteText || "This will delete all messages in this topic. Are you sure?"
|
|
381
|
-
if (!
|
|
388
|
+
if (!(await confirmDialog(confirmText, { danger: true }))) return
|
|
382
389
|
|
|
383
|
-
const topicId = event.currentTarget.dataset.id
|
|
384
390
|
if (!topicId) return
|
|
385
391
|
|
|
386
392
|
try {
|
|
@@ -398,7 +404,7 @@ export default class extends Controller {
|
|
|
398
404
|
}
|
|
399
405
|
this.loadTopics()
|
|
400
406
|
} else {
|
|
401
|
-
|
|
407
|
+
alertDialog("Failed to delete topic")
|
|
402
408
|
}
|
|
403
409
|
} catch (e) {
|
|
404
410
|
console.error("Error deleting topic", e)
|
|
@@ -425,7 +431,7 @@ export default class extends Controller {
|
|
|
425
431
|
}
|
|
426
432
|
this.loadTopics()
|
|
427
433
|
} else {
|
|
428
|
-
|
|
434
|
+
alertDialog("Failed to archive topic")
|
|
429
435
|
}
|
|
430
436
|
} catch (e) {
|
|
431
437
|
console.error("Error archiving topic", e)
|
|
@@ -448,7 +454,7 @@ export default class extends Controller {
|
|
|
448
454
|
if (response.ok) {
|
|
449
455
|
this.loadTopics()
|
|
450
456
|
} else {
|
|
451
|
-
|
|
457
|
+
alertDialog("Failed to restore topic")
|
|
452
458
|
}
|
|
453
459
|
} catch (e) {
|
|
454
460
|
console.error("Error restoring topic", e)
|
|
@@ -608,7 +614,7 @@ export default class extends Controller {
|
|
|
608
614
|
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
609
615
|
this.restoreSelection()
|
|
610
616
|
} else {
|
|
611
|
-
|
|
617
|
+
alertDialog("Failed to update topic")
|
|
612
618
|
this.loadTopics() // Reload to restore state
|
|
613
619
|
}
|
|
614
620
|
} catch (e) {
|
|
@@ -699,7 +705,7 @@ export default class extends Controller {
|
|
|
699
705
|
// Dispatch change event manually since we skipped the click handler
|
|
700
706
|
this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
|
|
701
707
|
} else {
|
|
702
|
-
|
|
708
|
+
alertDialog("Failed to create topic")
|
|
703
709
|
}
|
|
704
710
|
} catch (e) {
|
|
705
711
|
console.error("Error creating topic", e)
|
|
@@ -943,7 +949,7 @@ export default class extends Controller {
|
|
|
943
949
|
)
|
|
944
950
|
if (listController) listController.clearSelection()
|
|
945
951
|
} else {
|
|
946
|
-
|
|
952
|
+
alertDialog(result.error)
|
|
947
953
|
}
|
|
948
954
|
}
|
|
949
955
|
|
|
@@ -6,6 +6,7 @@ import { jest } from '@jest/globals'
|
|
|
6
6
|
|
|
7
7
|
jest.unstable_mockModule('../../../creatives/tree_renderer', () => ({
|
|
8
8
|
renderCreativeTree: jest.fn(),
|
|
9
|
+
appendCreativeNodes: jest.fn(),
|
|
9
10
|
dispatchCreativeTreeUpdated: jest.fn(),
|
|
10
11
|
applyRowProperties: jest.fn(),
|
|
11
12
|
}))
|
|
@@ -16,6 +17,7 @@ jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
|
|
|
16
17
|
|
|
17
18
|
const { Application } = await import('@hotwired/stimulus')
|
|
18
19
|
const TreeController = (await import('../tree_controller')).default
|
|
20
|
+
const { appendCreativeNodes } = await import('../../../creatives/tree_renderer')
|
|
19
21
|
|
|
20
22
|
const TRANSIENT_RETRY_DELAYS = [200, 600]
|
|
21
23
|
|
|
@@ -118,3 +120,151 @@ describe('CreativesTreeController retry on transient network errors', () => {
|
|
|
118
120
|
application.stop()
|
|
119
121
|
})
|
|
120
122
|
})
|
|
123
|
+
|
|
124
|
+
describe('CreativesTreeController Chats pagination (load more)', () => {
|
|
125
|
+
let originalFetch
|
|
126
|
+
let originalIO
|
|
127
|
+
|
|
128
|
+
class MockIntersectionObserver {
|
|
129
|
+
constructor(callback) {
|
|
130
|
+
this.callback = callback
|
|
131
|
+
MockIntersectionObserver.instances.push(this)
|
|
132
|
+
}
|
|
133
|
+
observe(el) { this.observed = el }
|
|
134
|
+
disconnect() { this.disconnected = true }
|
|
135
|
+
triggerIntersect() { this.callback([{ isIntersecting: true }]) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
originalFetch = global.fetch
|
|
140
|
+
originalIO = global.IntersectionObserver
|
|
141
|
+
MockIntersectionObserver.instances = []
|
|
142
|
+
global.IntersectionObserver = MockIntersectionObserver
|
|
143
|
+
appendCreativeNodes.mockClear()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
global.fetch = originalFetch
|
|
148
|
+
global.IntersectionObserver = originalIO
|
|
149
|
+
document.body.innerHTML = ''
|
|
150
|
+
jest.restoreAllMocks()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('observes a sentinel only when the response carries has_more pagination', async () => {
|
|
154
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
155
|
+
ok: true,
|
|
156
|
+
json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const { container, application } = installController()
|
|
160
|
+
await flush()
|
|
161
|
+
await flush()
|
|
162
|
+
|
|
163
|
+
expect(MockIntersectionObserver.instances).toHaveLength(1)
|
|
164
|
+
expect(container.querySelector('.creative-chats-load-sentinel')).not.toBeNull()
|
|
165
|
+
|
|
166
|
+
application.stop()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('does NOT set up pagination for a plain tree response (no pagination key)', async () => {
|
|
170
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
171
|
+
ok: true,
|
|
172
|
+
json: async () => ({ creatives: [{ id: 1 }] }),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const { container, application } = installController()
|
|
176
|
+
await flush()
|
|
177
|
+
await flush()
|
|
178
|
+
|
|
179
|
+
expect(MockIntersectionObserver.instances).toHaveLength(0)
|
|
180
|
+
expect(container.querySelector('.creative-chats-load-sentinel')).toBeNull()
|
|
181
|
+
|
|
182
|
+
application.stop()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('fetches the next page and appends rows when the sentinel intersects', async () => {
|
|
186
|
+
const page2Nodes = [{ id: 2 }, { id: 3 }]
|
|
187
|
+
global.fetch = jest
|
|
188
|
+
.fn()
|
|
189
|
+
.mockResolvedValueOnce({
|
|
190
|
+
ok: true,
|
|
191
|
+
json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
|
|
192
|
+
})
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
ok: true,
|
|
195
|
+
json: async () => ({ creatives: page2Nodes, pagination: { has_more: false, next_page: null } }),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const { application } = installController()
|
|
199
|
+
await flush()
|
|
200
|
+
await flush()
|
|
201
|
+
|
|
202
|
+
const observer = MockIntersectionObserver.instances[0]
|
|
203
|
+
observer.triggerIntersect()
|
|
204
|
+
await flush()
|
|
205
|
+
await flush()
|
|
206
|
+
|
|
207
|
+
const secondCallUrl = global.fetch.mock.calls[1][0]
|
|
208
|
+
expect(secondCallUrl).toContain('page=2')
|
|
209
|
+
expect(appendCreativeNodes).toHaveBeenCalledTimes(1)
|
|
210
|
+
expect(appendCreativeNodes.mock.calls[0][1]).toEqual(page2Nodes)
|
|
211
|
+
// has_more:false on page 2 tears the observer down.
|
|
212
|
+
expect(observer.disconnected).toBe(true)
|
|
213
|
+
|
|
214
|
+
application.stop()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('drops a stale load-more response that resolves after a fresh load', async () => {
|
|
218
|
+
let resolvePage2
|
|
219
|
+
const page2Nodes = [{ id: 2 }, { id: 3 }]
|
|
220
|
+
global.fetch = jest
|
|
221
|
+
.fn()
|
|
222
|
+
// initial page-1 (Chats filter, has more)
|
|
223
|
+
.mockResolvedValueOnce({
|
|
224
|
+
ok: true,
|
|
225
|
+
json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
|
|
226
|
+
})
|
|
227
|
+
// page-2 stays pending until we resolve it manually
|
|
228
|
+
.mockImplementationOnce(
|
|
229
|
+
() =>
|
|
230
|
+
new Promise((resolve) => {
|
|
231
|
+
resolvePage2 = () =>
|
|
232
|
+
resolve({
|
|
233
|
+
ok: true,
|
|
234
|
+
json: async () => ({ creatives: page2Nodes, pagination: { has_more: true, next_page: 3 } }),
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
)
|
|
238
|
+
// the fresh load() re-renders a different (non-paginated) view
|
|
239
|
+
.mockResolvedValueOnce({
|
|
240
|
+
ok: true,
|
|
241
|
+
json: async () => ({ creatives: [{ id: 99 }] }),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const { container, application } = installController()
|
|
245
|
+
await flush()
|
|
246
|
+
await flush()
|
|
247
|
+
|
|
248
|
+
// Sentinel intersects -> page-2 fetch kicks off but stays pending.
|
|
249
|
+
MockIntersectionObserver.instances[0].triggerIntersect()
|
|
250
|
+
await flush()
|
|
251
|
+
|
|
252
|
+
// A fresh load happens before page 2 resolves (filter change, sync refetch,
|
|
253
|
+
// archive toggle). load() tears down pagination and aborts the in-flight
|
|
254
|
+
// load-more.
|
|
255
|
+
const controller = application.getControllerForElementAndIdentifier(container, 'creatives--tree')
|
|
256
|
+
controller.load()
|
|
257
|
+
await flush()
|
|
258
|
+
await flush()
|
|
259
|
+
|
|
260
|
+
// The stale page-2 response finally arrives. It must NOT be appended into
|
|
261
|
+
// the freshly rendered view.
|
|
262
|
+
resolvePage2()
|
|
263
|
+
await flush()
|
|
264
|
+
await flush()
|
|
265
|
+
|
|
266
|
+
expect(appendCreativeNodes).not.toHaveBeenCalled()
|
|
267
|
+
|
|
268
|
+
application.stop()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus'
|
|
2
2
|
import csrfFetch from '../../lib/api/csrf_fetch'
|
|
3
|
+
import { alertDialog } from '../../lib/utils/dialog'
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
5
6
|
static targets = ['area', 'dropzone', 'input', 'progress', 'toggle']
|
|
@@ -66,7 +67,7 @@ export default class extends Controller {
|
|
|
66
67
|
const isPpt = lower.endsWith('.ppt') || lower.endsWith('.pptx')
|
|
67
68
|
|
|
68
69
|
if (!isMarkdown && !isPpt) {
|
|
69
|
-
|
|
70
|
+
alertDialog(this.onlyMarkdownValue)
|
|
70
71
|
return
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus'
|
|
2
2
|
import csrfFetch from '../../lib/api/csrf_fetch'
|
|
3
|
+
import { confirmDialog } from '../../lib/utils/confirm_dialog'
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
5
6
|
static targets = [
|
|
@@ -84,7 +85,7 @@ export default class extends Controller {
|
|
|
84
85
|
if (ids.length === 0) return
|
|
85
86
|
|
|
86
87
|
const confirmMessage = this.hasDeleteButtonTarget ? this.deleteButtonTarget.dataset.confirm : undefined
|
|
87
|
-
if (confirmMessage && !
|
|
88
|
+
if (confirmMessage && !(await confirmDialog(confirmMessage, { danger: true }))) {
|
|
88
89
|
return
|
|
89
90
|
}
|
|
90
91
|
|