collavre 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
- data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
- data/app/assets/stylesheets/collavre/popup.css +7 -0
- data/app/assets/stylesheets/collavre/print.css +18 -0
- data/app/channels/collavre/comments_presence_channel.rb +33 -0
- data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
- data/app/components/collavre/autocomplete_popup_component.rb +18 -0
- data/app/components/collavre/command_menu_component.rb +7 -0
- data/app/components/collavre/plans_timeline_component.html.erb +1 -1
- data/app/components/collavre/plans_timeline_component.rb +29 -32
- data/app/components/collavre/user_mention_menu_component.rb +4 -5
- data/app/controllers/collavre/comments_controller.rb +111 -10
- data/app/controllers/collavre/creatives_controller.rb +8 -0
- data/app/controllers/collavre/google_auth_controller.rb +5 -1
- data/app/controllers/collavre/plans_controller.rb +65 -9
- data/app/controllers/collavre/topics_controller.rb +42 -0
- data/app/controllers/collavre/users_controller.rb +4 -14
- data/app/errors/collavre/approval_pending_error.rb +54 -0
- data/app/errors/collavre/cancelled_error.rb +9 -0
- data/app/helpers/collavre/navigation_helper.rb +3 -1
- data/app/javascript/collavre.js +1 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
- data/app/javascript/controllers/comments/form_controller.js +2 -1
- data/app/javascript/controllers/comments/list_controller.js +185 -2
- data/app/javascript/controllers/comments/popup_controller.js +95 -20
- data/app/javascript/controllers/comments/presence_controller.js +30 -1
- data/app/javascript/controllers/comments/topics_controller.js +314 -4
- data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
- data/app/javascript/modules/command_menu.js +116 -0
- data/app/javascript/modules/creative_progress.js +14 -0
- data/app/javascript/modules/creative_row_editor.js +104 -20
- data/app/javascript/modules/plans_timeline.js +15 -4
- data/app/javascript/modules/share_modal.js +3 -0
- data/app/jobs/collavre/ai_agent_job.rb +35 -21
- data/app/models/collavre/calendar_event.rb +7 -1
- data/app/models/collavre/comment.rb +35 -2
- data/app/models/collavre/creative.rb +1 -3
- data/app/models/collavre/mcp_tool.rb +4 -0
- data/app/models/collavre/plan.rb +23 -0
- data/app/models/collavre/topic.rb +12 -0
- data/app/models/collavre/user.rb +15 -1
- data/app/services/collavre/ai_agent_service.rb +174 -66
- data/app/services/collavre/ai_client.rb +31 -2
- data/app/services/collavre/comments/action_executor.rb +47 -1
- data/app/services/collavre/comments/calendar_command.rb +117 -18
- data/app/services/collavre/google_calendar_service.rb +38 -15
- data/app/services/collavre/markdown_importer.rb +47 -8
- data/app/services/collavre/mcp_service.rb +23 -10
- data/app/services/collavre/system_events/router.rb +50 -26
- data/app/services/collavre/tools/creative_create_service.rb +97 -0
- data/app/services/collavre/tools/creative_update_service.rb +116 -0
- data/app/views/collavre/comments/_comment.html.erb +2 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
- data/app/views/collavre/comments/fullscreen.html.erb +5 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
- data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
- data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
- data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
- data/app/views/collavre/creatives/_share_button.html.erb +1 -1
- data/app/views/collavre/creatives/index.html.erb +22 -4
- data/app/views/collavre/users/edit_ai.html.erb +15 -0
- data/app/views/collavre/users/new_ai.html.erb +15 -0
- data/app/views/layouts/collavre/chat.html.erb +46 -0
- data/config/locales/ai_agent.en.yml +15 -0
- data/config/locales/ai_agent.ko.yml +15 -0
- data/config/locales/comments.en.yml +15 -3
- data/config/locales/comments.ko.yml +15 -3
- data/config/locales/creatives.en.yml +3 -31
- data/config/locales/creatives.ko.yml +3 -27
- data/config/locales/plans.en.yml +4 -0
- data/config/locales/plans.ko.yml +4 -0
- data/config/locales/users.en.yml +3 -0
- data/config/locales/users.ko.yml +3 -0
- data/config/routes.rb +8 -3
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
- data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
- data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
- data/lib/collavre/engine.rb +171 -6
- data/lib/collavre/integration_registry.rb +129 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +2 -0
- data/lib/navigation/registry.rb +130 -0
- metadata +22 -15
- data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
- data/app/controllers/collavre/notion_auth_controller.rb +0 -25
- data/app/jobs/collavre/notion_export_job.rb +0 -30
- data/app/jobs/collavre/notion_sync_job.rb +0 -48
- data/app/models/collavre/notion_account.rb +0 -17
- data/app/models/collavre/notion_block_link.rb +0 -10
- data/app/models/collavre/notion_page_link.rb +0 -19
- data/app/services/collavre/notion_client.rb +0 -231
- data/app/services/collavre/notion_creative_exporter.rb +0 -296
- data/app/services/collavre/notion_service.rb +0 -249
- data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
- data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
- data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
- data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
|
@@ -86,12 +86,25 @@ export default class extends Controller {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
renderTopics(topics, canManage = false) {
|
|
89
|
-
|
|
89
|
+
const dragActions = canManage
|
|
90
|
+
? 'dragstart->comments--topics#handleTopicDragStart dragend->comments--topics#handleTopicDragEnd'
|
|
91
|
+
: ''
|
|
92
|
+
const dropActions = 'dragover->comments--topics#handleDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleDrop'
|
|
93
|
+
const topicDropActions = canManage
|
|
94
|
+
? 'dragover->comments--topics#handleTopicReorderDragOver dragleave->comments--topics#handleTopicReorderDragLeave drop->comments--topics#handleTopicReorderDrop'
|
|
95
|
+
: ''
|
|
96
|
+
|
|
97
|
+
let html = `<span class="topic-tag topic-drop-target ${this.currentTopicId ? '' : 'active'}"
|
|
98
|
+
data-action="click->comments--topics#select ${dropActions}"
|
|
99
|
+
data-id="">#Main</span>`
|
|
90
100
|
|
|
91
101
|
topics.forEach(topic => {
|
|
92
102
|
// Ensure comparison handles string/number difference
|
|
93
103
|
const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
|
|
94
|
-
|
|
104
|
+
const draggable = canManage ? 'draggable="true"' : ''
|
|
105
|
+
html += `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
|
|
106
|
+
data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
|
|
107
|
+
data-id="${topic.id}">
|
|
95
108
|
#${topic.name}`
|
|
96
109
|
|
|
97
110
|
if (canManage) {
|
|
@@ -111,6 +124,163 @@ export default class extends Controller {
|
|
|
111
124
|
this.listTarget.innerHTML = html
|
|
112
125
|
}
|
|
113
126
|
|
|
127
|
+
handleDragOver(event) {
|
|
128
|
+
// Only accept comment drops
|
|
129
|
+
if (!event.dataTransfer.types.includes('application/x-comment-ids')) return
|
|
130
|
+
|
|
131
|
+
event.preventDefault()
|
|
132
|
+
event.dataTransfer.dropEffect = 'move'
|
|
133
|
+
event.currentTarget.classList.add('drag-over')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
handleDragLeave(event) {
|
|
137
|
+
event.currentTarget.classList.remove('drag-over')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async handleDrop(event) {
|
|
141
|
+
event.preventDefault()
|
|
142
|
+
event.currentTarget.classList.remove('drag-over')
|
|
143
|
+
|
|
144
|
+
const commentIdsJson = event.dataTransfer.getData('application/x-comment-ids')
|
|
145
|
+
if (!commentIdsJson) return
|
|
146
|
+
|
|
147
|
+
const commentIds = JSON.parse(commentIdsJson)
|
|
148
|
+
if (!commentIds || commentIds.length === 0) return
|
|
149
|
+
|
|
150
|
+
const targetTopicId = event.currentTarget.dataset.id // Empty string for Main
|
|
151
|
+
|
|
152
|
+
// Dispatch event for list_controller to handle the move
|
|
153
|
+
this.dispatch('move-to-topic', {
|
|
154
|
+
detail: {
|
|
155
|
+
commentIds,
|
|
156
|
+
targetTopicId
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Topic reorder drag & drop handlers
|
|
162
|
+
handleTopicDragStart(event) {
|
|
163
|
+
const topicEl = event.currentTarget
|
|
164
|
+
const topicId = topicEl.dataset.id
|
|
165
|
+
if (!topicId) {
|
|
166
|
+
event.preventDefault()
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.draggingTopicId = topicId
|
|
171
|
+
event.dataTransfer.setData('application/x-topic-id', topicId)
|
|
172
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
173
|
+
|
|
174
|
+
requestAnimationFrame(() => {
|
|
175
|
+
topicEl.classList.add('topic-dragging')
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleTopicDragEnd(event) {
|
|
180
|
+
this.draggingTopicId = null
|
|
181
|
+
event.currentTarget.classList.remove('topic-dragging')
|
|
182
|
+
this.listTarget.querySelectorAll('.topic-tag').forEach(el => {
|
|
183
|
+
el.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
handleTopicReorderDragOver(event) {
|
|
188
|
+
// Only accept topic reorder drops
|
|
189
|
+
if (!event.dataTransfer.types.includes('application/x-topic-id')) return
|
|
190
|
+
if (!this.draggingTopicId) return
|
|
191
|
+
|
|
192
|
+
const targetEl = event.currentTarget
|
|
193
|
+
const targetId = targetEl.dataset.id
|
|
194
|
+
|
|
195
|
+
// Don't allow drop on self or Main
|
|
196
|
+
if (!targetId || targetId === this.draggingTopicId) return
|
|
197
|
+
|
|
198
|
+
event.preventDefault()
|
|
199
|
+
event.dataTransfer.dropEffect = 'move'
|
|
200
|
+
|
|
201
|
+
// Determine drop position (left or right) based on mouse position
|
|
202
|
+
const rect = targetEl.getBoundingClientRect()
|
|
203
|
+
const midpoint = rect.left + rect.width / 2
|
|
204
|
+
const isLeft = event.clientX < midpoint
|
|
205
|
+
|
|
206
|
+
targetEl.classList.toggle('topic-drag-over-left', isLeft)
|
|
207
|
+
targetEl.classList.toggle('topic-drag-over-right', !isLeft)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
handleTopicReorderDragLeave(event) {
|
|
211
|
+
event.currentTarget.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async handleTopicReorderDrop(event) {
|
|
215
|
+
event.preventDefault()
|
|
216
|
+
|
|
217
|
+
const targetEl = event.currentTarget
|
|
218
|
+
targetEl.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
|
|
219
|
+
|
|
220
|
+
const draggedTopicId = event.dataTransfer.getData('application/x-topic-id')
|
|
221
|
+
const targetTopicId = targetEl.dataset.id
|
|
222
|
+
|
|
223
|
+
if (!draggedTopicId || !targetTopicId || draggedTopicId === targetTopicId) return
|
|
224
|
+
|
|
225
|
+
// Determine drop position
|
|
226
|
+
const rect = targetEl.getBoundingClientRect()
|
|
227
|
+
const midpoint = rect.left + rect.width / 2
|
|
228
|
+
const insertBefore = event.clientX < midpoint
|
|
229
|
+
|
|
230
|
+
// Reorder topics array
|
|
231
|
+
const topics = [...this.topics]
|
|
232
|
+
const draggedIndex = topics.findIndex(t => String(t.id) === String(draggedTopicId))
|
|
233
|
+
const targetIndex = topics.findIndex(t => String(t.id) === String(targetTopicId))
|
|
234
|
+
|
|
235
|
+
if (draggedIndex === -1 || targetIndex === -1) return
|
|
236
|
+
|
|
237
|
+
// Remove dragged topic
|
|
238
|
+
const [draggedTopic] = topics.splice(draggedIndex, 1)
|
|
239
|
+
|
|
240
|
+
// Calculate new position
|
|
241
|
+
let newIndex = targetIndex
|
|
242
|
+
if (draggedIndex < targetIndex) {
|
|
243
|
+
newIndex = insertBefore ? targetIndex - 1 : targetIndex
|
|
244
|
+
} else {
|
|
245
|
+
newIndex = insertBefore ? targetIndex : targetIndex + 1
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Insert at new position
|
|
249
|
+
topics.splice(newIndex, 0, draggedTopic)
|
|
250
|
+
|
|
251
|
+
// Update local state and UI immediately
|
|
252
|
+
this.topics = topics
|
|
253
|
+
this.renderTopics(topics, this.canManageTopics)
|
|
254
|
+
this.restoreSelection()
|
|
255
|
+
|
|
256
|
+
// Send to server
|
|
257
|
+
await this.saveTopicOrder(topics.map(t => t.id))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async saveTopicOrder(topicIds) {
|
|
261
|
+
if (!this.creativeId) return
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const response = await fetch(`/creatives/${this.creativeId}/topics/reorder`, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: {
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify({ topic_ids: topicIds })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
console.error('Failed to save topic order')
|
|
275
|
+
// Reload to restore server state
|
|
276
|
+
this.loadTopics()
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
console.error('Error saving topic order', e)
|
|
280
|
+
this.loadTopics()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
114
284
|
async deleteTopic(event) {
|
|
115
285
|
event.stopPropagation()
|
|
116
286
|
const confirmText = this.listTarget.dataset.confirmDeleteText || "This will delete all messages in this topic. Are you sure?"
|
|
@@ -182,8 +352,17 @@ export default class extends Controller {
|
|
|
182
352
|
select(event) {
|
|
183
353
|
// Ignore if clicking on delete button (though stopPropagation should handle it)
|
|
184
354
|
if (event.target.closest('.delete-topic-btn')) return
|
|
355
|
+
// Ignore if clicking on edit input
|
|
356
|
+
if (event.target.closest('.topic-edit-input')) return
|
|
185
357
|
|
|
186
358
|
const id = event.currentTarget.dataset.id
|
|
359
|
+
|
|
360
|
+
// If clicking on already active topic (not Main), show edit mode
|
|
361
|
+
if (id && String(this.currentTopicId) === String(id) && this.canManageTopics) {
|
|
362
|
+
this.showEditInput(event.currentTarget, id)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
187
366
|
this.selectTopic(id)
|
|
188
367
|
}
|
|
189
368
|
|
|
@@ -196,15 +375,105 @@ export default class extends Controller {
|
|
|
196
375
|
this.dispatch("change", { detail: { topicId: id } })
|
|
197
376
|
}
|
|
198
377
|
|
|
378
|
+
showEditInput(topicEl, topicId) {
|
|
379
|
+
const topic = this.topics.find(t => String(t.id) === String(topicId))
|
|
380
|
+
if (!topic) return
|
|
381
|
+
|
|
382
|
+
const currentName = topic.name
|
|
383
|
+
|
|
384
|
+
// Store original HTML for restore
|
|
385
|
+
topicEl.dataset.originalHtml = topicEl.innerHTML
|
|
386
|
+
|
|
387
|
+
// Replace content with input
|
|
388
|
+
topicEl.innerHTML = `<input type="text" class="topic-edit-input" value="${currentName}"
|
|
389
|
+
data-action="keydown->comments--topics#handleEditKey blur->comments--topics#cancelEdit"
|
|
390
|
+
data-topic-id="${topicId}">`
|
|
391
|
+
|
|
392
|
+
const input = topicEl.querySelector('input')
|
|
393
|
+
requestAnimationFrame(() => {
|
|
394
|
+
input.focus()
|
|
395
|
+
input.select()
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
handleEditKey(event) {
|
|
400
|
+
if (event.key === 'Enter') {
|
|
401
|
+
event.preventDefault()
|
|
402
|
+
const name = event.target.value.trim()
|
|
403
|
+
const topicId = event.target.dataset.topicId
|
|
404
|
+
if (name) {
|
|
405
|
+
this.updateTopic(topicId, name)
|
|
406
|
+
} else {
|
|
407
|
+
this.cancelEdit(event)
|
|
408
|
+
}
|
|
409
|
+
} else if (event.key === 'Escape') {
|
|
410
|
+
event.preventDefault()
|
|
411
|
+
this.cancelEdit(event)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
cancelEdit(event) {
|
|
416
|
+
// Prevent blur from firing multiple times
|
|
417
|
+
if (this.editCancelling) return
|
|
418
|
+
this.editCancelling = true
|
|
419
|
+
|
|
420
|
+
const topicEl = event.target.closest('.topic-tag')
|
|
421
|
+
if (topicEl && topicEl.dataset.originalHtml) {
|
|
422
|
+
topicEl.innerHTML = topicEl.dataset.originalHtml
|
|
423
|
+
delete topicEl.dataset.originalHtml
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
setTimeout(() => { this.editCancelling = false }, 100)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async updateTopic(topicId, name) {
|
|
430
|
+
if (!this.creativeId) return
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}`, {
|
|
434
|
+
method: 'PATCH',
|
|
435
|
+
headers: {
|
|
436
|
+
'Content-Type': 'application/json',
|
|
437
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
438
|
+
},
|
|
439
|
+
body: JSON.stringify({ topic: { name } })
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
if (response.ok) {
|
|
443
|
+
const updatedTopic = await response.json()
|
|
444
|
+
// Update local topics array
|
|
445
|
+
const index = this.topics.findIndex(t => String(t.id) === String(topicId))
|
|
446
|
+
if (index !== -1) {
|
|
447
|
+
this.topics[index] = updatedTopic
|
|
448
|
+
}
|
|
449
|
+
this.renderTopics(this.topics, this.canManageTopics)
|
|
450
|
+
this.restoreSelection()
|
|
451
|
+
} else {
|
|
452
|
+
alert("Failed to update topic")
|
|
453
|
+
this.loadTopics() // Reload to restore state
|
|
454
|
+
}
|
|
455
|
+
} catch (e) {
|
|
456
|
+
console.error("Error updating topic", e)
|
|
457
|
+
this.loadTopics()
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
199
461
|
updateSelectionUI(id) {
|
|
200
462
|
this.currentTopicId = id
|
|
201
463
|
// Update UI
|
|
464
|
+
let activeEl = null
|
|
202
465
|
this.listTarget.querySelectorAll('.topic-tag').forEach(el => {
|
|
203
|
-
|
|
204
|
-
|
|
466
|
+
const isActive = String(el.dataset.id) === String(id)
|
|
467
|
+
el.classList.toggle('active', isActive)
|
|
468
|
+
if (isActive) {
|
|
205
469
|
el.classList.remove('has-new-messages')
|
|
470
|
+
activeEl = el
|
|
206
471
|
}
|
|
207
472
|
})
|
|
473
|
+
// Scroll active topic into view
|
|
474
|
+
if (activeEl) {
|
|
475
|
+
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
|
476
|
+
}
|
|
208
477
|
}
|
|
209
478
|
|
|
210
479
|
handleNewMessage(event) {
|
|
@@ -308,6 +577,16 @@ export default class extends Controller {
|
|
|
308
577
|
return
|
|
309
578
|
}
|
|
310
579
|
|
|
580
|
+
if (action === "updated" && data.topic) {
|
|
581
|
+
this.updateTopicInList(data.topic)
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (action === "reordered" && data.topic_ids) {
|
|
586
|
+
this.reorderTopicsFromServer(data.topic_ids)
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
311
590
|
if (!data.topic) return
|
|
312
591
|
|
|
313
592
|
const topics = this.topics || []
|
|
@@ -319,6 +598,37 @@ export default class extends Controller {
|
|
|
319
598
|
this.restoreSelection()
|
|
320
599
|
}
|
|
321
600
|
|
|
601
|
+
reorderTopicsFromServer(topicIds) {
|
|
602
|
+
if (!topicIds || !Array.isArray(topicIds)) return
|
|
603
|
+
|
|
604
|
+
const reorderedTopics = []
|
|
605
|
+
topicIds.forEach(id => {
|
|
606
|
+
const topic = this.topics.find(t => String(t.id) === String(id))
|
|
607
|
+
if (topic) reorderedTopics.push(topic)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
// Add any topics not in the list (shouldn't happen, but safety)
|
|
611
|
+
this.topics.forEach(topic => {
|
|
612
|
+
if (!reorderedTopics.find(t => String(t.id) === String(topic.id))) {
|
|
613
|
+
reorderedTopics.push(topic)
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
this.topics = reorderedTopics
|
|
618
|
+
this.renderTopics(this.topics, this.canManageTopics)
|
|
619
|
+
this.restoreSelection()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
updateTopicInList(updatedTopic) {
|
|
623
|
+
const topics = this.topics || []
|
|
624
|
+
const index = topics.findIndex(t => String(t.id) === String(updatedTopic.id))
|
|
625
|
+
if (index === -1) return
|
|
626
|
+
|
|
627
|
+
this.topics[index] = updatedTopic
|
|
628
|
+
this.renderTopics(this.topics, this.canManageTopics)
|
|
629
|
+
this.restoreSelection()
|
|
630
|
+
}
|
|
631
|
+
|
|
322
632
|
removeTopic(topicId) {
|
|
323
633
|
if (!topicId) return
|
|
324
634
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from '../creative_progress';
|
|
5
|
+
|
|
6
|
+
describe('creative progress helpers', () => {
|
|
7
|
+
describe('isProgressComplete', () => {
|
|
8
|
+
test('returns true for values >= 1', () => {
|
|
9
|
+
expect(isProgressComplete(1)).toBe(true);
|
|
10
|
+
expect(isProgressComplete(1.0)).toBe(true);
|
|
11
|
+
expect(isProgressComplete(1.5)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('returns false for values < 1', () => {
|
|
15
|
+
expect(isProgressComplete(0)).toBe(false);
|
|
16
|
+
expect(isProgressComplete(0.5)).toBe(false);
|
|
17
|
+
expect(isProgressComplete(0.99)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('returns false for NaN values', () => {
|
|
21
|
+
expect(isProgressComplete(NaN)).toBe(false);
|
|
22
|
+
expect(isProgressComplete('invalid')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('progressBaselineValueFrom', () => {
|
|
27
|
+
test('returns 1 for complete values', () => {
|
|
28
|
+
expect(progressBaselineValueFrom(1)).toBe(1);
|
|
29
|
+
expect(progressBaselineValueFrom(1.5)).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns 0 for incomplete values', () => {
|
|
33
|
+
expect(progressBaselineValueFrom(0)).toBe(0);
|
|
34
|
+
expect(progressBaselineValueFrom(0.4)).toBe(0);
|
|
35
|
+
expect(progressBaselineValueFrom(0.99)).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('progressValueChangedFrom', () => {
|
|
40
|
+
test('preserves fractional values unless checkbox toggled', () => {
|
|
41
|
+
expect(progressValueChangedFrom(0.4, false)).toBe(false);
|
|
42
|
+
expect(progressValueChangedFrom(0.4, true)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('detects change from complete to incomplete', () => {
|
|
46
|
+
expect(progressValueChangedFrom(1, false)).toBe(true);
|
|
47
|
+
expect(progressValueChangedFrom(1, true)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import CommonPopup from '../lib/common_popup'
|
|
2
|
+
import { getCaretClientRect } from '../utils/caret_position'
|
|
3
|
+
|
|
4
|
+
let commandMenuInitialized = false
|
|
5
|
+
|
|
6
|
+
if (!commandMenuInitialized) {
|
|
7
|
+
commandMenuInitialized = true
|
|
8
|
+
|
|
9
|
+
document.addEventListener('turbo:load', function () {
|
|
10
|
+
const textarea = document.querySelector('#new-comment-form textarea')
|
|
11
|
+
const menu = document.getElementById('command-menu')
|
|
12
|
+
const popup = document.getElementById('comments-popup')
|
|
13
|
+
if (!textarea || !menu || !popup) return
|
|
14
|
+
|
|
15
|
+
const list = menu.querySelector('[data-popup-list]')
|
|
16
|
+
const commandCache = new Map()
|
|
17
|
+
|
|
18
|
+
const popupMenu = new CommonPopup(menu, {
|
|
19
|
+
listElement: list,
|
|
20
|
+
renderItem: (command) => {
|
|
21
|
+
const aliasLabel = command.aliases?.length
|
|
22
|
+
? `<span class="command-aliases">(${command.aliases.join(', ')})</span>`
|
|
23
|
+
: ''
|
|
24
|
+
const args = command.args ? `<span class="command-args">${command.args}</span>` : ''
|
|
25
|
+
const description = command.description
|
|
26
|
+
? `<div class="command-description">${command.description}</div>`
|
|
27
|
+
: ''
|
|
28
|
+
return `
|
|
29
|
+
<div class="command-item">
|
|
30
|
+
<span class="command-label">${command.label}</span>
|
|
31
|
+
${aliasLabel}
|
|
32
|
+
${args}
|
|
33
|
+
</div>
|
|
34
|
+
${description}
|
|
35
|
+
`
|
|
36
|
+
},
|
|
37
|
+
onSelect: (command) => {
|
|
38
|
+
insert(command)
|
|
39
|
+
popupMenu.hide()
|
|
40
|
+
textarea.focus()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function fetchCommands(creativeId) {
|
|
45
|
+
if (!creativeId) return Promise.resolve([])
|
|
46
|
+
if (commandCache.has(creativeId)) return Promise.resolve(commandCache.get(creativeId))
|
|
47
|
+
|
|
48
|
+
return fetch(`/creatives/${creativeId}/comments/commands`, { headers: { Accept: 'application/json' } })
|
|
49
|
+
.then((response) => (response.ok ? response.json() : []))
|
|
50
|
+
.then((data) => {
|
|
51
|
+
const list = Array.isArray(data) ? data : []
|
|
52
|
+
commandCache.set(creativeId, list)
|
|
53
|
+
return list
|
|
54
|
+
})
|
|
55
|
+
.catch(() => [])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function insert(command) {
|
|
59
|
+
const pos = textarea.selectionStart
|
|
60
|
+
const after = textarea.value.slice(pos)
|
|
61
|
+
// Since "/" is always at the start, replace from beginning
|
|
62
|
+
const replaced = `${command.label} `
|
|
63
|
+
textarea.value = replaced + after
|
|
64
|
+
textarea.setSelectionRange(replaced.length, replaced.length)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hide() {
|
|
68
|
+
popupMenu.hide()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function show(commands, query) {
|
|
72
|
+
if (!commands || commands.length === 0) {
|
|
73
|
+
hide()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lowered = query.toLowerCase()
|
|
78
|
+
const filtered = commands.filter((command) => {
|
|
79
|
+
if (!lowered) return true
|
|
80
|
+
const name = command.name?.toLowerCase?.() || ''
|
|
81
|
+
const aliases = (command.aliases || []).map((alias) => alias.toLowerCase())
|
|
82
|
+
return name.includes(lowered) || aliases.some((alias) => alias.replace('/', '').includes(lowered))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (filtered.length === 0) {
|
|
86
|
+
hide()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
popupMenu.setItems(filtered)
|
|
91
|
+
const caretRect = getCaretClientRect(textarea) || textarea.getBoundingClientRect()
|
|
92
|
+
popupMenu.showAt(caretRect)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
textarea.addEventListener('keydown', function (event) {
|
|
96
|
+
if (popupMenu.handleKey(event)) return
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
textarea.addEventListener('input', function () {
|
|
100
|
+
const pos = textarea.selectionStart
|
|
101
|
+
const before = textarea.value.slice(0, pos)
|
|
102
|
+
// Only trigger when "/" is at the very beginning of the message
|
|
103
|
+
const match = before.match(/^\/([^\s/]*)$/)
|
|
104
|
+
if (!match) {
|
|
105
|
+
hide()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const creativeId = popup.dataset.creativeId
|
|
110
|
+
const query = match[1]
|
|
111
|
+
|
|
112
|
+
fetchCommands(creativeId)
|
|
113
|
+
.then((commands) => show(commands, query))
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function isProgressComplete(value) {
|
|
2
|
+
const numeric = Number(value);
|
|
3
|
+
return !Number.isNaN(numeric) && numeric >= 1;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function progressBaselineValueFrom(originalProgress) {
|
|
7
|
+
return isProgressComplete(originalProgress) ? 1 : 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function progressValueChangedFrom(originalProgress, checked) {
|
|
11
|
+
const baseline = progressBaselineValueFrom(originalProgress);
|
|
12
|
+
const current = checked ? 1 : 0;
|
|
13
|
+
return current !== baseline;
|
|
14
|
+
}
|