collavre 0.24.0 → 0.25.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 +0 -1
- data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
- data/app/assets/stylesheets/collavre/creatives.css +346 -37
- data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
- data/app/assets/stylesheets/collavre/gnb.css +64 -3
- data/app/assets/stylesheets/collavre/landing.css +108 -3
- data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +14 -0
- data/app/assets/stylesheets/collavre/print.css +0 -1
- data/app/assets/stylesheets/collavre/search_popup.css +1 -1
- data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
- data/app/assets/stylesheets/collavre/workspace.css +307 -0
- data/app/channels/collavre/agent_channel.rb +14 -21
- data/app/channels/collavre/comments_presence_channel.rb +181 -21
- data/app/channels/collavre/inbox_badge_channel.rb +7 -1
- data/app/components/collavre/inbox/badge_component.rb +6 -13
- data/app/components/collavre/progress_filter_component.html.erb +2 -2
- data/app/components/collavre/progress_filter_component.rb +20 -0
- data/app/controllers/collavre/admin/settings_controller.rb +4 -1
- data/app/controllers/collavre/agent_connections_controller.rb +165 -0
- data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
- data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
- data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
- data/app/controllers/collavre/application_controller.rb +13 -2
- data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
- data/app/controllers/collavre/comments_controller.rb +45 -131
- data/app/controllers/collavre/concerns/shareable.rb +9 -3
- data/app/controllers/collavre/creatives_controller.rb +144 -11
- data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
- data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
- data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
- data/app/controllers/collavre/features_controller.rb +57 -0
- data/app/controllers/collavre/invites_controller.rb +12 -1
- data/app/controllers/collavre/llm_models_controller.rb +8 -0
- data/app/controllers/collavre/notices_controller.rb +20 -0
- data/app/controllers/collavre/tasks_controller.rb +40 -6
- data/app/controllers/collavre/topics_controller.rb +130 -53
- data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
- data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
- data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
- data/app/errors/collavre/turn_deadline_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +38 -0
- data/app/helpers/collavre/creatives_helper.rb +56 -22
- data/app/helpers/collavre/navigation_helper.rb +12 -0
- data/app/javascript/collavre.js +1 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
- data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
- data/app/javascript/components/creative_tree_row.js +62 -22
- data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
- data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
- data/app/javascript/controllers/__tests__/index.test.js +42 -0
- data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
- data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
- data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
- data/app/javascript/controllers/agent_connection_controller.js +357 -0
- data/app/javascript/controllers/agent_vendor_controller.js +23 -0
- data/app/javascript/controllers/comment_controller.js +3 -1
- data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
- data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
- data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
- data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
- data/app/javascript/controllers/comments/form_controller.js +795 -14
- data/app/javascript/controllers/comments/list_controller.js +338 -52
- data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
- data/app/javascript/controllers/comments/popup_controller.js +425 -83
- data/app/javascript/controllers/comments/presence_controller.js +317 -55
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +1772 -100
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
- data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
- data/app/javascript/controllers/creatives/tree_controller.js +140 -31
- data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
- data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
- data/app/javascript/controllers/gateway_check_controller.js +36 -0
- data/app/javascript/controllers/index.js +25 -1
- data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
- data/app/javascript/controllers/link_creative_controller.js +89 -18
- data/app/javascript/controllers/progress_filter_controller.js +12 -22
- data/app/javascript/controllers/search_popup_controller.js +64 -46
- data/app/javascript/controllers/share_user_search_controller.js +2 -2
- data/app/javascript/controllers/topic_list_controller.js +44 -7
- data/app/javascript/controllers/workspace_tree_controller.js +662 -0
- data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
- data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
- data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
- data/app/javascript/creatives/tree_renderer.js +65 -22
- data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
- data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
- data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
- data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
- data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
- data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
- data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
- data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
- data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
- data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
- data/app/javascript/lib/api/creatives.js +32 -13
- data/app/javascript/lib/api/csrf_fetch.js +6 -4
- data/app/javascript/lib/api/topics.js +63 -5
- data/app/javascript/lib/chat_drafts.js +786 -0
- data/app/javascript/lib/common_popup.js +164 -25
- data/app/javascript/lib/creative_link_prefetch.js +27 -0
- data/app/javascript/lib/last_visited_creative.js +97 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
- data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
- data/app/javascript/lib/lexical/creative_link_node.js +101 -0
- data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
- data/app/javascript/lib/lexical/link_toolbar.js +23 -0
- data/app/javascript/lib/turbo_stream_actions.js +23 -16
- data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
- data/app/javascript/lib/utils/filter_navigation.js +100 -0
- data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
- data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
- data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
- data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
- data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
- data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
- data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
- data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
- data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
- data/app/javascript/modules/command_args_form.js +0 -12
- data/app/javascript/modules/command_menu.js +80 -38
- data/app/javascript/modules/creative_link_picker.js +56 -0
- data/app/javascript/modules/creative_row_editor.js +446 -243
- data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
- data/app/javascript/modules/creative_tree_dom.js +4 -0
- data/app/javascript/modules/creative_tree_empty_state.js +80 -0
- data/app/javascript/modules/dom_listener_registry.js +23 -0
- data/app/javascript/modules/mention_menu.js +2 -3
- data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
- data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
- data/app/javascript/utils/caret_position.js +10 -0
- data/app/javascript/utils/chevron_icons.js +7 -0
- data/app/jobs/collavre/ai_agent_job.rb +262 -72
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
- data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
- data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
- data/app/jobs/collavre/comment_notification_job.rb +14 -0
- data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
- data/app/jobs/collavre/compress_job.rb +33 -22
- data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
- data/app/jobs/collavre/cron_action_job.rb +36 -18
- data/app/jobs/collavre/drop_trigger_job.rb +23 -9
- data/app/jobs/collavre/merge_comments_job.rb +33 -16
- data/app/jobs/collavre/push_notification_job.rb +62 -18
- data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
- data/app/jobs/collavre/stuck_detector_job.rb +1 -1
- data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
- data/app/models/collavre/agent_gateway.rb +200 -0
- data/app/models/collavre/agent_subscription.rb +4 -4
- data/app/models/collavre/agent_workspace.rb +210 -0
- data/app/models/collavre/comment/broadcastable.rb +22 -52
- data/app/models/collavre/comment/notifiable.rb +154 -77
- data/app/models/collavre/comment/topic_membership.rb +41 -0
- data/app/models/collavre/comment.rb +336 -31
- data/app/models/collavre/comment_notification_delivery.rb +61 -0
- data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
- data/app/models/collavre/comment_presence_store.rb +272 -12
- data/app/models/collavre/comment_read_pointer.rb +9 -15
- data/app/models/collavre/creative/describable.rb +8 -2
- data/app/models/collavre/creative/permissible.rb +11 -0
- data/app/models/collavre/creative.rb +29 -4
- data/app/models/collavre/creative_share.rb +109 -4
- data/app/models/collavre/current.rb +1 -0
- data/app/models/collavre/llm_model.rb +62 -0
- data/app/models/collavre/system_setting.rb +12 -1
- data/app/models/collavre/task.rb +121 -2
- data/app/models/collavre/topic.rb +51 -0
- data/app/models/collavre/user.rb +101 -41
- data/app/models/collavre/user_creative_preference.rb +3 -1
- data/app/services/collavre/agent_provisioning/archive.rb +108 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
- data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
- data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
- data/app/services/collavre/ai_agent/message_builder.rb +103 -16
- data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
- data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
- data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
- data/app/services/collavre/ai_agent_service.rb +108 -20
- data/app/services/collavre/ai_client.rb +211 -21
- data/app/services/collavre/cli_proxy/client.rb +147 -0
- data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
- data/app/services/collavre/cli_proxy/identity.rb +47 -0
- data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
- data/app/services/collavre/command_menu_service.rb +0 -10
- data/app/services/collavre/comment_move_service.rb +96 -7
- data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
- data/app/services/collavre/comments/action_executor.rb +48 -20
- data/app/services/collavre/comments/command_processor.rb +0 -1
- data/app/services/collavre/comments/list_response.rb +133 -0
- data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
- data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
- data/app/services/collavre/comments/read_receipt_index.rb +152 -0
- data/app/services/collavre/comments/topic_command.rb +72 -1
- data/app/services/collavre/comments/topic_mutation.rb +20 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
- data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
- data/app/services/collavre/creatives/reorderer.rb +15 -3
- data/app/services/collavre/creatives/tree_builder.rb +1 -1
- data/app/services/collavre/creatives/tree_formatter.rb +5 -6
- data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
- data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
- data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
- data/app/services/collavre/home_page_path.rb +55 -0
- data/app/services/collavre/html_text.rb +64 -0
- data/app/services/collavre/http_client.rb +11 -2
- data/app/services/collavre/inbox_reply_service.rb +1 -1
- data/app/services/collavre/markdown_converter.rb +13 -1
- data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
- data/app/services/collavre/orchestration/arbiter.rb +48 -5
- data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
- data/app/services/collavre/orchestration/delivery_record.rb +792 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
- data/app/services/collavre/orchestration/matcher.rb +132 -17
- data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
- data/app/services/collavre/orchestration/scheduler.rb +10 -4
- data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
- data/app/services/collavre/orchestration/selection.rb +43 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
- data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
- data/app/services/collavre/orchestration/topic_slot.rb +73 -0
- data/app/services/collavre/system_events/context_builder.rb +60 -11
- data/app/services/collavre/system_events/dispatcher.rb +58 -12
- data/app/services/collavre/system_events/envelope.rb +73 -0
- data/app/services/collavre/system_events/vocabulary.rb +45 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
- data/app/services/collavre/tools/cron_create_service.rb +32 -18
- data/app/services/collavre/tools/id_list.rb +38 -0
- data/app/services/collavre/tools/topic_authorizer.rb +61 -7
- data/app/services/collavre/tools/topic_branch_service.rb +85 -0
- data/app/services/collavre/tools/topic_create_service.rb +117 -0
- data/app/services/collavre/tools/topic_list_service.rb +101 -0
- data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
- data/app/services/collavre/tools/topic_messages_service.rb +346 -0
- data/app/services/collavre/tools/topic_move_service.rb +108 -0
- data/app/services/collavre/tools/topic_selection.rb +59 -0
- data/app/services/collavre/tools/topic_update_service.rb +165 -0
- data/app/services/collavre/topic_branch_service.rb +39 -10
- data/app/services/collavre/topics/agent_resolver.rb +87 -0
- data/app/services/collavre/topics/char_budget.rb +70 -0
- data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
- data/app/services/collavre/topics/message_page.rb +380 -0
- data/app/services/collavre/topics/message_page_markdown.rb +102 -0
- data/app/services/collavre/topics/message_scope.rb +61 -0
- data/app/services/collavre/topics/message_stats.rb +92 -0
- data/app/services/collavre/topics/move_blocker.rb +47 -0
- data/app/services/collavre/topics/next_name.rb +35 -0
- data/app/services/collavre/topics/pin_rejection.rb +25 -0
- data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
- data/app/services/collavre/topics/reserved_name.rb +18 -0
- data/app/services/collavre/topics/serializer.rb +92 -0
- data/app/services/collavre/topics/topic_destroy.rb +30 -0
- data/app/services/collavre/topics/topic_move.rb +80 -0
- data/app/services/collavre/topics/topic_move_effects.rb +35 -0
- data/app/services/collavre/topics/topic_mutation.rb +27 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
- data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
- data/app/views/collavre/agent_connections/show.html.erb +77 -0
- data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
- data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
- data/app/views/collavre/agent_gateways/index.html.erb +54 -0
- data/app/views/collavre/agent_gateways/new.html.erb +2 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
- data/app/views/collavre/comments/_empty_state.html.erb +33 -0
- data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
- data/app/views/collavre/comments/_feature_card.html.erb +35 -0
- data/app/views/collavre/comments/_list.html.erb +3 -2
- data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
- data/app/views/collavre/creatives/_add_button.html.erb +17 -19
- data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
- data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
- data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
- data/app/views/collavre/creatives/index.html.erb +79 -18
- data/app/views/collavre/desktop_setup/show.html.erb +114 -0
- data/app/views/collavre/features/index.html.erb +42 -0
- data/app/views/collavre/features/show.html.erb +52 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
- data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
- data/app/views/collavre/invites/show.html.erb +14 -1
- data/app/views/collavre/landing/show.html.erb +5 -1
- data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
- data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
- data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
- data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
- data/app/views/collavre/user_themes/index.html.erb +1 -1
- data/app/views/collavre/users/_llm_fields.html.erb +65 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
- data/app/views/collavre/users/edit_ai.html.erb +33 -31
- data/app/views/collavre/users/new.html.erb +4 -2
- data/app/views/collavre/users/new_ai.html.erb +14 -32
- data/app/views/collavre/users/show.html.erb +13 -1
- data/app/views/layouts/collavre/landing.html.erb +9 -4
- data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
- data/config/locales/admin.en.yml +3 -1
- data/config/locales/admin.ko.yml +3 -1
- data/config/locales/agent_gateway.en.yml +217 -0
- data/config/locales/agent_gateway.ko.yml +217 -0
- data/config/locales/ai_agent.en.yml +0 -1
- data/config/locales/ai_agent.ko.yml +0 -1
- data/config/locales/comments.en.yml +72 -30
- data/config/locales/comments.ko.yml +72 -30
- data/config/locales/creatives.en.yml +32 -1
- data/config/locales/creatives.ko.yml +32 -1
- data/config/locales/features.en.yml +140 -0
- data/config/locales/features.ko.yml +140 -0
- data/config/locales/invites.en.yml +3 -0
- data/config/locales/invites.ko.yml +3 -0
- data/config/locales/landing.en.yml +2 -1
- data/config/locales/landing.ko.yml +2 -1
- data/config/locales/tools.en.yml +20 -0
- data/config/locales/tools.ko.yml +20 -0
- data/config/locales/users.en.yml +15 -14
- data/config/locales/users.ko.yml +14 -14
- data/config/routes.rb +45 -19
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
- data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
- data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
- data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
- data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
- data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
- data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
- data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
- data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
- data/db/migrate/20260809000001_create_llm_models.rb +64 -0
- data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
- data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
- data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
- data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
- data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
- data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
- data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
- data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
- data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
- data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
- data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
- data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
- data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
- data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
- data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
- data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
- data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
- data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
- data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
- data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
- data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
- data/db/seeds.rb +1 -51
- data/lib/collavre/engine.rb +108 -1
- data/lib/collavre/feature_card_registry.rb +168 -0
- data/lib/collavre/hashed_access_token_lookup.rb +22 -0
- data/lib/collavre/sensitive_request_silencer.rb +23 -0
- data/lib/collavre/user_extensions.rb +0 -2
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +1 -0
- data/skills/collavre/SKILL.md +165 -0
- data/skills/collavre/package.json +8 -0
- data/skills/collavre/references/tool-reference.md +347 -0
- data/skills/collavre/scripts/collavre +634 -0
- data/skills/collavre/scripts/html_entities.js +24 -0
- metadata +228 -30
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
- data/app/controllers/collavre/inbox_items_controller.rb +0 -70
- data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
- data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
- data/app/javascript/lib/typo_correction.js +0 -146
- data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
- data/app/javascript/modules/creative_guide.js +0 -67
- data/app/javascript/modules/typo_corrector.js +0 -534
- data/app/jobs/collavre/inbox_summary_job.rb +0 -24
- data/app/mailers/collavre/inbox_mailer.rb +0 -19
- data/app/models/collavre/inbox_item.rb +0 -89
- data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
- data/app/services/collavre/comments/work_command.rb +0 -161
- data/app/services/collavre/comments/workflow_executor.rb +0 -276
- data/app/services/collavre/gemini_parent_recommender.rb +0 -77
- data/app/services/collavre/mobile/event_summarizer.rb +0 -40
- data/app/services/collavre/typo_corrector.rb +0 -188
- data/app/views/collavre/inbox_items/_item.html.erb +0 -18
- data/app/views/collavre/inbox_items/_items.html.erb +0 -3
- data/app/views/collavre/inbox_items/_list.html.erb +0 -7
- data/app/views/collavre/inbox_items/index.html.erb +0 -1
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
- data/app/views/collavre/users/typo_correction.html.erb +0 -50
- data/config/locales/mobile.en.yml +0 -16
- data/config/locales/mobile.ko.yml +0 -16
|
@@ -0,0 +1,2069 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
import { Application } from '@hotwired/stimulus'
|
|
7
|
+
import FormController from '../form_controller'
|
|
8
|
+
import chatDrafts from '../../../lib/chat_drafts'
|
|
9
|
+
|
|
10
|
+
describe('FormController - draft persistence', () => {
|
|
11
|
+
let application
|
|
12
|
+
let container
|
|
13
|
+
let controller
|
|
14
|
+
let popupEl
|
|
15
|
+
|
|
16
|
+
const FIXTURE = `
|
|
17
|
+
<div id="comments-popup"
|
|
18
|
+
data-controller="comments--form"
|
|
19
|
+
data-update-comment-text="Update">
|
|
20
|
+
<form data-comments--form-target="form">
|
|
21
|
+
<input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
|
|
22
|
+
<input type="hidden" name="comment[quoted_text]" data-comments--form-target="quotedText" value="" />
|
|
23
|
+
<div data-comments--form-target="quoteIndicator" style="display:none;">
|
|
24
|
+
<span data-comments--form-target="quoteIndicatorText"></span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="review-quotes-container" data-comments--form-target="reviewQuotesContainer" style="display:none;"></div>
|
|
27
|
+
<textarea data-comments--form-target="textarea" rows="2"></textarea>
|
|
28
|
+
<input type="checkbox" data-comments--form-target="privateCheckbox" />
|
|
29
|
+
<button type="submit" data-comments--form-target="submit">Send</button>
|
|
30
|
+
<button data-comments--form-target="cancel" style="display:none;">Cancel</button>
|
|
31
|
+
<button data-comments--form-target="searchButton" style="display:none;">Search</button>
|
|
32
|
+
<button data-comments--form-target="voiceButton" style="display:none;">Voice</button>
|
|
33
|
+
<input type="file" data-comments--form-target="imageInput" style="display:none;" />
|
|
34
|
+
<button data-comments--form-target="imageButton" style="display:none;">Image</button>
|
|
35
|
+
<div data-comments--form-target="attachmentList"></div>
|
|
36
|
+
</form>
|
|
37
|
+
</div>`
|
|
38
|
+
|
|
39
|
+
const dispatchTopicChange = (effectiveCreativeId, topicId = '10073') => {
|
|
40
|
+
popupEl.dataset.effectiveCreativeId = String(effectiveCreativeId)
|
|
41
|
+
popupEl.dispatchEvent(
|
|
42
|
+
new CustomEvent('comments--topics:change', { detail: { topicId } }),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const typeInto = (textarea, value) => {
|
|
47
|
+
textarea.value = value
|
|
48
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dismissAlert = () => {
|
|
52
|
+
document.querySelector('.modal-dialog-btn-primary')?.click()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const submissionBackup = (chatId) => (
|
|
56
|
+
chatDrafts.latestSubmissionBackup(chatId)?.text || null
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
window.localStorage.clear()
|
|
61
|
+
window.sessionStorage.clear()
|
|
62
|
+
document.body.dataset.currentUserId = '9'
|
|
63
|
+
|
|
64
|
+
if (typeof global.requestAnimationFrame !== 'function') {
|
|
65
|
+
global.requestAnimationFrame = (cb) => setTimeout(cb, 0)
|
|
66
|
+
}
|
|
67
|
+
if (!global.DataTransfer) {
|
|
68
|
+
global.DataTransfer = class {
|
|
69
|
+
constructor() {
|
|
70
|
+
this.files = []
|
|
71
|
+
this.items = { add: (file) => this.files.push(file) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const meta = document.createElement('meta')
|
|
76
|
+
meta.name = 'csrf-token'
|
|
77
|
+
meta.content = 'test-token'
|
|
78
|
+
document.head.appendChild(meta)
|
|
79
|
+
|
|
80
|
+
container = document.createElement('div')
|
|
81
|
+
container.innerHTML = FIXTURE
|
|
82
|
+
document.body.appendChild(container)
|
|
83
|
+
|
|
84
|
+
application = Application.start()
|
|
85
|
+
application.register('comments--form', FormController)
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
87
|
+
popupEl = container.querySelector('#comments-popup')
|
|
88
|
+
controller = application.getControllerForElementAndIdentifier(popupEl, 'comments--form')
|
|
89
|
+
jest.spyOn(controller, 'setImageFiles').mockImplementation(() => {})
|
|
90
|
+
controller.creativeId = '77'
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
application.stop()
|
|
95
|
+
document.body.removeChild(container)
|
|
96
|
+
document.head.querySelector('meta[name="csrf-token"]').remove()
|
|
97
|
+
delete document.body.dataset.currentUserId
|
|
98
|
+
jest.restoreAllMocks()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('restores a saved draft when the popup opens for that chat', () => {
|
|
102
|
+
chatDrafts.set('77', 'unfinished thought')
|
|
103
|
+
dispatchTopicChange('77')
|
|
104
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
105
|
+
expect(controller.textareaTarget.value).toBe('unfinished thought')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('does not clobber a non-empty textarea on restore', () => {
|
|
109
|
+
chatDrafts.set('77', 'stored draft')
|
|
110
|
+
dispatchTopicChange('77')
|
|
111
|
+
controller.textareaTarget.value = 'already typing'
|
|
112
|
+
controller._restoreDraft()
|
|
113
|
+
expect(controller.textareaTarget.value).toBe('already typing')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('typing saves the draft after the 500ms debounce', async () => {
|
|
117
|
+
dispatchTopicChange('77')
|
|
118
|
+
typeInto(controller.textareaTarget, 'saving me')
|
|
119
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
121
|
+
expect(chatDrafts.get('77')).toBe('saving me')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('switching to another chat flush-saves the previous draft under the previous key', () => {
|
|
125
|
+
dispatchTopicChange('77')
|
|
126
|
+
controller.textareaTarget.value = 'draft for 77'
|
|
127
|
+
dispatchTopicChange('88')
|
|
128
|
+
expect(chatDrafts.get('77')).toBe('draft for 77')
|
|
129
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
130
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('typing during an asynchronous chat switch is saved under the incoming chat', () => {
|
|
134
|
+
dispatchTopicChange('77')
|
|
135
|
+
typeInto(controller.textareaTarget, 'draft for 77')
|
|
136
|
+
|
|
137
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
138
|
+
typeInto(controller.textareaTarget, 'draft for 88')
|
|
139
|
+
|
|
140
|
+
expect(chatDrafts.get('77')).toBe('draft for 77')
|
|
141
|
+
expect(chatDrafts.get('88')).toBeNull()
|
|
142
|
+
|
|
143
|
+
dispatchTopicChange('88')
|
|
144
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
145
|
+
|
|
146
|
+
expect(controller.textareaTarget.value).toBe('draft for 88')
|
|
147
|
+
expect(chatDrafts.get('88')).toBe('draft for 88')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('a slash-command stash from the outgoing chat does not suppress the incoming draft', () => {
|
|
151
|
+
dispatchTopicChange('77')
|
|
152
|
+
typeInto(controller.textareaTarget, 'draft for 77')
|
|
153
|
+
controller.handleStashDraft(
|
|
154
|
+
new CustomEvent('comments--form:stash-draft', {
|
|
155
|
+
detail: { draft: 'draft for 77' },
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
controller.textareaTarget.value = '/calendar 2026-08-14 10:00 Sync'
|
|
159
|
+
|
|
160
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
161
|
+
typeInto(controller.textareaTarget, 'draft for 88')
|
|
162
|
+
controller.onPopupClosed()
|
|
163
|
+
|
|
164
|
+
expect(chatDrafts.get('77')).toBe('draft for 77')
|
|
165
|
+
expect(chatDrafts.get('88')).toBe('draft for 88')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('typing during a linked chat switch migrates from raw to effective draft key', () => {
|
|
169
|
+
popupEl.dataset.creativeId = '77'
|
|
170
|
+
dispatchTopicChange('70')
|
|
171
|
+
typeInto(controller.textareaTarget, 'shared draft before switch')
|
|
172
|
+
|
|
173
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
174
|
+
typeInto(controller.textareaTarget, 'shared draft after switch')
|
|
175
|
+
|
|
176
|
+
popupEl.dataset.creativeId = '78'
|
|
177
|
+
dispatchTopicChange('70')
|
|
178
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
179
|
+
|
|
180
|
+
expect(controller.textareaTarget.value).toBe('shared draft after switch')
|
|
181
|
+
expect(chatDrafts.get('70')).toBe('shared draft after switch')
|
|
182
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('a stale raw linked draft does not replace a newer effective draft', () => {
|
|
186
|
+
let now = 1000
|
|
187
|
+
jest.spyOn(Date, 'now').mockImplementation(() => now)
|
|
188
|
+
chatDrafts.set('78', 'stale raw draft')
|
|
189
|
+
now += 1000
|
|
190
|
+
chatDrafts.set('70', 'new canonical draft')
|
|
191
|
+
|
|
192
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
193
|
+
expect(controller.textareaTarget.value).toBe('stale raw draft')
|
|
194
|
+
|
|
195
|
+
popupEl.dataset.creativeId = '78'
|
|
196
|
+
dispatchTopicChange('70')
|
|
197
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
198
|
+
|
|
199
|
+
expect(controller.textareaTarget.value).toBe('new canonical draft')
|
|
200
|
+
expect(chatDrafts.get('70')).toBe('new canonical draft')
|
|
201
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('clearing a raw linked draft removes an older canonical draft', () => {
|
|
205
|
+
let now = 1000
|
|
206
|
+
jest.spyOn(Date, 'now').mockImplementation(() => now)
|
|
207
|
+
chatDrafts.set('70', 'canonical draft')
|
|
208
|
+
now += 1
|
|
209
|
+
chatDrafts.set('78', 'raw draft shown while loading')
|
|
210
|
+
|
|
211
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
212
|
+
expect(controller.textareaTarget.value).toBe('raw draft shown while loading')
|
|
213
|
+
|
|
214
|
+
now += 1
|
|
215
|
+
typeInto(controller.textareaTarget, '')
|
|
216
|
+
controller._flushDraftSave()
|
|
217
|
+
const tombstoneUpdatedAt = chatDrafts.updatedAt('78')
|
|
218
|
+
controller._flushDraftSave()
|
|
219
|
+
expect(chatDrafts.updatedAt('78')).toBe(tombstoneUpdatedAt)
|
|
220
|
+
|
|
221
|
+
popupEl.dataset.creativeId = '78'
|
|
222
|
+
dispatchTopicChange('70')
|
|
223
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
224
|
+
|
|
225
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
226
|
+
expect(chatDrafts.get('70')).toBeNull()
|
|
227
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('closing an untouched linked chat before topics load preserves the canonical draft', () => {
|
|
231
|
+
chatDrafts.set('70', 'canonical draft')
|
|
232
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
233
|
+
|
|
234
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
235
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
236
|
+
controller.onPopupClosed()
|
|
237
|
+
|
|
238
|
+
expect(chatDrafts.updatedAt('78')).toBeNull()
|
|
239
|
+
|
|
240
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
241
|
+
popupEl.dataset.creativeId = '78'
|
|
242
|
+
dispatchTopicChange('70')
|
|
243
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
244
|
+
|
|
245
|
+
expect(controller.textareaTarget.value).toBe('canonical draft')
|
|
246
|
+
expect(chatDrafts.get('70')).toBe('canonical draft')
|
|
247
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('switching topics within the same chat does not flush the draft', () => {
|
|
251
|
+
dispatchTopicChange('77', '10073')
|
|
252
|
+
controller.textareaTarget.value = 'still composing'
|
|
253
|
+
dispatchTopicChange('77', '10074')
|
|
254
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
255
|
+
expect(controller._activeDraftKey).toBe('77')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('switching linked creatives flushes before resetting a shared effective draft', () => {
|
|
259
|
+
popupEl.dataset.creativeId = '77'
|
|
260
|
+
dispatchTopicChange('70', '10073')
|
|
261
|
+
typeInto(controller.textareaTarget, 'draft from linked creative 77')
|
|
262
|
+
|
|
263
|
+
popupEl.dataset.creativeId = '78'
|
|
264
|
+
dispatchTopicChange('70', '10073')
|
|
265
|
+
|
|
266
|
+
expect(chatDrafts.get('70')).toBe('draft from linked creative 77')
|
|
267
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
268
|
+
expect(controller.textareaTarget.value).toBe('draft from linked creative 77')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('falls back to the raw creative id until an effective id is available', () => {
|
|
272
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
273
|
+
popupEl.dataset.creativeId = '66'
|
|
274
|
+
popupEl.dispatchEvent(
|
|
275
|
+
new CustomEvent('comments--topics:change', { detail: { topicId: '10073' } }),
|
|
276
|
+
)
|
|
277
|
+
expect(controller._activeDraftKey).toBe('66')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('keeps the draft key empty when no creative id is available', () => {
|
|
281
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
282
|
+
delete popupEl.dataset.creativeId
|
|
283
|
+
popupEl.dispatchEvent(
|
|
284
|
+
new CustomEvent('comments--topics:change', { detail: { topicId: '10073' } }),
|
|
285
|
+
)
|
|
286
|
+
expect(controller._activeDraftKey).toBeNull()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('closing the popup flush-saves the draft; blank input deletes it', () => {
|
|
290
|
+
dispatchTopicChange('77')
|
|
291
|
+
controller.textareaTarget.value = 'saved on close'
|
|
292
|
+
controller.onPopupClosed()
|
|
293
|
+
expect(chatDrafts.get('77')).toBe('saved on close')
|
|
294
|
+
|
|
295
|
+
dispatchTopicChange('77')
|
|
296
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
297
|
+
expect(controller.textareaTarget.value).toBe('saved on close')
|
|
298
|
+
controller.textareaTarget.value = ''
|
|
299
|
+
controller.onPopupClosed()
|
|
300
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('disconnect (Turbo navigation) flush-saves the draft', () => {
|
|
304
|
+
dispatchTopicChange('77')
|
|
305
|
+
controller.textareaTarget.value = 'leaving the page'
|
|
306
|
+
controller.disconnect()
|
|
307
|
+
expect(chatDrafts.get('77')).toBe('leaving the page')
|
|
308
|
+
controller.connect()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('Turbo reconnect keeps comment edits out of the ordinary draft', () => {
|
|
312
|
+
dispatchTopicChange('77')
|
|
313
|
+
typeInto(controller.textareaTarget, 'ordinary draft')
|
|
314
|
+
controller.startEditing({ id: '12', content: 'edited comment', private: false })
|
|
315
|
+
|
|
316
|
+
controller.disconnect()
|
|
317
|
+
controller.connect()
|
|
318
|
+
typeInto(controller.textareaTarget, 'edited after reconnect')
|
|
319
|
+
controller.disconnect()
|
|
320
|
+
|
|
321
|
+
expect(controller.editingId).toBe('12')
|
|
322
|
+
expect(chatDrafts.get('77')).toBe('ordinary draft')
|
|
323
|
+
controller.connect()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('Turbo reconnect keeps review feedback out of the ordinary draft', () => {
|
|
327
|
+
dispatchTopicChange('77')
|
|
328
|
+
typeInto(controller.textareaTarget, 'ordinary draft')
|
|
329
|
+
controller.appendReviewQuote(5, 'quoted review text')
|
|
330
|
+
typeInto(controller.textareaTarget, 'feedback before reconnect')
|
|
331
|
+
|
|
332
|
+
controller.disconnect()
|
|
333
|
+
controller.connect()
|
|
334
|
+
typeInto(controller.textareaTarget, 'feedback after reconnect')
|
|
335
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
336
|
+
|
|
337
|
+
expect(controller._reviewStore.isEmpty).toBe(false)
|
|
338
|
+
expect(chatDrafts.get('77')).toBe('ordinary draft')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('native pagehide flushes input before the debounce completes', () => {
|
|
342
|
+
dispatchTopicChange('77')
|
|
343
|
+
typeInto(controller.textareaTarget, 'reload-safe draft')
|
|
344
|
+
|
|
345
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
346
|
+
|
|
347
|
+
expect(chatDrafts.get('77')).toBe('reload-safe draft')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('a cross-tab logout discards pending input for the signed-out user', async () => {
|
|
351
|
+
dispatchTopicChange('77')
|
|
352
|
+
chatDrafts.set('88', 'already saved in this tab')
|
|
353
|
+
typeInto(controller.textareaTarget, 'must not return after logout')
|
|
354
|
+
|
|
355
|
+
window.dispatchEvent(new StorageEvent('storage', {
|
|
356
|
+
key: 'collavre_chat_drafts_clear',
|
|
357
|
+
newValue: JSON.stringify({
|
|
358
|
+
namespace: 'collavre_chat_drafts_9',
|
|
359
|
+
nonce: 'another-tab-logout',
|
|
360
|
+
}),
|
|
361
|
+
}))
|
|
362
|
+
controller.disconnect()
|
|
363
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
364
|
+
|
|
365
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
366
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
367
|
+
expect(chatDrafts.get('88')).toBeNull()
|
|
368
|
+
controller.connect()
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('a cross-tab logout keeps the cleared user namespace disabled', async () => {
|
|
372
|
+
dispatchTopicChange('77')
|
|
373
|
+
typeInto(controller.textareaTarget, 'old user draft')
|
|
374
|
+
|
|
375
|
+
controller.disconnect()
|
|
376
|
+
chatDrafts.clearAll()
|
|
377
|
+
const clearSignalKey = chatDrafts._clearEventKey()
|
|
378
|
+
const clearSignal = window.localStorage.getItem(clearSignalKey)
|
|
379
|
+
window.dispatchEvent(new StorageEvent('storage', {
|
|
380
|
+
key: clearSignalKey,
|
|
381
|
+
newValue: clearSignal,
|
|
382
|
+
}))
|
|
383
|
+
|
|
384
|
+
controller.connect()
|
|
385
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
386
|
+
popupEl.dataset.creativeId = '88'
|
|
387
|
+
dispatchTopicChange('88')
|
|
388
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
389
|
+
expect(controller._activeDraftKey).toBeNull()
|
|
390
|
+
typeInto(controller.textareaTarget, 'must not persist for the old user')
|
|
391
|
+
controller.onPopupClosed()
|
|
392
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
393
|
+
|
|
394
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
395
|
+
expect(chatDrafts.get('88')).toBeNull()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('a missed cross-tab logout invalidates an in-flight send on reconnect', async () => {
|
|
399
|
+
dispatchTopicChange('77')
|
|
400
|
+
typeInto(controller.textareaTarget, 'old user submission')
|
|
401
|
+
let failFetch
|
|
402
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
403
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
404
|
+
|
|
405
|
+
controller.disconnect()
|
|
406
|
+
chatDrafts.clearAll()
|
|
407
|
+
controller.connect()
|
|
408
|
+
controller.onChatWillOpen({ creativeId: '77' })
|
|
409
|
+
|
|
410
|
+
failFetch({
|
|
411
|
+
ok: false,
|
|
412
|
+
status: 500,
|
|
413
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
414
|
+
})
|
|
415
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
416
|
+
|
|
417
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
418
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
419
|
+
expect(submissionBackup('77')).toBeNull()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('a fresh controller clears submission backups from a missed logout once', async () => {
|
|
423
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission before logout')
|
|
424
|
+
window.localStorage.setItem(chatDrafts._clearEventKey(), JSON.stringify({
|
|
425
|
+
namespace: 'collavre_chat_drafts_9',
|
|
426
|
+
nonce: 'missed-logout',
|
|
427
|
+
}))
|
|
428
|
+
document.body.dataset.currentUserId = '10'
|
|
429
|
+
chatDrafts.clearAll()
|
|
430
|
+
document.body.dataset.currentUserId = '9'
|
|
431
|
+
|
|
432
|
+
controller.disconnect()
|
|
433
|
+
application.stop()
|
|
434
|
+
application = Application.start()
|
|
435
|
+
application.register('comments--form', FormController)
|
|
436
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
437
|
+
const freshController = application.getControllerForElementAndIdentifier(
|
|
438
|
+
popupEl,
|
|
439
|
+
'comments--form',
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
expect(freshController).not.toBe(controller)
|
|
443
|
+
expect(submissionBackup('77')).toBeNull()
|
|
444
|
+
|
|
445
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission after logout')
|
|
446
|
+
freshController.disconnect()
|
|
447
|
+
freshController.connect()
|
|
448
|
+
|
|
449
|
+
expect(submissionBackup('77')).toBe('failed submission after logout')
|
|
450
|
+
controller = freshController
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('a fresh controller suspends drafts until legacy marker promotion succeeds', async () => {
|
|
454
|
+
const namespace = chatDrafts.namespace()
|
|
455
|
+
const namespacedKey = chatDrafts._clearEventKey(namespace)
|
|
456
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission before logout')
|
|
457
|
+
window.localStorage.setItem(namespacedKey, JSON.stringify({
|
|
458
|
+
namespace,
|
|
459
|
+
nonce: 'logout-1',
|
|
460
|
+
legacyNamespace: namespace,
|
|
461
|
+
legacyNonce: 'logout-1',
|
|
462
|
+
}))
|
|
463
|
+
window.localStorage.setItem('collavre_chat_drafts_clear', JSON.stringify({
|
|
464
|
+
namespace,
|
|
465
|
+
nonce: 'logout-2',
|
|
466
|
+
}))
|
|
467
|
+
const originalSetItem = window.Storage.prototype.setItem
|
|
468
|
+
const setItem = jest.spyOn(window.Storage.prototype, 'setItem')
|
|
469
|
+
.mockImplementation(function(key, value) {
|
|
470
|
+
if (this === window.localStorage && key === namespacedKey) return
|
|
471
|
+
originalSetItem.call(this, key, value)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
controller.disconnect()
|
|
475
|
+
application.stop()
|
|
476
|
+
application = Application.start()
|
|
477
|
+
application.register('comments--form', FormController)
|
|
478
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
479
|
+
const freshController = application.getControllerForElementAndIdentifier(
|
|
480
|
+
popupEl,
|
|
481
|
+
'comments--form',
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
expect(submissionBackup('77')).toBeNull()
|
|
485
|
+
expect(freshController._draftPersistenceDisabled()).toBe(true)
|
|
486
|
+
expect(freshController._observedDraftClearNonces.has(namespace)).toBe(false)
|
|
487
|
+
|
|
488
|
+
setItem.mockRestore()
|
|
489
|
+
freshController.disconnect()
|
|
490
|
+
freshController.connect()
|
|
491
|
+
|
|
492
|
+
expect(freshController._draftPersistenceDisabled()).toBe(false)
|
|
493
|
+
expect(freshController._observedDraftClearNonces.get(namespace)).toBe('logout-2')
|
|
494
|
+
controller = freshController
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('a fresh controller retries missed logout cleanup after backup storage recovers', () => {
|
|
498
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission before logout')
|
|
499
|
+
window.localStorage.setItem('collavre_chat_drafts_clear', JSON.stringify({
|
|
500
|
+
namespace: 'collavre_chat_drafts_9',
|
|
501
|
+
nonce: 'missed-logout',
|
|
502
|
+
}))
|
|
503
|
+
controller.disconnect()
|
|
504
|
+
controller._observedDraftClearNonces.clear()
|
|
505
|
+
const storageGetter = jest.spyOn(window, 'sessionStorage', 'get').mockImplementation(() => {
|
|
506
|
+
throw new DOMException('denied', 'SecurityError')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
controller.connect()
|
|
510
|
+
expect(controller._draftPersistenceDisabled()).toBe(true)
|
|
511
|
+
|
|
512
|
+
controller.disconnect()
|
|
513
|
+
storageGetter.mockRestore()
|
|
514
|
+
controller.connect()
|
|
515
|
+
|
|
516
|
+
expect(controller._draftPersistenceDisabled()).toBe(false)
|
|
517
|
+
expect(submissionBackup('77')).toBeNull()
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
test('storage recovery does not treat an existing clear marker as a new logout', () => {
|
|
521
|
+
controller.disconnect()
|
|
522
|
+
chatDrafts.clearAll()
|
|
523
|
+
controller._observedDraftClearNonces.clear()
|
|
524
|
+
const storageGetter = jest.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
|
525
|
+
throw new DOMException('denied', 'SecurityError')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
controller.connect()
|
|
529
|
+
controller.disconnect()
|
|
530
|
+
storageGetter.mockRestore()
|
|
531
|
+
controller.connect()
|
|
532
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
533
|
+
popupEl.dataset.creativeId = '88'
|
|
534
|
+
dispatchTopicChange('88')
|
|
535
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
536
|
+
typeInto(controller.textareaTarget, 'draft after storage recovery')
|
|
537
|
+
controller._flushDraftSave()
|
|
538
|
+
|
|
539
|
+
expect(chatDrafts.get('88')).toBe('draft after storage recovery')
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
test('a failed in-flight send cannot recreate a draft after cross-tab logout', async () => {
|
|
543
|
+
dispatchTopicChange('77')
|
|
544
|
+
typeInto(controller.textareaTarget, 'old user submission')
|
|
545
|
+
let failFetch
|
|
546
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
547
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
548
|
+
|
|
549
|
+
window.dispatchEvent(new StorageEvent('storage', {
|
|
550
|
+
key: 'collavre_chat_drafts_clear',
|
|
551
|
+
newValue: JSON.stringify({
|
|
552
|
+
namespace: 'collavre_chat_drafts_9',
|
|
553
|
+
nonce: 'another-tab-logout',
|
|
554
|
+
}),
|
|
555
|
+
}))
|
|
556
|
+
dispatchTopicChange('77')
|
|
557
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
558
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
559
|
+
|
|
560
|
+
failFetch({
|
|
561
|
+
ok: false,
|
|
562
|
+
status: 500,
|
|
563
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
564
|
+
})
|
|
565
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
566
|
+
|
|
567
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
568
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
569
|
+
expect(controller.sending).toBe(false)
|
|
570
|
+
dismissAlert()
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
test('an invalidated send success does not reset input entered after logout', async () => {
|
|
574
|
+
dispatchTopicChange('77')
|
|
575
|
+
typeInto(controller.textareaTarget, 'old user submission')
|
|
576
|
+
let finishFetch
|
|
577
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
578
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
579
|
+
|
|
580
|
+
window.dispatchEvent(new StorageEvent('storage', {
|
|
581
|
+
key: 'collavre_chat_drafts_clear',
|
|
582
|
+
newValue: JSON.stringify({
|
|
583
|
+
namespace: 'collavre_chat_drafts_9',
|
|
584
|
+
nonce: 'another-tab-logout',
|
|
585
|
+
}),
|
|
586
|
+
}))
|
|
587
|
+
document.body.dataset.currentUserId = '10'
|
|
588
|
+
dispatchTopicChange('77')
|
|
589
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
590
|
+
typeInto(controller.textareaTarget, 'new input after logout')
|
|
591
|
+
controller._flushDraftSave()
|
|
592
|
+
|
|
593
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
594
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
595
|
+
|
|
596
|
+
expect(controller.textareaTarget.value).toBe('new input after logout')
|
|
597
|
+
expect(chatDrafts.get('77')).toBe('new input after logout')
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test('an unrelated storage event leaves the active draft intact', () => {
|
|
601
|
+
dispatchTopicChange('77')
|
|
602
|
+
typeInto(controller.textareaTarget, 'still active')
|
|
603
|
+
|
|
604
|
+
window.dispatchEvent(new StorageEvent('storage', {
|
|
605
|
+
key: 'unrelated',
|
|
606
|
+
newValue: 'x',
|
|
607
|
+
}))
|
|
608
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
609
|
+
|
|
610
|
+
expect(chatDrafts.get('77')).toBe('still active')
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('discarding drafts cancels a pending save and prevents disconnect from recreating it', async () => {
|
|
614
|
+
dispatchTopicChange('77')
|
|
615
|
+
typeInto(controller.textareaTarget, 'must be discarded')
|
|
616
|
+
|
|
617
|
+
controller.discardDraft()
|
|
618
|
+
controller.disconnect()
|
|
619
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
620
|
+
|
|
621
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
622
|
+
controller.connect()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
test('same-tab logout invalidates an in-flight send before clearing drafts', async () => {
|
|
626
|
+
dispatchTopicChange('77')
|
|
627
|
+
typeInto(controller.textareaTarget, 'old user submission')
|
|
628
|
+
let failFetch
|
|
629
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
630
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
631
|
+
|
|
632
|
+
controller.discardDraft()
|
|
633
|
+
chatDrafts.clearAll()
|
|
634
|
+
failFetch({
|
|
635
|
+
ok: false,
|
|
636
|
+
status: 500,
|
|
637
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
638
|
+
})
|
|
639
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
640
|
+
|
|
641
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
642
|
+
expect(submissionBackup('77')).toBeNull()
|
|
643
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
test('losing comment permission preserves the saved draft through close', () => {
|
|
647
|
+
dispatchTopicChange('77')
|
|
648
|
+
controller.textareaTarget.value = 'permission changed'
|
|
649
|
+
controller.setCommentPermission(false)
|
|
650
|
+
expect(chatDrafts.get('77')).toBe('permission changed')
|
|
651
|
+
|
|
652
|
+
typeInto(controller.textareaTarget, 'hidden stale text')
|
|
653
|
+
controller.onPopupClosed()
|
|
654
|
+
expect(chatDrafts.get('77')).toBe('permission changed')
|
|
655
|
+
|
|
656
|
+
dispatchTopicChange('77')
|
|
657
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
658
|
+
expect(controller.textareaTarget.value).toBe('permission changed')
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test('regaining comment permission restores the saved draft', () => {
|
|
662
|
+
dispatchTopicChange('77')
|
|
663
|
+
controller.textareaTarget.value = 'permission changed'
|
|
664
|
+
controller.setCommentPermission(false)
|
|
665
|
+
|
|
666
|
+
controller.setCommentPermission(true)
|
|
667
|
+
expect(controller.formTarget.style.display).toBe('')
|
|
668
|
+
expect(controller.textareaTarget.value).toBe('permission changed')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
test('permission loss preserves an undebounced submission when the send fails', async () => {
|
|
672
|
+
dispatchTopicChange('77')
|
|
673
|
+
typeInto(controller.textareaTarget, 'submission before permission loss')
|
|
674
|
+
let failFetch
|
|
675
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
676
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
677
|
+
|
|
678
|
+
controller.setCommentPermission(false)
|
|
679
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
680
|
+
failFetch({
|
|
681
|
+
ok: false,
|
|
682
|
+
status: 500,
|
|
683
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
684
|
+
})
|
|
685
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
686
|
+
|
|
687
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
688
|
+
expect(submissionBackup('77')).toBe('submission before permission loss')
|
|
689
|
+
controller.setCommentPermission(true)
|
|
690
|
+
expect(controller.textareaTarget.value).toBe('submission before permission loss')
|
|
691
|
+
dismissAlert()
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
test('permission loss preserves text entered after an in-flight submission', async () => {
|
|
695
|
+
dispatchTopicChange('77')
|
|
696
|
+
typeInto(controller.textareaTarget, 'first submitted message')
|
|
697
|
+
controller._flushDraftSave()
|
|
698
|
+
let finishFetch
|
|
699
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
700
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
701
|
+
|
|
702
|
+
typeInto(controller.textareaTarget, 'next message before permission loss')
|
|
703
|
+
controller.setCommentPermission(false)
|
|
704
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
705
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
706
|
+
|
|
707
|
+
expect(chatDrafts.get('77')).toBe('next message before permission loss')
|
|
708
|
+
controller.setCommentPermission(true)
|
|
709
|
+
expect(controller.textareaTarget.value).toBe('next message before permission loss')
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
test('opening a read-only chat does not clear its saved draft on close', () => {
|
|
713
|
+
chatDrafts.set('77', 'read-only saved draft')
|
|
714
|
+
dispatchTopicChange('77')
|
|
715
|
+
|
|
716
|
+
controller.onPopupOpened({ creativeId: '77', canComment: false })
|
|
717
|
+
controller.onPopupClosed()
|
|
718
|
+
|
|
719
|
+
expect(chatDrafts.get('77')).toBe('read-only saved draft')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('successful send clears the stored draft', async () => {
|
|
723
|
+
dispatchTopicChange('77')
|
|
724
|
+
typeInto(controller.textareaTarget, 'about to send')
|
|
725
|
+
controller._flushDraftSave()
|
|
726
|
+
expect(chatDrafts.get('77')).toBe('about to send')
|
|
727
|
+
|
|
728
|
+
global.fetch = jest.fn(() =>
|
|
729
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
730
|
+
)
|
|
731
|
+
controller.textareaTarget.dispatchEvent(
|
|
732
|
+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
733
|
+
)
|
|
734
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
735
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
736
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
test('successful send preserves newer text entered while the request is in flight', async () => {
|
|
740
|
+
dispatchTopicChange('77')
|
|
741
|
+
typeInto(controller.textareaTarget, 'first message')
|
|
742
|
+
controller._flushDraftSave()
|
|
743
|
+
|
|
744
|
+
let finishFetch
|
|
745
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
746
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
747
|
+
|
|
748
|
+
typeInto(controller.textareaTarget, 'next message')
|
|
749
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
750
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
751
|
+
|
|
752
|
+
expect(controller.textareaTarget.value).toBe('next message')
|
|
753
|
+
expect(chatDrafts.get('77')).toBe('next message')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
test('successful send restores a newer stored draft for the active chat', async () => {
|
|
757
|
+
dispatchTopicChange('77')
|
|
758
|
+
typeInto(controller.textareaTarget, 'first message')
|
|
759
|
+
controller._flushDraftSave()
|
|
760
|
+
|
|
761
|
+
let finishFetch
|
|
762
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
763
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
764
|
+
|
|
765
|
+
chatDrafts.set('77', 'draft updated outside this form')
|
|
766
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
767
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
768
|
+
|
|
769
|
+
expect(controller.textareaTarget.value).toBe('draft updated outside this form')
|
|
770
|
+
expect(chatDrafts.get('77')).toBe('draft updated outside this form')
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
test('successful send preserves a concurrent draft with the same timestamp', async () => {
|
|
774
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
775
|
+
dispatchTopicChange('77')
|
|
776
|
+
typeInto(controller.textareaTarget, 'first message')
|
|
777
|
+
controller._flushDraftSave()
|
|
778
|
+
|
|
779
|
+
let finishFetch
|
|
780
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
781
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
782
|
+
|
|
783
|
+
const submittedRevision = chatDrafts.revision('77')
|
|
784
|
+
chatDrafts._append('77', {
|
|
785
|
+
text: 'same-millisecond concurrent draft',
|
|
786
|
+
updatedAt: chatDrafts.updatedAt('77'),
|
|
787
|
+
version: `${submittedRevision}-later`,
|
|
788
|
+
})
|
|
789
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
790
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
791
|
+
|
|
792
|
+
expect(controller.textareaTarget.value).toBe('same-millisecond concurrent draft')
|
|
793
|
+
expect(chatDrafts.get('77')).toBe('same-millisecond concurrent draft')
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test('submission snapshots text and revision before a concurrent draft arrives', async () => {
|
|
797
|
+
dispatchTopicChange('77')
|
|
798
|
+
typeInto(controller.textareaTarget, 'first message')
|
|
799
|
+
controller._flushDraftSave()
|
|
800
|
+
|
|
801
|
+
const originalEntry = chatDrafts._entry.bind(chatDrafts)
|
|
802
|
+
let injectConcurrentDraft = true
|
|
803
|
+
jest.spyOn(chatDrafts, '_entry').mockImplementation((chatId) => {
|
|
804
|
+
const entry = originalEntry(chatId)
|
|
805
|
+
if (injectConcurrentDraft && String(chatId) === '77') {
|
|
806
|
+
injectConcurrentDraft = false
|
|
807
|
+
chatDrafts._append('77', {
|
|
808
|
+
text: 'draft saved concurrently after snapshot',
|
|
809
|
+
updatedAt: entry.updatedAt + 1,
|
|
810
|
+
version: `${entry.version}-concurrent`,
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
return entry
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
global.fetch = jest.fn(() =>
|
|
817
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
818
|
+
)
|
|
819
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
820
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
821
|
+
|
|
822
|
+
expect(controller.textareaTarget.value).toBe('draft saved concurrently after snapshot')
|
|
823
|
+
expect(chatDrafts.get('77')).toBe('draft saved concurrently after snapshot')
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
test('successful send preserves a draft another tab saved before submission', async () => {
|
|
827
|
+
dispatchTopicChange('77')
|
|
828
|
+
typeInto(controller.textareaTarget, 'stale message in this tab')
|
|
829
|
+
controller._flushDraftSave()
|
|
830
|
+
|
|
831
|
+
chatDrafts.set('77', 'newer draft from another tab')
|
|
832
|
+
|
|
833
|
+
global.fetch = jest.fn(() =>
|
|
834
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
835
|
+
)
|
|
836
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
837
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
838
|
+
|
|
839
|
+
expect(controller.textareaTarget.value).toBe('newer draft from another tab')
|
|
840
|
+
expect(chatDrafts.get('77')).toBe('newer draft from another tab')
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
test('submission debounce does not overwrite a newer draft from another tab', async () => {
|
|
844
|
+
chatDrafts.set('77', 'shared starting draft')
|
|
845
|
+
dispatchTopicChange('77')
|
|
846
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
847
|
+
typeInto(controller.textareaTarget, 'message submitted from this tab')
|
|
848
|
+
chatDrafts.set('77', 'newer draft from another tab')
|
|
849
|
+
|
|
850
|
+
let finishFetch
|
|
851
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
852
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
853
|
+
expect(chatDrafts.get('77')).toBe('newer draft from another tab')
|
|
854
|
+
|
|
855
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
856
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
857
|
+
|
|
858
|
+
expect(controller.textareaTarget.value).toBe('newer draft from another tab')
|
|
859
|
+
expect(chatDrafts.get('77')).toBe('newer draft from another tab')
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
test('submission debounce preserves a same-text revision from another tab', async () => {
|
|
863
|
+
chatDrafts.set('77', 'shared starting draft')
|
|
864
|
+
dispatchTopicChange('77')
|
|
865
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
866
|
+
typeInto(controller.textareaTarget, 'message submitted from this tab')
|
|
867
|
+
chatDrafts.set('77', 'other tab temporary change')
|
|
868
|
+
chatDrafts.set('77', 'shared starting draft')
|
|
869
|
+
|
|
870
|
+
global.fetch = jest.fn(() =>
|
|
871
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
872
|
+
)
|
|
873
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
874
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
875
|
+
|
|
876
|
+
expect(controller.textareaTarget.value).toBe('shared starting draft')
|
|
877
|
+
expect(chatDrafts.get('77')).toBe('shared starting draft')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('an idle tab does not overwrite a draft changed by another tab on close', () => {
|
|
881
|
+
chatDrafts.set('77', 'draft restored in both tabs')
|
|
882
|
+
dispatchTopicChange('77')
|
|
883
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
884
|
+
|
|
885
|
+
chatDrafts.set('77', 'newer draft from another tab')
|
|
886
|
+
controller.onPopupClosed()
|
|
887
|
+
|
|
888
|
+
expect(chatDrafts.get('77')).toBe('newer draft from another tab')
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
test('local input after another tab change remains eligible to save', () => {
|
|
892
|
+
chatDrafts.set('77', 'draft restored in both tabs')
|
|
893
|
+
dispatchTopicChange('77')
|
|
894
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
895
|
+
|
|
896
|
+
chatDrafts.set('77', 'draft from another tab')
|
|
897
|
+
typeInto(controller.textareaTarget, 'new local input')
|
|
898
|
+
controller.onPopupClosed()
|
|
899
|
+
|
|
900
|
+
expect(chatDrafts.get('77')).toBe('new local input')
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
test('successful send clears this tab draft changed before its debounce runs', async () => {
|
|
904
|
+
chatDrafts.set('77', 'older draft from this tab')
|
|
905
|
+
dispatchTopicChange('77')
|
|
906
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
907
|
+
typeInto(controller.textareaTarget, 'new message sent immediately')
|
|
908
|
+
|
|
909
|
+
global.fetch = jest.fn(() =>
|
|
910
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
911
|
+
)
|
|
912
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
913
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
914
|
+
|
|
915
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
916
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
test('successful slow send does not restore the submitted text when its debounce fires', async () => {
|
|
920
|
+
dispatchTopicChange('77')
|
|
921
|
+
typeInto(controller.textareaTarget, 'message sent before debounce')
|
|
922
|
+
const saveDraft = jest.spyOn(chatDrafts, 'set')
|
|
923
|
+
|
|
924
|
+
let finishFetch
|
|
925
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
926
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
927
|
+
|
|
928
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
929
|
+
expect(saveDraft).not.toHaveBeenCalled()
|
|
930
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
931
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
932
|
+
|
|
933
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
934
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
test('successful send respects a concurrent clear instead of restoring pending text', async () => {
|
|
938
|
+
dispatchTopicChange('77')
|
|
939
|
+
typeInto(controller.textareaTarget, 'message cleared from another tab')
|
|
940
|
+
controller._flushDraftSave()
|
|
941
|
+
|
|
942
|
+
let finishFetch
|
|
943
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
944
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
945
|
+
|
|
946
|
+
chatDrafts.clear('77')
|
|
947
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
948
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
949
|
+
|
|
950
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
951
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
test('send completion after an account change does not clear the new user draft', async () => {
|
|
955
|
+
dispatchTopicChange('77')
|
|
956
|
+
typeInto(controller.textareaTarget, 'user 9 message')
|
|
957
|
+
controller._flushDraftSave()
|
|
958
|
+
|
|
959
|
+
let finishFetch
|
|
960
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
961
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
962
|
+
|
|
963
|
+
document.body.dataset.currentUserId = '10'
|
|
964
|
+
chatDrafts.set('77', 'user 10 draft')
|
|
965
|
+
controller.resetForm()
|
|
966
|
+
controller._restoreDraft()
|
|
967
|
+
|
|
968
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
969
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
970
|
+
|
|
971
|
+
expect(controller.textareaTarget.value).toBe('user 10 draft')
|
|
972
|
+
expect(chatDrafts.get('77')).toBe('user 10 draft')
|
|
973
|
+
document.body.dataset.currentUserId = '9'
|
|
974
|
+
expect(chatDrafts.get('77')).toBe('user 9 message')
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
test('failed send keeps the stored draft', async () => {
|
|
978
|
+
dispatchTopicChange('77')
|
|
979
|
+
typeInto(controller.textareaTarget, 'will fail')
|
|
980
|
+
controller._flushDraftSave()
|
|
981
|
+
|
|
982
|
+
global.fetch = jest.fn(() =>
|
|
983
|
+
Promise.resolve({
|
|
984
|
+
ok: false,
|
|
985
|
+
status: 500,
|
|
986
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
987
|
+
}),
|
|
988
|
+
)
|
|
989
|
+
controller.textareaTarget.dispatchEvent(
|
|
990
|
+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
991
|
+
)
|
|
992
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
993
|
+
expect(chatDrafts.get('77')).toBe('will fail')
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
test('failed send persists input submitted before its debounce runs', async () => {
|
|
997
|
+
dispatchTopicChange('77')
|
|
998
|
+
typeInto(controller.textareaTarget, 'unsaved message that will fail')
|
|
999
|
+
|
|
1000
|
+
const failedResponse = {
|
|
1001
|
+
ok: false, status: 500, json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1002
|
+
}
|
|
1003
|
+
global.fetch = jest.fn(() => Promise.resolve(failedResponse))
|
|
1004
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1005
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1006
|
+
|
|
1007
|
+
expect(submissionBackup('77')).toBe('unsaved message that will fail')
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
test('failed send replaces an older restored backup with the submitted text', async () => {
|
|
1011
|
+
chatDrafts.saveSubmissionBackup('77', 'older failed submission')
|
|
1012
|
+
dispatchTopicChange('77')
|
|
1013
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1014
|
+
typeInto(controller.textareaTarget, 'new submission that will fail')
|
|
1015
|
+
|
|
1016
|
+
const failedResponse = {
|
|
1017
|
+
ok: false, status: 500, json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1018
|
+
}
|
|
1019
|
+
global.fetch = jest.fn(() => Promise.resolve(failedResponse))
|
|
1020
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1021
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1022
|
+
|
|
1023
|
+
expect(submissionBackup('77')).toBe('new submission that will fail')
|
|
1024
|
+
dismissAlert()
|
|
1025
|
+
|
|
1026
|
+
global.fetch = jest.fn(() =>
|
|
1027
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
1028
|
+
)
|
|
1029
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1030
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1031
|
+
controller.resetForm()
|
|
1032
|
+
controller._restoreDraft()
|
|
1033
|
+
|
|
1034
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1035
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
test('failed send persists unsaved input after switching chats', async () => {
|
|
1039
|
+
dispatchTopicChange('77')
|
|
1040
|
+
typeInto(controller.textareaTarget, 'unsaved message from 77')
|
|
1041
|
+
|
|
1042
|
+
let failFetch
|
|
1043
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1044
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1045
|
+
|
|
1046
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
1047
|
+
dispatchTopicChange('88')
|
|
1048
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1049
|
+
|
|
1050
|
+
failFetch({
|
|
1051
|
+
ok: false,
|
|
1052
|
+
status: 500,
|
|
1053
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1054
|
+
})
|
|
1055
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1056
|
+
|
|
1057
|
+
expect(submissionBackup('77')).toBe('unsaved message from 77')
|
|
1058
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
test('restores a failed submission newer than an existing stored draft', async () => {
|
|
1062
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
1063
|
+
chatDrafts.set('77', 'older stored draft')
|
|
1064
|
+
dispatchTopicChange('77')
|
|
1065
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1066
|
+
typeInto(controller.textareaTarget, 'newer failed submission')
|
|
1067
|
+
|
|
1068
|
+
let failFetch
|
|
1069
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1070
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1071
|
+
|
|
1072
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
1073
|
+
popupEl.dataset.creativeId = '88'
|
|
1074
|
+
dispatchTopicChange('88')
|
|
1075
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1076
|
+
|
|
1077
|
+
Date.now.mockReturnValue(2000)
|
|
1078
|
+
failFetch({
|
|
1079
|
+
ok: false,
|
|
1080
|
+
status: 500,
|
|
1081
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1082
|
+
})
|
|
1083
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1084
|
+
|
|
1085
|
+
expect(chatDrafts.get('77')).toBe('older stored draft')
|
|
1086
|
+
expect(submissionBackup('77')).toBe('newer failed submission')
|
|
1087
|
+
|
|
1088
|
+
controller.onChatWillOpen({ creativeId: '77' })
|
|
1089
|
+
popupEl.dataset.creativeId = '77'
|
|
1090
|
+
dispatchTopicChange('77')
|
|
1091
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1092
|
+
|
|
1093
|
+
expect(controller.textareaTarget.value).toBe('newer failed submission')
|
|
1094
|
+
|
|
1095
|
+
Date.now.mockReturnValue(3000)
|
|
1096
|
+
chatDrafts.set('77', 'newest draft from another tab')
|
|
1097
|
+
controller.onPopupClosed()
|
|
1098
|
+
|
|
1099
|
+
expect(chatDrafts.get('77')).toBe('newest draft from another tab')
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
test('failed send preserves newer text saved after the submission started', async () => {
|
|
1103
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
1104
|
+
chatDrafts.set('77', 'older stored draft')
|
|
1105
|
+
dispatchTopicChange('77')
|
|
1106
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1107
|
+
typeInto(controller.textareaTarget, 'submitted message that will fail')
|
|
1108
|
+
|
|
1109
|
+
let failFetch
|
|
1110
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1111
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1112
|
+
|
|
1113
|
+
Date.now.mockReturnValue(2000)
|
|
1114
|
+
typeInto(controller.textareaTarget, 'newer message after submission')
|
|
1115
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
1116
|
+
popupEl.dataset.creativeId = '88'
|
|
1117
|
+
dispatchTopicChange('88')
|
|
1118
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1119
|
+
|
|
1120
|
+
Date.now.mockReturnValue(3000)
|
|
1121
|
+
failFetch({
|
|
1122
|
+
ok: false,
|
|
1123
|
+
status: 500,
|
|
1124
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1125
|
+
})
|
|
1126
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1127
|
+
|
|
1128
|
+
expect(chatDrafts.get('77')).toBe('newer message after submission')
|
|
1129
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1130
|
+
|
|
1131
|
+
controller.onChatWillOpen({ creativeId: '77' })
|
|
1132
|
+
popupEl.dataset.creativeId = '77'
|
|
1133
|
+
dispatchTopicChange('77')
|
|
1134
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1135
|
+
|
|
1136
|
+
expect(controller.textareaTarget.value).toBe('newer message after submission')
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
test('failed send preserves a cross-tab draft saved after submission', async () => {
|
|
1140
|
+
dispatchTopicChange('77')
|
|
1141
|
+
typeInto(controller.textareaTarget, 'submitted message that will fail')
|
|
1142
|
+
|
|
1143
|
+
let failFetch
|
|
1144
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1145
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1146
|
+
|
|
1147
|
+
chatDrafts.set('77', 'newer draft from another tab')
|
|
1148
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
1149
|
+
popupEl.dataset.creativeId = '88'
|
|
1150
|
+
dispatchTopicChange('88')
|
|
1151
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1152
|
+
|
|
1153
|
+
failFetch({
|
|
1154
|
+
ok: false,
|
|
1155
|
+
status: 500,
|
|
1156
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1157
|
+
})
|
|
1158
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1159
|
+
|
|
1160
|
+
expect(chatDrafts.get('77')).toBe('newer draft from another tab')
|
|
1161
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1162
|
+
|
|
1163
|
+
controller.onChatWillOpen({ creativeId: '77' })
|
|
1164
|
+
popupEl.dataset.creativeId = '77'
|
|
1165
|
+
dispatchTopicChange('77')
|
|
1166
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1167
|
+
|
|
1168
|
+
expect(controller.textareaTarget.value).toBe('newer draft from another tab')
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
test('failed send respects a cross-tab clear after submission', async () => {
|
|
1172
|
+
dispatchTopicChange('77')
|
|
1173
|
+
typeInto(controller.textareaTarget, 'submitted message that will fail')
|
|
1174
|
+
|
|
1175
|
+
let failFetch
|
|
1176
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1177
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1178
|
+
|
|
1179
|
+
chatDrafts.set('77', 'temporary draft from another tab')
|
|
1180
|
+
chatDrafts.clear('77')
|
|
1181
|
+
failFetch({
|
|
1182
|
+
ok: false,
|
|
1183
|
+
status: 500,
|
|
1184
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1185
|
+
})
|
|
1186
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1187
|
+
|
|
1188
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1189
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
test('failure backup keeps submission-time ordering across a late cross-tab write', async () => {
|
|
1193
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
1194
|
+
dispatchTopicChange('77')
|
|
1195
|
+
typeInto(controller.textareaTarget, 'submitted message that will fail')
|
|
1196
|
+
|
|
1197
|
+
const saveSubmissionBackup = chatDrafts.saveSubmissionBackup.bind(chatDrafts)
|
|
1198
|
+
jest.spyOn(chatDrafts, 'saveSubmissionBackup').mockImplementation((...args) => {
|
|
1199
|
+
chatDrafts.set('77', 'draft written during failure handling')
|
|
1200
|
+
return saveSubmissionBackup(...args)
|
|
1201
|
+
})
|
|
1202
|
+
global.fetch = jest.fn(() => Promise.resolve({
|
|
1203
|
+
ok: false,
|
|
1204
|
+
status: 500,
|
|
1205
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1206
|
+
}))
|
|
1207
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1208
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1209
|
+
|
|
1210
|
+
controller.resetForm()
|
|
1211
|
+
controller._restoreDraft()
|
|
1212
|
+
|
|
1213
|
+
expect(controller.textareaTarget.value).toBe('draft written during failure handling')
|
|
1214
|
+
expect(chatDrafts.get('77')).toBe('draft written during failure handling')
|
|
1215
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
test('failed send does not mask a cross-tab draft observed at submission', async () => {
|
|
1219
|
+
chatDrafts.set('77', 'shared starting draft')
|
|
1220
|
+
dispatchTopicChange('77')
|
|
1221
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1222
|
+
typeInto(controller.textareaTarget, 'stale submitted message')
|
|
1223
|
+
chatDrafts.set('77', 'draft from another tab before submission')
|
|
1224
|
+
|
|
1225
|
+
global.fetch = jest.fn(() => Promise.resolve({
|
|
1226
|
+
ok: false,
|
|
1227
|
+
status: 500,
|
|
1228
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1229
|
+
}))
|
|
1230
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1231
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1232
|
+
|
|
1233
|
+
expect(chatDrafts.get('77')).toBe('draft from another tab before submission')
|
|
1234
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1235
|
+
|
|
1236
|
+
controller.resetForm()
|
|
1237
|
+
controller._restoreDraft()
|
|
1238
|
+
|
|
1239
|
+
expect(controller.textareaTarget.value).toBe('draft from another tab before submission')
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
test('restores a regular draft newer than a failed-submission backup', () => {
|
|
1243
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
1244
|
+
chatDrafts.saveSubmissionBackup('77', 'older failed submission')
|
|
1245
|
+
Date.now.mockReturnValue(2000)
|
|
1246
|
+
chatDrafts.set('77', 'newer stored draft')
|
|
1247
|
+
|
|
1248
|
+
dispatchTopicChange('77')
|
|
1249
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1250
|
+
|
|
1251
|
+
expect(controller.textareaTarget.value).toBe('newer stored draft')
|
|
1252
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
test('prefers a regular draft saved after a backup at the same timestamp', () => {
|
|
1256
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
1257
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission')
|
|
1258
|
+
chatDrafts.set('77', 'later regular draft')
|
|
1259
|
+
|
|
1260
|
+
dispatchTopicChange('77')
|
|
1261
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1262
|
+
|
|
1263
|
+
expect(controller.textareaTarget.value).toBe('later regular draft')
|
|
1264
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
test('does not restore a backup older than a regular draft clear', () => {
|
|
1268
|
+
jest.spyOn(Date, 'now').mockReturnValue(500)
|
|
1269
|
+
chatDrafts.set('77', 'regular draft')
|
|
1270
|
+
Date.now.mockReturnValue(1000)
|
|
1271
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission')
|
|
1272
|
+
Date.now.mockReturnValue(2000)
|
|
1273
|
+
chatDrafts.clear('77')
|
|
1274
|
+
|
|
1275
|
+
dispatchTopicChange('77')
|
|
1276
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1277
|
+
|
|
1278
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1279
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
test('prefers a same-timestamp regular clear saved after a backup', () => {
|
|
1283
|
+
jest.spyOn(Date, 'now').mockReturnValue(1000)
|
|
1284
|
+
chatDrafts.set('77', 'regular draft')
|
|
1285
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission')
|
|
1286
|
+
chatDrafts.clear('77')
|
|
1287
|
+
|
|
1288
|
+
dispatchTopicChange('77')
|
|
1289
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1290
|
+
|
|
1291
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1292
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
test('pagehide does not persist an uncertain in-flight submission', async () => {
|
|
1296
|
+
dispatchTopicChange('77')
|
|
1297
|
+
typeInto(controller.textareaTarget, 'message leaving with the page')
|
|
1298
|
+
let finishFetch
|
|
1299
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1300
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1301
|
+
|
|
1302
|
+
window.dispatchEvent(new PageTransitionEvent('pagehide'))
|
|
1303
|
+
|
|
1304
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1305
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1306
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1307
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1308
|
+
|
|
1309
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1310
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1311
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
test('sending a restored submission backup clears its exact backup key', async () => {
|
|
1315
|
+
chatDrafts.saveSubmissionBackup('77', 'restored submission')
|
|
1316
|
+
dispatchTopicChange('77')
|
|
1317
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1318
|
+
expect(controller.textareaTarget.value).toBe('restored submission')
|
|
1319
|
+
|
|
1320
|
+
global.fetch = jest.fn(() =>
|
|
1321
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
1322
|
+
)
|
|
1323
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1324
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1325
|
+
|
|
1326
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1327
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
test('clearing a restored submission backup removes it permanently', () => {
|
|
1331
|
+
chatDrafts.saveSubmissionBackup('77', 'failed submission to clear')
|
|
1332
|
+
dispatchTopicChange('77')
|
|
1333
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1334
|
+
expect(controller.textareaTarget.value).toBe('failed submission to clear')
|
|
1335
|
+
|
|
1336
|
+
typeInto(controller.textareaTarget, '')
|
|
1337
|
+
controller._flushDraftSave()
|
|
1338
|
+
controller.resetForm()
|
|
1339
|
+
controller._restoreDraft()
|
|
1340
|
+
|
|
1341
|
+
expect(submissionBackup('77')).toBeNull()
|
|
1342
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
test('pagehide does not claim a same-text draft saved by another tab', async () => {
|
|
1346
|
+
chatDrafts.set('77', 'starting draft')
|
|
1347
|
+
dispatchTopicChange('77')
|
|
1348
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1349
|
+
typeInto(controller.textareaTarget, 'same submitted and external text')
|
|
1350
|
+
|
|
1351
|
+
let finishFetch
|
|
1352
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1353
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1354
|
+
chatDrafts.set('77', 'same submitted and external text')
|
|
1355
|
+
const externalRevision = chatDrafts.revision('77')
|
|
1356
|
+
|
|
1357
|
+
window.dispatchEvent(new PageTransitionEvent('pagehide'))
|
|
1358
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1359
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1360
|
+
|
|
1361
|
+
expect(chatDrafts.revision('77')).toBe(externalRevision)
|
|
1362
|
+
expect(chatDrafts.get('77')).toBe('same submitted and external text')
|
|
1363
|
+
expect(controller.textareaTarget.value).toBe('same submitted and external text')
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
test('pagehide does not overwrite a different draft saved by another tab', async () => {
|
|
1367
|
+
chatDrafts.set('77', 'starting draft')
|
|
1368
|
+
dispatchTopicChange('77')
|
|
1369
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1370
|
+
typeInto(controller.textareaTarget, 'submitted from this tab')
|
|
1371
|
+
|
|
1372
|
+
let finishFetch
|
|
1373
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1374
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1375
|
+
chatDrafts.set('77', 'different draft from another tab')
|
|
1376
|
+
const externalRevision = chatDrafts.revision('77')
|
|
1377
|
+
|
|
1378
|
+
window.dispatchEvent(new PageTransitionEvent('pagehide'))
|
|
1379
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1380
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1381
|
+
|
|
1382
|
+
expect(chatDrafts.revision('77')).toBe(externalRevision)
|
|
1383
|
+
expect(chatDrafts.get('77')).toBe('different draft from another tab')
|
|
1384
|
+
expect(controller.textareaTarget.value).toBe('different draft from another tab')
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
test('successful send clears its restored backup after the user namespace changes', async () => {
|
|
1388
|
+
const backupKey = chatDrafts.saveSubmissionBackup('77', 'old user submission')
|
|
1389
|
+
dispatchTopicChange('77')
|
|
1390
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1391
|
+
let finishFetch
|
|
1392
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1393
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1394
|
+
|
|
1395
|
+
document.body.dataset.currentUserId = '10'
|
|
1396
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1397
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1398
|
+
|
|
1399
|
+
expect(window.sessionStorage.getItem(backupKey)).toBeNull()
|
|
1400
|
+
})
|
|
1401
|
+
|
|
1402
|
+
test('pagehide preserves an external draft detected when submission starts', async () => {
|
|
1403
|
+
chatDrafts.set('77', 'starting draft')
|
|
1404
|
+
dispatchTopicChange('77')
|
|
1405
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1406
|
+
typeInto(controller.textareaTarget, 'submitted from this tab')
|
|
1407
|
+
chatDrafts.set('77', 'external draft present before submit')
|
|
1408
|
+
const externalRevision = chatDrafts.revision('77')
|
|
1409
|
+
|
|
1410
|
+
let finishFetch
|
|
1411
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1412
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1413
|
+
window.dispatchEvent(new PageTransitionEvent('pagehide'))
|
|
1414
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1415
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1416
|
+
|
|
1417
|
+
expect(chatDrafts.revision('77')).toBe(externalRevision)
|
|
1418
|
+
expect(chatDrafts.get('77')).toBe('external draft present before submit')
|
|
1419
|
+
expect(controller.textareaTarget.value).toBe('external draft present before submit')
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
test('send completion after a chat switch clears only the submitted chat draft', async () => {
|
|
1423
|
+
dispatchTopicChange('77')
|
|
1424
|
+
typeInto(controller.textareaTarget, 'send from 77')
|
|
1425
|
+
controller._flushDraftSave()
|
|
1426
|
+
|
|
1427
|
+
let finishFetch
|
|
1428
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1429
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1430
|
+
|
|
1431
|
+
dispatchTopicChange('88')
|
|
1432
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1433
|
+
typeInto(controller.textareaTarget, 'draft for 88')
|
|
1434
|
+
|
|
1435
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1436
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1437
|
+
|
|
1438
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1439
|
+
expect(chatDrafts.get('88')).toBe('draft for 88')
|
|
1440
|
+
expect(controller.textareaTarget.value).toBe('draft for 88')
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
test('send completion clears a submitted draft after raw-to-effective key migration', async () => {
|
|
1444
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1445
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1446
|
+
typeInto(controller.textareaTarget, 'send before linked topics resolve')
|
|
1447
|
+
|
|
1448
|
+
let finishFetch
|
|
1449
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1450
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1451
|
+
|
|
1452
|
+
popupEl.dataset.creativeId = '78'
|
|
1453
|
+
dispatchTopicChange('70')
|
|
1454
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1455
|
+
expect(controller.textareaTarget.value).toBe('send before linked topics resolve')
|
|
1456
|
+
|
|
1457
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1458
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1459
|
+
|
|
1460
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1461
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1462
|
+
expect(chatDrafts.get('70')).toBeNull()
|
|
1463
|
+
})
|
|
1464
|
+
|
|
1465
|
+
test('linked-key migration rekeys a restored submission backup in flight', async () => {
|
|
1466
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1467
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1468
|
+
chatDrafts.saveSubmissionBackup('78', 'failed submission before linked topics resolve')
|
|
1469
|
+
controller.resetForm()
|
|
1470
|
+
controller._restoreDraft()
|
|
1471
|
+
|
|
1472
|
+
let finishFetch
|
|
1473
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1474
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1475
|
+
|
|
1476
|
+
popupEl.dataset.creativeId = '78'
|
|
1477
|
+
dispatchTopicChange('70')
|
|
1478
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1479
|
+
expect(submissionBackup('78')).toBeNull()
|
|
1480
|
+
expect(submissionBackup('70')).toBe('failed submission before linked topics resolve')
|
|
1481
|
+
|
|
1482
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1483
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1484
|
+
|
|
1485
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1486
|
+
expect(submissionBackup('70')).toBeNull()
|
|
1487
|
+
})
|
|
1488
|
+
|
|
1489
|
+
test('linked-key migration moves a persisted backup without in-memory submission state', () => {
|
|
1490
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1491
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1492
|
+
chatDrafts.saveSubmissionBackup('78', 'failed submission restored after reload')
|
|
1493
|
+
controller._pendingDraftSubmissions.clear()
|
|
1494
|
+
controller.resetForm()
|
|
1495
|
+
controller._restoreDraft()
|
|
1496
|
+
|
|
1497
|
+
popupEl.dataset.creativeId = '78'
|
|
1498
|
+
dispatchTopicChange('70')
|
|
1499
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1500
|
+
|
|
1501
|
+
expect(submissionBackup('78')).toBeNull()
|
|
1502
|
+
expect(submissionBackup('70')).toBe('failed submission restored after reload')
|
|
1503
|
+
expect(controller.textareaTarget.value).toBe('failed submission restored after reload')
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
test('failed send restores an undebounced submission after linked-key migration', async () => {
|
|
1507
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1508
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1509
|
+
typeInto(controller.textareaTarget, 'failed send before linked topics resolve')
|
|
1510
|
+
|
|
1511
|
+
let failFetch
|
|
1512
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1513
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1514
|
+
|
|
1515
|
+
popupEl.dataset.creativeId = '78'
|
|
1516
|
+
dispatchTopicChange('70')
|
|
1517
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1518
|
+
failFetch({
|
|
1519
|
+
ok: false,
|
|
1520
|
+
status: 500,
|
|
1521
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1522
|
+
})
|
|
1523
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1524
|
+
|
|
1525
|
+
expect(controller.textareaTarget.value).toBe('failed send before linked topics resolve')
|
|
1526
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1527
|
+
expect(submissionBackup('70')).toBe('failed send before linked topics resolve')
|
|
1528
|
+
})
|
|
1529
|
+
|
|
1530
|
+
test('failed linked send restores an edit of the migrated raw baseline', async () => {
|
|
1531
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1532
|
+
chatDrafts.set('78', 'stored raw baseline')
|
|
1533
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1534
|
+
expect(controller.textareaTarget.value).toBe('stored raw baseline')
|
|
1535
|
+
typeInto(controller.textareaTarget, 'edited submission before debounce')
|
|
1536
|
+
|
|
1537
|
+
let failFetch
|
|
1538
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { failFetch = resolve }))
|
|
1539
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1540
|
+
|
|
1541
|
+
popupEl.dataset.creativeId = '78'
|
|
1542
|
+
dispatchTopicChange('70')
|
|
1543
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1544
|
+
failFetch({
|
|
1545
|
+
ok: false,
|
|
1546
|
+
status: 500,
|
|
1547
|
+
json: () => Promise.resolve({ errors: ['boom'] }),
|
|
1548
|
+
})
|
|
1549
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1550
|
+
|
|
1551
|
+
controller.resetForm()
|
|
1552
|
+
controller._restoreDraft()
|
|
1553
|
+
|
|
1554
|
+
expect(controller.textareaTarget.value).toBe('edited submission before debounce')
|
|
1555
|
+
expect(submissionBackup('70')).toBe('edited submission before debounce')
|
|
1556
|
+
})
|
|
1557
|
+
|
|
1558
|
+
test('raw-to-effective migration preserves a newer canonical draft during submission', async () => {
|
|
1559
|
+
let now = 1000
|
|
1560
|
+
jest.spyOn(Date, 'now').mockImplementation(() => now)
|
|
1561
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1562
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1563
|
+
typeInto(controller.textareaTarget, 'stale linked message')
|
|
1564
|
+
controller._flushDraftSave()
|
|
1565
|
+
|
|
1566
|
+
let finishFetch
|
|
1567
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1568
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1569
|
+
|
|
1570
|
+
now += 1000
|
|
1571
|
+
chatDrafts.set('70', 'newer canonical draft')
|
|
1572
|
+
popupEl.dataset.creativeId = '78'
|
|
1573
|
+
dispatchTopicChange('70')
|
|
1574
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1575
|
+
|
|
1576
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1577
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1578
|
+
|
|
1579
|
+
expect(controller.textareaTarget.value).toBe('newer canonical draft')
|
|
1580
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1581
|
+
expect(chatDrafts.get('70')).toBe('newer canonical draft')
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
test('a failed raw-to-effective move keeps the submission bound to the raw key', async () => {
|
|
1585
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1586
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1587
|
+
typeInto(controller.textareaTarget, 'send despite migration failure')
|
|
1588
|
+
controller._flushDraftSave()
|
|
1589
|
+
|
|
1590
|
+
let finishFetch
|
|
1591
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1592
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1593
|
+
jest.spyOn(chatDrafts, 'move').mockReturnValue(false)
|
|
1594
|
+
|
|
1595
|
+
popupEl.dataset.creativeId = '78'
|
|
1596
|
+
dispatchTopicChange('70')
|
|
1597
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1598
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1599
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1600
|
+
|
|
1601
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1602
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1603
|
+
expect(chatDrafts.get('70')).toBeNull()
|
|
1604
|
+
})
|
|
1605
|
+
|
|
1606
|
+
test('an incomplete raw-to-effective move keeps the latest raw draft active', () => {
|
|
1607
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1608
|
+
chatDrafts.set('78', 'latest raw draft')
|
|
1609
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1610
|
+
jest.spyOn(chatDrafts, 'move').mockReturnValueOnce(false)
|
|
1611
|
+
|
|
1612
|
+
popupEl.dataset.creativeId = '78'
|
|
1613
|
+
dispatchTopicChange('70')
|
|
1614
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1615
|
+
|
|
1616
|
+
expect(controller._activeDraftKey).toBe('78')
|
|
1617
|
+
expect(controller._awaitingEffectiveDraftKeyFor).toBe('78')
|
|
1618
|
+
expect(controller.textareaTarget.value).toBe('latest raw draft')
|
|
1619
|
+
|
|
1620
|
+
dispatchTopicChange('70')
|
|
1621
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1622
|
+
|
|
1623
|
+
expect(controller._activeDraftKey).toBe('70')
|
|
1624
|
+
expect(controller._awaitingEffectiveDraftKeyFor).toBeNull()
|
|
1625
|
+
expect(controller.textareaTarget.value).toBe('latest raw draft')
|
|
1626
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1627
|
+
expect(chatDrafts.get('70')).toBe('latest raw draft')
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1630
|
+
test('an incomplete move restores a newer canonical draft', () => {
|
|
1631
|
+
let now = 1000
|
|
1632
|
+
jest.spyOn(Date, 'now').mockImplementation(() => now)
|
|
1633
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1634
|
+
chatDrafts.set('78', 'stale raw draft')
|
|
1635
|
+
now += 1
|
|
1636
|
+
chatDrafts.set('70', 'newer canonical draft')
|
|
1637
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1638
|
+
jest.spyOn(chatDrafts, 'move').mockReturnValue(false)
|
|
1639
|
+
|
|
1640
|
+
popupEl.dataset.creativeId = '78'
|
|
1641
|
+
dispatchTopicChange('70')
|
|
1642
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1643
|
+
|
|
1644
|
+
expect(controller._activeDraftKey).toBe('70')
|
|
1645
|
+
expect(controller._awaitingEffectiveDraftKeyFor).toBeNull()
|
|
1646
|
+
expect(controller.textareaTarget.value).toBe('newer canonical draft')
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
test('send completion clears a target copied before the source marker fails', async () => {
|
|
1650
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1651
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1652
|
+
typeInto(controller.textareaTarget, 'send during partial migration')
|
|
1653
|
+
controller._flushDraftSave()
|
|
1654
|
+
|
|
1655
|
+
let finishFetch
|
|
1656
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1657
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1658
|
+
jest.spyOn(chatDrafts, 'move').mockImplementation((sourceKey, targetKey) => {
|
|
1659
|
+
chatDrafts._append(String(targetKey), { ...chatDrafts._entry(String(sourceKey)) })
|
|
1660
|
+
})
|
|
1661
|
+
|
|
1662
|
+
popupEl.dataset.creativeId = '78'
|
|
1663
|
+
dispatchTopicChange('70')
|
|
1664
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1665
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1666
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1667
|
+
|
|
1668
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1669
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1670
|
+
expect(chatDrafts.get('70')).toBeNull()
|
|
1671
|
+
})
|
|
1672
|
+
|
|
1673
|
+
test('send completion rebinds when another tab already moved the submitted draft', async () => {
|
|
1674
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1675
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1676
|
+
typeInto(controller.textareaTarget, 'send after another tab migrates')
|
|
1677
|
+
controller._flushDraftSave()
|
|
1678
|
+
|
|
1679
|
+
let finishFetch
|
|
1680
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1681
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1682
|
+
chatDrafts.move('78', '70')
|
|
1683
|
+
|
|
1684
|
+
popupEl.dataset.creativeId = '78'
|
|
1685
|
+
dispatchTopicChange('70')
|
|
1686
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1687
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1688
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1689
|
+
|
|
1690
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1691
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1692
|
+
expect(chatDrafts.get('70')).toBeNull()
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
test('linked-key migration preserves a stashed draft that differs from the command', async () => {
|
|
1696
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1697
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1698
|
+
typeInto(controller.textareaTarget, 'ordinary draft before command')
|
|
1699
|
+
controller.handleStashDraft(
|
|
1700
|
+
new CustomEvent('comments--form:stash-draft', {
|
|
1701
|
+
detail: { draft: 'ordinary draft before command' },
|
|
1702
|
+
}),
|
|
1703
|
+
)
|
|
1704
|
+
typeInto(controller.textareaTarget, '/calendar 2026-08-14')
|
|
1705
|
+
|
|
1706
|
+
let finishFetch
|
|
1707
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1708
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1709
|
+
|
|
1710
|
+
popupEl.dataset.creativeId = '78'
|
|
1711
|
+
dispatchTopicChange('70')
|
|
1712
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1713
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1714
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1715
|
+
|
|
1716
|
+
expect(controller.textareaTarget.value).toBe('ordinary draft before command')
|
|
1717
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1718
|
+
expect(chatDrafts.get('70')).toBe('ordinary draft before command')
|
|
1719
|
+
})
|
|
1720
|
+
|
|
1721
|
+
test('an unrelated linked-key migration does not rebind a pending submission', async () => {
|
|
1722
|
+
dispatchTopicChange('77')
|
|
1723
|
+
typeInto(controller.textareaTarget, 'send from 77')
|
|
1724
|
+
controller._flushDraftSave()
|
|
1725
|
+
|
|
1726
|
+
let finishFetch
|
|
1727
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1728
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1729
|
+
|
|
1730
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1731
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1732
|
+
typeInto(controller.textareaTarget, 'draft for linked chat')
|
|
1733
|
+
popupEl.dataset.creativeId = '78'
|
|
1734
|
+
dispatchTopicChange('70')
|
|
1735
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1736
|
+
|
|
1737
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1738
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1739
|
+
|
|
1740
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1741
|
+
expect(chatDrafts.get('70')).toBe('draft for linked chat')
|
|
1742
|
+
expect(controller.textareaTarget.value).toBe('draft for linked chat')
|
|
1743
|
+
})
|
|
1744
|
+
|
|
1745
|
+
test('typing in another chat does not preserve a restored submitted message', async () => {
|
|
1746
|
+
dispatchTopicChange('77')
|
|
1747
|
+
typeInto(controller.textareaTarget, 'send from 77')
|
|
1748
|
+
controller._flushDraftSave()
|
|
1749
|
+
|
|
1750
|
+
let finishFetch
|
|
1751
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1752
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1753
|
+
|
|
1754
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
1755
|
+
dispatchTopicChange('88')
|
|
1756
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1757
|
+
typeInto(controller.textareaTarget, 'draft for 88')
|
|
1758
|
+
|
|
1759
|
+
controller.onChatWillOpen({ creativeId: '77' })
|
|
1760
|
+
dispatchTopicChange('77')
|
|
1761
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1762
|
+
expect(controller.textareaTarget.value).toBe('send from 77')
|
|
1763
|
+
|
|
1764
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1765
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1766
|
+
|
|
1767
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1768
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1769
|
+
expect(chatDrafts.get('88')).toBe('draft for 88')
|
|
1770
|
+
})
|
|
1771
|
+
|
|
1772
|
+
test('reconnecting during a send does not preserve the restored submitted message', async () => {
|
|
1773
|
+
dispatchTopicChange('77')
|
|
1774
|
+
typeInto(controller.textareaTarget, 'send before reconnect')
|
|
1775
|
+
controller._flushDraftSave()
|
|
1776
|
+
|
|
1777
|
+
let finishFetch
|
|
1778
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1779
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1780
|
+
|
|
1781
|
+
controller.disconnect()
|
|
1782
|
+
controller.connect()
|
|
1783
|
+
dispatchTopicChange('77')
|
|
1784
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1785
|
+
expect(controller.textareaTarget.value).toBe('send before reconnect')
|
|
1786
|
+
|
|
1787
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1788
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1789
|
+
|
|
1790
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1791
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1792
|
+
})
|
|
1793
|
+
|
|
1794
|
+
test('reconnecting before the submission debounce does not persist the sent message', async () => {
|
|
1795
|
+
dispatchTopicChange('77')
|
|
1796
|
+
typeInto(controller.textareaTarget, 'send before debounce and reconnect')
|
|
1797
|
+
|
|
1798
|
+
let finishFetch
|
|
1799
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1800
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1801
|
+
|
|
1802
|
+
controller.disconnect()
|
|
1803
|
+
controller.connect()
|
|
1804
|
+
dispatchTopicChange('77')
|
|
1805
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1806
|
+
expect(controller.textareaTarget.value).toBe('send before debounce and reconnect')
|
|
1807
|
+
|
|
1808
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1809
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1810
|
+
|
|
1811
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1812
|
+
expect(chatDrafts.get('77')).toBeNull()
|
|
1813
|
+
})
|
|
1814
|
+
|
|
1815
|
+
test('reconnecting during a send preserves text typed after the reconnect', async () => {
|
|
1816
|
+
dispatchTopicChange('77')
|
|
1817
|
+
typeInto(controller.textareaTarget, 'send before reconnect')
|
|
1818
|
+
controller._flushDraftSave()
|
|
1819
|
+
|
|
1820
|
+
let finishFetch
|
|
1821
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1822
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1823
|
+
|
|
1824
|
+
controller.disconnect()
|
|
1825
|
+
controller.connect()
|
|
1826
|
+
dispatchTopicChange('77')
|
|
1827
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
1828
|
+
typeInto(controller.textareaTarget, 'next message after reconnect')
|
|
1829
|
+
|
|
1830
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1831
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1832
|
+
|
|
1833
|
+
expect(controller.textareaTarget.value).toBe('next message after reconnect')
|
|
1834
|
+
expect(chatDrafts.get('77')).toBe('next message after reconnect')
|
|
1835
|
+
})
|
|
1836
|
+
|
|
1837
|
+
test('reconnecting while linked topics load preserves migration and new input', async () => {
|
|
1838
|
+
delete popupEl.dataset.effectiveCreativeId
|
|
1839
|
+
controller.onChatWillOpen({ creativeId: '78' })
|
|
1840
|
+
typeInto(controller.textareaTarget, 'send before reconnect')
|
|
1841
|
+
controller._flushDraftSave()
|
|
1842
|
+
|
|
1843
|
+
let finishFetch
|
|
1844
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1845
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1846
|
+
|
|
1847
|
+
controller.disconnect()
|
|
1848
|
+
controller.connect()
|
|
1849
|
+
expect(controller._activeDraftKey).toBe('78')
|
|
1850
|
+
expect(controller._awaitingEffectiveDraftKeyFor).toBe('78')
|
|
1851
|
+
typeInto(controller.textareaTarget, 'next message after reconnect')
|
|
1852
|
+
|
|
1853
|
+
popupEl.dataset.creativeId = '78'
|
|
1854
|
+
dispatchTopicChange('70')
|
|
1855
|
+
controller.onPopupOpened({ creativeId: '78', canComment: true })
|
|
1856
|
+
|
|
1857
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1858
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1859
|
+
|
|
1860
|
+
expect(controller.textareaTarget.value).toBe('next message after reconnect')
|
|
1861
|
+
expect(chatDrafts.get('78')).toBeNull()
|
|
1862
|
+
expect(chatDrafts.get('70')).toBe('next message after reconnect')
|
|
1863
|
+
})
|
|
1864
|
+
|
|
1865
|
+
test('send completion preserves newer submitted-chat text after switching away', async () => {
|
|
1866
|
+
dispatchTopicChange('77')
|
|
1867
|
+
typeInto(controller.textareaTarget, 'first message')
|
|
1868
|
+
controller._flushDraftSave()
|
|
1869
|
+
|
|
1870
|
+
let finishFetch
|
|
1871
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1872
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1873
|
+
|
|
1874
|
+
typeInto(controller.textareaTarget, 'next message')
|
|
1875
|
+
dispatchTopicChange('88')
|
|
1876
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1877
|
+
|
|
1878
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1879
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1880
|
+
|
|
1881
|
+
expect(chatDrafts.get('77')).toBe('next message')
|
|
1882
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
test('editing a comment preserves the draft and suspends draft saving', async () => {
|
|
1886
|
+
dispatchTopicChange('77')
|
|
1887
|
+
typeInto(controller.textareaTarget, 'my draft')
|
|
1888
|
+
controller.startEditing({ id: 5, content: 'existing comment', private: false })
|
|
1889
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1890
|
+
|
|
1891
|
+
typeInto(controller.textareaTarget, 'existing comment edited')
|
|
1892
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
1893
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1894
|
+
|
|
1895
|
+
controller.handleCancel(new Event('click'))
|
|
1896
|
+
expect(controller.textareaTarget.value).toBe('my draft')
|
|
1897
|
+
})
|
|
1898
|
+
|
|
1899
|
+
test('successful edit restores the unsent draft', async () => {
|
|
1900
|
+
dispatchTopicChange('77')
|
|
1901
|
+
typeInto(controller.textareaTarget, 'my draft')
|
|
1902
|
+
controller.startEditing({ id: 5, content: 'existing comment', private: false })
|
|
1903
|
+
jest.spyOn(controller, 'renderCommentHtml').mockImplementation(() => {})
|
|
1904
|
+
global.fetch = jest.fn(() =>
|
|
1905
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1909
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1910
|
+
|
|
1911
|
+
expect(controller.textareaTarget.value).toBe('my draft')
|
|
1912
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1913
|
+
})
|
|
1914
|
+
|
|
1915
|
+
test('edit completion after a chat switch preserves the submitted chat draft', async () => {
|
|
1916
|
+
dispatchTopicChange('77')
|
|
1917
|
+
typeInto(controller.textareaTarget, 'my draft')
|
|
1918
|
+
controller.startEditing({ id: 5, content: 'existing comment', private: false })
|
|
1919
|
+
jest.spyOn(controller, 'renderCommentHtml').mockImplementation(() => {})
|
|
1920
|
+
|
|
1921
|
+
let finishFetch
|
|
1922
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1923
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1924
|
+
|
|
1925
|
+
dispatchTopicChange('88')
|
|
1926
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1927
|
+
typeInto(controller.textareaTarget, 'draft for 88')
|
|
1928
|
+
|
|
1929
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1930
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1931
|
+
|
|
1932
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1933
|
+
expect(chatDrafts.get('88')).toBe('draft for 88')
|
|
1934
|
+
expect(controller.textareaTarget.value).toBe('draft for 88')
|
|
1935
|
+
})
|
|
1936
|
+
|
|
1937
|
+
test('a stashed slash-command draft is never replaced by the command text', async () => {
|
|
1938
|
+
dispatchTopicChange('77')
|
|
1939
|
+
typeInto(controller.textareaTarget, 'my draft')
|
|
1940
|
+
controller.handleStashDraft(
|
|
1941
|
+
new CustomEvent('comments--form:stash-draft', { detail: { draft: 'my draft' } }),
|
|
1942
|
+
)
|
|
1943
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1944
|
+
|
|
1945
|
+
typeInto(controller.textareaTarget, '/calendar 2026-08-14')
|
|
1946
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
1947
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1948
|
+
|
|
1949
|
+
controller._restoreStashedDraft('/calendar 2026-08-14')
|
|
1950
|
+
expect(controller.textareaTarget.value).toBe('my draft')
|
|
1951
|
+
expect(chatDrafts.get('77')).toBe('my draft')
|
|
1952
|
+
})
|
|
1953
|
+
|
|
1954
|
+
test('a message typed while a slash command is pending is saved on close', async () => {
|
|
1955
|
+
dispatchTopicChange('77')
|
|
1956
|
+
typeInto(controller.textareaTarget, 'my draft')
|
|
1957
|
+
controller.handleStashDraft(
|
|
1958
|
+
new CustomEvent('comments--form:stash-draft', { detail: { draft: 'my draft' } }),
|
|
1959
|
+
)
|
|
1960
|
+
typeInto(controller.textareaTarget, '/calendar 2026-08-14')
|
|
1961
|
+
|
|
1962
|
+
let finishFetch
|
|
1963
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1964
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1965
|
+
|
|
1966
|
+
typeInto(controller.textareaTarget, 'next message')
|
|
1967
|
+
controller.onPopupClosed()
|
|
1968
|
+
const savedOnClose = chatDrafts.get('77')
|
|
1969
|
+
|
|
1970
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1971
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1972
|
+
expect(savedOnClose).toBe('next message')
|
|
1973
|
+
expect(chatDrafts.get('77')).toBe('next message')
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
test('switching chats while a slash command succeeds preserves its stashed draft', async () => {
|
|
1977
|
+
dispatchTopicChange('77')
|
|
1978
|
+
typeInto(controller.textareaTarget, 'ordinary draft')
|
|
1979
|
+
controller.handleStashDraft(
|
|
1980
|
+
new CustomEvent('comments--form:stash-draft', { detail: { draft: 'ordinary draft' } }),
|
|
1981
|
+
)
|
|
1982
|
+
typeInto(controller.textareaTarget, '/calendar 2026-08-14')
|
|
1983
|
+
|
|
1984
|
+
let finishFetch
|
|
1985
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
|
|
1986
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
1987
|
+
|
|
1988
|
+
controller.onChatWillOpen({ creativeId: '88' })
|
|
1989
|
+
dispatchTopicChange('88')
|
|
1990
|
+
controller.onPopupOpened({ creativeId: '88', canComment: true })
|
|
1991
|
+
|
|
1992
|
+
finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
|
|
1993
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1994
|
+
|
|
1995
|
+
expect(chatDrafts.get('77')).toBe('ordinary draft')
|
|
1996
|
+
expect(controller.textareaTarget.value).toBe('')
|
|
1997
|
+
})
|
|
1998
|
+
|
|
1999
|
+
test('review quote feedback never replaces the ordinary chat draft', async () => {
|
|
2000
|
+
dispatchTopicChange('77')
|
|
2001
|
+
typeInto(controller.textareaTarget, 'my ordinary draft')
|
|
2002
|
+
|
|
2003
|
+
controller.appendReviewQuote(5, 'quoted review text')
|
|
2004
|
+
typeInto(controller.textareaTarget, 'feedback for the quote')
|
|
2005
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
2006
|
+
|
|
2007
|
+
expect(chatDrafts.get('77')).toBe('my ordinary draft')
|
|
2008
|
+
|
|
2009
|
+
controller.onPopupClosed()
|
|
2010
|
+
dispatchTopicChange('77')
|
|
2011
|
+
controller.onPopupOpened({ creativeId: '77', canComment: true })
|
|
2012
|
+
expect(controller.textareaTarget.value).toBe('my ordinary draft')
|
|
2013
|
+
})
|
|
2014
|
+
|
|
2015
|
+
test('successful review send restores the ordinary chat draft', async () => {
|
|
2016
|
+
dispatchTopicChange('77')
|
|
2017
|
+
typeInto(controller.textareaTarget, 'my ordinary draft')
|
|
2018
|
+
controller.appendReviewQuote(5, 'quoted review text')
|
|
2019
|
+
typeInto(controller.textareaTarget, 'feedback for the quote')
|
|
2020
|
+
controller._commitActiveQuote()
|
|
2021
|
+
|
|
2022
|
+
global.fetch = jest.fn(() =>
|
|
2023
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
2024
|
+
)
|
|
2025
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
2026
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
2027
|
+
|
|
2028
|
+
expect(controller.textareaTarget.value).toBe('my ordinary draft')
|
|
2029
|
+
expect(chatDrafts.get('77')).toBe('my ordinary draft')
|
|
2030
|
+
})
|
|
2031
|
+
|
|
2032
|
+
test('removing the last review quote restores the ordinary chat draft', () => {
|
|
2033
|
+
dispatchTopicChange('77')
|
|
2034
|
+
typeInto(controller.textareaTarget, 'my ordinary draft')
|
|
2035
|
+
controller.appendReviewQuote(5, 'quoted review text')
|
|
2036
|
+
typeInto(controller.textareaTarget, 'feedback for the quote')
|
|
2037
|
+
|
|
2038
|
+
controller.reviewQuotesContainerTarget
|
|
2039
|
+
.querySelector('.review-quote-chip-remove')
|
|
2040
|
+
.click()
|
|
2041
|
+
|
|
2042
|
+
expect(controller.textareaTarget.value).toBe('my ordinary draft')
|
|
2043
|
+
expect(chatDrafts.get('77')).toBe('my ordinary draft')
|
|
2044
|
+
})
|
|
2045
|
+
|
|
2046
|
+
test('sending the last question quote restores the ordinary chat draft', async () => {
|
|
2047
|
+
dispatchTopicChange('77')
|
|
2048
|
+
typeInto(controller.textareaTarget, 'my ordinary draft')
|
|
2049
|
+
controller.appendReviewQuote(5, 'quoted question text')
|
|
2050
|
+
controller._reviewStore.toggleType(controller._reviewStore.activeId)
|
|
2051
|
+
typeInto(controller.textareaTarget, 'question feedback')
|
|
2052
|
+
jest.spyOn(controller, 'renderCommentHtml').mockImplementation(() => {})
|
|
2053
|
+
global.fetch = jest.fn(() =>
|
|
2054
|
+
Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') }),
|
|
2055
|
+
)
|
|
2056
|
+
|
|
2057
|
+
controller.handleSend(new Event('submit', { cancelable: true }))
|
|
2058
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
2059
|
+
|
|
2060
|
+
expect(controller.textareaTarget.value).toBe('my ordinary draft')
|
|
2061
|
+
expect(chatDrafts.get('77')).toBe('my ordinary draft')
|
|
2062
|
+
})
|
|
2063
|
+
|
|
2064
|
+
test('typing without an active draft key does not write to storage', async () => {
|
|
2065
|
+
typeInto(controller.textareaTarget, 'orphan text')
|
|
2066
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
2067
|
+
expect(window.localStorage.getItem('collavre_chat_drafts_9')).toBeNull()
|
|
2068
|
+
})
|
|
2069
|
+
})
|