collavre 0.5.0 → 0.7.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/comment_versions.css +76 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
- data/app/assets/stylesheets/collavre/creatives.css +73 -1
- data/app/assets/stylesheets/collavre/org_chart.css +319 -0
- data/app/assets/stylesheets/collavre/popup.css +68 -1
- data/app/controllers/collavre/application_controller.rb +13 -0
- data/app/controllers/collavre/comments/versions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +14 -153
- data/app/controllers/collavre/concerns/exportable.rb +30 -0
- data/app/controllers/collavre/concerns/shareable.rb +28 -0
- data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
- data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
- data/app/controllers/collavre/creative_imports_controller.rb +6 -0
- data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
- data/app/controllers/collavre/creative_plans_controller.rb +1 -1
- data/app/controllers/collavre/creative_shares_controller.rb +84 -14
- data/app/controllers/collavre/creatives_controller.rb +70 -194
- data/app/controllers/collavre/google_auth_controller.rb +3 -0
- data/app/controllers/collavre/invites_controller.rb +2 -1
- data/app/controllers/collavre/sessions_controller.rb +3 -0
- data/app/controllers/collavre/topics_controller.rb +39 -2
- data/app/controllers/collavre/users_controller.rb +5 -404
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
- data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
- data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
- data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
- data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +12 -9
- data/app/helpers/collavre/navigation_helper.rb +1 -1
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/controllers/comment_controller.js +33 -70
- data/app/javascript/controllers/comment_version_controller.js +164 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
- data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
- data/app/javascript/controllers/comments/contexts_controller.js +363 -0
- data/app/javascript/controllers/comments/form_controller.js +304 -13
- data/app/javascript/controllers/comments/list_controller.js +151 -62
- data/app/javascript/controllers/comments/popup_controller.js +66 -38
- data/app/javascript/controllers/comments/presence_controller.js +2 -10
- data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
- data/app/javascript/controllers/comments/topics_controller.js +34 -10
- data/app/javascript/controllers/index.js +15 -1
- data/app/javascript/controllers/org_chart_controller.js +46 -0
- data/app/javascript/controllers/share_modal_controller.js +369 -0
- data/app/javascript/controllers/topic_search_controller.js +103 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
- data/app/javascript/lib/api/creatives.js +12 -0
- data/app/javascript/lib/api/csrf_fetch.js +35 -0
- data/app/javascript/lib/api/drag_drop.js +17 -0
- data/app/javascript/modules/command_menu.js +40 -0
- data/app/javascript/modules/creative_row_editor.js +88 -0
- data/app/javascript/modules/slide_view.js +2 -1
- data/app/jobs/collavre/ai_agent_job.rb +42 -30
- data/app/jobs/collavre/compress_job.rb +92 -0
- data/app/models/collavre/comment.rb +36 -1
- data/app/models/collavre/comment_version.rb +15 -0
- data/app/models/collavre/creative/describable.rb +1 -1
- data/app/models/collavre/creative.rb +51 -0
- data/app/models/collavre/task.rb +30 -2
- data/app/models/collavre/user.rb +20 -3
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
- data/app/services/collavre/ai_agent/message_builder.rb +85 -6
- data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
- data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
- data/app/services/collavre/ai_agent/review_handler.rb +18 -1
- data/app/services/collavre/ai_agent_service.rb +130 -183
- data/app/services/collavre/ai_client.rb +6 -0
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/command_menu_service.rb +19 -0
- data/app/services/collavre/comments/command_processor.rb +3 -1
- data/app/services/collavre/comments/compress_command.rb +75 -0
- data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
- data/app/services/collavre/comments/work_command.rb +161 -0
- data/app/services/collavre/comments/workflow_executor.rb +276 -0
- data/app/services/collavre/creatives/plan_tagger.rb +14 -3
- data/app/services/collavre/creatives/tree_formatter.rb +53 -13
- data/app/services/collavre/gemini_parent_recommender.rb +4 -4
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
- data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
- data/app/services/collavre/orchestration/scheduler.rb +3 -2
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/dispatcher.rb +9 -0
- data/app/services/collavre/tools/creative_create_service.rb +1 -8
- data/app/services/collavre/tools/creative_import_service.rb +46 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
- data/app/services/collavre/tools/creative_update_service.rb +1 -8
- data/app/services/collavre/tools/cron_list_service.rb +1 -1
- data/app/services/collavre/tools/description_normalizable.rb +16 -0
- data/app/views/collavre/comments/_comment.html.erb +25 -8
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
- data/app/views/collavre/creatives/_share_button.html.erb +4 -1
- data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
- data/app/views/collavre/creatives/index.html.erb +5 -5
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
- data/app/views/collavre/users/_org_chart.html.erb +68 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
- data/app/views/collavre/users/new_ai.html.erb +9 -0
- data/app/views/collavre/users/show.html.erb +32 -8
- data/config/locales/comments.en.yml +57 -2
- data/config/locales/comments.ko.yml +57 -2
- data/config/locales/contacts.en.yml +31 -0
- data/config/locales/contacts.ko.yml +31 -0
- data/config/locales/contexts.en.yml +8 -0
- data/config/locales/contexts.ko.yml +8 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +6 -0
- data/config/locales/users.en.yml +1 -0
- data/config/locales/users.ko.yml +1 -0
- data/config/routes.rb +14 -1
- data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
- data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
- data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
- data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
- data/lib/collavre/version.rb +1 -1
- metadata +47 -10
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
- data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
- data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
- data/app/javascript/modules/share_modal.js +0 -76
- data/app/javascript/modules/share_user_popup.js +0 -77
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
- data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
- data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["prevBtn", "nextBtn", "indicator", "deleteBtn", "selectBtn"]
|
|
5
|
+
static values = {
|
|
6
|
+
commentId: Number,
|
|
7
|
+
creativeId: Number,
|
|
8
|
+
total: Number,
|
|
9
|
+
contentTarget: String,
|
|
10
|
+
versionsUrl: String,
|
|
11
|
+
selectedVersionId: Number,
|
|
12
|
+
initialIndex: Number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
this.versions = null
|
|
17
|
+
this.currentIndex = this.initialIndexValue || this.totalValue
|
|
18
|
+
this.updateButtons()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async fetchVersions() {
|
|
22
|
+
if (this.versions) return this.versions
|
|
23
|
+
|
|
24
|
+
const response = await fetch(
|
|
25
|
+
this.versionsUrlValue,
|
|
26
|
+
{ headers: { "Accept": "application/json" } }
|
|
27
|
+
)
|
|
28
|
+
const data = await response.json()
|
|
29
|
+
this.versions = data.versions
|
|
30
|
+
this.selectedVersionId = data.selected_version_id
|
|
31
|
+
this.totalValue = data.total
|
|
32
|
+
|
|
33
|
+
// Set currentIndex based on selected version
|
|
34
|
+
if (this.selectedVersionId) {
|
|
35
|
+
const idx = this.versions.findIndex(v => v.id === this.selectedVersionId)
|
|
36
|
+
if (idx !== -1) this.currentIndex = idx + 1
|
|
37
|
+
} else {
|
|
38
|
+
this.currentIndex = this.totalValue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return this.versions
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async prev() {
|
|
45
|
+
if (this.currentIndex <= 1) return
|
|
46
|
+
await this.fetchVersions()
|
|
47
|
+
this.currentIndex--
|
|
48
|
+
this.render()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async next() {
|
|
52
|
+
if (this.currentIndex >= this.totalValue) return
|
|
53
|
+
await this.fetchVersions()
|
|
54
|
+
this.currentIndex++
|
|
55
|
+
this.render()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async selectVersion() {
|
|
59
|
+
const versions = await this.fetchVersions()
|
|
60
|
+
const version = versions[this.currentIndex - 1]
|
|
61
|
+
if (!version || version.id === this.selectedVersionId) return
|
|
62
|
+
|
|
63
|
+
const response = await fetch(
|
|
64
|
+
`${this.versionsUrlValue}/${version.id}/select`,
|
|
65
|
+
{ method: "POST", headers: { "X-CSRF-Token": this.csrfToken } }
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if (response.ok) {
|
|
69
|
+
this.selectedVersionId = version.id
|
|
70
|
+
this.render()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async deleteVersion() {
|
|
75
|
+
const versions = await this.fetchVersions()
|
|
76
|
+
const version = versions[this.currentIndex - 1]
|
|
77
|
+
if (!version) return
|
|
78
|
+
|
|
79
|
+
// Can't delete if it's the only version
|
|
80
|
+
if (versions.length <= 1) return
|
|
81
|
+
|
|
82
|
+
if (!confirm(this.deleteBtnTarget.dataset.confirmMessage || "Are you sure?")) return
|
|
83
|
+
|
|
84
|
+
const response = await fetch(
|
|
85
|
+
`${this.versionsUrlValue}/${version.id}`,
|
|
86
|
+
{ method: "DELETE", headers: { "X-CSRF-Token": this.csrfToken } }
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (response.ok) {
|
|
90
|
+
const data = await response.json()
|
|
91
|
+
versions.splice(this.currentIndex - 1, 1)
|
|
92
|
+
this.totalValue = data.total
|
|
93
|
+
this.selectedVersionId = data.selected_version_id
|
|
94
|
+
|
|
95
|
+
if (this.currentIndex > this.totalValue) {
|
|
96
|
+
this.currentIndex = this.totalValue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Jump to newly selected version if changed
|
|
100
|
+
if (data.selected_version_id) {
|
|
101
|
+
const idx = versions.findIndex(v => v.id === data.selected_version_id)
|
|
102
|
+
if (idx !== -1) this.currentIndex = idx + 1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.render()
|
|
106
|
+
|
|
107
|
+
if (versions.length <= 1) {
|
|
108
|
+
// Only one version left — no need for navigator
|
|
109
|
+
this.setContentText(versions[0]?.content || data.content)
|
|
110
|
+
this.element.remove()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
render() {
|
|
116
|
+
const version = this.versions[this.currentIndex - 1]
|
|
117
|
+
if (version) this.setContentText(version.content)
|
|
118
|
+
|
|
119
|
+
this.indicatorTarget.textContent = `v${this.currentIndex}/${this.totalValue}`
|
|
120
|
+
this.updateButtons()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setContentText(text) {
|
|
124
|
+
const el = document.getElementById(this.contentTargetValue)
|
|
125
|
+
const target = el?.querySelector(".comment-content") || el?.querySelector("[data-comment-target='content']")
|
|
126
|
+
if (target) target.textContent = text
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
updateButtons() {
|
|
130
|
+
this.prevBtnTarget.disabled = this.currentIndex <= 1
|
|
131
|
+
this.nextBtnTarget.disabled = this.currentIndex >= this.totalValue
|
|
132
|
+
|
|
133
|
+
const isCurrentlySelected = this.isSelectedIndex()
|
|
134
|
+
const isOnlyVersion = this.totalValue <= 1
|
|
135
|
+
|
|
136
|
+
// Delete: disabled if it's the selected version or the only version
|
|
137
|
+
if (this.hasDeleteBtnTarget) {
|
|
138
|
+
this.deleteBtnTarget.disabled = isCurrentlySelected || isOnlyVersion
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Select: disabled if already selected
|
|
142
|
+
if (this.hasSelectBtnTarget) {
|
|
143
|
+
this.selectBtnTarget.disabled = isCurrentlySelected
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Highlight indicator when viewing the selected version
|
|
147
|
+
this.indicatorTarget.classList.toggle("comment-version-selected", isCurrentlySelected)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
isSelectedIndex() {
|
|
151
|
+
if (!this.versions) {
|
|
152
|
+
// Before versions are fetched, use initialIndex to determine
|
|
153
|
+
// If no selectedVersionId, the latest (total) is selected
|
|
154
|
+
if (!this.selectedVersionIdValue) return this.currentIndex === this.totalValue
|
|
155
|
+
return this.currentIndex === this.initialIndexValue
|
|
156
|
+
}
|
|
157
|
+
const version = this.versions[this.currentIndex - 1]
|
|
158
|
+
return version && version.id === this.selectedVersionId
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get csrfToken() {
|
|
162
|
+
return document.querySelector('meta[name="csrf-token"]')?.content || ""
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
import FormController from '../form_controller'
|
|
7
|
+
|
|
8
|
+
describe('FormController - Review Quote Chips', () => {
|
|
9
|
+
let application
|
|
10
|
+
let container
|
|
11
|
+
let controller
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
container = document.createElement('div')
|
|
15
|
+
container.innerHTML = `
|
|
16
|
+
<div id="comments-popup"
|
|
17
|
+
data-controller="comments--form"
|
|
18
|
+
data-review-feedback-placeholder="Write feedback for this quote..."
|
|
19
|
+
data-review-summary-placeholder="Overall comment (optional)..."
|
|
20
|
+
data-review-add-quote="+ Add"
|
|
21
|
+
data-review-send="Send review"
|
|
22
|
+
data-review-send-question="Send question"
|
|
23
|
+
data-review-type-review="💬"
|
|
24
|
+
data-review-type-question="❓"
|
|
25
|
+
data-review-type-review-label="Review"
|
|
26
|
+
data-review-type-question-label="Question">
|
|
27
|
+
<form data-comments--form-target="form">
|
|
28
|
+
<input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
|
|
29
|
+
<input type="hidden" name="comment[quoted_text]" data-comments--form-target="quotedText" value="" />
|
|
30
|
+
<div data-comments--form-target="quoteIndicator" style="display:none;">
|
|
31
|
+
<span data-comments--form-target="quoteIndicatorText"></span>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="review-quotes-container" data-comments--form-target="reviewQuotesContainer" style="display:none;"></div>
|
|
34
|
+
<textarea data-comments--form-target="textarea" rows="2"></textarea>
|
|
35
|
+
<input type="checkbox" data-comments--form-target="privateCheckbox" />
|
|
36
|
+
<button type="submit" data-comments--form-target="submit">Send</button>
|
|
37
|
+
<button data-comments--form-target="cancel" style="display:none;">Cancel</button>
|
|
38
|
+
<button data-comments--form-target="moveButton" style="display:none;">Move</button>
|
|
39
|
+
<button data-comments--form-target="searchButton" style="display:none;">Search</button>
|
|
40
|
+
<button data-comments--form-target="voiceButton" style="display:none;">Voice</button>
|
|
41
|
+
<input type="file" data-comments--form-target="imageInput" style="display:none;" />
|
|
42
|
+
<button data-comments--form-target="imageButton" style="display:none;">Image</button>
|
|
43
|
+
<div data-comments--form-target="attachmentList"></div>
|
|
44
|
+
</form>
|
|
45
|
+
</div>
|
|
46
|
+
`
|
|
47
|
+
document.body.appendChild(container)
|
|
48
|
+
|
|
49
|
+
application = Application.start()
|
|
50
|
+
application.register('comments--form', FormController)
|
|
51
|
+
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
53
|
+
const el = container.querySelector('#comments-popup')
|
|
54
|
+
controller = application.getControllerForElementAndIdentifier(el, 'comments--form')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
application.stop()
|
|
59
|
+
document.body.removeChild(container)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Helper accessors for the refactored store-based API
|
|
63
|
+
const getQuotes = (ctrl) => ctrl._reviewStore.quotes
|
|
64
|
+
const getActiveId = (ctrl) => ctrl._reviewStore.activeId
|
|
65
|
+
|
|
66
|
+
describe('appendReviewQuote', () => {
|
|
67
|
+
test('adds a review quote chip', () => {
|
|
68
|
+
controller.appendReviewQuote(42, 'Hello world')
|
|
69
|
+
|
|
70
|
+
expect(getQuotes(controller)).toHaveLength(1)
|
|
71
|
+
expect(getQuotes(controller)[0]).toMatchObject({
|
|
72
|
+
commentId: 42,
|
|
73
|
+
text: 'Hello world',
|
|
74
|
+
type: 'review',
|
|
75
|
+
feedback: '',
|
|
76
|
+
})
|
|
77
|
+
expect(container.querySelectorAll('.review-quote-chip')).toHaveLength(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('sets active quote id', () => {
|
|
81
|
+
controller.appendReviewQuote(1, 'text')
|
|
82
|
+
expect(getActiveId(controller)).toBe(getQuotes(controller)[0].id)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('saves previous active quote feedback', () => {
|
|
86
|
+
controller.appendReviewQuote(1, 'first')
|
|
87
|
+
controller.textareaTarget.value = 'feedback for first'
|
|
88
|
+
controller.appendReviewQuote(2, 'second')
|
|
89
|
+
|
|
90
|
+
expect(getQuotes(controller)[0].feedback).toBe('feedback for first')
|
|
91
|
+
expect(getQuotes(controller)[1].feedback).toBe('')
|
|
92
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('sets placeholder to feedback prompt', () => {
|
|
96
|
+
controller.appendReviewQuote(1, 'text')
|
|
97
|
+
expect(controller.textareaTarget.placeholder).toBe('Write feedback for this quote...')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('ignores empty text', () => {
|
|
101
|
+
controller.appendReviewQuote(1, '')
|
|
102
|
+
expect(getQuotes(controller)).toHaveLength(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('ignores null text', () => {
|
|
106
|
+
controller.appendReviewQuote(1, null)
|
|
107
|
+
expect(getQuotes(controller)).toHaveLength(0)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('accumulates multiple quotes', () => {
|
|
111
|
+
controller.appendReviewQuote(1, 'first')
|
|
112
|
+
controller.appendReviewQuote(2, 'second')
|
|
113
|
+
controller.appendReviewQuote(3, 'third')
|
|
114
|
+
|
|
115
|
+
expect(getQuotes(controller)).toHaveLength(3)
|
|
116
|
+
expect(container.querySelectorAll('.review-quote-chip')).toHaveLength(3)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('_commitActiveQuote', () => {
|
|
121
|
+
test('saves feedback and deactivates', () => {
|
|
122
|
+
controller.appendReviewQuote(1, 'text')
|
|
123
|
+
controller.textareaTarget.value = 'my feedback'
|
|
124
|
+
controller._commitActiveQuote()
|
|
125
|
+
|
|
126
|
+
expect(getQuotes(controller)[0].feedback).toBe('my feedback')
|
|
127
|
+
expect(getActiveId(controller)).toBeNull()
|
|
128
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
129
|
+
expect(controller.textareaTarget.placeholder).toBe('Overall comment (optional)...')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('_updateSubmitButton', () => {
|
|
134
|
+
test('default state when no quotes', () => {
|
|
135
|
+
controller._updateSubmitButton()
|
|
136
|
+
expect(controller.submitTarget.classList.contains('review-submit-btn')).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('"+ Add" when active review quote', () => {
|
|
140
|
+
controller.appendReviewQuote(1, 'text')
|
|
141
|
+
expect(controller.submitTarget.textContent).toBe('+ Add')
|
|
142
|
+
expect(controller.submitTarget.classList.contains('review-submit-btn')).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('"Send question" when active question quote', () => {
|
|
146
|
+
controller.appendReviewQuote(1, 'text')
|
|
147
|
+
controller._reviewStore.toggleType(getQuotes(controller)[0].id)
|
|
148
|
+
controller._updateSubmitButton()
|
|
149
|
+
expect(controller.submitTarget.textContent).toBe('Send question')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('"Send review" when all quotes committed', () => {
|
|
153
|
+
controller.appendReviewQuote(1, 'text')
|
|
154
|
+
controller._commitActiveQuote()
|
|
155
|
+
expect(controller.submitTarget.textContent).toBe('Send review')
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('_buildReviewContent (via store.buildContent)', () => {
|
|
160
|
+
test('builds markdown from review quotes with feedback', () => {
|
|
161
|
+
controller.appendReviewQuote(1, 'line one')
|
|
162
|
+
controller.textareaTarget.value = 'good'
|
|
163
|
+
controller._commitActiveQuote()
|
|
164
|
+
controller.textareaTarget.value = ''
|
|
165
|
+
|
|
166
|
+
const content = controller._reviewStore.buildContent(controller.textareaTarget.value)
|
|
167
|
+
expect(content).toContain('> line one')
|
|
168
|
+
expect(content).toContain('good')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('blank line between quotes prevents blockquote merging', () => {
|
|
172
|
+
controller.appendReviewQuote(1, 'first')
|
|
173
|
+
controller._commitActiveQuote()
|
|
174
|
+
controller.appendReviewQuote(2, 'second')
|
|
175
|
+
controller._commitActiveQuote()
|
|
176
|
+
controller.textareaTarget.value = ''
|
|
177
|
+
|
|
178
|
+
const content = controller._reviewStore.buildContent(controller.textareaTarget.value)
|
|
179
|
+
const lines = content.split('\n')
|
|
180
|
+
const firstIdx = lines.indexOf('> first')
|
|
181
|
+
const secondIdx = lines.indexOf('> second')
|
|
182
|
+
expect(secondIdx).toBeGreaterThan(firstIdx + 1)
|
|
183
|
+
expect(lines[firstIdx + 1]).toBe('')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('summary after --- separator', () => {
|
|
187
|
+
controller.appendReviewQuote(1, 'quoted')
|
|
188
|
+
controller.textareaTarget.value = 'fix'
|
|
189
|
+
controller._commitActiveQuote()
|
|
190
|
+
|
|
191
|
+
const content = controller._reviewStore.buildContent('overall good')
|
|
192
|
+
expect(content).toContain('---')
|
|
193
|
+
expect(content).toContain('overall good')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('no summary: no --- separator', () => {
|
|
197
|
+
controller.appendReviewQuote(1, 'quoted')
|
|
198
|
+
controller.textareaTarget.value = 'fix'
|
|
199
|
+
controller._commitActiveQuote()
|
|
200
|
+
|
|
201
|
+
const content = controller._reviewStore.buildContent('')
|
|
202
|
+
expect(content).not.toContain('---')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('question type uses ❓ prefix', () => {
|
|
206
|
+
controller.appendReviewQuote(1, 'why?')
|
|
207
|
+
controller._reviewStore.toggleType(getQuotes(controller)[0].id)
|
|
208
|
+
controller._commitActiveQuote()
|
|
209
|
+
controller.textareaTarget.value = ''
|
|
210
|
+
|
|
211
|
+
const content = controller._reviewStore.buildContent(controller.textareaTarget.value)
|
|
212
|
+
expect(content).toContain('> ❓ why?')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('type toggle', () => {
|
|
217
|
+
test('toggles between review and question', () => {
|
|
218
|
+
controller.appendReviewQuote(1, 'text')
|
|
219
|
+
expect(getQuotes(controller)[0].type).toBe('review')
|
|
220
|
+
|
|
221
|
+
container.querySelector('.review-quote-type-toggle').click()
|
|
222
|
+
expect(getQuotes(controller)[0].type).toBe('question')
|
|
223
|
+
|
|
224
|
+
container.querySelector('.review-quote-type-toggle').click()
|
|
225
|
+
expect(getQuotes(controller)[0].type).toBe('review')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('updates button text on toggle', () => {
|
|
229
|
+
controller.appendReviewQuote(1, 'text')
|
|
230
|
+
expect(controller.submitTarget.textContent).toBe('+ Add')
|
|
231
|
+
|
|
232
|
+
container.querySelector('.review-quote-type-toggle').click()
|
|
233
|
+
expect(controller.submitTarget.textContent).toBe('Send question')
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('chip removal', () => {
|
|
238
|
+
test('resets UI when last chip removed', () => {
|
|
239
|
+
controller.appendReviewQuote(1, 'text')
|
|
240
|
+
container.querySelector('.review-quote-chip-remove').click()
|
|
241
|
+
|
|
242
|
+
expect(getQuotes(controller)).toHaveLength(0)
|
|
243
|
+
expect(getActiveId(controller)).toBeNull()
|
|
244
|
+
expect(controller.textareaTarget.placeholder).toBe('')
|
|
245
|
+
expect(container.querySelector('.review-quotes-container').style.display).toBe('none')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('preserves other quotes', () => {
|
|
249
|
+
controller.appendReviewQuote(1, 'first')
|
|
250
|
+
controller._commitActiveQuote()
|
|
251
|
+
controller.appendReviewQuote(2, 'second')
|
|
252
|
+
controller._commitActiveQuote()
|
|
253
|
+
|
|
254
|
+
container.querySelectorAll('.review-quote-chip-remove')[0].click()
|
|
255
|
+
|
|
256
|
+
expect(getQuotes(controller)).toHaveLength(1)
|
|
257
|
+
expect(getQuotes(controller)[0].text).toBe('second')
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('cancelQuote', () => {
|
|
262
|
+
test('clears all review state', () => {
|
|
263
|
+
controller.appendReviewQuote(1, 'text')
|
|
264
|
+
controller.cancelQuote()
|
|
265
|
+
|
|
266
|
+
expect(getQuotes(controller)).toHaveLength(0)
|
|
267
|
+
expect(getActiveId(controller)).toBeNull()
|
|
268
|
+
expect(controller.textareaTarget.placeholder).toBe('')
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
describe('chip click - feedback editing', () => {
|
|
273
|
+
test('clicking committed chip loads its feedback', () => {
|
|
274
|
+
controller.appendReviewQuote(1, 'first')
|
|
275
|
+
controller.textareaTarget.value = 'feedback1'
|
|
276
|
+
controller._commitActiveQuote()
|
|
277
|
+
controller.appendReviewQuote(2, 'second')
|
|
278
|
+
controller._commitActiveQuote()
|
|
279
|
+
|
|
280
|
+
container.querySelectorAll('.review-quote-chip-text')[0].click()
|
|
281
|
+
|
|
282
|
+
expect(getActiveId(controller)).toBe(getQuotes(controller)[0].id)
|
|
283
|
+
expect(controller.textareaTarget.value).toBe('feedback1')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('switching chips saves current feedback', () => {
|
|
287
|
+
controller.appendReviewQuote(1, 'first')
|
|
288
|
+
controller.textareaTarget.value = 'fb1'
|
|
289
|
+
controller._commitActiveQuote()
|
|
290
|
+
controller.appendReviewQuote(2, 'second')
|
|
291
|
+
controller.textareaTarget.value = 'fb2'
|
|
292
|
+
controller._commitActiveQuote()
|
|
293
|
+
|
|
294
|
+
// Click first chip
|
|
295
|
+
container.querySelectorAll('.review-quote-chip-text')[0].click()
|
|
296
|
+
// Type new feedback
|
|
297
|
+
controller.textareaTarget.value = 'fb1-updated'
|
|
298
|
+
// Click second chip
|
|
299
|
+
container.querySelectorAll('.review-quote-chip-text')[1].click()
|
|
300
|
+
|
|
301
|
+
expect(getQuotes(controller)[0].feedback).toBe('fb1-updated')
|
|
302
|
+
expect(controller.textareaTarget.value).toBe('fb2')
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for the selection action bar behavior.
|
|
7
|
+
* We test the DOM rendering logic directly without importing the full controller
|
|
8
|
+
* (which has deep dependencies that are hard to mock in ESM mode).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe('Selection Action Bar - DOM behavior', () => {
|
|
12
|
+
let container
|
|
13
|
+
|
|
14
|
+
function createActionBar(count, dataset = {}) {
|
|
15
|
+
const existing = document.querySelector('.selection-action-bar')
|
|
16
|
+
if (existing) existing.remove()
|
|
17
|
+
|
|
18
|
+
if (count === 0) return null
|
|
19
|
+
|
|
20
|
+
const i18n = (key, fallback) => dataset[key] || fallback
|
|
21
|
+
|
|
22
|
+
const bar = document.createElement('div')
|
|
23
|
+
bar.className = 'selection-action-bar'
|
|
24
|
+
bar.innerHTML = `
|
|
25
|
+
<div class="selection-action-bar-main">
|
|
26
|
+
<span class="selection-action-bar-count">${i18n('selectionCountText', '{count} selected').replace('{count}', count)}</span>
|
|
27
|
+
<button type="button" class="selection-action-bar-btn selection-action-delete">🗑 ${i18n('selectionDeleteText', 'Delete')}</button>
|
|
28
|
+
<button type="button" class="selection-action-bar-btn selection-action-move">📤 ${i18n('selectionMoveText', 'Move')}</button>
|
|
29
|
+
<button type="button" class="selection-action-bar-btn selection-action-topic">🏷 ${i18n('selectionTopicMoveText', 'Move to topic')}</button>
|
|
30
|
+
<button type="button" class="selection-action-bar-close">✕</button>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="selection-action-bar-hint no-touch">
|
|
33
|
+
💡 ${i18n('selectionDragHintText', 'Drag & drop to move to topic')}
|
|
34
|
+
</div>
|
|
35
|
+
`
|
|
36
|
+
document.body.appendChild(bar)
|
|
37
|
+
return bar
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
container = document.createElement('div')
|
|
42
|
+
document.body.appendChild(container)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
document.body.removeChild(container)
|
|
47
|
+
document.querySelectorAll('.selection-action-bar').forEach(el => el.remove())
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('creates action bar with correct count', () => {
|
|
51
|
+
const bar = createActionBar(3)
|
|
52
|
+
expect(bar).toBeTruthy()
|
|
53
|
+
expect(bar.querySelector('.selection-action-bar-count').textContent).toBe('3 selected')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('returns null and removes bar when count is 0', () => {
|
|
57
|
+
createActionBar(2)
|
|
58
|
+
expect(document.querySelector('.selection-action-bar')).toBeTruthy()
|
|
59
|
+
|
|
60
|
+
const result = createActionBar(0)
|
|
61
|
+
expect(result).toBeNull()
|
|
62
|
+
expect(document.querySelector('.selection-action-bar')).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('has all required action buttons', () => {
|
|
66
|
+
const bar = createActionBar(1)
|
|
67
|
+
expect(bar.querySelector('.selection-action-delete')).toBeTruthy()
|
|
68
|
+
expect(bar.querySelector('.selection-action-move')).toBeTruthy()
|
|
69
|
+
expect(bar.querySelector('.selection-action-topic')).toBeTruthy()
|
|
70
|
+
expect(bar.querySelector('.selection-action-bar-close')).toBeTruthy()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('shows drag hint with no-touch class', () => {
|
|
74
|
+
const bar = createActionBar(1)
|
|
75
|
+
const hint = bar.querySelector('.selection-action-bar-hint')
|
|
76
|
+
expect(hint).toBeTruthy()
|
|
77
|
+
expect(hint.classList.contains('no-touch')).toBe(true)
|
|
78
|
+
expect(hint.textContent).toContain('Drag & drop')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('uses i18n dataset values when provided', () => {
|
|
82
|
+
const bar = createActionBar(5, {
|
|
83
|
+
selectionCountText: '{count}개 선택',
|
|
84
|
+
selectionDeleteText: '삭제',
|
|
85
|
+
selectionMoveText: '이동',
|
|
86
|
+
selectionTopicMoveText: '토픽 이동',
|
|
87
|
+
selectionDragHintText: '드래그&드롭으로도 토픽 이동 가능',
|
|
88
|
+
})
|
|
89
|
+
expect(bar.querySelector('.selection-action-bar-count').textContent).toBe('5개 선택')
|
|
90
|
+
expect(bar.querySelector('.selection-action-delete').textContent).toContain('삭제')
|
|
91
|
+
expect(bar.querySelector('.selection-action-move').textContent).toContain('이동')
|
|
92
|
+
expect(bar.querySelector('.selection-action-topic').textContent).toContain('토픽 이동')
|
|
93
|
+
expect(bar.querySelector('.selection-action-bar-hint').textContent).toContain('드래그&드롭')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('replaces existing bar when called again', () => {
|
|
97
|
+
createActionBar(1)
|
|
98
|
+
createActionBar(3)
|
|
99
|
+
const bars = document.querySelectorAll('.selection-action-bar')
|
|
100
|
+
expect(bars.length).toBe(1)
|
|
101
|
+
expect(bars[0].querySelector('.selection-action-bar-count').textContent).toBe('3 selected')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import ReviewQuotesStore from '../review_quotes_store'
|
|
2
|
+
|
|
3
|
+
describe('ReviewQuotesStore', () => {
|
|
4
|
+
let store
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
store = new ReviewQuotesStore()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('add / remove', () => {
|
|
11
|
+
test('adds a quote and sets it active', () => {
|
|
12
|
+
const q = store.add(1, 'hello world')
|
|
13
|
+
expect(store.count).toBe(1)
|
|
14
|
+
expect(store.activeId).toBe(q.id)
|
|
15
|
+
expect(q.type).toBe('review')
|
|
16
|
+
expect(q.feedback).toBe('')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('removes a quote', () => {
|
|
20
|
+
const q = store.add(1, 'text')
|
|
21
|
+
store.remove(q.id)
|
|
22
|
+
expect(store.isEmpty).toBe(true)
|
|
23
|
+
expect(store.activeId).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('feedback', () => {
|
|
28
|
+
test('saves feedback for active quote', () => {
|
|
29
|
+
const q = store.add(1, 'text')
|
|
30
|
+
store.saveActiveFeedback('my feedback')
|
|
31
|
+
expect(q.feedback).toBe('my feedback')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('commit deactivates', () => {
|
|
35
|
+
store.add(1, 'text')
|
|
36
|
+
store.commitActive('feedback')
|
|
37
|
+
expect(store.hasActive).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('toggleType', () => {
|
|
42
|
+
test('toggles between review and question', () => {
|
|
43
|
+
const q = store.add(1, 'text')
|
|
44
|
+
store.toggleType(q.id)
|
|
45
|
+
expect(q.type).toBe('question')
|
|
46
|
+
store.toggleType(q.id)
|
|
47
|
+
expect(q.type).toBe('review')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('buttonState', () => {
|
|
52
|
+
test('normal when empty', () => {
|
|
53
|
+
expect(store.buttonState).toBe('normal')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('add when active review', () => {
|
|
57
|
+
store.add(1, 'text')
|
|
58
|
+
expect(store.buttonState).toBe('add')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('send-question when active question', () => {
|
|
62
|
+
const q = store.add(1, 'text')
|
|
63
|
+
store.toggleType(q.id)
|
|
64
|
+
expect(store.buttonState).toBe('send-question')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('send-review when no active', () => {
|
|
68
|
+
store.add(1, 'text')
|
|
69
|
+
store.commitActive('')
|
|
70
|
+
expect(store.buttonState).toBe('send-review')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('buildContent', () => {
|
|
75
|
+
test('builds markdown with quotes and summary', () => {
|
|
76
|
+
const q1 = store.add(1, 'quote one')
|
|
77
|
+
store.commitActive('feedback one')
|
|
78
|
+
store.add(2, 'quote two')
|
|
79
|
+
store.commitActive('feedback two')
|
|
80
|
+
|
|
81
|
+
const md = store.buildContent('summary text')
|
|
82
|
+
expect(md).toContain('> quote one')
|
|
83
|
+
expect(md).toContain('feedback one')
|
|
84
|
+
expect(md).toContain('> quote two')
|
|
85
|
+
expect(md).toContain('feedback two')
|
|
86
|
+
expect(md).toContain('---')
|
|
87
|
+
expect(md).toContain('summary text')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('question prefix', () => {
|
|
91
|
+
const q = store.add(1, 'why?')
|
|
92
|
+
store.toggleType(q.id)
|
|
93
|
+
const md = store.buildContent('')
|
|
94
|
+
expect(md).toContain('> ❓ why?')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('backup / restore', () => {
|
|
99
|
+
test('restores quotes after backup', () => {
|
|
100
|
+
store.add(1, 'text')
|
|
101
|
+
store.backup('saved textarea')
|
|
102
|
+
// Simulate what happens after send: quotes are manually emptied
|
|
103
|
+
// but backup remains (clear() also wipes backup, so avoid it here)
|
|
104
|
+
store._quotes = []
|
|
105
|
+
store._activeId = null
|
|
106
|
+
expect(store.isEmpty).toBe(true)
|
|
107
|
+
|
|
108
|
+
const restored = store.restore()
|
|
109
|
+
expect(restored).toBe('saved textarea')
|
|
110
|
+
expect(store.count).toBe(1)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|