collavre 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,192 @@
1
+ import {
2
+ detectDevice,
3
+ shouldRun,
4
+ buildCandidateList,
5
+ anchorEdits,
6
+ applyEditAt,
7
+ shiftEditsAfter,
8
+ partitionByThreshold,
9
+ DEVICE_VOICE,
10
+ DEVICE_SOFT_KEYBOARD,
11
+ DEVICE_PHYSICAL_KEYBOARD,
12
+ } from '../typo_correction'
13
+
14
+ const settings = ({
15
+ enabled = true,
16
+ onVoice = true,
17
+ onSoftKeyboard = true,
18
+ onPhysicalKeyboard = false,
19
+ inChat = true,
20
+ inEditor = false,
21
+ } = {}) => ({ enabled, onVoice, onSoftKeyboard, onPhysicalKeyboard, inChat, inEditor })
22
+
23
+ describe('detectDevice', () => {
24
+ test('voice recognition wins regardless of keyboard signals', () => {
25
+ expect(detectDevice({ voiceActive: true, lastPrintableKeydownAt: 100, inputAt: 110 })).toBe(DEVICE_VOICE)
26
+ })
27
+
28
+ test('printable keydown right before input => physical keyboard', () => {
29
+ expect(detectDevice({ lastPrintableKeydownAt: 1000, inputAt: 1005 })).toBe(DEVICE_PHYSICAL_KEYBOARD)
30
+ })
31
+
32
+ test('input with no preceding printable keydown => soft keyboard', () => {
33
+ expect(detectDevice({ lastPrintableKeydownAt: null, inputAt: 1000 })).toBe(DEVICE_SOFT_KEYBOARD)
34
+ })
35
+
36
+ test('stale keydown (too far before input) => soft keyboard', () => {
37
+ expect(detectDevice({ lastPrintableKeydownAt: 1000, inputAt: 2000 })).toBe(DEVICE_SOFT_KEYBOARD)
38
+ })
39
+ })
40
+
41
+ describe('shouldRun (2D gating)', () => {
42
+ test('runs when master + device + location all on', () => {
43
+ expect(shouldRun(settings(), { device: DEVICE_SOFT_KEYBOARD, location: 'chat' })).toBe(true)
44
+ })
45
+
46
+ test('blocked when master off', () => {
47
+ expect(shouldRun(settings({ enabled: false }), { device: DEVICE_SOFT_KEYBOARD, location: 'chat' })).toBe(false)
48
+ })
49
+
50
+ test('physical keyboard off by default blocks even in chat', () => {
51
+ expect(shouldRun(settings(), { device: DEVICE_PHYSICAL_KEYBOARD, location: 'chat' })).toBe(false)
52
+ })
53
+
54
+ test('editor location off by default blocks even with soft keyboard', () => {
55
+ expect(shouldRun(settings(), { device: DEVICE_SOFT_KEYBOARD, location: 'editor' })).toBe(false)
56
+ })
57
+
58
+ test('voice in chat runs', () => {
59
+ expect(shouldRun(settings(), { device: DEVICE_VOICE, location: 'chat' })).toBe(true)
60
+ })
61
+
62
+ test('null settings never runs', () => {
63
+ expect(shouldRun(null, { device: DEVICE_VOICE, location: 'chat' })).toBe(false)
64
+ })
65
+ })
66
+
67
+ describe('buildCandidateList', () => {
68
+ test('candidate state: original pre-filled, suggestions by confidence', () => {
69
+ const list = buildCandidateList({
70
+ currentValue: '안녀하세요',
71
+ originalWord: '안녀하세요',
72
+ suggestions: [
73
+ { suggestion: '안녕하세요', confidence: 0.95 },
74
+ { suggestion: '안녕하셔요', confidence: 0.4 },
75
+ ],
76
+ })
77
+ expect(list.map((i) => i.value)).toEqual(['안녀하세요', '안녕하세요', '안녕하셔요'])
78
+ expect(list[0].isCurrent).toBe(true)
79
+ expect(list[0].role).toBe('original')
80
+ })
81
+
82
+ test('auto-applied state: corrected word current, original offered as undo', () => {
83
+ const list = buildCandidateList({
84
+ currentValue: '있습니다',
85
+ originalWord: '잇습니다',
86
+ suggestions: [{ suggestion: '있습니다', confidence: 0.99 }],
87
+ })
88
+ // current (applied) first, then original (undo); suggestion dupes current and is dropped.
89
+ expect(list.map((i) => i.value)).toEqual(['있습니다', '잇습니다'])
90
+ expect(list[0].role).toBe('applied')
91
+ expect(list[0].isCurrent).toBe(true)
92
+ expect(list[1].role).toBe('original')
93
+ })
94
+
95
+ test('de-duplicates and skips empties', () => {
96
+ const list = buildCandidateList({
97
+ currentValue: 'a',
98
+ originalWord: 'a',
99
+ suggestions: [{ suggestion: 'a' }, { suggestion: '' }, { suggestion: 'b', confidence: 0.5 }],
100
+ })
101
+ expect(list.map((i) => i.value)).toEqual(['a', 'b'])
102
+ })
103
+ })
104
+
105
+ describe('anchorEdits', () => {
106
+ test('binds offsets to substrings', () => {
107
+ const text = '안녀하세요 저는 콜라브를 만들고 잇습니다'
108
+ const edits = anchorEdits(text, [
109
+ { original: '안녀하세요', suggestion: '안녕하세요' },
110
+ { original: '잇습니다', suggestion: '있습니다' },
111
+ ])
112
+ expect(text.slice(edits[0].start, edits[0].end)).toBe('안녀하세요')
113
+ expect(text.slice(edits[1].start, edits[1].end)).toBe('잇습니다')
114
+ })
115
+
116
+ test('repeated words anchor to distinct occurrences, not all to the first', () => {
117
+ const text = 'teh cat and teh dog'
118
+ const edits = anchorEdits(text, [
119
+ { original: 'teh', suggestion: 'the' },
120
+ { original: 'teh', suggestion: 'the' },
121
+ ])
122
+ expect(edits[0].start).toBe(0)
123
+ expect(edits[1].start).toBe(12)
124
+ })
125
+
126
+ test('drops edits whose original is gone', () => {
127
+ const edits = anchorEdits('hello world', [{ original: 'xyz', suggestion: 'abc' }])
128
+ expect(edits).toEqual([])
129
+ })
130
+
131
+ test('honors the server offset so a duplicate inside a protected span is skipped', () => {
132
+ // "teh" appears first inside backticks (a region the server excludes) and
133
+ // again in plain text at offset 15. A plain search would bind the protected
134
+ // one; the offset pins us to the valid occurrence.
135
+ const text = 'use `teh` then teh'
136
+ const edits = anchorEdits(text, [{ original: 'teh', suggestion: 'the', offset: 15 }])
137
+ expect(edits[0].start).toBe(15)
138
+ expect(text.slice(edits[0].start, edits[0].end)).toBe('teh')
139
+ })
140
+
141
+ test('falls back to search when the offset no longer matches the text', () => {
142
+ const text = 'hello teh'
143
+ const edits = anchorEdits(text, [{ original: 'teh', suggestion: 'the', offset: 99 }])
144
+ expect(edits[0].start).toBe(6)
145
+ })
146
+ })
147
+
148
+ describe('applyEditAt + shiftEditsAfter (re-anchoring)', () => {
149
+ test('applying one edit shifts the offsets of later edits', () => {
150
+ let text = 'teh cat saw teh dog'
151
+ const edits = anchorEdits(text, [
152
+ { original: 'teh', suggestion: 'the' },
153
+ { original: 'teh', suggestion: 'the' },
154
+ ])
155
+ const first = edits[0]
156
+ const { text: t2, delta } = applyEditAt(text, { start: first.start, end: first.end, replacement: 'the' })
157
+ text = t2
158
+ expect(text).toBe('the cat saw teh dog')
159
+ const remaining = shiftEditsAfter(edits.slice(1), first.start, delta)
160
+ // 'the' and 'teh' are the same length here, delta 0; offset unchanged & still valid.
161
+ expect(text.slice(remaining[0].start, remaining[0].end)).toBe('teh')
162
+ })
163
+
164
+ test('length-changing replacement re-anchors later edits correctly', () => {
165
+ let text = 'a wrng word and anothr'
166
+ const edits = anchorEdits(text, [
167
+ { original: 'wrng', suggestion: 'wrong' },
168
+ { original: 'anothr', suggestion: 'another' },
169
+ ])
170
+ const first = edits[0]
171
+ const { text: t2, delta } = applyEditAt(text, { start: first.start, end: first.end, replacement: 'wrong' })
172
+ text = t2
173
+ expect(text).toBe('a wrong word and anothr')
174
+ const remaining = shiftEditsAfter(edits.slice(1), first.start, delta)
175
+ expect(text.slice(remaining[0].start, remaining[0].end)).toBe('anothr')
176
+ })
177
+ })
178
+
179
+ describe('partitionByThreshold', () => {
180
+ test('splits at/above vs below threshold', () => {
181
+ const { autoApplied, candidates } = partitionByThreshold(
182
+ [
183
+ { original: 'a', confidence: 0.9 },
184
+ { original: 'b', confidence: 0.8 },
185
+ { original: 'c', confidence: 0.5 },
186
+ ],
187
+ 80,
188
+ )
189
+ expect(autoApplied.map((e) => e.original)).toEqual(['a', 'b'])
190
+ expect(candidates.map((e) => e.original)).toEqual(['c'])
191
+ })
192
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { ApiError, apiErrorFromResponse, serverErrorMessage } from '../api_error'
5
+
6
+ // Minimal Response-like stub: text() resolves once with the given body.
7
+ function fakeResponse({ status = 422, statusText = 'Unprocessable Entity', body = '' } = {}) {
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ statusText,
12
+ text: async () => body,
13
+ }
14
+ }
15
+
16
+ describe('apiErrorFromResponse', () => {
17
+ test('surfaces a Rails {errors: [...]} string array as the message', async () => {
18
+ const response = fakeResponse({
19
+ body: JSON.stringify({
20
+ errors: ['Description cannot be changed directly for GitHub synced content'],
21
+ }),
22
+ })
23
+
24
+ const error = await apiErrorFromResponse(response)
25
+
26
+ expect(error).toBeInstanceOf(ApiError)
27
+ expect(error.status).toBe(422)
28
+ expect(error.errors).toEqual([
29
+ 'Description cannot be changed directly for GitHub synced content',
30
+ ])
31
+ expect(error.message).toBe(
32
+ 'Description cannot be changed directly for GitHub synced content',
33
+ )
34
+ })
35
+
36
+ test('joins multiple error messages with newlines', async () => {
37
+ const response = fakeResponse({
38
+ body: JSON.stringify({ errors: ['First problem', 'Second problem'] }),
39
+ })
40
+
41
+ const error = await apiErrorFromResponse(response)
42
+
43
+ expect(error.errors).toEqual(['First problem', 'Second problem'])
44
+ expect(error.message).toBe('First problem\nSecond problem')
45
+ })
46
+
47
+ test('flattens a Rails hash-style {errors: {field: [...]}} payload', async () => {
48
+ const response = fakeResponse({
49
+ body: JSON.stringify({ errors: { description: ['is invalid', 'is too long'] } }),
50
+ })
51
+
52
+ const error = await apiErrorFromResponse(response)
53
+
54
+ expect(error.errors).toEqual(['is invalid', 'is too long'])
55
+ })
56
+
57
+ test('supports a singular {error: "..."} payload', async () => {
58
+ const response = fakeResponse({ status: 403, body: JSON.stringify({ error: 'Forbidden' }) })
59
+
60
+ const error = await apiErrorFromResponse(response)
61
+
62
+ expect(error.errors).toEqual(['Forbidden'])
63
+ expect(error.message).toBe('Forbidden')
64
+ })
65
+
66
+ test('falls back to HTTP status when the body has no usable payload', async () => {
67
+ const response = fakeResponse({ status: 500, statusText: 'Internal Server Error', body: '' })
68
+
69
+ const error = await apiErrorFromResponse(response)
70
+
71
+ expect(error.errors).toEqual([])
72
+ expect(error.message).toBe('HTTP 500: Internal Server Error')
73
+ })
74
+
75
+ test('falls back to HTTP status when the body is not JSON', async () => {
76
+ const response = fakeResponse({ status: 502, statusText: 'Bad Gateway', body: '<html>oops</html>' })
77
+
78
+ const error = await apiErrorFromResponse(response)
79
+
80
+ expect(error.errors).toEqual([])
81
+ expect(error.message).toBe('HTTP 502: Bad Gateway')
82
+ })
83
+ })
84
+
85
+ describe('serverErrorMessage', () => {
86
+ test('returns the joined server messages when present', () => {
87
+ const error = new ApiError('boom', { errors: ['Bad thing', 'Worse thing'] })
88
+ expect(serverErrorMessage(error)).toBe('Bad thing\nWorse thing')
89
+ })
90
+
91
+ test('returns null for a plain error with no server payload', () => {
92
+ expect(serverErrorMessage(new Error('HTTP 422: Unprocessable Entity'))).toBeNull()
93
+ expect(serverErrorMessage(new ApiError('boom', { errors: [] }))).toBeNull()
94
+ expect(serverErrorMessage(undefined)).toBeNull()
95
+ })
96
+ })
@@ -5,9 +5,11 @@ import { jest } from '@jest/globals';
5
5
 
6
6
  // Mock csrfFetch using unstable_mockModule for ESM support
7
7
  const mockCsrfFetch = jest.fn();
8
+ const mockRefreshCsrfToken = jest.fn().mockResolvedValue('fresh-token');
8
9
  jest.unstable_mockModule('../csrf_fetch', () => ({
9
10
  __esModule: true,
10
- default: mockCsrfFetch
11
+ default: mockCsrfFetch,
12
+ refreshCsrfToken: mockRefreshCsrfToken,
11
13
  }));
12
14
 
13
15
  // Dynamic imports are required when using unstable_mockModule
@@ -19,6 +21,7 @@ describe('ApiQueueManager', () => {
19
21
  apiQueue.clear();
20
22
  localStorage.clear();
21
23
  mockCsrfFetch.mockClear();
24
+ mockRefreshCsrfToken.mockClear();
22
25
  // Reset processing state
23
26
  apiQueue.processing = false;
24
27
  // Mock processQueue to prevent auto-execution during enqueue tests
@@ -177,4 +180,88 @@ describe('ApiQueueManager', () => {
177
180
  expect(failedItems[0].path).toBe('/fail');
178
181
  expect(failedItems[0].failedAt).toBeDefined();
179
182
  });
183
+
184
+ test('executeRequest throws an ApiError carrying the server error payload', async () => {
185
+ mockCsrfFetch.mockResolvedValue({
186
+ ok: false,
187
+ status: 422,
188
+ statusText: 'Unprocessable Entity',
189
+ text: async () => JSON.stringify({
190
+ errors: ['Description cannot be changed directly for GitHub synced content'],
191
+ }),
192
+ });
193
+
194
+ await expect(apiQueue.executeRequest({ path: '/creatives/42', method: 'PATCH' }))
195
+ .rejects.toMatchObject({
196
+ status: 422,
197
+ errors: ['Description cannot be changed directly for GitHub synced content'],
198
+ message: 'Description cannot be changed directly for GitHub synced content',
199
+ });
200
+ });
201
+
202
+ test('does not retry non-retryable client errors (422)', async () => {
203
+ apiQueue.processQueue.mockRestore();
204
+
205
+ const eventSpy = jest.spyOn(window, 'dispatchEvent');
206
+
207
+ // A 422 validation error will never succeed on retry — it must fail fast.
208
+ mockCsrfFetch.mockResolvedValue({
209
+ ok: false,
210
+ status: 422,
211
+ statusText: 'Unprocessable Entity',
212
+ text: async () => JSON.stringify({ errors: ['Cannot do that'] }),
213
+ });
214
+
215
+ const item = { path: '/creatives/42', method: 'PATCH', retries: 0 };
216
+ apiQueue.queue = [item];
217
+
218
+ await apiQueue.processQueue();
219
+
220
+ // Exactly one attempt — no retries.
221
+ expect(mockCsrfFetch).toHaveBeenCalledTimes(1);
222
+
223
+ const failureEvent = eventSpy.mock.calls
224
+ .map(([event]) => event)
225
+ .find((event) => event.type === 'api-queue-request-failed');
226
+ expect(failureEvent).toBeDefined();
227
+ expect(failureEvent.detail.error.errors).toEqual(['Cannot do that']);
228
+
229
+ const failedItems = JSON.parse(localStorage.getItem('api_queue_test_user_failed'));
230
+ expect(failedItems).toHaveLength(1);
231
+ });
232
+
233
+ test('refreshes the CSRF token and retries a payload-less 422 (stale token)', async () => {
234
+ apiQueue.processQueue.mockRestore();
235
+
236
+ // A stale CSRF token (e.g. after the tab was backgrounded) returns 422
237
+ // with no error payload — unlike a validation 422, it is recoverable by
238
+ // refreshing the token and retrying.
239
+ mockCsrfFetch
240
+ .mockResolvedValueOnce({
241
+ ok: false,
242
+ status: 422,
243
+ statusText: 'Unprocessable Entity',
244
+ text: async () => '',
245
+ })
246
+ .mockResolvedValueOnce({
247
+ ok: true,
248
+ status: 200,
249
+ text: async () => JSON.stringify({ id: 42 }),
250
+ });
251
+
252
+ const onSuccess = jest.fn();
253
+ const item = { path: '/creatives/42', method: 'PATCH', retries: 0, onSuccess };
254
+ apiQueue.queue = [item];
255
+
256
+ await apiQueue.processQueue();
257
+
258
+ // Token refreshed once, then the request retried and succeeded.
259
+ expect(mockRefreshCsrfToken).toHaveBeenCalledTimes(1);
260
+ expect(mockCsrfFetch).toHaveBeenCalledTimes(2);
261
+ expect(onSuccess).toHaveBeenCalled();
262
+ expect(apiQueue.queue).toHaveLength(0);
263
+
264
+ const stored = localStorage.getItem('api_queue_test_user_failed');
265
+ expect(stored ? JSON.parse(stored) : []).toHaveLength(0);
266
+ });
180
267
  });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared API error handling.
3
+ *
4
+ * Server endpoints report failures as JSON, most commonly the Rails shape
5
+ * `{ "errors": ["..."] }` (see CreativesController#update). When a request
6
+ * fails we want the user to see the *server's* message ("Description cannot be
7
+ * changed directly for GitHub synced content") rather than an opaque
8
+ * "HTTP 422". This module centralises that extraction so every API caller can
9
+ * surface the real reason with a generic fallback.
10
+ */
11
+
12
+ /**
13
+ * Error carrying the parsed server payload alongside the HTTP status.
14
+ * `errors` is always an array of human-readable strings (possibly empty).
15
+ */
16
+ export class ApiError extends Error {
17
+ constructor(message, { status, statusText, errors = [], payload = null } = {}) {
18
+ super(message)
19
+ this.name = 'ApiError'
20
+ this.status = status
21
+ this.statusText = statusText
22
+ this.errors = errors
23
+ this.payload = payload
24
+ }
25
+ }
26
+
27
+ function stringifyEntry(entry) {
28
+ if (typeof entry === 'string') return entry.trim()
29
+ if (entry && typeof entry === 'object') {
30
+ return String(entry.message || entry.detail || entry.title || '').trim()
31
+ }
32
+ return ''
33
+ }
34
+
35
+ /**
36
+ * Normalise the assorted server error payload shapes into a flat list of
37
+ * human-readable strings.
38
+ *
39
+ * Supported shapes:
40
+ * { errors: ["a", "b"] } -> ["a", "b"]
41
+ * { errors: { field: ["a"], x: ["b"] } } -> ["a", "b"] (Rails errors hash)
42
+ * { errors: [{ message: "a" }] } -> ["a"]
43
+ * { error: "a" } / { message: "a" } -> ["a"]
44
+ */
45
+ export function extractServerErrors(payload) {
46
+ if (!payload || typeof payload !== 'object') return []
47
+
48
+ const { errors } = payload
49
+ if (Array.isArray(errors)) {
50
+ return errors.map(stringifyEntry).filter(Boolean)
51
+ }
52
+ if (errors && typeof errors === 'object') {
53
+ return Object.values(errors).flat().map(stringifyEntry).filter(Boolean)
54
+ }
55
+ if (typeof payload.error === 'string' && payload.error.trim()) {
56
+ return [payload.error.trim()]
57
+ }
58
+ if (typeof payload.message === 'string' && payload.message.trim()) {
59
+ return [payload.message.trim()]
60
+ }
61
+ return []
62
+ }
63
+
64
+ /**
65
+ * Build an {@link ApiError} from a non-ok Response, reading and parsing its
66
+ * body. Best-effort: an empty or non-JSON body yields an error that falls back
67
+ * to the HTTP status line. Consumes the response body, so only call this on the
68
+ * failure path.
69
+ *
70
+ * @param {Response} response
71
+ * @returns {Promise<ApiError>}
72
+ */
73
+ export async function apiErrorFromResponse(response) {
74
+ let payload = null
75
+ try {
76
+ const text = typeof response.text === 'function' ? await response.text() : ''
77
+ payload = text ? JSON.parse(text) : null
78
+ } catch (_parseError) {
79
+ payload = null
80
+ }
81
+
82
+ const errors = extractServerErrors(payload)
83
+ const message = errors.length
84
+ ? errors.join('\n')
85
+ : `HTTP ${response.status}: ${response.statusText}`
86
+
87
+ return new ApiError(message, {
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ errors,
91
+ payload,
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Best user-facing message for a caught error: the server-provided messages
97
+ * when available, otherwise `null` so the caller can apply its own generic
98
+ * fallback copy.
99
+ *
100
+ * @param {unknown} error
101
+ * @returns {string|null}
102
+ */
103
+ export function serverErrorMessage(error) {
104
+ if (error && Array.isArray(error.errors) && error.errors.length) {
105
+ return error.errors.join('\n')
106
+ }
107
+ return null
108
+ }
@@ -1,8 +1,32 @@
1
- import csrfFetch from './csrf_fetch'
1
+ import csrfFetch, { refreshCsrfToken } from './csrf_fetch'
2
+ import { apiErrorFromResponse } from './api_error'
2
3
 
3
4
  const STORAGE_KEY = 'api_queue'
4
5
  const MAX_RETRIES = 3
5
6
 
7
+ // Client errors that will never succeed on retry (validation, auth, conflict,
8
+ // not-found). Retrying them just delays the real error and wastes round-trips,
9
+ // so they fail fast straight to failedItems. 408 (timeout) and 429 (rate limit)
10
+ // are intentionally excluded — those are worth retrying.
11
+ const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 404, 409])
12
+
13
+ // A 422 is ambiguous. A validation failure carries a server error payload
14
+ // (e.g. { errors: [...] }) and will never succeed on retry. A stale CSRF token
15
+ // (the meta-tag token drifts out of sync after the tab is backgrounded — see
16
+ // Application#set_csrf_token_header) also returns 422 but with no payload, and
17
+ // IS recoverable by refreshing the token and retrying.
18
+ function isStaleCsrf(error) {
19
+ return !!error && error.status === 422 && !(error.errors && error.errors.length)
20
+ }
21
+
22
+ function isRetryable(error) {
23
+ if (!error) return true
24
+ if (NON_RETRYABLE_STATUSES.has(error.status)) return false
25
+ // Validation 422 (has payload) fails fast; payload-less 422 (stale CSRF) retries.
26
+ if (error.status === 422) return isStaleCsrf(error)
27
+ return true
28
+ }
29
+
6
30
  /**
7
31
  * API Queue Manager
8
32
  * Manages asynchronous API requests with localStorage persistence,
@@ -267,9 +291,17 @@ class ApiQueueManager {
267
291
  } catch (error) {
268
292
  console.error('API request failed:', error, item)
269
293
 
270
- // Retry logic
271
- if (item.retries < MAX_RETRIES) {
294
+ // Retry logic — skip retries for client errors that can't
295
+ // succeed on a repeat (e.g. 422 validation): fail fast so the
296
+ // user sees the real error immediately.
297
+ if (isRetryable(error) && item.retries < MAX_RETRIES) {
272
298
  item.retries++
299
+ // A payload-less 422 is a stale CSRF token; refresh it first
300
+ // so the retry has a fresh token (the failed response itself
301
+ // carries none — the forgery exception skips the after_action).
302
+ if (isStaleCsrf(error)) {
303
+ await refreshCsrfToken()
304
+ }
273
305
  // Move to end of queue for retry
274
306
  this.queue.shift()
275
307
  this.queue.push(item)
@@ -353,7 +385,9 @@ class ApiQueueManager {
353
385
  const response = await csrfFetch(url, options)
354
386
 
355
387
  if (!response.ok) {
356
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
388
+ // Surface the server's error payload (e.g. Rails { errors: [...] })
389
+ // instead of an opaque "HTTP 422" so the UI can show the real reason.
390
+ throw await apiErrorFromResponse(response)
357
391
  }
358
392
 
359
393
  return response
@@ -15,18 +15,31 @@ export default class CommonPopup {
15
15
  showAt(anchorRect) {
16
16
  if (!this.element) return
17
17
 
18
+ // Re-opening while already open (e.g. clicking the same typo mark twice):
19
+ // a listener from the previous open is still live, so the opening mousedown
20
+ // would bubble to it and hide() the popup right after we set display:block,
21
+ // leaving it stuck (the rAF below only re-flips visibility, not display).
22
+ // Drop stale listeners first so the opening event can't self-close it.
23
+ document.removeEventListener('mousedown', this.handleOutsideClick)
24
+ document.removeEventListener('touchstart', this.handleOutsideClick)
25
+
18
26
  this.element.style.display = 'block'
19
27
  this.element.style.visibility = 'hidden'
20
28
 
21
29
  requestAnimationFrame(() => {
22
30
  this.updatePosition(anchorRect)
23
31
  this.element.style.visibility = 'visible'
32
+ // Register the outside-click listeners only after the opening event has
33
+ // finished propagating. When a popup is opened from a mousedown handler
34
+ // (e.g. clicking a typo highlight), registering synchronously here would
35
+ // let that same mousedown keep bubbling to document, hit handleOutsideClick
36
+ // (target is outside the popup), and immediately hide() it — the popup
37
+ // would open and instantly vanish. Deferring one frame avoids that race.
38
+ if (this.closeOnOutsideClick) {
39
+ document.addEventListener('mousedown', this.handleOutsideClick)
40
+ document.addEventListener('touchstart', this.handleOutsideClick)
41
+ }
24
42
  })
25
-
26
- if (this.closeOnOutsideClick) {
27
- document.addEventListener('mousedown', this.handleOutsideClick)
28
- document.addEventListener('touchstart', this.handleOutsideClick)
29
- }
30
43
  }
31
44
 
32
45
  updatePosition(anchorRect) {