collavre 0.22.0 → 0.24.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/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -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 +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- 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/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -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/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- 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/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -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/__tests__/topics_controller_topic_list.test.js +61 -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 +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- 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 +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- 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__/common_popup_bounds.test.js +58 -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 +42 -10
- 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__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -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/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -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__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- 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/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- 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/system_events/context_builder.rb +1 -14
- 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/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -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/landing/show.html.erb +52 -10
- 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 +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- 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/20260120045354_encrypt_oauth_tokens.rb +13 -4
- 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/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -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 +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import CommonPopup from '../common_popup'
|
|
5
|
+
|
|
6
|
+
// Cage-inside-bounds positioning: when a boundsElement is passed to showAt,
|
|
7
|
+
// updatePosition must clamp the popup within that element's rect (not the
|
|
8
|
+
// viewport) and cap its size so a long list scrolls inside instead of spilling.
|
|
9
|
+
describe('CommonPopup bounds clamping', () => {
|
|
10
|
+
const BOUNDS = { left: 100, top: 100, right: 500, bottom: 700, width: 400, height: 600 }
|
|
11
|
+
|
|
12
|
+
let element, boundsElement
|
|
13
|
+
|
|
14
|
+
const build = ({ popupWidth, popupHeight }) => {
|
|
15
|
+
boundsElement = document.createElement('div')
|
|
16
|
+
boundsElement.getBoundingClientRect = () => BOUNDS
|
|
17
|
+
|
|
18
|
+
element = document.createElement('div')
|
|
19
|
+
// offsetParent is the bounds (chat box); parentRect === BOUNDS.
|
|
20
|
+
Object.defineProperty(element, 'offsetParent', { value: boundsElement, configurable: true })
|
|
21
|
+
Object.defineProperty(element, 'offsetWidth', { value: popupWidth, configurable: true })
|
|
22
|
+
Object.defineProperty(element, 'offsetHeight', { value: popupHeight, configurable: true })
|
|
23
|
+
document.body.appendChild(element)
|
|
24
|
+
return new CommonPopup(element, { listElement: document.createElement('ul') })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
28
|
+
|
|
29
|
+
test('clamps an anchor near the right/bottom edge back inside the bounds', () => {
|
|
30
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
31
|
+
popup._boundsElement = boundsElement
|
|
32
|
+
// Anchor button hard against the right edge — would overflow the box.
|
|
33
|
+
// maxLeft = right - width - pad = 500 - 320 - 8 = 172; parent coords: 172 - 100 = 72
|
|
34
|
+
popup.updatePosition({ left: 450, bottom: 130 })
|
|
35
|
+
expect(element.style.left).toBe('72px')
|
|
36
|
+
// viewportTop = 130 + 4 = 134; within [108, 292] → 134; parent coords: 134 - 100 = 34
|
|
37
|
+
expect(element.style.top).toBe('34px')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('caps max size to fit inside the bounds', () => {
|
|
41
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
42
|
+
popup._boundsElement = boundsElement
|
|
43
|
+
popup.updatePosition({ left: 200, bottom: 200 })
|
|
44
|
+
// inner box = dimension - 2*pad
|
|
45
|
+
expect(element.style.maxWidth).toBe('384px')
|
|
46
|
+
expect(element.style.maxHeight).toBe('584px')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('falls back to viewport clamping when no bounds element is set', () => {
|
|
50
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
51
|
+
popup._boundsElement = null
|
|
52
|
+
// jsdom viewport defaults 1024x768; anchor at 0 → left clamps to padding 8, parent 0
|
|
53
|
+
Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
|
|
54
|
+
popup.updatePosition({ left: 0, bottom: 0 })
|
|
55
|
+
expect(element.style.maxHeight).toBe('')
|
|
56
|
+
expect(element.style.left).toBe('8px')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
|
|
6
|
+
// Mock the Turbo import so the module installs its confirm method onto a fake
|
|
7
|
+
// config we control, instead of pulling in (and starting) the real Turbo.
|
|
8
|
+
const fakeTurbo = { config: { forms: {} } }
|
|
9
|
+
jest.unstable_mockModule('@hotwired/turbo-rails', () => ({ Turbo: fakeTurbo }))
|
|
10
|
+
|
|
11
|
+
// Importing the module installs the override (side effect).
|
|
12
|
+
await import('../turbo_confirm')
|
|
13
|
+
|
|
14
|
+
const turboConfirm = fakeTurbo.config.forms.confirm
|
|
15
|
+
const modal = () => document.querySelector('.modal-dialog')
|
|
16
|
+
const message = () => document.querySelector('.confirm-dialog-message')
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.innerHTML = ''
|
|
20
|
+
document.documentElement.lang = ''
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('installs a confirm method on Turbo.config.forms', () => {
|
|
24
|
+
expect(typeof turboConfirm).toBe('function')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('renders the in-app confirm dialog with the Turbo message', () => {
|
|
28
|
+
const form = document.createElement('form')
|
|
29
|
+
turboConfirm('Revoke this token?', form, null)
|
|
30
|
+
expect(modal()).not.toBeNull()
|
|
31
|
+
expect(message().textContent).toBe('Revoke this token?')
|
|
32
|
+
// Cancel button present (it is a confirm, not an alert).
|
|
33
|
+
expect(document.querySelector('.modal-dialog-btn-secondary')).not.toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('resolves true when confirmed, false when cancelled', async () => {
|
|
37
|
+
const form = document.createElement('form')
|
|
38
|
+
|
|
39
|
+
const okP = turboConfirm('ok?', form, null)
|
|
40
|
+
document.querySelector('.modal-dialog-btn-danger, .modal-dialog-btn-primary').click()
|
|
41
|
+
await expect(okP).resolves.toBe(true)
|
|
42
|
+
|
|
43
|
+
const cancelP = turboConfirm('no?', form, null)
|
|
44
|
+
document.querySelector('.modal-dialog-btn-secondary').click()
|
|
45
|
+
await expect(cancelP).resolves.toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('styles the confirm button as danger for delete submits', () => {
|
|
49
|
+
const form = document.createElement('form')
|
|
50
|
+
form.setAttribute('data-turbo-method', 'delete')
|
|
51
|
+
turboConfirm('Delete?', form, null)
|
|
52
|
+
expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
|
|
53
|
+
expect(document.querySelector('.modal-dialog-btn-primary')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('detects delete via the submitter and the hidden _method input', () => {
|
|
57
|
+
// Submitter carries the method (button_to renders a button submitter).
|
|
58
|
+
const form1 = document.createElement('form')
|
|
59
|
+
const submitter = document.createElement('button')
|
|
60
|
+
submitter.setAttribute('data-turbo-method', 'delete')
|
|
61
|
+
turboConfirm('x', form1, submitter)
|
|
62
|
+
expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
|
|
63
|
+
document.body.innerHTML = ''
|
|
64
|
+
|
|
65
|
+
// Rails non-Turbo fallback: hidden _method=delete input.
|
|
66
|
+
const form2 = document.createElement('form')
|
|
67
|
+
const hidden = document.createElement('input')
|
|
68
|
+
hidden.name = '_method'
|
|
69
|
+
hidden.value = 'delete'
|
|
70
|
+
form2.appendChild(hidden)
|
|
71
|
+
turboConfirm('x', form2, null)
|
|
72
|
+
expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('non-destructive submit uses the primary (non-danger) button', () => {
|
|
76
|
+
const form = document.createElement('form')
|
|
77
|
+
form.setAttribute('method', 'post')
|
|
78
|
+
turboConfirm('Proceed?', form, null)
|
|
79
|
+
expect(document.querySelector('.modal-dialog-btn-primary')).not.toBeNull()
|
|
80
|
+
expect(document.querySelector('.modal-dialog-btn-danger')).toBeNull()
|
|
81
|
+
})
|
|
@@ -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
|
+
}
|