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.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -1,5 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
- import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
2
+ import { renderCreativeTree, appendCreativeNodes, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
3
3
  import { parseEmojis } from '../../utils/emoji_parser'
4
4
 
5
5
  const TREE_RETRY_DELAYS_MS = [200, 600]
@@ -14,6 +14,12 @@ export default class extends Controller {
14
14
  this.abortController = null
15
15
  this.loadingIndicator = null
16
16
  this._editing = false
17
+ this._pagination = null
18
+ this._sentinel = null
19
+ this._sentinelObserver = null
20
+ this._loadingMore = false
21
+ this._loadMoreAbort = null
22
+ this._loadMoreIndicator = null
17
23
  this.handleResize = this.updateAlignmentOffset.bind(this)
18
24
  this.handleTreeUpdated = () => this.queueAlignmentUpdate()
19
25
  this._handleEditStart = () => { this._editing = true }
@@ -60,6 +66,7 @@ export default class extends Controller {
60
66
  document.removeEventListener('creative-editing:start', this._handleEditStart)
61
67
  document.removeEventListener('creative-editing:stop', this._handleEditStop)
62
68
  document.removeEventListener('creative-sync:refetch', this._handleSyncRefetch)
69
+ this._teardownPagination()
63
70
  if (this._debouncedLoadTimer) clearTimeout(this._debouncedLoadTimer)
64
71
  if (this._archiveToggleHandler) {
65
72
  document.getElementById('toggle-archived-btn')?.removeEventListener('click', this._archiveToggleHandler)
@@ -140,6 +147,11 @@ export default class extends Controller {
140
147
  load() {
141
148
  if (!this.hasUrlValue) return
142
149
 
150
+ // A fresh load replaces the whole list (filter change, archive toggle, sync
151
+ // refetch), so any active load-more session is stale — tear it down before
152
+ // re-rendering page 1.
153
+ this._teardownPagination()
154
+
143
155
  if (this.abortController) {
144
156
  this.abortController.abort()
145
157
  }
@@ -196,6 +208,135 @@ export default class extends Controller {
196
208
  this.markContentLoaded()
197
209
  dispatchCreativeTreeUpdated(this.element)
198
210
  this.queueAlignmentUpdate()
211
+ this._setupPagination(data?.pagination)
212
+ }
213
+
214
+ // --- Load-more (paginated "Chats" feed) -------------------------------------
215
+ // Only engaged when the JSON response carries a `pagination` object (the
216
+ // comment filter). For the tree and search views the payload has no
217
+ // pagination, so none of this runs and behavior is unchanged.
218
+
219
+ _setupPagination(pagination) {
220
+ this._teardownPagination()
221
+ this._pagination = pagination || null
222
+ if (!this._pagination || !this._pagination.has_more) return
223
+ if (typeof IntersectionObserver === 'undefined') return
224
+ this._createSentinel()
225
+ }
226
+
227
+ _createSentinel() {
228
+ const sentinel = document.createElement('div')
229
+ sentinel.className = 'creative-chats-load-sentinel'
230
+ sentinel.setAttribute('aria-hidden', 'true')
231
+ this.element.appendChild(sentinel)
232
+ this._sentinel = sentinel
233
+ this._sentinelObserver = new IntersectionObserver((entries) => {
234
+ if (entries.some((entry) => entry.isIntersecting)) this._loadMore()
235
+ }, { root: null, rootMargin: '300px' })
236
+ this._sentinelObserver.observe(sentinel)
237
+ }
238
+
239
+ _loadMore() {
240
+ if (this._loadingMore) return
241
+ const nextPage = this._pagination && this._pagination.next_page
242
+ if (!nextPage) return
243
+
244
+ this._loadingMore = true
245
+ this._showLoadMoreIndicator()
246
+
247
+ // Tie this request to an AbortController so a fresh load() / teardown (filter
248
+ // change, archive toggle, sync refetch, disconnect) cancels an in-flight
249
+ // page fetch. Without this the stale promise could append rows for a
250
+ // different urlValue into the freshly rendered list and resurrect the
251
+ // pagination state via _repositionSentinel().
252
+ if (this._loadMoreAbort) this._loadMoreAbort.abort()
253
+ this._loadMoreAbort = new AbortController()
254
+ const signal = this._loadMoreAbort.signal
255
+
256
+ const url = new URL(this.urlValue, window.location.origin)
257
+ url.searchParams.set('page', String(nextPage))
258
+
259
+ fetch(url.pathname + url.search, { headers: { Accept: 'application/json' }, signal })
260
+ .then((response) => {
261
+ if (!response.ok) throw new Error(`Failed to load more chats: ${response.status}`)
262
+ return response.json()
263
+ })
264
+ .then((data) => {
265
+ if (signal.aborted) return
266
+ this._hideLoadMoreIndicator()
267
+ const nodes = Array.isArray(data?.creatives) ? data.creatives : []
268
+ if (nodes.length > 0) {
269
+ appendCreativeNodes(this.element, nodes)
270
+ dispatchCreativeTreeUpdated(this.element)
271
+ this.queueAlignmentUpdate()
272
+ }
273
+ this._loadingMore = false
274
+ this._pagination = data?.pagination || null
275
+ if (this._pagination && this._pagination.has_more) {
276
+ this._repositionSentinel()
277
+ } else {
278
+ this._teardownPagination()
279
+ }
280
+ })
281
+ .catch((error) => {
282
+ if (error.name === 'AbortError') return
283
+ console.error(error)
284
+ this._hideLoadMoreIndicator()
285
+ this._loadingMore = false
286
+ // Leave the sentinel in place so a later scroll can retry the page.
287
+ })
288
+ }
289
+
290
+ _repositionSentinel() {
291
+ // New rows were appended after the sentinel; move it back to the tail so it
292
+ // keeps trailing the list and can trigger the following page.
293
+ if (this._sentinel && this._sentinel.parentNode === this.element) {
294
+ this.element.appendChild(this._sentinel)
295
+ } else {
296
+ this._createSentinel()
297
+ }
298
+ }
299
+
300
+ _teardownPagination() {
301
+ if (this._loadMoreAbort) {
302
+ this._loadMoreAbort.abort()
303
+ this._loadMoreAbort = null
304
+ }
305
+ if (this._sentinelObserver) {
306
+ this._sentinelObserver.disconnect()
307
+ this._sentinelObserver = null
308
+ }
309
+ if (this._sentinel && this._sentinel.parentNode) {
310
+ this._sentinel.remove()
311
+ }
312
+ this._sentinel = null
313
+ this._pagination = null
314
+ this._loadingMore = false
315
+ this._hideLoadMoreIndicator()
316
+ }
317
+
318
+ _showLoadMoreIndicator() {
319
+ if (this._loadMoreIndicator) return
320
+ const indicator = document.createElement('div')
321
+ indicator.className = 'creative-tree-loading-placeholder creative-chats-load-more'
322
+ indicator.setAttribute('role', 'status')
323
+ indicator.setAttribute('aria-live', 'polite')
324
+ indicator.innerHTML = `
325
+ <span class="creative-loading-indicator" aria-hidden="true">
326
+ <span class="creative-loading-dot">.</span>
327
+ <span class="creative-loading-dot">.</span>
328
+ <span class="creative-loading-dot">.</span>
329
+ </span>
330
+ `
331
+ this.element.appendChild(indicator)
332
+ this._loadMoreIndicator = indicator
333
+ }
334
+
335
+ _hideLoadMoreIndicator() {
336
+ if (this._loadMoreIndicator && this._loadMoreIndicator.parentNode) {
337
+ this._loadMoreIndicator.remove()
338
+ }
339
+ this._loadMoreIndicator = null
199
340
  }
200
341
 
201
342
  showEmptyState() {
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { confirmDialog } from "../lib/utils/dialog"
2
3
 
3
4
  // Connects to data-controller="image-lightbox"
4
5
  // Provides a fullscreen image carousel with navigation, download, and zoom
@@ -338,7 +339,7 @@ export default class extends Controller {
338
339
 
339
340
  async _deleteCurrentImage() {
340
341
  if (!this.hasDownloadAllUrlValue) return
341
- if (!confirm(this.i18nDeleteConfirmValue)) return
342
+ if (!(await confirmDialog(this.i18nDeleteConfirmValue, { danger: true }))) return
342
343
 
343
344
  const url = `${this.downloadAllUrlValue.replace("download_images", "remove_image")}?index=${this._currentIndex}`
344
345
  const csrfToken = document.querySelector("meta[name='csrf-token']")?.content
@@ -0,0 +1,33 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { Turbo } from '@hotwired/turbo-rails'
3
+ import { createSubscription } from '../services/cable'
4
+
5
+ // Keeps the global inbox badge live across WebSocket gaps.
6
+ //
7
+ // Steady-state, the badge is updated by Turbo Stream broadcasts on the
8
+ // `["inbox", user]` stream (see Comment::Broadcastable). Those broadcasts are
9
+ // fire-and-forget: ActionCable never replays a message missed while the socket
10
+ // was down, so a badge update sent during a Turbo navigation gap, sleep/wake,
11
+ // network blip, or server restart was lost until the next full page render
12
+ // (the "badge only appears after refresh" bug).
13
+ //
14
+ // Subscribing to InboxBadgeChannel closes that gap: ActionCable re-runs the
15
+ // channel's #subscribed on every (re)connect, and the server transmits the
16
+ // authoritative badge snapshot straight back over THIS subscription. We render
17
+ // it ourselves (rather than relying on the sibling turbo_stream_from stream,
18
+ // which may not have re-attached yet) so the catch-up can't race a reconnect.
19
+ export default class extends Controller {
20
+ connect() {
21
+ this.subscription = createSubscription(
22
+ { channel: 'Collavre::InboxBadgeChannel' },
23
+ { received: (snapshot) => Turbo.renderStreamMessage(snapshot) },
24
+ )
25
+ }
26
+
27
+ disconnect() {
28
+ if (this.subscription) {
29
+ this.subscription.unsubscribe()
30
+ this.subscription = null
31
+ }
32
+ }
33
+ }
@@ -34,6 +34,7 @@ import ShareModalController from "./share_modal_controller"
34
34
  import ImageLightboxController from "./image_lightbox_controller"
35
35
  import SearchPopupController from "./search_popup_controller"
36
36
  import LandingVideoController from "./landing_video_controller"
37
+ import InboxBadgeController from "./inbox_badge_controller"
37
38
 
38
39
  // Export all controllers
39
40
  export {
@@ -69,7 +70,8 @@ export {
69
70
  ImageLightboxController,
70
71
  SearchPopupController,
71
72
  CommentBadgeController,
72
- LandingVideoController
73
+ LandingVideoController,
74
+ InboxBadgeController
73
75
  }
74
76
 
75
77
  // Registration function for use with a Stimulus application
@@ -108,4 +110,5 @@ export function registerControllers(application) {
108
110
  application.register("search-popup", SearchPopupController)
109
111
  application.register("comment-badge", CommentBadgeController)
110
112
  application.register("landing-video", LandingVideoController)
113
+ application.register("inbox-badge", InboxBadgeController)
111
114
  }
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { confirmDialog, alertDialog } from "../lib/utils/dialog"
2
3
 
3
4
  export default class extends Controller {
4
5
  static targets = ["container"]
@@ -277,13 +278,13 @@ export default class extends Controller {
277
278
  const methodInput = form.querySelector("input[name='_method'][value='delete']")
278
279
  if (!methodInput) return
279
280
 
280
- form.addEventListener("submit", (e) => {
281
+ form.addEventListener("submit", async (e) => {
281
282
  e.preventDefault()
282
283
 
283
284
  const confirmMessage = form.dataset.turboConfirm
284
285
  || form.querySelector("button[type='submit']")?.dataset?.turboConfirm
285
286
  || form.querySelector("button")?.dataset?.confirm
286
- if (confirmMessage && !window.confirm(confirmMessage)) return
287
+ if (confirmMessage && !(await confirmDialog(confirmMessage, { danger: true }))) return
287
288
 
288
289
  const listItem = form.closest("li")
289
290
  if (listItem) {
@@ -379,7 +380,7 @@ export default class extends Controller {
379
380
  const copiedTemplate = inviteLinkBtn.dataset.copiedTemplate
380
381
 
381
382
  if (permission === "no_access") {
382
- alert(noAccessMessage)
383
+ alertDialog(noAccessMessage)
383
384
  return
384
385
  }
385
386
 
@@ -1,5 +1,6 @@
1
1
  import CommonPopupController from './common_popup_controller'
2
2
  import { fetchNextTopicName, createTopicWithComments } from '../lib/api/topics'
3
+ import { alertDialog } from '../lib/utils/dialog'
3
4
 
4
5
  export default class extends CommonPopupController {
5
6
  static targets = ['input', 'list', 'close']
@@ -148,7 +149,7 @@ export default class extends CommonPopupController {
148
149
  this.onSelectCallback({ id: result.topic.id, label: `#${result.topic.name}`, created: true })
149
150
  }
150
151
  } else {
151
- alert(result.error)
152
+ alertDialog(result.error)
152
153
  }
153
154
  }
154
155
 
@@ -26,6 +26,8 @@ import {
26
26
  import { createMoveContext, applyMove, revertMove } from './operations';
27
27
  import { sendNewOrder, sendLinkedCreative, sendTopicMove } from '../../lib/api/drag_drop';
28
28
  import { initIndicator, showLinkHover, hideLinkHover } from './indicator';
29
+ import { showMissingMembersPopup } from '../topic_move_members_popup';
30
+ import { alertDialog } from '../../lib/utils/dialog';
29
31
 
30
32
  const childZoneRatio = 0.3;
31
33
  const coordPrecision = 5;
@@ -500,9 +502,7 @@ function getDraggedContext(event) {
500
502
 
501
503
  function notifyInvalidDrop() {
502
504
  console.error('Rejected invalid creative drop payload');
503
- if (typeof window !== 'undefined' && typeof window.alert === 'function') {
504
- window.alert(INVALID_DROP_MESSAGE);
505
- }
505
+ alertDialog(INVALID_DROP_MESSAGE);
506
506
  }
507
507
 
508
508
  import { attachBundleDragImage } from '../../utils/drag_bundle_image.js';
@@ -640,15 +640,24 @@ export function handleDrop(event) {
640
640
  if (sourceCreativeId === targetCreativeId) return;
641
641
 
642
642
  sendTopicMove({ topicId, sourceCreativeId, targetCreativeId })
643
- .then(() => {
643
+ .then((data) => {
644
644
  // Dispatch event so topic list refreshes
645
645
  window.dispatchEvent(new CustomEvent('collavre:topic-moved', {
646
646
  detail: { topicId, sourceCreativeId, targetCreativeId }
647
647
  }));
648
+
649
+ // Offer to re-add members who lose access at the new location.
650
+ if (data && Array.isArray(data.missing_members) && data.missing_members.length > 0) {
651
+ showMissingMembersPopup({
652
+ members: data.missing_members,
653
+ targetCreativeId: data.target_creative_id || targetCreativeId,
654
+ targetCreativeName: data.target_creative_name,
655
+ });
656
+ }
648
657
  })
649
658
  .catch((error) => {
650
659
  console.error('Failed to move topic', error);
651
- alert(error.message || 'Failed to move topic');
660
+ alertDialog(error.message || 'Failed to move topic');
652
661
  });
653
662
  } catch (error) {
654
663
  console.error('Failed to parse topic move data', error);
@@ -0,0 +1,156 @@
1
+ import csrfFetch from '../lib/api/csrf_fetch';
2
+
3
+ // After a topic is moved to another creative, TopicsController#move returns the
4
+ // members who had access on the source creative but not on the target. This
5
+ // module renders the (hidden) `#topic-move-members-modal` partial with those
6
+ // members and lets the user re-add them with one click. All user-facing text
7
+ // comes from the partial's i18n data-attributes — nothing is hardcoded here.
8
+
9
+ function getModal() {
10
+ return document.getElementById('topic-move-members-modal');
11
+ }
12
+
13
+ function closeModal(modal) {
14
+ modal.style.display = 'none';
15
+ document.body.classList.remove('no-scroll');
16
+ const list = modal.querySelector('#topic-move-members-list');
17
+ if (list) list.innerHTML = '';
18
+ const message = modal.querySelector('#topic-move-members-message');
19
+ if (message) message.innerHTML = '';
20
+ }
21
+
22
+ function buildRow(modal, member) {
23
+ const template = modal.querySelector('#topic-move-member-row');
24
+ const row = template.content.firstElementChild.cloneNode(true);
25
+ const user = member.user || {};
26
+
27
+ const avatar = row.querySelector('.tmm-avatar');
28
+ if (user.avatar_url && !user.default_avatar) {
29
+ const img = document.createElement('img');
30
+ img.src = user.avatar_url;
31
+ img.alt = '';
32
+ img.style.width = '100%';
33
+ img.style.height = '100%';
34
+ img.style.objectFit = 'cover';
35
+ avatar.appendChild(img);
36
+ } else {
37
+ avatar.textContent = user.initial || '?';
38
+ }
39
+
40
+ row.querySelector('.tmm-name').textContent = user.name || user.email || '';
41
+ row.querySelector('.tmm-email').textContent = user.email || '';
42
+
43
+ const select = row.querySelector('.tmm-permission');
44
+ if (member.permission) {
45
+ const option = Array.from(select.options).find((o) => o.value === member.permission);
46
+ if (option) select.value = member.permission;
47
+ }
48
+
49
+ row.dataset.userEmail = user.email || '';
50
+ return row;
51
+ }
52
+
53
+ function setMessage(modal, text) {
54
+ const message = modal.querySelector('#topic-move-members-message');
55
+ if (message) message.textContent = text || '';
56
+ }
57
+
58
+ async function addSelected(modal, targetCreativeId) {
59
+ const addBtn = modal.querySelector('#topic-move-members-add');
60
+ const rows = Array.from(modal.querySelectorAll('.topic-move-member-row')).filter((row) => {
61
+ const checkbox = row.querySelector('.tmm-checkbox');
62
+ return checkbox && checkbox.checked && row.dataset.userEmail;
63
+ });
64
+
65
+ if (rows.length === 0) {
66
+ closeModal(modal);
67
+ return;
68
+ }
69
+
70
+ addBtn.disabled = true;
71
+ setMessage(modal, addBtn.dataset.addingText || modal.dataset.addingText || '');
72
+
73
+ const urlTemplate = modal.dataset.sharesUrlTemplate || modal.dataset.sharesUrl;
74
+ const url = urlTemplate.replace('__CREATIVE_ID__', encodeURIComponent(targetCreativeId));
75
+
76
+ let added = 0;
77
+ let failed = 0;
78
+
79
+ // Sequential so we reuse the existing per-user share endpoint (with all its
80
+ // invitation / linked-creative / contact side effects) and keep ordering.
81
+ for (const row of rows) {
82
+ const status = row.querySelector('.tmm-status');
83
+ const select = row.querySelector('.tmm-permission');
84
+ const body = new FormData();
85
+ body.append('user_email', row.dataset.userEmail);
86
+ body.append('permission', select ? select.value : 'feedback');
87
+
88
+ try {
89
+ // eslint-disable-next-line no-await-in-loop
90
+ const response = await csrfFetch(url, {
91
+ method: 'POST',
92
+ headers: { Accept: 'application/json' },
93
+ body,
94
+ });
95
+ if (response.ok) {
96
+ added += 1;
97
+ if (status) status.textContent = '✓';
98
+ row.style.opacity = '0.6';
99
+ } else {
100
+ failed += 1;
101
+ if (status) status.textContent = '✗';
102
+ }
103
+ } catch (error) {
104
+ failed += 1;
105
+ if (status) status.textContent = '✗';
106
+ }
107
+ }
108
+
109
+ if (failed === 0) {
110
+ const template = added === 1
111
+ ? (modal.dataset.addedOne || '')
112
+ : (modal.dataset.addedOther || '');
113
+ setMessage(modal, template.replace('__COUNT__', added));
114
+ setTimeout(() => closeModal(modal), 1200);
115
+ } else if (added === 0) {
116
+ setMessage(modal, modal.dataset.failedText || '');
117
+ addBtn.disabled = false;
118
+ } else {
119
+ const template = modal.dataset.partialText || '';
120
+ setMessage(modal, template.replace('__ADDED__', added).replace('__FAILED__', failed));
121
+ addBtn.disabled = false;
122
+ }
123
+ }
124
+
125
+ export function showMissingMembersPopup({ members, targetCreativeId, targetCreativeName }) {
126
+ if (!Array.isArray(members) || members.length === 0) return;
127
+ const modal = getModal();
128
+ if (!modal) return;
129
+
130
+ const list = modal.querySelector('#topic-move-members-list');
131
+ list.innerHTML = '';
132
+ members.forEach((member) => list.appendChild(buildRow(modal, member)));
133
+
134
+ const description = modal.querySelector('#topic-move-members-description');
135
+ if (description) {
136
+ const template = modal.dataset.descriptionTemplate || '';
137
+ description.textContent = template.replace('__CREATIVE_NAME__', targetCreativeName || '');
138
+ }
139
+
140
+ setMessage(modal, '');
141
+ const addBtn = modal.querySelector('#topic-move-members-add');
142
+ addBtn.disabled = false;
143
+
144
+ // (Re)wire controls. Using onclick keeps a single handler across re-opens.
145
+ modal.querySelector('#topic-move-members-close').onclick = () => closeModal(modal);
146
+ modal.querySelector('#topic-move-members-skip').onclick = () => closeModal(modal);
147
+ modal.onclick = (event) => {
148
+ if (event.target === modal) closeModal(modal);
149
+ };
150
+ addBtn.onclick = () => addSelected(modal, targetCreativeId);
151
+
152
+ modal.style.display = 'flex';
153
+ modal.style.alignItems = 'center';
154
+ modal.style.justifyContent = 'center';
155
+ document.body.classList.add('no-scroll');
156
+ }
@@ -169,6 +169,9 @@ function applyRowProperties(row, node) {
169
169
  if (Object.prototype.hasOwnProperty.call(inlinePayload, 'markdown_source')) {
170
170
  setDatasetValue(row, 'markdownSource', inlinePayload.markdown_source ?? '')
171
171
  }
172
+ if (Object.prototype.hasOwnProperty.call(inlinePayload, 'markdown_editor')) {
173
+ setDatasetValue(row, 'markdownEditor', inlinePayload.markdown_editor ?? '')
174
+ }
172
175
 
173
176
  if (dirty && typeof row.requestUpdate === 'function') {
174
177
  // Before Lit re-renders, sync progressHtml from current DOM.
@@ -308,6 +311,14 @@ export function renderCreativeTree(container, nodes, { replace = true } = {}) {
308
311
  reconcileNodes(container, nodes)
309
312
  }
310
313
 
314
+ // Append a page of flat nodes to an already-rendered list without clearing the
315
+ // existing rows. Used by the "Chats" feed's load-more, where each page is added
316
+ // below the previous one rather than replacing it.
317
+ export function appendCreativeNodes(container, nodes) {
318
+ if (!container) return
319
+ appendNodes(container, nodes)
320
+ }
321
+
311
322
  export function dispatchCreativeTreeUpdated(container) {
312
323
  if (!container) return
313
324
  const event = new CustomEvent('creative-tree:updated', { bubbles: true })
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+
6
+ // Mock the Turbo import so the module installs its confirm method onto a fake
7
+ // config we control, instead of pulling in (and starting) the real Turbo.
8
+ const fakeTurbo = { config: { forms: {} } }
9
+ jest.unstable_mockModule('@hotwired/turbo-rails', () => ({ Turbo: fakeTurbo }))
10
+
11
+ // Importing the module installs the override (side effect).
12
+ await import('../turbo_confirm')
13
+
14
+ const turboConfirm = fakeTurbo.config.forms.confirm
15
+ const modal = () => document.querySelector('.modal-dialog')
16
+ const message = () => document.querySelector('.confirm-dialog-message')
17
+
18
+ afterEach(() => {
19
+ document.body.innerHTML = ''
20
+ document.documentElement.lang = ''
21
+ })
22
+
23
+ test('installs a confirm method on Turbo.config.forms', () => {
24
+ expect(typeof turboConfirm).toBe('function')
25
+ })
26
+
27
+ test('renders the in-app confirm dialog with the Turbo message', () => {
28
+ const form = document.createElement('form')
29
+ turboConfirm('Revoke this token?', form, null)
30
+ expect(modal()).not.toBeNull()
31
+ expect(message().textContent).toBe('Revoke this token?')
32
+ // Cancel button present (it is a confirm, not an alert).
33
+ expect(document.querySelector('.modal-dialog-btn-secondary')).not.toBeNull()
34
+ })
35
+
36
+ test('resolves true when confirmed, false when cancelled', async () => {
37
+ const form = document.createElement('form')
38
+
39
+ const okP = turboConfirm('ok?', form, null)
40
+ document.querySelector('.modal-dialog-btn-danger, .modal-dialog-btn-primary').click()
41
+ await expect(okP).resolves.toBe(true)
42
+
43
+ const cancelP = turboConfirm('no?', form, null)
44
+ document.querySelector('.modal-dialog-btn-secondary').click()
45
+ await expect(cancelP).resolves.toBe(false)
46
+ })
47
+
48
+ test('styles the confirm button as danger for delete submits', () => {
49
+ const form = document.createElement('form')
50
+ form.setAttribute('data-turbo-method', 'delete')
51
+ turboConfirm('Delete?', form, null)
52
+ expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
53
+ expect(document.querySelector('.modal-dialog-btn-primary')).toBeNull()
54
+ })
55
+
56
+ test('detects delete via the submitter and the hidden _method input', () => {
57
+ // Submitter carries the method (button_to renders a button submitter).
58
+ const form1 = document.createElement('form')
59
+ const submitter = document.createElement('button')
60
+ submitter.setAttribute('data-turbo-method', 'delete')
61
+ turboConfirm('x', form1, submitter)
62
+ expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
63
+ document.body.innerHTML = ''
64
+
65
+ // Rails non-Turbo fallback: hidden _method=delete input.
66
+ const form2 = document.createElement('form')
67
+ const hidden = document.createElement('input')
68
+ hidden.name = '_method'
69
+ hidden.value = 'delete'
70
+ form2.appendChild(hidden)
71
+ turboConfirm('x', form2, null)
72
+ expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
73
+ })
74
+
75
+ test('non-destructive submit uses the primary (non-danger) button', () => {
76
+ const form = document.createElement('form')
77
+ form.setAttribute('method', 'post')
78
+ turboConfirm('Proceed?', form, null)
79
+ expect(document.querySelector('.modal-dialog-btn-primary')).not.toBeNull()
80
+ expect(document.querySelector('.modal-dialog-btn-danger')).toBeNull()
81
+ })