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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. 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
+ })