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
|
@@ -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 (!
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|