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,981 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
const saveLastTopic = jest.fn().mockResolvedValue(true)
|
|
8
|
+
|
|
9
|
+
jest.unstable_mockModule('../../../lib/api/topics', () => ({
|
|
10
|
+
fetchNextTopicName: jest.fn(),
|
|
11
|
+
createTopicWithComments: jest.fn(),
|
|
12
|
+
saveLastTopic,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
16
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
17
|
+
|
|
18
|
+
const TOPICS = [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }, { id: 3, name: 'Beta' }]
|
|
19
|
+
|
|
20
|
+
describe('TopicsController selection vs. in-flight loadTopics', () => {
|
|
21
|
+
let application
|
|
22
|
+
let controller
|
|
23
|
+
let changeEvents
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
document.body.innerHTML = `
|
|
27
|
+
<div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
28
|
+
<div data-comments--topics-target="list"></div>
|
|
29
|
+
</div>
|
|
30
|
+
`
|
|
31
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
32
|
+
document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
|
|
33
|
+
|
|
34
|
+
application = Application.start()
|
|
35
|
+
application.register('comments--topics', TopicsController)
|
|
36
|
+
|
|
37
|
+
changeEvents = []
|
|
38
|
+
document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
41
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
42
|
+
document.getElementById('topics'), 'comments--topics'
|
|
43
|
+
)
|
|
44
|
+
controller.creativeIdValue = '42'
|
|
45
|
+
controller.topics = TOPICS
|
|
46
|
+
controller.archivedTopics = []
|
|
47
|
+
controller.mainTopicId = '1'
|
|
48
|
+
controller.canManageTopics = true
|
|
49
|
+
controller.showingArchived = false
|
|
50
|
+
controller.serverLastTopicId = '2'
|
|
51
|
+
// The strip is on screen before any of these races start — that is what
|
|
52
|
+
// makes a chip clickable, and it is what marks the pick as being about
|
|
53
|
+
// creative 42.
|
|
54
|
+
controller.renderTopics(TOPICS, true)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
controller?.cancelPendingSaveLastTopic()
|
|
60
|
+
document.body.innerHTML = ''
|
|
61
|
+
document.head.innerHTML = ''
|
|
62
|
+
application.stop()
|
|
63
|
+
jest.clearAllMocks()
|
|
64
|
+
delete global.fetch
|
|
65
|
+
window.history.replaceState({}, '', '/')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// The user picked a topic while the strip was still fetching. The server's
|
|
69
|
+
// last_topic_id still names the topic they left, because the save for the new
|
|
70
|
+
// pick is debounced and has not landed. Honouring it throws the click away.
|
|
71
|
+
test('a topic picked while the strip is loading survives the response', async () => {
|
|
72
|
+
let resolveFetch
|
|
73
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
74
|
+
|
|
75
|
+
const loading = controller.loadTopics()
|
|
76
|
+
|
|
77
|
+
// The user clicks Beta while the fetch is in flight.
|
|
78
|
+
controller.selectTopic('3')
|
|
79
|
+
expect(controller.currentTopicId).toBe('3')
|
|
80
|
+
|
|
81
|
+
resolveFetch({
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
json: async () => ({
|
|
85
|
+
topics: TOPICS,
|
|
86
|
+
archived_topics: [],
|
|
87
|
+
can_manage: true,
|
|
88
|
+
main_topic_id: 1,
|
|
89
|
+
last_topic_id: 2,
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
await loading
|
|
93
|
+
|
|
94
|
+
expect(controller.currentTopicId).toBe('3')
|
|
95
|
+
expect(changeEvents.at(-1).topicId).toBe('3')
|
|
96
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
|
|
97
|
+
.toContain('active')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Same race, one step earlier: the pick has to survive the assignment of the
|
|
101
|
+
// server's answer to serverLastTopicId, not just restoreSelection().
|
|
102
|
+
test('the stale server last_topic_id does not overwrite the pick', async () => {
|
|
103
|
+
let resolveFetch
|
|
104
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
105
|
+
|
|
106
|
+
const loading = controller.loadTopics()
|
|
107
|
+
controller.selectTopic('3')
|
|
108
|
+
|
|
109
|
+
resolveFetch({
|
|
110
|
+
ok: true,
|
|
111
|
+
status: 200,
|
|
112
|
+
json: async () => ({
|
|
113
|
+
topics: TOPICS,
|
|
114
|
+
archived_topics: [],
|
|
115
|
+
can_manage: true,
|
|
116
|
+
main_topic_id: 1,
|
|
117
|
+
last_topic_id: 2,
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
await loading
|
|
121
|
+
|
|
122
|
+
expect(controller.serverLastTopicId).toBe('3')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// The strip can also be stale about its own creative: another member deletes
|
|
126
|
+
// a topic while the chip for it is still on screen. The pick is about this
|
|
127
|
+
// creative, but it names a topic that no longer exists, so keeping it would
|
|
128
|
+
// fail the lookup in restoreSelection() and persist Main over the preference
|
|
129
|
+
// the server actually holds.
|
|
130
|
+
test('a pick naming a topic the response no longer lists yields to the answer', async () => {
|
|
131
|
+
let resolveFetch
|
|
132
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
133
|
+
|
|
134
|
+
const loading = controller.loadTopics()
|
|
135
|
+
controller.selectTopic('3')
|
|
136
|
+
|
|
137
|
+
resolveFetch({
|
|
138
|
+
ok: true,
|
|
139
|
+
status: 200,
|
|
140
|
+
json: async () => ({
|
|
141
|
+
topics: [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }],
|
|
142
|
+
archived_topics: [],
|
|
143
|
+
can_manage: true,
|
|
144
|
+
main_topic_id: 1,
|
|
145
|
+
last_topic_id: 2,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
await loading
|
|
149
|
+
|
|
150
|
+
expect(controller.currentTopicId).toBe('2')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// All Messages carries no topic id, so nothing about it can be read off the
|
|
154
|
+
// saved preference — but it is still a pick, and a landing load must not undo
|
|
155
|
+
// it any more than it may undo a chip click.
|
|
156
|
+
describe('an All Messages pick while the strip is loading', () => {
|
|
157
|
+
const respond = (resolveFetch) => resolveFetch({
|
|
158
|
+
ok: true,
|
|
159
|
+
status: 200,
|
|
160
|
+
json: async () => ({
|
|
161
|
+
topics: TOPICS,
|
|
162
|
+
archived_topics: [],
|
|
163
|
+
can_manage: true,
|
|
164
|
+
main_topic_id: 1,
|
|
165
|
+
last_topic_id: 2,
|
|
166
|
+
}),
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('survives the response', async () => {
|
|
170
|
+
let resolveFetch
|
|
171
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
172
|
+
|
|
173
|
+
const loading = controller.loadTopics()
|
|
174
|
+
controller.selectTopic('')
|
|
175
|
+
|
|
176
|
+
respond(resolveFetch)
|
|
177
|
+
await loading
|
|
178
|
+
|
|
179
|
+
expect(controller.currentTopicId).toBe('')
|
|
180
|
+
expect(controller.listTarget.querySelector('.topic-all-messages').classList)
|
|
181
|
+
.toContain('active')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// The revert this PR is about is the message list snapping back, so the
|
|
185
|
+
// change event matters as much as the strip: nothing may re-announce the
|
|
186
|
+
// topic the user just left.
|
|
187
|
+
test('does not re-announce the topic the user left', async () => {
|
|
188
|
+
let resolveFetch
|
|
189
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
190
|
+
|
|
191
|
+
const loading = controller.loadTopics()
|
|
192
|
+
controller.selectTopic('')
|
|
193
|
+
|
|
194
|
+
respond(resolveFetch)
|
|
195
|
+
await loading
|
|
196
|
+
|
|
197
|
+
expect(changeEvents.at(-1).topicId).toBe('')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ...and the debounced save must still record the pick, not the stale
|
|
201
|
+
// answer that overtook it.
|
|
202
|
+
test('persists All Messages, not the stale answer', async () => {
|
|
203
|
+
let resolveFetch
|
|
204
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
205
|
+
|
|
206
|
+
const loading = controller.loadTopics()
|
|
207
|
+
controller.selectTopic('')
|
|
208
|
+
|
|
209
|
+
respond(resolveFetch)
|
|
210
|
+
await loading
|
|
211
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
212
|
+
|
|
213
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', null, expect.any(String), expect.anything())
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// The legacy per-browser preference is adopted on the first load that finds
|
|
218
|
+
// the server holding nothing. A winning empty pick leaves the server value
|
|
219
|
+
// empty on purpose, which reads exactly the same to the migration — so it
|
|
220
|
+
// would hand the user back the topic they just left, by another route.
|
|
221
|
+
describe('a winning empty pick against the localStorage migration', () => {
|
|
222
|
+
const LEGACY_KEY = 'collavre_creative_42_last_topic'
|
|
223
|
+
|
|
224
|
+
const respond = (resolveFetch, lastTopicId) => resolveFetch({
|
|
225
|
+
ok: true,
|
|
226
|
+
status: 200,
|
|
227
|
+
json: async () => ({
|
|
228
|
+
topics: TOPICS,
|
|
229
|
+
archived_topics: [],
|
|
230
|
+
can_manage: true,
|
|
231
|
+
main_topic_id: 1,
|
|
232
|
+
last_topic_id: lastTopicId,
|
|
233
|
+
}),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
afterEach(() => localStorage.clear())
|
|
237
|
+
|
|
238
|
+
test('the migration does not restore the legacy topic over the pick', async () => {
|
|
239
|
+
localStorage.setItem(LEGACY_KEY, '2')
|
|
240
|
+
let resolveFetch
|
|
241
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
242
|
+
|
|
243
|
+
const loading = controller.loadTopics()
|
|
244
|
+
controller.selectTopic('')
|
|
245
|
+
|
|
246
|
+
respond(resolveFetch, 2)
|
|
247
|
+
await loading
|
|
248
|
+
|
|
249
|
+
expect(controller.currentTopicId).toBe('')
|
|
250
|
+
expect(controller.listTarget.querySelector('.topic-all-messages').classList)
|
|
251
|
+
.toContain('active')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('the legacy topic is not persisted as the preference either', async () => {
|
|
255
|
+
localStorage.setItem(LEGACY_KEY, '2')
|
|
256
|
+
let resolveFetch
|
|
257
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
258
|
+
|
|
259
|
+
const loading = controller.loadTopics()
|
|
260
|
+
controller.selectTopic('')
|
|
261
|
+
|
|
262
|
+
respond(resolveFetch, null)
|
|
263
|
+
await loading
|
|
264
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
265
|
+
|
|
266
|
+
expect(saveLastTopic).not.toHaveBeenCalledWith('42', '2', expect.any(String), expect.anything())
|
|
267
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', null, expect.any(String), expect.anything())
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// The pick supersedes the legacy value, so the key has served its purpose
|
|
271
|
+
// and must not be left behind to re-apply on the next load.
|
|
272
|
+
test('the legacy key is still cleared', async () => {
|
|
273
|
+
localStorage.setItem(LEGACY_KEY, '2')
|
|
274
|
+
let resolveFetch
|
|
275
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
276
|
+
|
|
277
|
+
const loading = controller.loadTopics()
|
|
278
|
+
controller.selectTopic('')
|
|
279
|
+
|
|
280
|
+
respond(resolveFetch, null)
|
|
281
|
+
await loading
|
|
282
|
+
|
|
283
|
+
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// ...and the migration itself is untouched when no pick is racing it.
|
|
287
|
+
test('an unraced load still migrates the legacy topic', async () => {
|
|
288
|
+
localStorage.setItem(LEGACY_KEY, '2')
|
|
289
|
+
controller.serverLastTopicId = ''
|
|
290
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
291
|
+
ok: true,
|
|
292
|
+
status: 200,
|
|
293
|
+
json: async () => ({
|
|
294
|
+
topics: TOPICS,
|
|
295
|
+
archived_topics: [],
|
|
296
|
+
can_manage: true,
|
|
297
|
+
main_topic_id: 1,
|
|
298
|
+
last_topic_id: null,
|
|
299
|
+
}),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
await controller.loadTopics()
|
|
303
|
+
|
|
304
|
+
expect(controller.currentTopicId).toBe('2')
|
|
305
|
+
expect(saveLastTopic).toHaveBeenCalledWith('42', '2', expect.any(String), expect.anything())
|
|
306
|
+
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// A pick can only outrank the response if it was made against the strip of
|
|
311
|
+
// the creative the response describes. Switching creatives leaves the
|
|
312
|
+
// previous creative's chips on screen until the new strip lands, so a click
|
|
313
|
+
// in that window is not intent about the creative being loaded.
|
|
314
|
+
describe('a pick against the previous creative\'s strip', () => {
|
|
315
|
+
const OTHER_TOPICS = [{ id: 10, name: 'Main' }, { id: 11, name: 'Gamma' }]
|
|
316
|
+
|
|
317
|
+
const switchTo = (creativeId) => {
|
|
318
|
+
controller.subscribe = jest.fn()
|
|
319
|
+
return controller.onPopupOpened({ creativeId })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const otherCreativeResponse = {
|
|
323
|
+
ok: true,
|
|
324
|
+
status: 200,
|
|
325
|
+
json: async () => ({
|
|
326
|
+
topics: OTHER_TOPICS,
|
|
327
|
+
archived_topics: [],
|
|
328
|
+
can_manage: true,
|
|
329
|
+
main_topic_id: 10,
|
|
330
|
+
last_topic_id: 11,
|
|
331
|
+
}),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
test('does not discard the new creative\'s last_topic_id', async () => {
|
|
335
|
+
let resolveFetch
|
|
336
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
337
|
+
|
|
338
|
+
const switching = switchTo('99')
|
|
339
|
+
// Beta belongs to creative 42; its chip is still rendered.
|
|
340
|
+
controller.selectTopic('3')
|
|
341
|
+
|
|
342
|
+
resolveFetch(otherCreativeResponse)
|
|
343
|
+
await switching
|
|
344
|
+
|
|
345
|
+
expect(controller.serverLastTopicId).toBe('11')
|
|
346
|
+
expect(controller.currentTopicId).toBe('11')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test('does not persist Main as the new creative\'s preference', async () => {
|
|
350
|
+
let resolveFetch
|
|
351
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
352
|
+
|
|
353
|
+
const switching = switchTo('99')
|
|
354
|
+
controller.selectTopic('3')
|
|
355
|
+
|
|
356
|
+
resolveFetch(otherCreativeResponse)
|
|
357
|
+
await switching
|
|
358
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
359
|
+
|
|
360
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('99', '11', expect.any(String), expect.anything())
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// An empty pick is authoritative for the creative whose strip it was made
|
|
364
|
+
// against, and this one was made against the previous creative's — so the
|
|
365
|
+
// creative being opened still resolves to its own saved topic.
|
|
366
|
+
test('an empty pick does not suppress the response either', async () => {
|
|
367
|
+
let resolveFetch
|
|
368
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
369
|
+
|
|
370
|
+
const switching = switchTo('99')
|
|
371
|
+
controller.selectTopic('')
|
|
372
|
+
|
|
373
|
+
resolveFetch(otherCreativeResponse)
|
|
374
|
+
await switching
|
|
375
|
+
|
|
376
|
+
expect(controller.currentTopicId).toBe('11')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('toggling stale archived topics does not make their strip look current', async () => {
|
|
380
|
+
controller.archivedTopics = [{ id: 4, name: 'Archived Alpha' }]
|
|
381
|
+
controller.renderTopics(TOPICS, true)
|
|
382
|
+
let resolveFetch
|
|
383
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
384
|
+
|
|
385
|
+
const switching = switchTo('99')
|
|
386
|
+
controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
|
|
387
|
+
controller.selectTopic('')
|
|
388
|
+
|
|
389
|
+
resolveFetch(otherCreativeResponse)
|
|
390
|
+
await switching
|
|
391
|
+
|
|
392
|
+
expect(controller.serverLastTopicId).toBe('11')
|
|
393
|
+
expect(controller.currentTopicId).toBe('11')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('attributes a new-creative broadcast to the current creative', async () => {
|
|
397
|
+
const NEW_OTHER_TOPIC = { id: 12, name: 'Delta' }
|
|
398
|
+
let resolveFetch
|
|
399
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
400
|
+
|
|
401
|
+
const switching = switchTo('99')
|
|
402
|
+
controller.handleTopicMessage({ action: 'created', topic: NEW_OTHER_TOPIC, user_id: '88' })
|
|
403
|
+
controller.selectTopic('12')
|
|
404
|
+
|
|
405
|
+
resolveFetch({
|
|
406
|
+
ok: true,
|
|
407
|
+
status: 200,
|
|
408
|
+
json: async () => ({
|
|
409
|
+
topics: [...OTHER_TOPICS, NEW_OTHER_TOPIC],
|
|
410
|
+
archived_topics: [],
|
|
411
|
+
can_manage: true,
|
|
412
|
+
main_topic_id: 10,
|
|
413
|
+
last_topic_id: 11,
|
|
414
|
+
}),
|
|
415
|
+
})
|
|
416
|
+
await switching
|
|
417
|
+
|
|
418
|
+
expect(controller._pickCreativeId).toBe('99')
|
|
419
|
+
expect(controller.currentTopicId).toBe('12')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Not every write to the selection is a pick. loadTopics() empties the strip
|
|
424
|
+
// for the duration of its fetch, so any re-render landing in that window
|
|
425
|
+
// restores against an empty topic list and falls back to Main. That fallback
|
|
426
|
+
// is derived from state the load is about to replace — treating it as intent
|
|
427
|
+
// newer than the answer suppresses the very value it was derived from.
|
|
428
|
+
describe('a programmatic restore while the strip is loading', () => {
|
|
429
|
+
const NEW_TOPIC = { id: 4, name: 'Delta' }
|
|
430
|
+
|
|
431
|
+
const respond = (resolveFetch, topics = TOPICS) => resolveFetch({
|
|
432
|
+
ok: true,
|
|
433
|
+
status: 200,
|
|
434
|
+
json: async () => ({
|
|
435
|
+
topics,
|
|
436
|
+
archived_topics: [],
|
|
437
|
+
can_manage: true,
|
|
438
|
+
main_topic_id: 1,
|
|
439
|
+
last_topic_id: 2,
|
|
440
|
+
}),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// Another member creates a topic mid-load. handleTopicMessage() renders it
|
|
444
|
+
// on its own — this.topics was emptied — and restoreSelection() cannot find
|
|
445
|
+
// the saved topic among the one topic on screen.
|
|
446
|
+
const broadcastCreate = (userId = '99') => controller.handleTopicMessage({
|
|
447
|
+
action: 'created', topic: NEW_TOPIC, user_id: userId,
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
afterEach(() => { delete document.body.dataset.currentUserId })
|
|
451
|
+
|
|
452
|
+
test('does not suppress the response it was derived from', async () => {
|
|
453
|
+
let resolveFetch
|
|
454
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
455
|
+
|
|
456
|
+
const loading = controller.loadTopics()
|
|
457
|
+
broadcastCreate()
|
|
458
|
+
expect(controller.currentTopicId).toBe('1') // fell back to Main
|
|
459
|
+
|
|
460
|
+
respond(resolveFetch)
|
|
461
|
+
await loading
|
|
462
|
+
|
|
463
|
+
expect(controller.currentTopicId).toBe('2')
|
|
464
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="2"]').classList)
|
|
465
|
+
.toContain('active')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('does not persist Main over the saved preference', async () => {
|
|
469
|
+
let resolveFetch
|
|
470
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
471
|
+
|
|
472
|
+
const loading = controller.loadTopics()
|
|
473
|
+
broadcastCreate()
|
|
474
|
+
|
|
475
|
+
respond(resolveFetch)
|
|
476
|
+
await loading
|
|
477
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
478
|
+
|
|
479
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// A real pick has already won this load. A later restore is derived from
|
|
483
|
+
// the interim one-topic strip, so it may fall back to Main, but it must not
|
|
484
|
+
// replace the id associated with that authoritative pick.
|
|
485
|
+
test('retains a picked topic across an interim restore', async () => {
|
|
486
|
+
let resolveFetch
|
|
487
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
488
|
+
|
|
489
|
+
const loading = controller.loadTopics()
|
|
490
|
+
controller.selectTopic('3')
|
|
491
|
+
broadcastCreate()
|
|
492
|
+
expect(controller.currentTopicId).toBe('1')
|
|
493
|
+
|
|
494
|
+
respond(resolveFetch)
|
|
495
|
+
await loading
|
|
496
|
+
|
|
497
|
+
expect(controller.currentTopicId).toBe('3')
|
|
498
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
499
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
test('re-dispatches a picked All Messages after an interim restore', async () => {
|
|
503
|
+
let resolveFetch
|
|
504
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
505
|
+
|
|
506
|
+
const loading = controller.loadTopics()
|
|
507
|
+
controller.selectTopic('')
|
|
508
|
+
broadcastCreate()
|
|
509
|
+
expect(changeEvents.at(-1).topicId).toBe('1')
|
|
510
|
+
|
|
511
|
+
respond(resolveFetch)
|
|
512
|
+
await loading
|
|
513
|
+
|
|
514
|
+
expect(controller.currentTopicId).toBe('')
|
|
515
|
+
expect(changeEvents.at(-1).topicId).toBe('')
|
|
516
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id=""]').classList)
|
|
517
|
+
.toContain('active')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// The fallback is not the user moving off the link either, so it must not
|
|
521
|
+
// consume the sources that outrank the preference. They are one-shot: once
|
|
522
|
+
// released, not even a reload gets the linked conversation back.
|
|
523
|
+
test('does not consume a deep-link override', async () => {
|
|
524
|
+
let resolveFetch
|
|
525
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
526
|
+
controller.setOverrideTopicId('3')
|
|
527
|
+
|
|
528
|
+
const loading = controller.loadTopics()
|
|
529
|
+
broadcastCreate()
|
|
530
|
+
|
|
531
|
+
respond(resolveFetch)
|
|
532
|
+
await loading
|
|
533
|
+
|
|
534
|
+
expect(controller.overrideTopicId).toBe('3')
|
|
535
|
+
expect(controller.currentTopicId).toBe('3')
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
test('does not strip ?topic_id=', async () => {
|
|
539
|
+
let resolveFetch
|
|
540
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
541
|
+
window.history.replaceState({}, '', '/creatives/42?topic_id=3')
|
|
542
|
+
|
|
543
|
+
const loading = controller.loadTopics()
|
|
544
|
+
broadcastCreate()
|
|
545
|
+
|
|
546
|
+
respond(resolveFetch)
|
|
547
|
+
await loading
|
|
548
|
+
|
|
549
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('3')
|
|
550
|
+
expect(controller.currentTopicId).toBe('3')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
// The fallback is not the only restore that can land mid-load. archivedTopics
|
|
554
|
+
// outlives the emptied strip, so a preference naming an archived topic is
|
|
555
|
+
// still found — and re-selecting it is no more a pick than falling back to
|
|
556
|
+
// Main is. Left as one, it discards a preference another session moved and
|
|
557
|
+
// writes the local value back over it.
|
|
558
|
+
test('re-selecting the topic already held does not suppress the response', async () => {
|
|
559
|
+
let resolveFetch
|
|
560
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
561
|
+
controller.archivedTopics = [{ id: 5, name: 'Archived' }]
|
|
562
|
+
controller.serverLastTopicId = '5'
|
|
563
|
+
|
|
564
|
+
const loading = controller.loadTopics()
|
|
565
|
+
broadcastCreate()
|
|
566
|
+
expect(controller.currentTopicId).toBe('5') // found among the archived
|
|
567
|
+
|
|
568
|
+
resolveFetch({
|
|
569
|
+
ok: true,
|
|
570
|
+
status: 200,
|
|
571
|
+
json: async () => ({
|
|
572
|
+
topics: TOPICS,
|
|
573
|
+
archived_topics: [{ id: 5, name: 'Archived' }],
|
|
574
|
+
can_manage: true,
|
|
575
|
+
main_topic_id: 1,
|
|
576
|
+
last_topic_id: 2,
|
|
577
|
+
}),
|
|
578
|
+
})
|
|
579
|
+
await loading
|
|
580
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
581
|
+
|
|
582
|
+
expect(controller.currentTopicId).toBe('2')
|
|
583
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// The same broadcast for a topic this user created elsewhere auto-selects
|
|
587
|
+
// it, and that is intent — it still has to outrank the landing answer.
|
|
588
|
+
test('the auto-select of the user\'s own new topic still outranks the response', async () => {
|
|
589
|
+
let resolveFetch
|
|
590
|
+
global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
|
|
591
|
+
document.body.dataset.currentUserId = '7'
|
|
592
|
+
|
|
593
|
+
const loading = controller.loadTopics()
|
|
594
|
+
broadcastCreate('7')
|
|
595
|
+
|
|
596
|
+
respond(resolveFetch, [...TOPICS, NEW_TOPIC])
|
|
597
|
+
await loading
|
|
598
|
+
|
|
599
|
+
expect(controller.currentTopicId).toBe('4')
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// A subsequent reload can begin before the debounce lands — archive state
|
|
604
|
+
// broadcasts do exactly that. Its answer still predates the unsaved pick,
|
|
605
|
+
// even though the pick happened before this particular request began.
|
|
606
|
+
test('an unsaved pick survives a load started after the pick', async () => {
|
|
607
|
+
controller.selectTopic('3')
|
|
608
|
+
|
|
609
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
610
|
+
ok: true,
|
|
611
|
+
status: 200,
|
|
612
|
+
json: async () => ({
|
|
613
|
+
topics: TOPICS,
|
|
614
|
+
archived_topics: [],
|
|
615
|
+
can_manage: true,
|
|
616
|
+
main_topic_id: 1,
|
|
617
|
+
last_topic_id: 2,
|
|
618
|
+
}),
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
await controller.loadTopics()
|
|
622
|
+
|
|
623
|
+
expect(controller.currentTopicId).toBe('3')
|
|
624
|
+
await controller.flushSaveLastTopic(controller.currentTopicId)
|
|
625
|
+
expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
// Once that pick reaches the server, a later load is free to take the
|
|
629
|
+
// server's value again. The pending marker must not become a permanent
|
|
630
|
+
// preference override.
|
|
631
|
+
test('a saved pick yields to a later load', async () => {
|
|
632
|
+
controller.selectTopic('3')
|
|
633
|
+
await controller.flushSaveLastTopic('3')
|
|
634
|
+
|
|
635
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
636
|
+
ok: true,
|
|
637
|
+
status: 200,
|
|
638
|
+
json: async () => ({
|
|
639
|
+
topics: TOPICS,
|
|
640
|
+
archived_topics: [],
|
|
641
|
+
can_manage: true,
|
|
642
|
+
main_topic_id: 1,
|
|
643
|
+
last_topic_id: 2,
|
|
644
|
+
}),
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
await controller.loadTopics()
|
|
648
|
+
|
|
649
|
+
expect(controller.currentTopicId).toBe('2')
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
test('a failed save keeps the pick authoritative for a later load', async () => {
|
|
653
|
+
controller.selectTopic('3')
|
|
654
|
+
saveLastTopic.mockResolvedValueOnce(false)
|
|
655
|
+
await controller.flushSaveLastTopic('3')
|
|
656
|
+
|
|
657
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
658
|
+
ok: true,
|
|
659
|
+
status: 200,
|
|
660
|
+
json: async () => ({
|
|
661
|
+
topics: TOPICS,
|
|
662
|
+
archived_topics: [],
|
|
663
|
+
can_manage: true,
|
|
664
|
+
main_topic_id: 1,
|
|
665
|
+
last_topic_id: 2,
|
|
666
|
+
}),
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
await controller.loadTopics()
|
|
670
|
+
|
|
671
|
+
expect(controller.currentTopicId).toBe('3')
|
|
672
|
+
})
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
// The deep-link override and ?topic_id= both outrank the saved preference in
|
|
676
|
+
// the getter, so a pick that only writes the preference never becomes the
|
|
677
|
+
// answer — the next render lights the old chip and the next restore navigates
|
|
678
|
+
// back to it.
|
|
679
|
+
describe('TopicsController selection vs. higher-priority selection sources', () => {
|
|
680
|
+
let application
|
|
681
|
+
let controller
|
|
682
|
+
|
|
683
|
+
beforeEach(() => {
|
|
684
|
+
document.body.innerHTML = `
|
|
685
|
+
<div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
686
|
+
<div data-comments--topics-target="list"></div>
|
|
687
|
+
</div>
|
|
688
|
+
`
|
|
689
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
690
|
+
|
|
691
|
+
application = Application.start()
|
|
692
|
+
application.register('comments--topics', TopicsController)
|
|
693
|
+
|
|
694
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
695
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
696
|
+
document.getElementById('topics'), 'comments--topics'
|
|
697
|
+
)
|
|
698
|
+
controller.creativeIdValue = '42'
|
|
699
|
+
controller.topics = TOPICS
|
|
700
|
+
controller.archivedTopics = []
|
|
701
|
+
controller.mainTopicId = '1'
|
|
702
|
+
controller.canManageTopics = true
|
|
703
|
+
controller.showingArchived = false
|
|
704
|
+
controller.serverLastTopicId = ''
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
afterEach(() => {
|
|
709
|
+
document.body.innerHTML = ''
|
|
710
|
+
application.stop()
|
|
711
|
+
jest.clearAllMocks()
|
|
712
|
+
window.history.replaceState({}, '', '/')
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test('a pick supersedes a deep-link override for a different topic', () => {
|
|
716
|
+
controller.setOverrideTopicId('2')
|
|
717
|
+
|
|
718
|
+
controller.selectTopic('3')
|
|
719
|
+
|
|
720
|
+
expect(controller.currentTopicId).toBe('3')
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
test('restoreSelection after such a pick stays on the picked topic', () => {
|
|
724
|
+
controller.setOverrideTopicId('2')
|
|
725
|
+
controller.selectTopic('3')
|
|
726
|
+
|
|
727
|
+
controller.restoreSelection()
|
|
728
|
+
controller.renderTopics(controller.topics, controller.canManageTopics)
|
|
729
|
+
|
|
730
|
+
expect(controller.currentTopicId).toBe('3')
|
|
731
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
|
|
732
|
+
.toContain('active')
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test('an override agreeing with the pick is kept so it can still outrank the server', () => {
|
|
736
|
+
controller.setOverrideTopicId('3')
|
|
737
|
+
|
|
738
|
+
controller.selectTopic('3')
|
|
739
|
+
|
|
740
|
+
expect(controller.overrideTopicId).toBe('3')
|
|
741
|
+
controller.serverLastTopicId = '2' // as a later loadTopics() would write it
|
|
742
|
+
expect(controller.currentTopicId).toBe('3')
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
test('a pick drops a ?topic_id= naming a different topic', () => {
|
|
746
|
+
window.history.replaceState({}, '', '/creatives/42?topic_id=2')
|
|
747
|
+
|
|
748
|
+
controller.selectTopic('3')
|
|
749
|
+
|
|
750
|
+
expect(controller.currentTopicId).toBe('3')
|
|
751
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
test('a pick keeps a ?topic_id= naming the topic it selected', () => {
|
|
755
|
+
window.history.replaceState({}, '', '/creatives/42?topic_id=3')
|
|
756
|
+
|
|
757
|
+
controller.selectTopic('3')
|
|
758
|
+
|
|
759
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('3')
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
test('selecting All Messages drops a ?topic_id= too', () => {
|
|
763
|
+
window.history.replaceState({}, '', '/creatives/42?topic_id=2')
|
|
764
|
+
|
|
765
|
+
controller.selectTopic('')
|
|
766
|
+
|
|
767
|
+
expect(controller.currentTopicId).toBe('')
|
|
768
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
test('selecting All Messages drops a deep-link override too', () => {
|
|
772
|
+
controller.setOverrideTopicId('2')
|
|
773
|
+
|
|
774
|
+
controller.selectTopic('')
|
|
775
|
+
|
|
776
|
+
expect(controller.currentTopicId).toBe('')
|
|
777
|
+
})
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
// last_topic_changed is another session of the same user moving the saved
|
|
781
|
+
// preference. It is not a click in this popup, so it must not consume the
|
|
782
|
+
// deep-link sources this popup was opened on — they outrank the preference by
|
|
783
|
+
// design, and they are gone for good once released.
|
|
784
|
+
describe('TopicsController deep-link sources vs. a preference broadcast', () => {
|
|
785
|
+
let application
|
|
786
|
+
let controller
|
|
787
|
+
|
|
788
|
+
beforeEach(() => {
|
|
789
|
+
document.body.innerHTML = `
|
|
790
|
+
<div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
791
|
+
<div data-comments--topics-target="list"></div>
|
|
792
|
+
</div>
|
|
793
|
+
`
|
|
794
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
795
|
+
|
|
796
|
+
application = Application.start()
|
|
797
|
+
application.register('comments--topics', TopicsController)
|
|
798
|
+
|
|
799
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
800
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
801
|
+
document.getElementById('topics'), 'comments--topics'
|
|
802
|
+
)
|
|
803
|
+
controller.creativeIdValue = '42'
|
|
804
|
+
controller.topics = TOPICS
|
|
805
|
+
controller.archivedTopics = []
|
|
806
|
+
controller.mainTopicId = '1'
|
|
807
|
+
controller.canManageTopics = true
|
|
808
|
+
controller.showingArchived = false
|
|
809
|
+
controller.serverLastTopicId = ''
|
|
810
|
+
controller.renderTopics(TOPICS, true)
|
|
811
|
+
})
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
afterEach(() => {
|
|
815
|
+
document.body.innerHTML = ''
|
|
816
|
+
application.stop()
|
|
817
|
+
jest.clearAllMocks()
|
|
818
|
+
window.history.replaceState({}, '', '/')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('a broadcast does not consume a deep-link override', () => {
|
|
822
|
+
controller.setOverrideTopicId('3')
|
|
823
|
+
|
|
824
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
825
|
+
|
|
826
|
+
expect(controller.overrideTopicId).toBe('3')
|
|
827
|
+
expect(controller.currentTopicId).toBe('3')
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
test('a broadcast does not strip ?topic_id=', () => {
|
|
831
|
+
window.history.replaceState({}, '', '/creatives/42?topic_id=3')
|
|
832
|
+
|
|
833
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
834
|
+
|
|
835
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('3')
|
|
836
|
+
expect(controller.currentTopicId).toBe('3')
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
test('a broadcast leaves the linked topic selected in the strip', () => {
|
|
840
|
+
controller.setOverrideTopicId('3')
|
|
841
|
+
|
|
842
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
843
|
+
controller.restoreSelection()
|
|
844
|
+
|
|
845
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
|
|
846
|
+
.toContain('active')
|
|
847
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="2"]').classList)
|
|
848
|
+
.not.toContain('active')
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
test('the broadcast preference is still recorded behind the deep link', () => {
|
|
852
|
+
controller.setOverrideTopicId('3')
|
|
853
|
+
|
|
854
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
855
|
+
|
|
856
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
857
|
+
// Releasing the link hands the popup back to the preference the other
|
|
858
|
+
// session set, rather than to a value this popup never heard about.
|
|
859
|
+
controller.clearOverrideTopicId()
|
|
860
|
+
expect(controller.currentTopicId).toBe('2')
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
// comments_controller sends X-Topic-Id as effective_topic_id.to_s, so a
|
|
864
|
+
// comment that belongs to no topic resolves the deep link to All Messages and
|
|
865
|
+
// list_controller sets the override to "". It outranks the preference exactly
|
|
866
|
+
// like a named one — truthiness is the wrong test for "is a link set".
|
|
867
|
+
test('a broadcast does not consume a deep link that resolved to All Messages', () => {
|
|
868
|
+
controller.setOverrideTopicId('')
|
|
869
|
+
|
|
870
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
871
|
+
|
|
872
|
+
expect(controller.overrideTopicId).toBe('')
|
|
873
|
+
expect(controller.currentTopicId).toBe('')
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
test('a broadcast still moves the selection when no deep link outranks it', () => {
|
|
877
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
878
|
+
|
|
879
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
880
|
+
expect(controller.currentTopicId).toBe('2')
|
|
881
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="2"]').classList)
|
|
882
|
+
.toContain('active')
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
test('a pick after a broadcast still supersedes the deep link', () => {
|
|
886
|
+
controller.setOverrideTopicId('3')
|
|
887
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
888
|
+
|
|
889
|
+
controller.selectTopic('1')
|
|
890
|
+
|
|
891
|
+
expect(controller.currentTopicId).toBe('1')
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
// Every selection schedules a save, restores included, and the save is
|
|
895
|
+
// debounced by 500ms. Accepting a broadcast without following it leaves that
|
|
896
|
+
// timer holding the topic the link put us on, so it lands afterwards and
|
|
897
|
+
// writes the deep link back over the preference the other session just set.
|
|
898
|
+
describe('a pending save against an accepted broadcast', () => {
|
|
899
|
+
beforeEach(() => {
|
|
900
|
+
jest.useFakeTimers()
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
afterEach(() => {
|
|
904
|
+
controller.cancelPendingSaveLastTopic()
|
|
905
|
+
jest.clearAllTimers()
|
|
906
|
+
jest.useRealTimers()
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
test('cancels a zero-valued debounce handle after accepting a broadcast', () => {
|
|
910
|
+
const clearTimer = jest.spyOn(global, 'clearTimeout')
|
|
911
|
+
controller.setOverrideTopicId('3')
|
|
912
|
+
controller._saveLastTopicTimer = 0
|
|
913
|
+
|
|
914
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
915
|
+
|
|
916
|
+
expect(clearTimer).toHaveBeenCalledWith(0)
|
|
917
|
+
expect(controller._saveLastTopicTimer).toBeNull()
|
|
918
|
+
clearTimer.mockRestore()
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
test('the deep link is not written back over the broadcast preference', async () => {
|
|
922
|
+
window.history.replaceState({}, '', '/creatives/42?topic_id=3')
|
|
923
|
+
controller.restoreSelection()
|
|
924
|
+
|
|
925
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
926
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
927
|
+
|
|
928
|
+
expect(saveLastTopic).not.toHaveBeenCalledWith('42', '3', expect.any(String), expect.anything())
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
// The broadcast is the preference now; nothing this popup had queued
|
|
932
|
+
// beforehand describes it, so nothing queued beforehand may be sent.
|
|
933
|
+
test('no save at all follows an accepted broadcast', async () => {
|
|
934
|
+
controller.setOverrideTopicId('3')
|
|
935
|
+
controller.restoreSelection()
|
|
936
|
+
|
|
937
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
938
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
939
|
+
|
|
940
|
+
expect(saveLastTopic).not.toHaveBeenCalled()
|
|
941
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
// Cancelling is scoped to the accepted broadcast. A selection the user
|
|
945
|
+
// makes afterwards is theirs, and still has to reach the server.
|
|
946
|
+
test('a pick after the broadcast still saves', async () => {
|
|
947
|
+
controller.setOverrideTopicId('3')
|
|
948
|
+
controller.restoreSelection()
|
|
949
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
950
|
+
|
|
951
|
+
controller.selectTopic('1')
|
|
952
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
953
|
+
|
|
954
|
+
expect(saveLastTopic).toHaveBeenCalledWith('42', '1', expect.any(String), expect.anything())
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
// With no link to hold the view, the broadcast is followed, and following
|
|
958
|
+
// it re-arms the debounce with the broadcast's own value — so the timer
|
|
959
|
+
// must not be left cancelled on that path.
|
|
960
|
+
test('a followed broadcast still persists its own value', async () => {
|
|
961
|
+
controller.restoreSelection()
|
|
962
|
+
|
|
963
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
964
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
965
|
+
|
|
966
|
+
expect(saveLastTopic).toHaveBeenCalledWith('42', '2', expect.any(String), expect.anything())
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
test('a later restore leaves the broadcast preference behind the deep link', async () => {
|
|
970
|
+
controller.setOverrideTopicId('3')
|
|
971
|
+
controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
|
|
972
|
+
|
|
973
|
+
controller.handleTopicMessage({ action: 'updated', topic: { id: 1, name: 'Renamed Main' } })
|
|
974
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
975
|
+
|
|
976
|
+
expect(controller.currentTopicId).toBe('3')
|
|
977
|
+
expect(controller.serverLastTopicId).toBe('2')
|
|
978
|
+
expect(saveLastTopic).not.toHaveBeenCalledWith('42', '3', expect.any(String), expect.anything())
|
|
979
|
+
})
|
|
980
|
+
})
|
|
981
|
+
})
|