diff --git a/.circleci/config.yml b/.circleci/config.yml index f764cb611..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 20 + parallelism: 18 steps: - checkout - node/install: diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7..2430fae2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,18 @@ ## General Guidelines -- MVP focus: Least code change, happy-path only -- No unnecessary defensive programming -- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer the smallest production-ready change that solves the current problem. +- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. +- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. +- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. +- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing. +- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read. - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked +- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity. - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs diff --git a/Gemfile b/Gemfile index 7533cf3cf..7735dc099 100644 --- a/Gemfile +++ b/Gemfile @@ -195,7 +195,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.10.0' +gem 'ai-agents', '>= 0.12.0' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.14.1' diff --git a/Gemfile.lock b/Gemfile.lock index 8da80f52c..bd41474a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.10.0) + ai-agents (0.12.0) ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) @@ -198,7 +198,7 @@ GEM crack (1.0.0) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) cronex (0.15.0) tzinfo unicode (>= 0.4.4.5) @@ -570,7 +570,7 @@ GEM minitest (5.25.5) mock_redis (0.36.0) ruby2_keywords - msgpack (1.8.0) + msgpack (1.8.3) multi_json (1.15.0) multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) @@ -1058,7 +1058,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.10.0) + ai-agents (>= 0.12.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 24b6d9e70..c7608399e 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder def fallback_params(attachment) { - fallback_title: attachment['title'], + fallback_title: attachment['title'] || attachment.dig('payload', 'title'), external_url: attachment['url'] || attachment.dig('payload', 'url') } end # Facebook shared posts point to page URLs, not downloadable media URLs. + # Both `share` and `post` attachment types carry a page URL rather than a media file, + # so map them to `fallback` (which keeps the title/link without attempting a download). # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. def normalize_file_type(type) - return :fallback if type.to_sym == :share + return :fallback if [:share, :post].include?(type.to_sym) super end diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC def assignment_policy_params params.require(:assignment_policy).permit( :name, :description, :assignment_order, :conversation_priority, - :fair_distribution_limit, :fair_distribution_window, :enabled + :fair_distribution_limit, :fair_distribution_window, :enabled, + :exclude_older_than_hours ) end end diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 156c031fa..04eeff92b 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def update params_to_update = captain_params - @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] - @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models) + @current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features) @current_account.save! render json: preferences_payload @@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def merged_captain_models existing_models = @current_account.captain_models || {} - existing_models.merge(permitted_captain_models) + existing_models.merge(permitted_captain_models).compact_blank.presence end def merged_captain_features @@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas end def permitted_captain_models - params.require(:captain_models).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys end def permitted_captain_features - params.require(:captain_features).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys + end + + def captain_feature_keys + Llm::Models.feature_keys.map(&:to_sym) end def features_with_account_preferences preferences = Current.account.captain_preferences account_features = preferences[:features] || {} - account_models = preferences[:models] || {} Llm::Models.feature_keys.index_with do |feature_key| config = Llm::Models.feature_config(feature_key) + route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account) config.merge( enabled: account_features[feature_key] == true, - selected: account_models[feature_key] || config[:default] + model: route[:model], + selected: route[:model], + provider: route[:provider], + source: route[:source] ) end end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..b632ac78d 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end render json: { content: translated_content } + rescue Google::Cloud::Error => e + # `details` carries the clean human message; `message` includes gRPC debug noise + render_could_not_create_error(e.details.presence || e.message) end private diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC end render_response( - dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) ) end diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb index 181e4965e..d7c49b35d 100644 --- a/app/controllers/api/v1/accounts/onboardings_controller.rb +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -1,17 +1,19 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController before_action :check_admin_authorization? + ONBOARDING_STEP_KEY = 'onboarding_step'.freeze + STEP_ACCOUNT_DETAILS = 'account_details'.freeze + STEP_INBOX_SETUP = 'inbox_setup'.freeze + ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze + def update + return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step]) + @account = Current.account - finalize = finalizing_account_details? - - @account.assign_attributes(account_params) - @account.custom_attributes.merge!(custom_attributes_params) - @account.custom_attributes.delete('onboarding_step') if finalize - @account.save! - - # TODO: re-enable when the help center generation UI is ready to surface progress - # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + # The client declares the step it is completing; `account_details` runs + # `complete_account_details`, and so on. The known-step guard above keeps the + # client value from `send`-ing an arbitrary method. + send("complete_#{params[:onboarding_step]}") render 'api/v1/accounts/update', format: :json end @@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll private - def finalizing_account_details? - @account.custom_attributes['onboarding_step'] == 'account_details' + def complete_account_details + # Only act while the cursor still points here, so a stale replay after + # onboarding finished can't re-enter it. + return unless current_step == STEP_ACCOUNT_DETAILS + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + + # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded + # environment check); self-hosted finishes onboarding here. + if ChatwootApp.chatwoot_cloud? + move_to_step(STEP_INBOX_SETUP) + create_onboarding_inboxes + else + finish_onboarding + end end - def website - custom_attributes_params[:website] + def complete_inbox_setup + # Only finalize while the cursor still points here, so a stale or out-of-order + # request can't end onboarding early. Replays are no-ops. + return unless current_step == STEP_INBOX_SETUP + + finish_onboarding + end + + def current_step + @account.custom_attributes[ONBOARDING_STEP_KEY] + end + + def move_to_step(step) + @account.custom_attributes[ONBOARDING_STEP_KEY] = step + @account.save! + end + + def finish_onboarding + @account.custom_attributes.delete(ONBOARDING_STEP_KEY) + @account.save! + end + + def create_onboarding_inboxes + Onboarding::WebWidgetCreationService.new(@account, Current.user).perform end def account_params diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index e8688dcfb..6239e00eb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController end def team_params - params.require(:team).permit(:name, :description, :allow_auto_assign) + params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color) end end diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr response = dyte_processor_service.add_participant_to_meeting( @message.content_attributes['data']['meeting_id'], - @conversation.contact + @conversation.contact, + @message ) render_response(response) end diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 000000000..633071301 --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,29 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index b6df015f7..a369830b6 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ActionController::Base include SwitchLocale + include PortalHomeData GLOBAL_CONFIG_KEYS = %w[ LOGO @@ -63,6 +64,10 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale + if @portal.layout == 'documentation' + request.variant = :documentation + load_home_data + end render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 2991b84d2..323440304 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - @locale = validate_and_get_locale(params[:locale]) + # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, + # while UI translations fall back to an available I18n locale (e.g. th). + @locale = params[:locale] - I18n.with_locale(@locale, &) + I18n.with_locale(validate_and_get_locale(@locale), &) end def switch_locale_with_article(&) @@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - article_locale = if article.category.present? - article.category.locale - else - article.locale - end - @locale = validate_and_get_locale(article_locale) - I18n.with_locale(@locale, &) + @locale = if article.category.present? + article.category.locale + else + article.locale + end + I18n.with_locale(validate_and_get_locale(@locale), &) end def allow_iframe_requests diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 57db11aec..4982278d7 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,4 +1,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal @@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end - - def load_home_data - base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) - @visible_categories = @portal.categories - .where(locale: @locale) - .joins(:articles).where(articles: { status: :published }) - .order(position: :asc) - .group('categories.id') - @popular_topics = @visible_categories.first(3) - @featured = base_articles.order_by_views.limit(6) - @category_contributors = build_category_contributors(@visible_categories) - end - - def build_category_contributors(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - @portal.articles - .published - .where(locale: @locale, category_id: category_ids) - .includes(:author) - .group_by(&:category_id) - .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } - end end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 27ce587f7..59b99c37e 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params permitted_params = super - permitted_params[:limits] = permitted_params[:limits].to_h.compact + permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits) + permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models) permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present? permitted_params end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..b2683f2e0 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard # Add all_features last so it appears after manually_managed_features attributes[:all_features] = AccountFeaturesField + attributes[:captain_models] = CaptainModelOverridesField attributes else @@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[custom_attributes limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - attrs = super + [limits: {}] + attrs = super + [limits: {}, captain_models: {}] # Add manually_managed_features to permitted attributes only for Chatwoot Cloud attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue index e6bd3c272..bca6e877b 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue @@ -20,6 +20,7 @@ const ICON_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', + [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x', }; const COLOR_MAP = { @@ -28,13 +29,18 @@ const COLOR_MAP = { [VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11', [VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9', + [VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9', }; const isOutbound = computed( () => props.direction === VOICE_CALL_DIRECTION.OUTBOUND ); const isFailed = computed(() => - [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status) + [ + VOICE_CALL_STATUS.NO_ANSWER, + VOICE_CALL_STATUS.FAILED, + VOICE_CALL_STATUS.REJECTED, + ].includes(props.status) ); const labelKey = computed(() => { diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue index ee20fded5..c689065fb 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue @@ -29,7 +29,6 @@ const initialState = { handoffMessage: '', resolutionMessage: '', instructions: '', - temperature: 1, }; const state = reactive({ ...initialState }); @@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => { state.handoffMessage = config.handoff_message; state.resolutionMessage = config.resolution_message; state.instructions = config.instructions; - state.temperature = config.temperature || 1; }; const handleSystemMessagesUpdate = async () => { @@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => { ...props.assistant.config, handoff_message: state.handoffMessage, resolution_message: state.resolutionMessage, - temperature: state.temperature || 1, }, }; @@ -131,26 +128,6 @@ watch( class="z-0" /> -
- -
- - {{ state.temperature }} -
-

- {{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }} -

-
-
+ diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue index 3a1d65bc6..7e4fbc84e 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue @@ -1,21 +1,22 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue new file mode 100644 index 000000000..c0157398e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue new file mode 100644 index 000000000..8c0e1decc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue @@ -0,0 +1,116 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue new file mode 100644 index 000000000..45f170137 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue @@ -0,0 +1,139 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue new file mode 100644 index 000000000..a3631649d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue @@ -0,0 +1,210 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue new file mode 100644 index 000000000..b5c138c65 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue @@ -0,0 +1,70 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue new file mode 100644 index 000000000..48be07821 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue @@ -0,0 +1,157 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue new file mode 100644 index 000000000..aeb40e24c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue @@ -0,0 +1,44 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js new file mode 100644 index 000000000..0ebdd728f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js @@ -0,0 +1,18 @@ +import { INBOX_TYPES } from 'dashboard/helper/inbox'; + +// A detected channel maps to a real inbox when they share a channel_type. Gmail +// and Outlook both use Channel::Email, so for email we also match on provider. +// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox). + +// Returns the matching inbox (not a boolean) so callers can show the connected +// account's real name rather than the detected handle. +export const findConnectedInbox = (inboxes, stub) => + inboxes.find( + inbox => + inbox.channel_type === stub?.channel_type && + (stub?.channel_type !== INBOX_TYPES.EMAIL || + inbox.provider === stub?.provider) + ); + +export const isChannelConnected = (inboxes, stub) => + Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub)); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js new file mode 100644 index 000000000..91e800d06 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js @@ -0,0 +1,149 @@ +import { CHANNEL_TYPES } from 'dashboard/helper/inbox'; + +// Channels whose connect flow opens the channels dialog preselected to their +// in-dialog step — Facebook (page picker) and the credential-form channels +// (Telegram, Line) — rather than redirecting through OAuth. +export const DIALOG_CHANNELS = [ + CHANNEL_TYPES.FACEBOOK, + CHANNEL_TYPES.TELEGRAM, + CHANNEL_TYPES.LINE, +]; + +// Suggested channels (in priority order) to offer as rows when nothing is +// detected, so the step isn't empty. The mainstream OAuth channels show on +// configured installs, while credential-free Telegram/LINE keep the list +// non-empty on a bare self-host. +export const DEFAULT_CHANNEL_TYPES = [ + CHANNEL_TYPES.WHATSAPP, + CHANNEL_TYPES.FACEBOOK, + CHANNEL_TYPES.INSTAGRAM, + CHANNEL_TYPES.TELEGRAM, + CHANNEL_TYPES.LINE, +]; + +// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped +// like a real inbox so ChannelIcon can resolve the icon from the shared provider. +// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one +// exists and falls back to the monochrome glyph otherwise, so no per-channel +// style flag is needed. Entries without a channel type (Voice, Other Email +// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an +// inline credential form; `setupLater: true` defers the channel to in-app setup +// for this phase. `labelKey` is an i18n key — most reuse the shared channel +// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the +// names translate without duplicating strings; resolve it with `t()` at display. +export const CHANNEL_LIST = [ + { + type: 'website', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE', + inbox: { channel_type: 'Channel::WebWidget' }, + }, + { + type: 'whatsapp', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', + inbox: { channel_type: 'Channel::Whatsapp' }, + }, + { + type: 'instagram', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', + inbox: { channel_type: 'Channel::Instagram' }, + }, + { + type: 'facebook', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', + inbox: { channel_type: 'Channel::FacebookPage' }, + }, + { + type: 'tiktok', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE', + inbox: { channel_type: 'Channel::Tiktok' }, + }, + { + type: 'telegram', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE', + inbox: { channel_type: 'Channel::Telegram' }, + form: true, + }, + { + type: 'line', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', + inbox: { channel_type: 'Channel::Line' }, + form: true, + }, + // Email channels (including Gmail/Outlook OAuth) are set up later in-app for + // this phase; they will be enabled in a future PR. + { + type: 'gmail', + labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL', + inbox: { channel_type: 'Channel::Email', provider: 'google' }, + setupLater: true, + }, + { + type: 'outlook', + labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK', + inbox: { channel_type: 'Channel::Email', provider: 'microsoft' }, + setupLater: true, + }, + { + type: 'sms', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE', + inbox: { channel_type: 'Channel::Sms' }, + setupLater: true, + }, + { + type: 'api', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE', + inbox: { channel_type: 'Channel::Api' }, + setupLater: true, + }, + { + type: 'voice', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE', + fallbackIcon: 'i-woot-voice', + setupLater: true, + }, + { + type: 'email', + labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL', + fallbackIcon: 'i-woot-mail', + setupLater: true, + }, +]; + +const channelByType = type => + CHANNEL_LIST.find(channel => channel.type === type); + +// Icons shown next to "View all" when every detected channel is already +// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs +// aren't duplicated. +export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map( + channelByType +); + +// Social channels that detected brand_info socials map to, keyed by social type +// in the order they're offered as rows. Derived from CHANNEL_LIST so channel +// identity (label, channel_type) has a single source. Keys mirror +// SocialLinkParser::SOCIAL_DOMAIN_MAP. +const SOCIAL_PLATFORM_TYPES = [ + 'whatsapp', + 'facebook', + 'line', + 'instagram', + 'telegram', + 'tiktok', +]; + +export const SOCIAL_PLATFORMS = Object.fromEntries( + SOCIAL_PLATFORM_TYPES.map(type => { + const { labelKey, inbox } = channelByType(type); + return [type, { labelKey, channelType: inbox.channel_type }]; + }) +); + +// Mailbox providers inferred from the signup domain's MX records, keyed by +// Channel::Email#provider. Derived from CHANNEL_LIST's email entries. +export const EMAIL_PROVIDERS = Object.fromEntries( + CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [ + channel.inbox.provider, + { labelKey: channel.labelKey }, + ]) +); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js new file mode 100644 index 000000000..ba2d6f0dc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js @@ -0,0 +1,29 @@ +import { useMapGetter } from 'dashboard/composables/store'; + +// OAuth/SDK channels need installation-level app credentials to be usable. When +// the credential is missing the channel is "not configured" and is hidden from +// onboarding entirely. Channels without an entry (Website, Telegram, Line, …) +// need no installation credential and are always considered configured. +// Mirrors the availability checks in ChannelItem.vue. +export function useChannelConfig() { + const globalConfig = useMapGetter('globalConfig/get'); + const installationConfig = window.chatwootConfig || {}; + + const CHANNEL_CONFIGURED = { + // WhatsApp is onboarded only via Meta embedded signup, which needs both the + // app id (not the 'none' sentinel) and the signup configuration id. + whatsapp: () => + Boolean(installationConfig.whatsappAppId) && + installationConfig.whatsappAppId !== 'none' && + Boolean(installationConfig.whatsappConfigurationId), + facebook: () => Boolean(installationConfig.fbAppId), + instagram: () => Boolean(installationConfig.instagramAppId), + tiktok: () => Boolean(installationConfig.tiktokAppId), + gmail: () => Boolean(installationConfig.googleOAuthClientId), + outlook: () => Boolean(globalConfig.value.azureAppId), + }; + + const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true; + + return { isConfigured }; +} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js new file mode 100644 index 000000000..bd34d5f20 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js @@ -0,0 +1,67 @@ +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; +import { useStore } from 'dashboard/composables/store'; +import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup'; +import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; +import googleClient from 'dashboard/api/channel/googleClient'; +import microsoftClient from 'dashboard/api/channel/microsoftClient'; +import instagramClient from 'dashboard/api/channel/instagramClient'; +import tiktokClient from 'dashboard/api/channel/tiktokClient'; + +// Channels that complete via an OAuth redirect. Email channels are keyed by their +// Channel::Email provider, others by channel type. The request is tagged with a +// return hint so the callback brings the user back to onboarding instead of the +// inbox settings page. +const OAUTH_CLIENTS = { + google: googleClient, + microsoft: microsoftClient, + instagram: instagramClient, + tiktok: tiktokClient, +}; + +export function useChannelConnect() { + const { t } = useI18n(); + const store = useStore(); + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + + const connectViaOAuth = async provider => { + const client = OAUTH_CLIENTS[provider]; + if (!client) return; + + try { + const { + data: { url }, + } = await client.generateAuthorization({ return_to: 'onboarding' }); + window.location.href = url; + } catch { + useAlert(t('ONBOARDING_INBOX_SETUP.ERROR')); + } + }; + + // WhatsApp connects via Meta's embedded-signup popup instead of the redirect + // OAuth flow above. Collect the signup credentials, exchange them for an + // inbox, and surface the result inline — then refetch so the connected state + // reflects the freshly created inbox (and renders its real channel icon). + const connectWhatsapp = async () => { + let credentials; + try { + credentials = await runEmbeddedSignup(); + } catch { + useAlert(t('ONBOARDING_INBOX_SETUP.ERROR')); + return; + } + if (!credentials) return; // user dismissed the popup + + try { + await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials); + await store.dispatch('inboxes/get'); + useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED')); + } catch (error) { + useAlert( + parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR') + ); + } + }; + + return { connectViaOAuth, connectWhatsapp }; +} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js new file mode 100644 index 000000000..6ba68c6bf --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js @@ -0,0 +1,130 @@ +import { computed } from 'vue'; +import { useMapGetter } from 'dashboard/composables/store'; +import { useAccount } from 'dashboard/composables/useAccount'; +import { + SOCIAL_PLATFORMS, + EMAIL_PROVIDERS, + DEFAULT_CHANNEL_TYPES, +} from './constants'; +import { findConnectedInbox } from './channelMatchers'; +import { useChannelConfig } from './useChannelConfig'; + +// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES +// is config-gated like everything else, then sliced to this limit. +const DISPLAYED_CHANNEL_LIMIT = 3; + +// Pull the handle/username out of a detected social URL, formatted per channel. +const extractHandle = ({ type, url }) => { + try { + const { pathname } = new URL(url); + const path = pathname.replace(/^\/+|\/+$/g, ''); + if (type === 'whatsapp') { + const digits = path.replace(/\D/g, ''); + return digits ? `+${digits}` : ''; + } + if (type === 'line') return path; + return path.startsWith('@') ? path : `@${path}`; + } catch { + return ''; + } +}; + +// Derives the channel rows for the inbox-setup step from the account's detected +// brand_info (socials + mailbox provider) and the real connected inboxes, +// keeping InboxSetup.vue focused on layout, connect routing, and completion. +export function useDetectedChannels() { + const { currentAccount } = useAccount(); + const inboxes = useMapGetter('inboxes/getInboxes'); + const { isConfigured } = useChannelConfig(); + + const brandSocials = computed( + () => currentAccount.value?.custom_attributes?.brand_info?.socials || [] + ); + + const connectedChannels = computed(() => + brandSocials.value + .filter(social => SOCIAL_PLATFORMS[social.type] && social.url) + .map(social => ({ + type: social.type, + handle: extractHandle(social), + labelKey: SOCIAL_PLATFORMS[social.type].labelKey, + inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType }, + })) + ); + + const detectedEmailChannel = computed(() => { + const brandInfo = currentAccount.value?.custom_attributes?.brand_info; + const provider = brandInfo?.email_provider; + if (!EMAIL_PROVIDERS[provider]) return null; + + return { + type: 'email', + handle: brandInfo?.email || '', + labelKey: EMAIL_PROVIDERS[provider].labelKey, + inbox: { channel_type: 'Channel::Email', provider }, + }; + }); + + // The real inbox backing a channel, if one exists — returned (not just a + // boolean) so the row can show the connected account's real name. + const connectedInbox = channel => + findConnectedInbox(inboxes.value, channel.inbox); + + // A channel row built from a social type, with no detected handle — used for + // the default suggestions when nothing was detected. + const toChannelRow = type => ({ + type, + handle: '', + labelKey: SOCIAL_PLATFORMS[type].labelKey, + inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType }, + }); + + const detectedChannels = computed(() => + [detectedEmailChannel.value, ...connectedChannels.value] + .filter(Boolean) + // Email channels (including Gmail/Outlook OAuth) are disabled for this + // phase; they will be enabled in a future PR. + .filter(channel => channel.type !== 'email') + // Hide channels whose installation OAuth credentials are missing — their + // connect flow would only error. + .filter(channel => isConfigured(channel.type)) + ); + + const defaultChannels = computed(() => + DEFAULT_CHANNEL_TYPES.filter(isConfigured) + .slice(0, DISPLAYED_CHANNEL_LIMIT) + .map(toChannelRow) + ); + + // Show the detected channels, or fall back to the default suggestions so the + // step is never an empty list. + const displayedChannels = computed(() => + detectedChannels.value.length + ? detectedChannels.value + : defaultChannels.value + ); + + const remainingChannels = computed(() => { + // Exclude whatever is already shown as a row (detected or defaulted) so the + // footer preview doesn't duplicate it. + const shownTypes = new Set(displayedChannels.value.map(c => c.type)); + return Object.entries(SOCIAL_PLATFORMS) + .filter(([type]) => !shownTypes.has(type)) + .filter(([type]) => isConfigured(type)) + .slice(0, 3) + .map(([type, { labelKey, channelType }]) => ({ + type, + labelKey, + inbox: { channel_type: channelType }, + })); + }); + + const hasDetectedChannels = computed(() => detectedChannels.value.length > 0); + + return { + displayedChannels, + remainingChannels, + connectedInbox, + hasDetectedChannels, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue similarity index 81% rename from app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue index 63b3fa391..90abff58f 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue +++ b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue @@ -5,11 +5,12 @@ defineProps({ greeting: { type: String, required: true }, subtitle: { type: String, default: '' }, continueLabel: { type: String, default: 'Continue' }, + skipLabel: { type: String, default: '' }, isLoading: { type: Boolean, default: false }, disabled: { type: Boolean, default: false }, }); -defineEmits(['continue']); +defineEmits(['continue', 'skip']); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js similarity index 100% rename from app/javascript/dashboard/routes/dashboard/onboarding/constants.js rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js new file mode 100644 index 000000000..a8d85010a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js @@ -0,0 +1,186 @@ +import { defineComponent, h, ref } from 'vue'; +import { createStore } from 'vuex'; +import { mount } from '@vue/test-utils'; +import { useRoute } from 'vue-router'; +import { useAccountEnrichment } from '../../account-details/useAccountEnrichment'; + +vi.mock('vue-router'); + +const ENABLED_LANGUAGES = [ + { iso_639_1_code: 'en', name: 'English' }, + { iso_639_1_code: 'fr', name: 'French' }, +]; + +// Mounts the composable against a real store and the real useAccount/useConfig +// (only useRoute and the underlying account getter / window config are faked), +// so a change to how those resolve their data is exercised here too. `presets` +// seeds form fields as if the user had already typed them. +const mountComposable = ({ + account = {}, + enabledLanguages = ENABLED_LANGUAGES, + presets = {}, +} = {}) => { + window.chatwootConfig = { enabledLanguages }; + + const store = createStore({ + modules: { + accounts: { + namespaced: true, + getters: { getAccount: () => () => account }, + }, + }, + }); + + const fields = { + locale: ref(presets.locale || ''), + website: ref(presets.website || ''), + timezone: ref(presets.timezone || ''), + companySize: ref(presets.companySize || ''), + industry: ref(presets.industry || ''), + referralSource: ref(presets.referralSource || ''), + }; + + let api; + const Component = defineComponent({ + setup() { + api = useAccountEnrichment(fields); + return () => h('div'); + }, + }); + const wrapper = mount(Component, { global: { plugins: [store] } }); + return { ...api, fields, wrapper }; +}; + +beforeEach(() => { + useRoute.mockReturnValue({ params: { accountId: '1' } }); +}); + +afterEach(() => { + delete window.chatwootConfig; +}); + +describe('useAccountEnrichment', () => { + describe('populateFormFields', () => { + it('fills empty fields from the enriched attributes on mount', () => { + const { fields } = mountComposable({ + account: { + locale: 'en', + custom_attributes: { + website: 'https://acme.com', + timezone: 'America/New_York', + company_size: '11-50', + industry: 'Technology', + referral_source: 'google', + }, + }, + }); + + expect(fields.website.value).toBe('https://acme.com'); + expect(fields.timezone.value).toBe('America/New_York'); + expect(fields.companySize.value).toBe('11-50'); + expect(fields.industry.value).toBe('Technology'); + expect(fields.referralSource.value).toBe('google'); + }); + + it('falls back to brand_info for website and industry', () => { + const { fields } = mountComposable({ + account: { + custom_attributes: { + brand_info: { + domain: 'acme.com', + industries: [{ industry: 'Retail & E-commerce' }], + }, + }, + }, + }); + + expect(fields.website.value).toBe('acme.com'); + expect(fields.industry.value).toBe('Retail & E-commerce'); + }); + + it('does not clobber fields the user already set', () => { + const { fields } = mountComposable({ + presets: { website: 'mysite.com', industry: 'Finance' }, + account: { + custom_attributes: { + website: 'https://enriched.com', + industry: 'Technology', + }, + }, + }); + + expect(fields.website.value).toBe('mysite.com'); + expect(fields.industry.value).toBe('Finance'); + }); + + it('detects the locale from the browser, else the account locale', () => { + // jsdom reports navigator.language as 'en-US' -> base 'en' is enabled. + const { fields } = mountComposable({ account: { locale: 'de' } }); + expect(fields.locale.value).toBe('en'); + + // No enabled language matches the browser -> fall back to account locale. + const { fields: other } = mountComposable({ + account: { locale: 'de' }, + enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }], + }); + expect(other.locale.value).toBe('de'); + }); + }); + + describe('isEnriching', () => { + it('is true while the account is on the enrichment step', () => { + const { isEnriching } = mountComposable({ + account: { custom_attributes: { onboarding_step: 'enrichment' } }, + }); + expect(isEnriching.value).toBe(true); + }); + + it('is false on any other step', () => { + const { isEnriching } = mountComposable({ + account: { custom_attributes: { onboarding_step: 'account_details' } }, + }); + expect(isEnriching.value).toBe(false); + }); + + it('times out after 30s, flipping to false and populating', () => { + vi.useFakeTimers(); + try { + const { isEnriching, fields } = mountComposable({ + account: { + custom_attributes: { + onboarding_step: 'enrichment', + company_size: '51-200', + }, + }, + }); + expect(isEnriching.value).toBe(true); + + vi.advanceTimersByTime(30000); + + expect(isEnriching.value).toBe(false); + expect(fields.companySize.value).toBe('51-200'); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('getChangedFields', () => { + it('lists only enrichable fields edited after auto-fill', () => { + const { fields, getChangedFields } = mountComposable({ + account: { + custom_attributes: { + website: 'https://acme.com', + company_size: '11-50', + industry: 'Technology', + }, + }, + }); + + expect(getChangedFields()).toEqual([]); + + fields.industry.value = 'Finance'; + expect(getChangedFields()).toEqual(['industry']); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js new file mode 100644 index 000000000..68dca058a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js @@ -0,0 +1,123 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue'; +import OnboardingAPI from 'dashboard/api/onboarding'; + +vi.mock('dashboard/api/onboarding', () => ({ + default: { + getHelpCenterGeneration: vi.fn(), + }, +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params = {}) => { + if (key.endsWith('HELP_CENTER_CATEGORIES')) { + return `${params.count} categories`; + } + if (key.endsWith('HELP_CENTER_SUMMARY')) { + return `${params.count} articles across ${params.categories}`; + } + if (key.endsWith('HELP_CENTER_ARTICLES')) { + return `${params.count} articles`; + } + return key; + }, + }), +})); + +const mountStatus = () => + mount(HelpCenterCreationStatus, { + global: { + stubs: { + CreationStatusRow: { + props: ['ready', 'title', 'description', 'status'], + template: + '
{{ status }}
', + }, + }, + }, + }); + +describe('HelpCenterCreationStatus', () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('renders completed summary from the status endpoint', async () => { + OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({ + data: { + generation_id: 'generation-123', + state: { status: 'completed' }, + articles_count: 3, + categories_count: 2, + }, + }); + + const wrapper = mountStatus(); + await flushPromises(); + + expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe( + 'true' + ); + expect(wrapper.find('[data-test="row"]').text()).toBe( + '3 articles across 2 categories' + ); + }); + + it('hides the row when generation is skipped', async () => { + OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({ + data: { + generation_id: 'generation-123', + state: { status: 'skipped' }, + }, + }); + + const wrapper = mountStatus(); + await flushPromises(); + + expect(wrapper.find('[data-test="row"]').exists()).toBe(false); + }); + + it('polls while generating and stops after completion', async () => { + vi.useFakeTimers(); + OnboardingAPI.getHelpCenterGeneration + .mockResolvedValueOnce({ + data: { + generation_id: 'generation-123', + state: { status: 'generating' }, + articles_count: 1, + categories_count: 0, + }, + }) + .mockResolvedValueOnce({ + data: { + generation_id: 'generation-123', + state: { status: 'completed' }, + articles_count: 2, + categories_count: 1, + }, + }); + + const wrapper = mountStatus(); + await flushPromises(); + + expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles'); + + vi.advanceTimersByTime(5000); + await flushPromises(); + + expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2); + expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe( + 'true' + ); + expect(wrapper.find('[data-test="row"]').text()).toBe( + '2 articles across 1 categories' + ); + + vi.advanceTimersByTime(5000); + await flushPromises(); + + expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js new file mode 100644 index 000000000..bf150f95d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue'; + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) })); +vi.mock('dashboard/composables/store', () => ({ + useMapGetter: () => ({ value: {} }), +})); +vi.mock('../../inbox-setup/useChannelConnect', () => ({ + useChannelConnect: () => ({ + connectViaOAuth: vi.fn(), + connectWhatsapp: vi.fn(), + }), +})); + +const mountDialog = () => + mount(InboxChannelsDialog, { + props: { inboxes: [] }, + global: { + stubs: { + Dialog: { + template: '
', + methods: { open() {}, close() {} }, + }, + InboxFacebookForm: { template: '
' }, + InboxChannelForm: { template: '
' }, + ChannelIcon: true, + Icon: true, + }, + }, + }); + +describe('InboxChannelsDialog Facebook gating', () => { + afterEach(() => { + delete window.chatwootConfig; + }); + + it('opens the Facebook page picker when fbAppId is configured', async () => { + window.chatwootConfig = { fbAppId: 'fb-app' }; + const wrapper = mountDialog(); + + wrapper.vm.open('facebook'); + await nextTick(); + + expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true); + }); + + it('shows the grid (not the picker) when fbAppId is missing', async () => { + window.chatwootConfig = {}; + const wrapper = mountDialog(); + + wrapper.vm.open('facebook'); + await nextTick(); + + expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false); + // The channel grid renders its cards instead. + expect(wrapper.find('button').exists()).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js new file mode 100644 index 000000000..888082f68 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js @@ -0,0 +1,158 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { ref, nextTick } from 'vue'; +import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue'; +import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect'; + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) })); +vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() })); +vi.mock('dashboard/store/utils/api', () => ({ + parseAPIErrorResponse: vi.fn(), +})); +vi.mock('dashboard/composables/useFacebookPageConnect', () => ({ + useFacebookPageConnect: vi.fn(), +})); + +const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() })); +vi.mock('dashboard/composables/store', () => ({ + useStore: () => ({ dispatch }), +})); + +const NextButtonStub = { + props: ['label', 'disabled', 'isLoading'], + emits: ['click'], + template: ``, +}; +const ComboBoxStub = { + props: ['modelValue', 'options'], + emits: ['update:modelValue'], + template: '
', +}; + +const PAGES = [ + { id: 'p1', name: 'Page One', access_token: 'pt1' }, + { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true }, +]; + +const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH'; +const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT'; + +let loginAndFetchPages; +let preloadSdk; + +const mountForm = () => + mount(InboxFacebookForm, { + global: { + stubs: { + NextButton: NextButtonStub, + ComboBox: ComboBoxStub, + Spinner: true, + }, + }, + }); + +const clickButton = (wrapper, label) => + wrapper + .findAll('button') + .find(button => button.text() === label) + .trigger('click'); + +beforeEach(() => { + vi.clearAllMocks(); + preloadSdk = vi.fn(); + loginAndFetchPages = vi.fn(); + useFacebookPageConnect.mockReturnValue({ + isAuthenticating: ref(false), + preloadSdk, + loginAndFetchPages, + }); + dispatch.mockResolvedValue({ id: 1 }); +}); + +describe('InboxFacebookForm', () => { + it('preloads the SDK on mount', () => { + mountForm(); + expect(preloadSdk).toHaveBeenCalled(); + }); + + it('lists only connectable pages and creates an inbox for the selected one', async () => { + loginAndFetchPages.mockResolvedValue({ + userAccessToken: 'tok', + pages: PAGES, + }); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + // p2 is already connected (exists), so only p1 is offered. + const combobox = wrapper.findComponent(ComboBoxStub); + expect(combobox.props('options')).toEqual([ + { value: 'p1', label: 'Page One' }, + ]); + + combobox.vm.$emit('update:modelValue', 'p1'); + await nextTick(); + + await clickButton(wrapper, CONNECT); + await flushPromises(); + + expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', { + user_access_token: 'tok', + page_access_token: 'pt1', + page_id: 'p1', + inbox_name: 'Page One', + }); + expect(wrapper.emitted('created')).toBeTruthy(); + }); + + it('shows the empty state when every page is already connected', async () => { + loginAndFetchPages.mockResolvedValue({ + userAccessToken: 'tok', + pages: [ + { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true }, + ], + }); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).toContain( + 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES' + ); + expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false); + }); + + it('shows an error when the connection fails', async () => { + loginAndFetchPages.mockRejectedValue(new Error('boom')); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).toContain( + 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR' + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('stays on the connect prompt without an error when cancelled', async () => { + loginAndFetchPages.mockResolvedValue(null); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).not.toContain( + 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR' + ); + // Launch button is still available to retry. + expect( + wrapper.findAll('button').some(button => button.text() === LAUNCH) + ).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js new file mode 100644 index 000000000..acde1159d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js @@ -0,0 +1,62 @@ +import { + findConnectedInbox, + isChannelConnected, +} from '../../inbox-setup/channelMatchers'; + +const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' }; +const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' }; +const OUTLOOK = { + id: 3, + channel_type: 'Channel::Email', + provider: 'microsoft', +}; + +describe('channelMatchers', () => { + describe('findConnectedInbox', () => { + it('returns the inbox sharing the channel type', () => { + expect( + findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' }) + ).toBe(WHATSAPP); + }); + + it('matches email inboxes on provider', () => { + expect( + findConnectedInbox([OUTLOOK, GMAIL], { + channel_type: 'Channel::Email', + provider: 'google', + }) + ).toBe(GMAIL); + }); + + it('does not match a different email provider', () => { + expect( + findConnectedInbox([OUTLOOK], { + channel_type: 'Channel::Email', + provider: 'google', + }) + ).toBeUndefined(); + }); + + it('returns undefined when nothing matches', () => { + expect( + findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' }) + ).toBeUndefined(); + }); + }); + + describe('isChannelConnected', () => { + it('is true when a matching inbox exists', () => { + expect( + isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' }) + ).toBe(true); + }); + + it('is false when no inbox matches', () => { + expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false); + }); + + it('is false for a channel without an inbox stub', () => { + expect(isChannelConnected([WHATSAPP], undefined)).toBe(false); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js new file mode 100644 index 000000000..1134d4494 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js @@ -0,0 +1,300 @@ +import { defineComponent, h } from 'vue'; +import { createStore } from 'vuex'; +import { mount } from '@vue/test-utils'; +import { useRoute } from 'vue-router'; +import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels'; + +vi.mock('vue-router'); + +// Mounts the composable against a real store and the real useAccount (only +// useRoute and the underlying getters are faked), so a change to how useAccount +// resolves the current account is exercised here too. The real ./constants are +// used, so assertions validate against the actual channel identity (label keys, +// channel_type, social ordering) derived from CHANNEL_LIST. +const mountComposable = ({ brandInfo, inboxes = [] } = {}) => { + const store = createStore({ + modules: { + accounts: { + namespaced: true, + getters: { + getAccount: () => () => ({ + id: 1, + custom_attributes: { brand_info: brandInfo }, + }), + }, + }, + inboxes: { + namespaced: true, + getters: { getInboxes: () => inboxes }, + }, + }, + }); + + let result; + const Component = defineComponent({ + setup() { + result = useDetectedChannels(); + return () => h('div'); + }, + }); + mount(Component, { global: { plugins: [store] } }); + return result; +}; + +beforeEach(() => { + useRoute.mockReturnValue({ params: { accountId: '1' } }); + // Configure the installation OAuth credentials so detected channels aren't + // hidden by the config gate; individual tests clear this to assert hiding. + window.chatwootConfig = { + fbAppId: 'fb', + instagramAppId: 'ig', + tiktokAppId: 'tt', + whatsappAppId: 'wa', + whatsappConfigurationId: 'wa-config', + }; +}); + +afterEach(() => { + delete window.chatwootConfig; +}); + +describe('useDetectedChannels', () => { + describe('displayedChannels', () => { + it('maps detected socials with a url to channel rows', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' }, + { type: 'instagram', url: 'https://instagram.com/acme' }, + ], + }, + }); + + expect(displayedChannels.value).toEqual([ + { + type: 'whatsapp', + handle: '+14155552671', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', + inbox: { channel_type: 'Channel::Whatsapp' }, + }, + { + type: 'instagram', + handle: '@acme', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', + inbox: { channel_type: 'Channel::Instagram' }, + }, + ]); + }); + + it('skips socials without a url or with an unknown type', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'telegram' }, // no url + { type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown + { type: 'tiktok', url: 'https://tiktok.com/@acme' }, + ], + }, + }); + + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'tiktok', + ]); + }); + + it('uses the raw path for line and falls back to empty on a bad url', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'line', url: 'https://line.me/acme' }, + { type: 'facebook', url: 'not-a-url' }, + ], + }, + }); + + expect(displayedChannels.value).toEqual([ + { + type: 'line', + handle: 'acme', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', + inbox: { channel_type: 'Channel::Line' }, + }, + { + type: 'facebook', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', + inbox: { channel_type: 'Channel::FacebookPage' }, + }, + ]); + }); + + it('omits the detected email channel while email is disabled for this phase', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + email_provider: 'google', + email: 'support@acme.com', + socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], + }, + }); + + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'whatsapp', + ]); + }); + + it('falls back to the default channel suggestions when nothing is detected', () => { + const { displayedChannels } = mountComposable({ brandInfo: undefined }); + + // The configured mainstream channels, with no detected handle. + expect(displayedChannels.value).toEqual([ + { + type: 'whatsapp', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', + inbox: { channel_type: 'Channel::Whatsapp' }, + }, + { + type: 'facebook', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', + inbox: { channel_type: 'Channel::FacebookPage' }, + }, + { + type: 'instagram', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', + inbox: { channel_type: 'Channel::Instagram' }, + }, + ]); + }); + + it('gates the default suggestions by installation config, keeping the list non-empty', () => { + window.chatwootConfig = {}; // no OAuth credentials configured + const { displayedChannels } = mountComposable({ brandInfo: undefined }); + + // Only the credential-free defaults survive (Telegram, LINE). + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'telegram', + 'line', + ]); + }); + + it('hides detected channels whose installation OAuth credentials are missing', () => { + window.chatwootConfig = {}; // nothing configured + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'facebook', url: 'https://facebook.com/acme' }, + { type: 'line', url: 'https://line.me/acme' }, + ], + }, + }); + + // Facebook needs fbAppId (absent → hidden); LINE needs no install credential. + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'line', + ]); + }); + }); + + describe('remainingChannels', () => { + it('returns the platforms not already shown as default rows', () => { + // Nothing detected → displayed falls back to the defaults (WhatsApp, + // Facebook, Instagram), so the footer previews the remaining platforms. + const { remainingChannels } = mountComposable({ brandInfo: {} }); + + expect(remainingChannels.value).toEqual([ + { + type: 'line', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', + inbox: { channel_type: 'Channel::Line' }, + }, + { + type: 'telegram', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE', + inbox: { channel_type: 'Channel::Telegram' }, + }, + { + type: 'tiktok', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE', + inbox: { channel_type: 'Channel::Tiktok' }, + }, + ]); + }); + + it('excludes already-detected socials, preserving order', () => { + const { remainingChannels } = mountComposable({ + brandInfo: { + socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], + }, + }); + + expect(remainingChannels.value.map(channel => channel.type)).toEqual([ + 'facebook', + 'line', + 'instagram', + ]); + }); + + it('excludes channels whose installation OAuth credentials are missing', () => { + window.chatwootConfig = {}; // nothing configured + const { remainingChannels } = mountComposable({ brandInfo: {} }); + + // The only configured channels (Telegram, LINE) are shown as default rows, + // and every other platform is gated out — so nothing remains for the footer. + expect(remainingChannels.value).toEqual([]); + }); + }); + + describe('connectedInbox', () => { + it('returns the real inbox sharing the channel type', () => { + const inbox = { + id: 1, + channel_type: 'Channel::Whatsapp', + name: 'WA Biz', + }; + const { connectedInbox } = mountComposable({ + brandInfo: {}, + inboxes: [inbox], + }); + + expect( + connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } }) + ).toBe(inbox); + }); + + it('matches email inboxes on provider', () => { + const gmail = { + id: 1, + channel_type: 'Channel::Email', + provider: 'google', + }; + const outlook = { + id: 2, + channel_type: 'Channel::Email', + provider: 'microsoft', + }; + const { connectedInbox } = mountComposable({ + brandInfo: {}, + inboxes: [outlook, gmail], + }); + + expect( + connectedInbox({ + inbox: { channel_type: 'Channel::Email', provider: 'google' }, + }) + ).toBe(gmail); + }); + + it('returns undefined when nothing matches', () => { + const { connectedInbox } = mountComposable({ + brandInfo: {}, + inboxes: [], + }); + + expect( + connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } }) + ).toBeUndefined(); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue index 034f40d35..54eafa59e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue @@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useAccount } from 'dashboard/composables/useAccount'; import { useAlert } from 'dashboard/composables'; import WithLabel from 'v3/components/Form/WithLabel.vue'; -import TextArea from 'next/textarea/TextArea.vue'; +import Editor from 'next/Editor/Editor.vue'; import Switch from 'next/switch/Switch.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import DurationInput from 'next/input/DurationInput.vue'; @@ -162,9 +162,13 @@ const toggleAutoResolve = async () => { :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')" :help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')" > -