collavre 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|