Compare commits

..
Author SHA1 Message Date
Pranav 8993810af8 Merge remote-tracking branch 'origin/develop' into captain-message-generation-metadata
# Conflicts:
#	config/routes.rb
#	db/schema.rb
#	enterprise/app/models/enterprise/concerns/message.rb
2026-07-02 07:09:59 -07:00
PranavandClaude Opus 4.8 eb203bb144 feat(captain): capture and surface how a reply was generated
Records reasoning, citations (the FAQs the assistant found), the tool
path, and the model for each V1 Captain reply in a new
captain_message_generations table. The assistant reports the source IDs
it actually used so cited FAQs can be flagged, and handoff messages
capture the transfer reason.

Agents can expand any Captain message in the conversation (a sparkle
toggle on the timestamp line) to see this breakdown, fetched on demand
from a dedicated endpoint. Model is shown only in development.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:10:55 -07:00
584 changed files with 1855 additions and 25756 deletions
+3 -6
View File
@@ -77,8 +77,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -119,8 +118,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -151,8 +149,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
+15 -17
View File
@@ -211,14 +211,13 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
logger
msgpack
datadog-ruby_core_source (3.5.3)
datadog-ruby_core_source (3.4.1)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
@@ -523,16 +522,15 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-arm64-darwin)
libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-darwin)
libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-linux)
libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -549,7 +547,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.2)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -742,8 +740,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
@@ -1032,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.2)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2026 Chatwoot Inc.
Copyright (c) 2017-2024 Chatwoot Inc.
Portions of this software are licensed as follows:
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -67,26 +67,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
:locale, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -2,14 +2,5 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end
@@ -1,28 +0,0 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -66,7 +66,6 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -75,10 +74,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -1,17 +1,6 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
around_action :handle_with_exception
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -22,16 +11,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
@@ -1,40 +1,27 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end
@@ -2,28 +2,12 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
@@ -164,7 +164,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -1,5 +1,4 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
@@ -1,159 +0,0 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = validate_intercom_source
render json: { valid: true, totals: totals }
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def start
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
render_show
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, import_types: [])
end
def creation_service
DataImports::Intercom::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def validate_intercom_source
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
DataImports::Intercom::CredentialsValidator.new(
access_token: permitted_params[:access_token],
import_types: import_types
).perform
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end
@@ -2,6 +2,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -45,20 +46,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
continue_update = false
ActiveRecord::Base.transaction do
continue_update = update_branded_email_layout
raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
def agent_bot
@@ -133,8 +125,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
@@ -164,34 +156,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
@@ -6,10 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
state: state
}
)
if redirect_url
@@ -81,8 +81,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
)
end
@@ -90,10 +89,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def popular_content_keys
params.dig(:portal, :config, :popular_content)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -1,6 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -10,10 +8,8 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
render_error_response(e)
end
private
@@ -33,7 +29,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
@@ -41,13 +37,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_reconfigure_channel?
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
# Reconfiguring a live embedded-signup channel requires the feature flag.
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
@@ -62,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_embedded_signup_error(error)
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -8,7 +8,6 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -106,12 +105,6 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
@@ -59,10 +59,6 @@ class Api::V1::Widget::BaseController < ApplicationController
permitted_params.dig(:contact, :phone_number)
end
def contact_custom_attributes
permitted_params.dig(:contact, :custom_attributes)&.to_h
end
def browser_params
{
browser_name: browser.name,
@@ -2,7 +2,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -47,16 +46,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -73,15 +62,11 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
@@ -14,8 +14,6 @@ module EnsureCurrentAccountHelper
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
else
render_unauthorized(I18n.t('errors.account.not_authorized'))
end
account
end
@@ -23,7 +21,7 @@ module EnsureCurrentAccountHelper
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
end
def account_accessible_for_bot?(account)
+2 -38
View File
@@ -4,53 +4,17 @@ module PortalHomeData
private
def load_home_data
load_recommended_content
# The classic hero only needs the recommendations above; the rest is
# documentation-layout home data (also used on custom-domain home pages).
return unless @portal.layout == 'documentation'
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 = @recommended_categories.presence || @visible_categories.first(3)
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def load_recommended_content
@recommended_categories = recommended_categories
@recommended_articles = recommended_articles
end
def base_articles
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
end
# Admin-recommended categories for the locale, in the chosen order. Unlike the
# position-based fallback, published articles aren't required: the admin's pick wins.
def recommended_categories
ids = @portal.popular_category_ids(@locale)
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
end
# Admin-recommended articles for the locale, in the chosen order, limited to
# published articles that still exist.
def recommended_articles
ids = @portal.popular_article_ids(@locale)
ordered_by_ids(base_articles.where(id: ids), ids)
end
# Loads the scope and returns its records ordered to match `ids`, dropping any
# that no longer exist. Skips the query entirely when `ids` is blank.
def ordered_by_ids(scope, ids)
return [] if ids.blank?
by_id = scope.index_by(&:id)
ids.filter_map { |id| by_id[id] }
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@@ -1,15 +1,8 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -25,9 +18,6 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -41,8 +31,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -69,19 +59,4 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
+4 -2
View File
@@ -64,8 +64,10 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
request.variant = :documentation if @portal.layout == 'documentation'
load_home_data
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -11,8 +11,6 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -49,14 +47,6 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -18,8 +18,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
contact_identify_action.perform
@contact_inbox.reload
render json: contact_identify_action.perform
end
private
@@ -36,15 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -24,11 +24,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_view_variant
request.variant = if @is_plain_layout_enabled
:plain
elsif @portal_layout == 'documentation'
:documentation
end
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
end
def portal
@@ -69,8 +65,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def show
@@ -6,8 +6,6 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -38,14 +36,6 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
def handle_limit_error(error)
redirect_to_error_page(
error_type: error.class.name,
code: Rack::Utils.status_code(error.http_status),
error_message: error.message
)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
-4
View File
@@ -1,10 +1,6 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
def id
@obj.try(:display_id)
end
def display_id
@obj.try(:display_id)
end
-14
View File
@@ -2,18 +2,4 @@ class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
def business_name
@obj.try(:sanitized_business_name)
end
def avatar_url
@obj.try(:avatar_url)
end
def email
return unless @obj.try(:email?)
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
end
end
-11
View File
@@ -1,11 +0,0 @@
class DataImportErrorFinder
RESULTS_LIMIT = 5
def initialize(data_import)
@data_import = data_import
end
def import_errors
@data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
end
end
@@ -1,35 +0,0 @@
class DataImportSkipLogFinder
RESULTS_LIMIT = 5
SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
attr_reader :selected_source_object_type
def initialize(data_import, params = {})
@data_import = data_import
@selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
end
def skip_logs
filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
end
def counts_by_type
base_scope.group(:source_object_type).count
end
private
def base_scope
@base_scope ||= @data_import.import_errors.skip_logs
end
def filtered_scope
return base_scope if selected_source_object_type.blank?
base_scope.where(source_object_type: selected_source_object_type)
end
def valid_source_object_type(source_object_type)
source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
end
end
+6
View File
@@ -114,4 +114,10 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
-12
View File
@@ -22,16 +22,4 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
-10
View File
@@ -68,8 +68,6 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
default_filter(query_hash, filter_operator_value)
end
end
@@ -84,18 +82,10 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_cast_filter(query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_search_on_display_id?(query_hash)
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
@@ -6,12 +6,9 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds, { includeAgentBots = false } = {}) {
get(inboxIds) {
return axios.get(this.url, {
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
params: { inbox_ids: inboxIds },
});
}
}
@@ -1,10 +1,6 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -25,32 +21,6 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class MessageGenerations extends ApiClient {
constructor() {
super('captain/message_generations', { accountScoped: true });
}
}
export default new MessageGenerations();
@@ -1,39 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class DataImportsAPI extends ApiClient {
constructor() {
super('data_imports', { accountScoped: true });
}
start(id) {
return axios.post(`${this.url}/${id}/start`);
}
abandon(id) {
return axios.post(`${this.url}/${id}/abandon`);
}
show(id, params = {}) {
return axios.get(`${this.url}/${id}`, { params });
}
validateSource(payload) {
return axios.post(`${this.url}/validate_source`, payload);
}
downloadSkipLogs(id) {
return axios.get(`${this.url}/${id}/skip_logs.csv`, {
responseType: 'blob',
});
}
downloadErrorLogs(id) {
return axios.get(`${this.url}/${id}/error_logs.csv`, {
responseType: 'blob',
});
}
}
export default new DataImportsAPI();
@@ -14,10 +14,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -31,11 +27,6 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -17,7 +17,6 @@ class ArticlesAPI extends PortalsAPI {
categorySlug,
sort,
query,
signal,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -31,7 +30,7 @@ class ArticlesAPI extends PortalsAPI {
host: this.url,
});
return axios.get(url, { signal });
return axios.get(url);
}
searchArticles({ portalSlug, query }) {
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
@@ -37,8 +37,7 @@ describe('#PortalAPI', () => {
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
{ signal: undefined }
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
);
});
});
@@ -26,15 +26,5 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -7,9 +7,7 @@ import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
getArticleStatus,
} from 'dashboard/helper/portalHelper';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
@@ -55,10 +53,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
hasPendingChanges: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
@@ -74,16 +68,12 @@ const emit = defineEmits([
'articleAction',
'toggleSelect',
'hover',
'draftResolved',
'draftFailed',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -115,18 +105,7 @@ const articleMenuItems = computed(() => {
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
const draftItems = props.hasPendingChanges
? [
{
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
},
]
: [];
return [...statusItems, ...draftItems, commonItems.delete];
return [...statusItems, commonItems.delete];
});
const statusTextColor = computed(() => {
@@ -174,12 +153,6 @@ const lastUpdatedAt = computed(() => {
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
// Un-publishing an article with staged edits confirm apply/discard first;
// the popover applies the chosen status itself.
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
emit('articleAction', { action, value, id: props.id });
};
@@ -211,18 +184,6 @@ const handleClick = id => {
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="hasPendingChanges"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
)
"
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
@@ -243,15 +204,9 @@ const handleClick = id => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 end-0 top-full w-40"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="id"
@resolved="emit('draftResolved', $event)"
@failed="emit('draftFailed', $event)"
/>
</div>
</div>
</div>
@@ -56,9 +56,6 @@ const localeMenuLabels = computed(() => ({
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
'select-popular-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -1,173 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
renderInlineDiff,
buildDiffBlocks,
} from 'dashboard/helper/articleDiffHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const isOpen = defineModel({ type: Boolean, default: false });
const { t } = useI18n();
const liveTitle = computed(() => props.article?.title ?? '');
const liveContent = computed(() => props.article?.content ?? '');
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
const draftContent = computed(
() => props.article?.draftContent ?? liveContent.value
);
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
const titleDiff = computed(() =>
renderInlineDiff(liveTitle.value, draftTitle.value)
);
const contentBlocks = computed(() =>
buildDiffBlocks(liveContent.value, draftContent.value)
);
const contentChanged = computed(() =>
contentBlocks.value.some(block => block.type !== 'equal')
);
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
// unsized columns so they don't collapse.
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
const DEFAULT_COL_WIDTH = 50;
const applyColumnWidths = (html, widths) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.body.querySelector('table');
if (!table) return html;
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
const colgroup = doc.createElement('colgroup');
sized.forEach(width => {
const col = doc.createElement('col');
col.style.width = `${width}px`;
colgroup.appendChild(col);
});
table.insertBefore(colgroup, table.firstChild);
table.style.tableLayout = 'fixed';
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
return doc.body.innerHTML;
};
const renderMarkdown = markdown => {
if (!markdown) return '';
const html = new MessageFormatter(markdown).formattedMessage;
const match = markdown.match(COLWIDTHS_RE);
return match
? applyColumnWidths(html, match[1].split(',').map(Number))
: html;
};
const blockClass = type => {
if (type === 'added') {
return 'border-n-teal-9 bg-n-teal-2';
}
if (type === 'removed') {
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
}
return 'border-transparent';
};
const close = () => {
isOpen.value = false;
};
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
</script>
<template>
<TeleportWithDirection to="body">
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<aside
v-if="isOpen"
v-on-click-outside="dismissOnClickOutside"
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
>
<header
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
</h3>
</div>
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="sm"
class="shrink-0 hover:text-n-slate-11"
@click="close"
/>
</header>
<div
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="titleChanged"
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
>
<span
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
>
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
</span>
<h1
class="text-lg font-semibold leading-snug text-n-slate-12"
v-html="titleDiff"
/>
</div>
<div
v-if="contentChanged"
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
>
<div
v-for="(block, index) in contentBlocks"
:key="index"
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
:class="blockClass(block.type)"
v-html="renderMarkdown(block.md)"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</aside>
</Transition>
</TeleportWithDirection>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -9,7 +9,6 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
const props = defineProps({
article: {
@@ -39,75 +38,32 @@ const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
// falling back to the live value.
const effectiveTitle = () =>
props.article?.draftTitle ?? props.article?.title ?? '';
const effectiveContent = () =>
props.article?.draftContent ?? props.article?.content ?? '';
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
const hasPendingChanges = computed(
() => props.article?.draftTitle != null || props.article?.draftContent != null
);
const localTitle = ref(effectiveTitle());
const localContent = ref(effectiveContent());
const isDiffPanelOpen = ref(false);
// Autosave 500ms after the last edit. It sends both title and content so an
// edit to one never drops a recent edit to the other. `stop` cancels a queued
// save; `isPending` tells the header to wait before allowing a publish.
const {
isPending: isSaving,
start: debouncedSave,
stop: cancelSave,
} = useTimeoutFn(
() =>
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
}),
500,
{ immediate: false }
);
const syncLocalState = () => {
cancelSave();
localTitle.value = effectiveTitle();
localContent.value = effectiveContent();
};
// Reseed on article switch or once a draft is published/discarded; close the
// diff panel in the latter case since there's nothing left to compare.
// Sync local state when navigating to a different article or on initial fetch
watch(
[() => props.article?.id, hasPendingChanges],
([id, pending], [prevId, prevPending]) => {
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
if (prevPending && !pending) isDiffPanelOpen.value = false;
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
}
);
const scheduleSave = () => {
if (isNewArticle.value) return;
debouncedSave();
};
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
onBeforeUnmount(() => {
if (isNewArticle.value || !isSaving.value) return;
cancelSave();
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
});
});
const handleSave = value => {
if (isNewArticle.value) return;
debouncedSave(value);
};
const articleTitle = computed({
get: () => localTitle.value,
set: value => {
localTitle.value = value;
scheduleSave();
handleSave({ title: value });
},
});
@@ -115,7 +71,7 @@ const articleContent = computed({
get: () => localContent.value,
set: content => {
localContent.value = content;
scheduleSave();
handleSave({ content });
},
});
@@ -152,13 +108,9 @@ const handleCreateArticle = event => {
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
:pending-changes="hasPendingChanges"
:is-saving="isSaving"
@go-back="onClickGoBack"
@preview-article="previewArticle"
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
/>
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
@@ -1,8 +1,8 @@
<script setup>
import { computed, ref, useTemplateRef } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
@@ -17,7 +17,6 @@ import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
const props = defineProps({
isUpdating: {
@@ -36,17 +35,9 @@ const props = defineProps({
type: Number,
default: 0,
},
pendingChanges: {
type: Boolean,
default: false,
},
isSaving: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
const emit = defineEmits(['goBack', 'previewArticle']);
const { t } = useI18n();
const store = useStore();
@@ -58,30 +49,9 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
// Per-article update flag the store already maintains.
const articleUiFlags = useMapGetter('articles/uiFlags');
const isUpdatingArticle = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
const blockedWhileSaving = () => {
if (!props.isSaving && !isUpdatingArticle.value) return false;
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
return true;
};
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
const hasPendingChanges = computed(
() => isPublished.value && props.pendingChanges
);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
const items = statusOptions.map(option => {
return statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
@@ -90,17 +60,6 @@ const articleMenuItems = computed(() => {
icon,
};
});
if (hasPendingChanges.value) {
items.push({
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
});
}
return items;
});
const statusText = computed(() =>
@@ -126,9 +85,8 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
// update; omit it for a plain status change.
const performStatusUpdate = async (value, draftAction) => {
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
@@ -136,7 +94,7 @@ const performStatusUpdate = async (value, draftAction) => {
const { portalSlug } = route.params;
try {
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
await store.dispatch('articles/update', {
portalSlug,
articleId: props.articleId,
status,
@@ -149,100 +107,12 @@ const performStatusUpdate = async (value, draftAction) => {
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
} finally {
isArticlePublishing.value = false;
}
};
const updateArticleStatus = ({ value }) => {
showArticleActionMenu.value = false;
// Leaving published with unsaved draft edits ask whether to apply or discard
// first; the popover applies the status itself once resolved.
if (hasPendingChanges.value) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
performStatusUpdate(value);
};
const publishDraftChanges = async () => {
isArticlePublishing.value = true;
const { portalSlug } = route.params;
try {
await store.dispatch('articles/publishDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
} finally {
isArticlePublishing.value = false;
}
};
const discardDraftChanges = async () => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
);
}
};
const onPrimaryAction = () => {
if (blockedWhileSaving()) return;
if (hasPendingChanges.value) {
publishDraftChanges();
} else if (props.pendingChanges) {
// Promote leftover draft edits on publish instead of republishing stale content.
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
} else {
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
}
};
const onMenuAction = event => {
showArticleActionMenu.value = false;
// Don't resolve a draft while an autosave is still in flight it could land
// after and recreate the draft we just discarded/applied.
if (blockedWhileSaving()) return;
if (event.action === 'discard-draft') {
discardDraftChanges();
} else {
updateArticleStatus(event);
}
};
// The popover applies the draft + status itself; we just surface the outcome.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
};
const onDraftFailed = error => {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
</script>
<template>
@@ -257,24 +127,13 @@ const onDraftFailed = error => {
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<button
v-if="hasPendingChanges"
type="button"
data-diff-toggle
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
@click="emit('showDiff')"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
</button>
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
<div class="relative flex items-center gap-2">
<div class="flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
@@ -284,21 +143,17 @@ const onDraftFailed = error => {
/>
<ButtonGroup class="flex items-center">
<Button
:label="
hasPendingChanges
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
"
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing ||
(isPublished && !hasPendingChanges)
isArticlePublishing
"
@click="onPrimaryAction"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
@@ -314,17 +169,11 @@ const onDraftFailed = error => {
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="onMenuAction($event)"
@action="updateArticleStatus($event)"
/>
</OnClickOutside>
</div>
</ButtonGroup>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="articleId"
@resolved="onDraftResolved"
@failed="onDraftFailed"
/>
</div>
</div>
</div>
@@ -1,132 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { onKeyStroke } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
articleId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['resolved', 'failed']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isOpen = ref(false);
const requestedStatus = ref(null);
// Which button is in flight, so only that one shows the spinner.
const activeAction = ref(null);
const articleUiFlags = useMapGetter('articles/uiFlags');
const isLoading = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Open the confirmation for a target status; resolving it also applies that status.
const open = status => {
requestedStatus.value = status;
activeAction.value = null;
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
// Don't let a click-outside or Escape dismiss the popover mid-action.
const dismiss = () => {
if (!isLoading.value) close();
};
const resolve = async draftAction => {
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
try {
await store.dispatch(`articles/${draftAction}`, {
portalSlug: route.params.portalSlug,
articleId: props.articleId,
status: requestedStatus.value,
});
emit('resolved', requestedStatus.value);
close();
} catch (error) {
emit('failed', error);
}
};
const onApply = () => resolve('publishDraft');
const onDiscard = () => resolve('discardDraft');
onKeyStroke('Escape', () => {
if (isOpen.value) dismiss();
});
defineExpose({ open, close });
</script>
<template>
<div
v-show="isOpen"
v-on-click-outside="dismiss"
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
>
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
)
}}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="xs"
class="shrink-0 -me-1 -mt-1"
:disabled="isLoading"
@click="close"
/>
</div>
<div class="flex items-center justify-between gap-2">
<Button
type="button"
variant="faded"
color="ruby"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'discard'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
"
@click="onDiscard"
/>
<Button
type="button"
color="blue"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'apply'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
"
@click="onApply"
/>
</div>
</div>
</template>
@@ -5,12 +5,8 @@ import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
getArticleStatus,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
@@ -149,24 +145,6 @@ const updateArticlesMeta = () => {
});
};
const refreshArticleMeta = async () => {
await updateArticlesMeta();
await updatePortalMeta();
};
// The card's pending-changes popover applies the status itself; surface the result.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
refreshArticleMeta();
};
const onDraftFailed = error => {
useAlert(
error?.message ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -176,14 +154,6 @@ const handleArticleAction = async (action, { status, id }) => {
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else if (action === 'discard-draft') {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: id,
});
useAlert(
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
);
} else {
await store.dispatch('articles/update', {
portalSlug,
@@ -198,16 +168,15 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await refreshArticleMeta();
await updateArticlesMeta();
await updatePortalMeta();
} catch (error) {
const fallbackMessage =
{
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
'discard-draft': t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
),
}[action] ?? getStatusMessage(status, false);
useAlert(error?.message || fallbackMessage);
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
}
};
@@ -241,15 +210,10 @@ const updateArticle = ({ action, value, id }) => {
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
:has-pending-changes="
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@draft-resolved="onDraftResolved"
@draft-failed="onDraftFailed"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
@@ -3,15 +3,10 @@ import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
@@ -65,7 +60,6 @@ const emit = defineEmits([
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -233,46 +227,15 @@ const onBulkActionSuccess = message => {
};
const bulkUpdateStatus = async status => {
const selectedIds = [...selectedArticleIds.value];
const { portalSlug } = route.params;
const pendingIds = props.articles
.filter(
article =>
selectedIds.includes(article.id) &&
article.status === ARTICLE_STATUSES.PUBLISHED &&
hasPendingChanges(article)
)
.map(article => article.id);
// Publish promotes each pending draft; other status changes skip them.
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
const draftIds = isPublishing ? pendingIds : [];
const skippedCount = isPublishing ? 0 : pendingIds.length;
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
if (!articleIds.length && !draftIds.length) {
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
return;
}
try {
if (articleIds.length) {
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
}
await Promise.all(
draftIds.map(articleId =>
store.dispatch('articles/publishDraft', { portalSlug, articleId })
)
);
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
if (skippedCount) {
useAlert(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
);
}
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
@@ -2,7 +2,6 @@
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -27,7 +26,6 @@ const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const popularContentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -156,8 +154,6 @@ const handleAction = ({ action }, localeCode) => {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'select-popular-content') {
popularContentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -178,6 +174,5 @@ const handleAction = ({ action }, localeCode) => {
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
</ul>
</template>
@@ -1,235 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import categoriesAPI from 'dashboard/api/helpCenter/categories';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const MAX_CATEGORIES = 3;
const MAX_ARTICLES = 6;
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
// One flag per picker so each shows its skeleton until its own data loads.
const categoriesLoading = ref(false);
const { run: runArticleRequest, isPending: articlesLoading } =
useAbortableRequest();
const categoryOptions = ref([]);
const selectedCategoryIds = ref([]);
// Articles are searched server-side, so cache options for selected items that
// fall outside the current results.
const articleResults = ref([]);
const articleOptionById = ref({});
const selectedArticleIds = ref([]);
const popularContent = computed(
() => props.portal?.config?.popular_content || {}
);
const articleOptions = computed(() => {
const options = new Map();
selectedArticleIds.value.forEach(id => {
if (articleOptionById.value[id])
options.set(id, articleOptionById.value[id]);
});
articleResults.value.forEach(option => options.set(option.value, option));
return [...options.values()];
});
const toArticleOption = article => ({
value: article.id,
label: article.title,
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
}),
});
const fetchCategories = async localeCode => {
const {
data: { payload },
} = await categoriesAPI.get({
portalSlug: props.portal?.slug,
locale: localeCode,
});
return payload.map(category => ({
value: category.id,
label: category.name,
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
count: category.meta?.articles_count || 0,
}),
icon: category.icon,
iconColor: category.icon_color,
}));
};
const requestArticles = async (query, signal) => {
const { data } = await articlesAPI.getArticles({
pageNumber: 1,
portalSlug: props.portal?.slug,
locale: activeLocale.value,
status: 'published',
query,
signal,
});
articleResults.value = data.payload.map(article => {
const option = toArticleOption(article);
articleOptionById.value[article.id] = option;
return option;
});
};
const searchArticles = (query = '') =>
runArticleRequest(signal => requestArticles(query, signal));
const onArticleSearch = useDebounceFn(searchArticles, 300);
// Resolve options for pre-selected articles that aren't in the current results.
const cacheSelectedArticleOptions = async () => {
const unknownIds = selectedArticleIds.value.filter(
id => !articleOptionById.value[id]
);
await Promise.all(
unknownIds.map(async id => {
try {
const { data } = await articlesAPI.getArticle({
id,
portalSlug: props.portal?.slug,
});
articleOptionById.value[id] = toArticleOption(data.payload);
} catch {
// Deleted since it was picked; leave it for the id fallback.
}
})
);
};
const loadCategories = async localeCode => {
categoriesLoading.value = true;
try {
const options = await fetchCategories(localeCode);
// Reopened for another locale mid-flight; drop the stale response.
if (localeCode !== activeLocale.value) return;
categoryOptions.value = options;
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
} finally {
if (localeCode === activeLocale.value) categoriesLoading.value = false;
}
};
const loadArticles = async () => {
try {
await runArticleRequest(signal =>
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
);
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
}
};
const openForLocale = localeCode => {
const existing = popularContent.value[localeCode] || {};
activeLocale.value = localeCode;
selectedCategoryIds.value = [...(existing.category_ids || [])];
selectedArticleIds.value = [...(existing.article_ids || [])];
categoryOptions.value = [];
articleResults.value = [];
articleOptionById.value = {};
dialogRef.value?.open();
loadCategories(localeCode);
loadArticles();
};
const onConfirm = async () => {
const updated = { ...popularContent.value };
const entry = {
category_ids: selectedCategoryIds.value,
article_ids: selectedArticleIds.value,
};
if (entry.category_ids.length || entry.article_ids.length) {
updated[activeLocale.value] = entry;
} else {
delete updated[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { popular_content: updated },
});
dialogRef.value?.close();
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t(`${KEY}.TITLE`)"
:description="t(`${KEY}.DESCRIPTION`)"
:confirm-button-label="t(`${KEY}.CONFIRM`)"
@confirm="onConfirm"
>
<div class="flex flex-col gap-5">
<ReorderableMultiSelect
v-model="selectedCategoryIds"
:options="categoryOptions"
:max="MAX_CATEGORIES"
:label="t(`${KEY}.CATEGORIES.LABEL`)"
:add-label="t(`${KEY}.ADD_ANOTHER`)"
:search-placeholder="t(`${KEY}.SEARCH`)"
:empty-state="t(`${KEY}.EMPTY`)"
fallback-icon="i-lucide-folder"
:loading="categoriesLoading"
>
<template #counter="{ remaining }">
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
</template>
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
</ReorderableMultiSelect>
<ReorderableMultiSelect
v-model="selectedArticleIds"
server-search
:options="articleOptions"
:max="MAX_ARTICLES"
:label="t(`${KEY}.ARTICLES.LABEL`)"
:add-label="t(`${KEY}.ADD_ANOTHER`)"
:search-placeholder="t(`${KEY}.SEARCH`)"
:empty-state="t(`${KEY}.EMPTY`)"
:loading="articlesLoading"
@search="onArticleSearch"
>
<template #counter="{ remaining }">
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
</template>
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
</ReorderableMultiSelect>
</div>
</Dialog>
</template>
@@ -61,10 +61,10 @@ const triggerAction = () => {
<div>
<slot />
</div>
<div class="flex-shrink-0">
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
:class="buttonClass"
@click="triggerAction"
>
@@ -14,7 +14,6 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -62,7 +61,6 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
:disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
@@ -182,8 +182,7 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex items-center gap-2">
<slot name="headerActions" />
<div class="flex gap-2">
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -107,7 +107,6 @@ const onClickCancel = () => {
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -66,10 +66,6 @@ const props = defineProps({
type: Number,
default: null,
},
responsesCount: {
type: Number,
default: 0,
},
isSelected: {
type: Boolean,
default: false,
@@ -116,10 +112,10 @@ const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
value: 'viewDetails',
action: 'viewDetails',
icon: 'i-lucide-eye',
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
];
@@ -147,9 +143,6 @@ const menuItems = computed(() => {
});
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const responsesCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
);
const displayLink = computed(() =>
isPdf.value
@@ -165,10 +158,6 @@ const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
};
const handleViewDetails = () => {
emit('action', { action: 'viewDetails', id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
@@ -188,13 +177,9 @@ const handleRetry = () => {
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full">
<button
type="button"
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
@click="handleViewDetails"
>
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</button>
</span>
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
@@ -243,9 +228,6 @@ const handleRetry = () => {
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<span class="text-sm shrink-0 text-n-slate-11">
{{ responsesCountLabel }}
</span>
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
@@ -213,7 +213,6 @@ const renderInstruction = instruction => () =>
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -122,7 +122,6 @@ watch(
<Editor
v-model="state.description"
:max-length="500"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
@@ -117,7 +117,6 @@ watch(
<Editor
v-model="state.description"
:max-length="500"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
@@ -1,86 +0,0 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import DocumentDetails from './DocumentDetails.vue';
const { dispatch, getterValues } = vi.hoisted(() => ({
dispatch: vi.fn(),
getterValues: {
'captainResponses/getUIFlags': { value: { fetchingList: false } },
'captainResponses/getRecords': { value: [] },
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
},
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: key => getterValues[key],
}));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
const captainDocument = {
id: 42,
name: 'FAQ source',
external_link: 'https://example.com/docs',
assistant: { id: 7 },
content: 'Document content',
pdf_document: false,
};
const DialogStub = {
name: 'Dialog',
template: '<div><slot /></div>',
};
const TabBarStub = {
name: 'TabBar',
template:
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
};
const PaginationFooterStub = {
name: 'PaginationFooter',
template:
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
};
describe('DocumentDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
dispatch.mockResolvedValue([]);
});
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
const wrapper = shallowMount(DocumentDetails, {
props: { captainDocument },
global: {
directives: { dompurifyHtml: {} },
stubs: {
Dialog: DialogStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
},
},
});
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
page: 1,
assistantId: 7,
documentId: 42,
});
await wrapper.get('[data-test="faq-tab"]').trigger('click');
await wrapper.get('[data-test="next-page"]').trigger('click');
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
page: 2,
assistantId: 7,
documentId: 42,
});
});
});
@@ -1,374 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const TAB_KEYS = {
CONTENT: 'content',
FAQS: 'faqs',
};
const RESPONSES_PER_PAGE = 25;
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false);
const activeTabIndex = ref(0);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const currentPage = computed(() => meta.value.page || 1);
const showPaginationFooter = computed(
() => totalCount.value > RESPONSES_PER_PAGE
);
const documentContent = computed(() => documentDetails.value?.content?.trim());
const documentContentLength = computed(
() => documentContent.value?.length || 0
);
const isPdf = computed(() => documentDetails.value?.pdf_document);
const displayUrl = computed(() => documentDetails.value?.display_url);
const externalLink = computed(() => documentDetails.value?.external_link);
const sourceHref = computed(() => displayUrl.value || externalLink.value);
const hasSafeLink = computed(() => isSafeHttpLink(sourceHref.value));
const displayLink = computed(() => {
if (isPdf.value) return formatDocumentLink(externalLink.value);
return getDocumentDisplayPath(displayUrl.value || externalLink.value);
});
const contentTabLabel = computed(() =>
isPdf.value
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
);
const tabs = computed(() => [
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
{
key: TAB_KEYS.FAQS,
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
count: totalCount.value,
},
]);
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
const isUnreadableContent = computed(() => {
if (!documentContent.value) return false;
const content = documentContent.value;
const sample = content.slice(0, 2000);
const characters = Array.from(sample);
const nonPrintableCharacters = characters.filter(character => {
const charCode = character.charCodeAt(0);
return (
(charCode <= 31 && ![9, 10, 13].includes(charCode)) ||
(charCode >= 127 && charCode <= 159)
);
});
const nonPrintableRatio =
nonPrintableCharacters.length / Math.max(characters.length, 1);
const replacementCharacterRatio =
characters.filter(character => character === '\uFFFD').length /
Math.max(characters.length, 1);
const hasPdfObjectMarkers =
content.includes(' obj') &&
content.includes(' endobj') &&
content.includes(' stream');
return (
content.startsWith('%PDF') ||
hasPdfObjectMarkers ||
nonPrintableRatio > 0.02 ||
replacementCharacterRatio > 0.05
);
});
const formattedDocumentContent = computed(() => {
if (!documentContent.value || isUnreadableContent.value) return '';
const formatter = new MessageFormatter(documentContent.value);
formatter.disableImageRendering();
return formatter.formattedMessage;
});
const updatedAtLabel = computed(() => {
if (!documentDetails.value?.updated_at) return null;
return messageTimestamp(
documentDetails.value.updated_at,
'MMM d, yyyy h:mm a'
);
});
const syncedAtLabel = computed(() => {
if (!documentDetails.value?.last_synced_at) return null;
return messageTimestamp(
documentDetails.value.last_synced_at,
'MMM d, yyyy h:mm a'
);
});
const handleClose = () => {
emit('close');
};
const handleCopyContent = async () => {
try {
await copyTextToClipboard(documentContent.value);
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_SUCCESS'));
} catch {
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_ERROR'));
}
};
const handleTabChanged = tab => {
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
const fetchResponses = (page = 1) => {
return store.dispatch('captainResponses/get', {
page,
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
};
const handlePageChange = page => {
fetchResponses(page);
};
onMounted(() => {
fetchResponses();
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="documentDetails.name || documentDetails.external_link"
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-6 min-h-48">
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.SOURCE') }}
</span>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center min-w-0 gap-1 text-sm text-n-slate-12 hover:underline"
>
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</a>
<span v-else class="text-sm truncate text-n-slate-12">
{{ displayLink }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.GENERATED_FAQS') }}
</span>
<span class="text-sm text-n-slate-12">
{{ totalCount }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.LAST_UPDATED') }}
</span>
<span class="text-sm text-n-slate-12">
{{
syncedAtLabel ||
updatedAtLabel ||
t('CAPTAIN.DOCUMENTS.DETAILS.NOT_AVAILABLE')
}}
</span>
</div>
</div>
</section>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChanged"
/>
<div class="h-[32rem] overflow-y-auto">
<section
v-if="activeTabKey === TAB_KEYS.CONTENT"
class="flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h4 class="text-sm font-medium text-n-slate-12">
{{
isPdf
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TITLE')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TITLE')
}}
</h4>
<span
v-if="documentContent && !isPdf"
class="text-xs text-n-slate-10"
>
{{
t('CAPTAIN.DOCUMENTS.DETAILS.CHARACTER_COUNT', {
count: documentContentLength.toLocaleString(),
})
}}
</span>
</div>
<div
v-if="documentContent && !isPdf"
class="flex flex-wrap items-center justify-end gap-4"
>
<Button
:label="
showRawContent
? t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_PREVIEW')
: t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_RAW')
"
sm
slate
link
@click="showRawContent = !showRawContent"
/>
<Button
:label="t('CAPTAIN.DOCUMENTS.DETAILS.COPY_CONTENT')"
icon="i-lucide-copy"
sm
slate
link
@click="handleCopyContent"
/>
</div>
</div>
<div
v-if="isPdf"
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4 text-sm text-n-slate-11"
>
<p class="mb-3">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.PDF_DESCRIPTION') }}
</p>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-n-blue-11 hover:underline"
>
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
<Icon icon="i-lucide-external-link" class="size-3" />
</a>
<span v-else class="inline-flex items-center gap-1 text-n-slate-12">
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
</span>
</div>
<template v-else-if="documentContent">
<div
v-if="isUnreadableContent && !showRawContent"
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.UNREADABLE_CONTENT') }}
</div>
<div
v-else
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
>
<pre
v-if="showRawContent || isUnreadableContent"
class="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-n-slate-12"
><code>{{ documentContent }}</code></pre>
<div
v-else
v-dompurify-html="formattedDocumentContent"
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-a:text-n-blue-11 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-img:hidden"
/>
</div>
</template>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.EMPTY_CONTENT') }}
</div>
</section>
<section
v-if="activeTabKey === TAB_KEYS.FAQS"
class="flex flex-col gap-3"
>
<div v-if="responses.length" class="flex flex-col gap-3">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.EMPTY') }}
</div>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="RESPONSES_PER_PAGE"
class="!px-0"
@update:current-page="handlePageChange"
/>
</footer>
</section>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
};
onMounted(() => {
store.dispatch('captainResponses/get', {
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
</Dialog>
</template>
@@ -1,234 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
open: { type: Boolean, default: false },
assistantId: { type: [String, Number], default: null },
metric: { type: String, default: '' },
metricName: { type: String, default: '' },
metricValue: { type: String, default: '' },
range: { type: String, default: '' },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const subtitle = computed(() => {
if (!meta.value.conversation_count) return '';
return t('CAPTAIN.OVERVIEW.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
count: meta.value.conversation_count,
});
});
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return;
openDrilldown({
assistantId: props.assistantId,
metric: props.metric,
range: props.range,
});
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
close();
restoreFocus();
return;
}
rememberActiveElement();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
);
watch(
() => [props.metric, props.range],
() => {
if (props.open) fetchDrilldown();
}
);
// The drawer's headline value is a snapshot of the previous assistant's card,
// so switching assistants (e.g. browser Back/Forward) closes it instead of
// refetching records that would no longer match the header.
watch(
() => props.assistantId,
() => {
if (props.open) closeDrawer();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div>
</Transition>
</TeleportWithDirection>
</template>
@@ -1,96 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const route = useRoute();
const router = useRouter();
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
const DISMISS_STORE = 'captain_overview_coverage_banner';
const accountId = computed(() => route.params.accountId);
const assistantId = computed(() => route.params.assistantId);
// Re-read the stored flag whenever the assistant changes, otherwise the banner
// would keep the first assistant's dismissed state after switching.
const dismissed = ref(false);
watch(
[accountId, assistantId],
([account, assistant]) => {
dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant);
},
{ immediate: true }
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
);
const dismiss = () => {
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
dismissed.value = true;
};
const goToPending = () => {
router.push({
name: 'captain_assistants_responses_pending',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
coverage: knowledge.coverage,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
@click="dismiss"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -1,73 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const route = useRoute();
const router = useRouter();
const store = useStore();
const dismissed = ref(false);
const assistantId = computed(() => route.params.assistantId);
const inboxes = useMapGetter('captainInboxes/getRecords');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
// Only surface once we know the assistant has no connected inboxes.
const showBanner = computed(
() =>
!dismissed.value &&
!uiFlags.value.fetchingList &&
inboxes.value.length === 0
);
watch(
assistantId,
id => {
dismissed.value = false;
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
},
{ immediate: true }
);
const goToInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToInboxes"
>
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
@click="dismissed = true"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -1,87 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const { t } = useI18n();
const route = useRoute();
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
const linkTo = routeName => ({
name: routeName,
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
const stats = computed(() => [
{
key: 'approved',
value: props.knowledge.approved,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
},
{
key: 'documents',
value: props.knowledge.documents,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
to: linkTo('captain_assistants_documents_index'),
},
]);
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<RouterLink
v-for="stat in stats"
:key="stat.key"
:to="stat.to"
class="flex flex-col gap-1 group/stat"
>
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
{{ stat.value }}
</span>
<span
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
>
{{ stat.label }}
<span
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
/>
</span>
</RouterLink>
</div>
</div>
</template>
@@ -1,59 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
trend: { type: String, default: '' },
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
const onActivate = () => {
if (props.clickable) emit('click');
};
</script>
<template>
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
: ''
"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="onActivate"
@keydown.enter.self.prevent="onActivate"
@keydown.space.self.prevent="onActivate"
>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ value }}
</span>
<span class="text-sm font-medium tabular-nums" :class="trendClass">
{{ trend }}
</span>
</div>
</div>
</template>
@@ -1,81 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
const { t } = useI18n();
const route = useRoute();
const { isOnChatwootCloud } = useAccount();
const assistantParams = computed(() => ({
accountId: route.params.accountId,
assistantId: route.params.assistantId,
}));
const links = computed(() => [
{
key: 'docs',
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
icon: 'i-lucide-book-open',
href: getHelpUrlForFeature('captain'),
},
{
key: 'playground',
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
icon: 'i-lucide-flask-conical',
to: {
name: 'captain_assistants_playground_index',
params: assistantParams.value,
},
},
{
key: 'billing',
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
icon: 'i-lucide-credit-card',
to: {
name: 'billing_settings_index',
params: { accountId: route.params.accountId },
},
},
]);
</script>
<template>
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<component
:is="link.href ? 'a' : RouterLink"
v-for="link in links"
:key="link.key"
:href="link.href"
:to="link.to"
:target="link.href ? '_blank' : undefined"
:rel="link.href ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
>
<span
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
>
<span :class="link.icon" class="size-4" />
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ link.title }}
</span>
<span class="text-xs truncate text-n-slate-11">
{{ link.description }}
</span>
</div>
<span
:class="
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
"
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
/>
</component>
</div>
</template>
@@ -1,78 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const modelValue = defineModel({ type: String, default: '30' });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const DAY_RANGES = ['7', '30', '90'];
const decorate = item => ({
...item,
action: 'select',
isSelected: item.value === modelValue.value,
});
const menuSections = computed(() => {
const dayItems = DAY_RANGES.map(value =>
decorate({
value,
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
})
);
const monthItems = [
decorate({
value: 'this_month',
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
}),
decorate({
value: 'last_month',
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
}),
];
return [{ items: dayItems }, { items: monthItems }];
});
const menuItems = computed(() =>
menuSections.value.flatMap(section => section.items)
);
const selectedLabel = computed(
() => menuItems.value.find(item => item.isSelected)?.label || ''
);
const handleAction = ({ value }) => {
toggleDropdown(false);
modelValue.value = value;
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
trailing-icon
icon="i-lucide-chevron-down"
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -1,75 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
const props = defineProps({
range: {
type: String,
default: '30',
},
});
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
// Markdown summary generated by the model from the assistant's stats (served by
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
// **bold** so we can highlight them (see prose-strong styling below).
const welcomeMarkdown = ref('');
const isLoading = ref(false);
const fetchSummary = async () => {
isLoading.value = true;
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
});
welcomeMarkdown.value = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
// handles paragraphs, line breaks, links, lists and emphasis consistently.
const formattedSummary = computed(
() => new MessageFormatter(welcomeMarkdown.value).formattedMessage
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
<div class="flex items-center gap-1.5 text-n-slate-10">
<span class="i-lucide-sparkles size-3.5" />
<span class="text-xs">
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
</span>
</div>
<div
v-if="isLoading"
class="flex flex-col gap-5"
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
>
<div class="flex flex-col gap-2.5">
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div
v-else
v-dompurify-html="formattedSummary"
class="max-w-none prose prose-p:text-lg prose-p:leading-relaxed prose-p:mt-0 prose-p:mb-3 last:prose-p:mb-0 prose-strong:font-bold prose-strong:tabular-nums prose-strong:text-n-brand text-n-slate-12"
/>
</div>
</template>
@@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => {
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_overview_index';
currentRouteName || 'captain_assistants_responses_index';
await fetchDataForRoute(targetRouteName, assistant.id);
@@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {
<template>
<div
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -114,10 +114,7 @@ const openCreateAssistantDialog = () => {
@click="openCreateAssistantDialog"
/>
</div>
<div
v-if="assistants.length > 0"
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="assistant in assistants"
:key="assistant.id"
@@ -1,8 +1,6 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
open: {
@@ -29,10 +27,6 @@ const props = defineProps({
type: [String, Number, Array],
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'search']);
@@ -69,22 +63,13 @@ defineExpose({
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<Spinner
v-if="loading"
:size="16"
class="absolute top-2.5 start-3 text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-search"
class="absolute top-2.5 size-4 start-3"
/>
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<input
ref="searchInput"
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="reset-base w-full py-2 !ps-10 !pe-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
@input="onInputSearch"
/>
</div>
@@ -1,297 +0,0 @@
<script setup>
import { ref, computed, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// Drag-reorderable multi-select capped at `max`. The model is the ordered list
// of selected values.
const props = defineProps({
// { value, label, subtitle?, icon?, iconColor? } icon is an emoji or an
// icon-picker value, and falls back to `fallbackIcon`.
options: {
type: Array,
default: () => [],
},
max: {
type: Number,
default: 3,
},
label: {
type: String,
default: '',
},
addLabel: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
fallbackIcon: {
type: String,
default: 'i-lucide-file-text',
},
// Show skeleton rows while options load, so the row height stays stable.
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
// Let the parent filter `options` (via the `search` event) instead of locally.
serverSearch: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['search']);
const selectedIds = defineModel({ type: Array, default: () => [] });
const isOpen = ref(false);
const searchQuery = ref('');
const dragIndex = ref(null);
const dropdownRef = ref(null);
const optionsByValue = computed(
() => new Map(props.options.map(option => [option.value, option]))
);
const selectedRows = computed(() =>
selectedIds.value.map(
id => optionsByValue.value.get(id) || { value: id, label: String(id) }
)
);
const remaining = computed(() => props.max - selectedIds.value.length);
const canAddMore = computed(() => selectedIds.value.length < props.max);
const dropdownOptions = computed(() => {
const available = props.options.filter(
option => !selectedIds.value.includes(option.value)
);
if (props.serverSearch) return available;
const query = searchQuery.value.toLowerCase();
return available.filter(option =>
option.label?.toLowerCase().includes(query)
);
});
const onSearch = value => {
searchQuery.value = value;
emit('search', value);
};
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
onSearch('');
nextTick(() => dropdownRef.value?.focus());
}
};
const onSelect = option => {
if (!canAddMore.value) return;
selectedIds.value = [...selectedIds.value, option.value];
if (!canAddMore.value) isOpen.value = false;
};
const removeItem = id => {
selectedIds.value = selectedIds.value.filter(value => value !== id);
};
const onDragStart = index => {
dragIndex.value = index;
};
const onDragOver = index => {
if (dragIndex.value === null || dragIndex.value === index) return;
const ids = [...selectedIds.value];
const [moved] = ids.splice(dragIndex.value, 1);
ids.splice(index, 0, moved);
dragIndex.value = index;
selectedIds.value = ids;
};
const onDragEnd = () => {
dragIndex.value = null;
};
</script>
<template>
<div>
<div
v-if="label || $slots.counter"
class="flex items-center justify-between mb-1.5"
>
<label class="text-sm font-medium text-n-slate-12">{{ label }}</label>
<div class="flex items-center gap-2">
<span class="text-xs text-n-slate-10">
<slot name="counter" :remaining="remaining" :max="max" />
</span>
<div class="flex items-center gap-1">
<span
v-for="slot in max"
:key="slot"
class="w-4 h-1 rounded-full"
:class="slot <= selectedIds.length ? 'bg-n-brand' : 'bg-n-slate-4'"
/>
</div>
</div>
</div>
<OnClickOutside @trigger="isOpen = false">
<div
class="flex flex-col gap-1 p-1 border rounded-xl border-n-weak bg-n-background"
:class="{ 'opacity-50 pointer-events-none': disabled }"
>
<div
v-if="loading && selectedIds.length && !isOpen"
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
aria-busy="true"
>
<div
v-for="n in selectedIds.length"
:key="n"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-n-alpha-2"
>
<span
class="flex-shrink-0 opacity-40 i-lucide-grip-vertical size-4 text-n-slate-9"
/>
<div class="flex-shrink-0 rounded-md size-6 bg-n-alpha-3" />
<div class="flex-grow min-w-0">
<p class="mb-0 text-sm">
<span
class="inline-block w-32 h-2.5 align-middle rounded bg-n-alpha-3 animate-pulse"
/>
</p>
<p class="mb-0 text-xs">
<span
class="inline-block w-20 h-2 align-middle rounded bg-n-alpha-3 animate-pulse"
/>
</p>
</div>
<span class="flex-shrink-0 size-6" />
</div>
</div>
<div
v-else-if="selectedRows.length"
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
>
<div
v-for="(row, index) in selectedRows"
:key="row.value"
draggable="true"
class="flex items-center gap-2 px-2 py-1.5 transition-colors rounded-lg cursor-grab group/row"
:class="
index === dragIndex
? 'opacity-40 bg-n-alpha-3 ring-1 ring-inset ring-n-brand'
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
"
@dragstart="onDragStart(index)"
@dragover.prevent="onDragOver(index)"
@dragend="onDragEnd"
>
<span
class="flex-shrink-0 transition-colors i-lucide-grip-vertical size-4 text-n-slate-9 group-hover/row:text-n-slate-11"
/>
<div
class="flex items-center justify-center flex-shrink-0 text-sm rounded-md size-6 bg-n-alpha-3 text-n-slate-11"
>
<EmojiIcon
v-if="row.icon"
:value="row.icon"
:color="row.iconColor"
class="shrink-0 size-4"
/>
<span v-else class="size-4" :class="fallbackIcon" />
</div>
<div class="flex-grow min-w-0">
<p class="mb-0 text-sm truncate text-n-slate-12">
{{ row.label }}
</p>
<p
v-if="row.subtitle"
class="mb-0 text-xs truncate text-n-slate-10"
>
{{ row.subtitle }}
</p>
</div>
<Button
type="button"
ghost
slate
xs
no-animation
icon="i-lucide-x"
class="flex-shrink-0"
@click="removeItem(row.value)"
/>
</div>
</div>
<div v-if="canAddMore" class="relative">
<Button
type="button"
ghost
slate
sm
no-animation
justify="start"
:label="addLabel"
:disabled="loading && !isOpen"
class="w-full"
@click="toggleDropdown"
>
<template #icon>
<Spinner
v-if="loading && !isOpen"
:size="16"
class="text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-search"
class="flex-shrink-0 size-4"
/>
</template>
</Button>
<ComboBoxDropdown
ref="dropdownRef"
:open="isOpen"
:options="dropdownOptions"
:search-value="searchQuery"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:loading="loading"
@update:search-value="onSearch"
@select="onSelect"
/>
</div>
</div>
</OnClickOutside>
<p
v-if="selectedIds.length && $slots.note"
class="flex items-center gap-1.5 mt-1.5 mb-0 text-xs text-n-slate-11"
>
<span class="rounded-full size-1.5 bg-n-teal-9" />
<slot name="note" />
</p>
</div>
</template>
@@ -1,224 +0,0 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
const OPTIONS = [
{ value: 1, label: 'Getting started', subtitle: 'Guides' },
{ value: 2, label: 'Billing', subtitle: 'Payments' },
{ value: 3, label: 'Security' },
{ value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
];
// A findable dropdown stub that exposes the `focus()` the component calls on open.
const ComboBoxDropdownStub = {
name: 'ComboBoxDropdown',
props: [
'open',
'options',
'searchValue',
'searchPlaceholder',
'emptyState',
'loading',
],
emits: ['select', 'update:searchValue'],
methods: { focus() {} },
template: '<div class="combo-dropdown" />',
};
// Renders a real <button> so clicks reach the parent handlers; `data-icon`
// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
const ButtonStub = {
name: 'Button',
props: ['label', 'icon', 'disabled'],
emits: ['click'],
template:
'<button :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" />{{ label }}</button>',
};
const mountSelect = (props = {}, slots = {}) =>
mount(ReorderableMultiSelect, {
props: { options: OPTIONS, max: 3, ...props },
slots,
global: {
stubs: {
Button: ButtonStub,
ComboBoxDropdown: ComboBoxDropdownStub,
Spinner: true,
Icon: true,
EmojiIcon: true,
OnClickOutside: { template: '<div><slot /></div>' },
},
},
});
const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
const addTrigger = wrapper =>
wrapper.findAll('button').find(button => !button.attributes('data-icon'));
const removeButtons = wrapper =>
wrapper.findAll('button[data-icon="i-lucide-x"]');
const rows = wrapper => wrapper.findAll('[draggable="true"]');
const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
describe('ReorderableMultiSelect', () => {
describe('rendering selected rows', () => {
it('renders rows in model order with labels resolved from options', () => {
const wrapper = mountSelect({ modelValue: [2, 1] });
const labels = rows(wrapper).map(row => row.find('p').text());
expect(labels).toEqual(['Billing', 'Getting started']);
});
it('falls back to the stringified id when an option is unknown', () => {
const wrapper = mountSelect({ modelValue: [99] });
expect(rows(wrapper)[0].find('p').text()).toBe('99');
});
it('renders the progress dots filled up to the selection count', () => {
const wrapper = mountSelect({
modelValue: [1, 2],
max: 3,
label: 'Tags',
});
const filled = wrapper.findAll('.bg-n-brand').length;
expect(filled).toBe(2);
});
it('exposes remaining and max to the counter slot', () => {
const wrapper = mountSelect(
{ modelValue: [1], max: 3 },
{ counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
);
expect(wrapper.text()).toContain('2/3');
});
});
describe('adding options', () => {
it('appends the chosen option to the model', () => {
const wrapper = mountSelect({ modelValue: [1] });
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
expect(lastModel(wrapper)).toEqual([1, 2]);
});
it('hides the add trigger once the model reaches max', () => {
const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
expect(addTrigger(wrapper)).toBeUndefined();
expect(dropdown(wrapper).exists()).toBe(false);
});
it('closes the dropdown when the last slot is filled', async () => {
const wrapper = mountSelect({ modelValue: [1], max: 2 });
await addTrigger(wrapper).trigger('click');
expect(dropdown(wrapper).props('open')).toBe(true);
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
await wrapper.vm.$nextTick();
// Reaching max removes the trigger (and its dropdown) entirely.
expect(dropdown(wrapper).exists()).toBe(false);
});
it('excludes already-selected options from the dropdown', () => {
const wrapper = mountSelect({ modelValue: [1] });
const values = dropdown(wrapper)
.props('options')
.map(option => option.value);
expect(values).toEqual([2, 3, 4]);
});
});
describe('removing options', () => {
it('removes the clicked item from the model', async () => {
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
await removeButtons(wrapper)[1].trigger('click');
expect(lastModel(wrapper)).toEqual([1, 3]);
});
});
describe('searching', () => {
it('filters options locally by label', async () => {
const wrapper = mountSelect({ modelValue: [] });
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
await wrapper.vm.$nextTick();
const values = dropdown(wrapper)
.props('options')
.map(option => option.value);
expect(values).toEqual([2]);
});
it('emits search and skips local filtering when serverSearch is set', async () => {
const wrapper = mountSelect({ modelValue: [], serverSearch: true });
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
// All unselected options remain; the parent owns filtering.
expect(dropdown(wrapper).props('options')).toHaveLength(4);
});
it('emits an empty search when the trigger opens', async () => {
const wrapper = mountSelect({ modelValue: [1] });
await addTrigger(wrapper).trigger('click');
expect(wrapper.emitted('search').at(-1)).toEqual(['']);
expect(dropdown(wrapper).props('open')).toBe(true);
});
});
describe('reordering', () => {
it('moves a row to the dropped position within the model', async () => {
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
await rows(wrapper)[0].trigger('dragstart');
await rows(wrapper)[2].trigger('dragover');
expect(lastModel(wrapper)).toEqual([2, 3, 1]);
});
});
describe('loading state', () => {
it('shows skeleton rows when loading a non-empty, closed selection', () => {
const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
const skeleton = wrapper.find('[aria-busy="true"]');
expect(skeleton.exists()).toBe(true);
expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
});
it('does not show skeletons when the selection is empty', () => {
const wrapper = mountSelect({ modelValue: [], loading: true });
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
});
it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
// Open first (trigger is enabled), then a live search turns loading on.
const wrapper = mountSelect({ modelValue: [1, 2] });
await addTrigger(wrapper).trigger('click');
await wrapper.setProps({ loading: true });
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
expect(rows(wrapper)).toHaveLength(2);
});
it('forwards loading to the dropdown and disables the closed trigger', () => {
const wrapper = mountSelect({ modelValue: [1], loading: true });
expect(dropdown(wrapper).props('loading')).toBe(true);
expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
});
});
});
@@ -0,0 +1,178 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from './provider.js';
import { ORIENTATION } from './constants';
import MessageGenerationsAPI from 'dashboard/api/captain/messageGenerations';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation } = useMessageContext();
const isExpanded = ref(false);
const isLoading = ref(false);
const hasFetched = ref(false);
const generation = ref(null);
const reasoning = computed(() => generation.value?.reasoning);
const citations = computed(() => generation.value?.citations || []);
const generationPath = computed(() => generation.value?.generationPath || []);
const tools = computed(() =>
generationPath.value.map(step => step?.tool).filter(Boolean)
);
// Model is only surfaced in development to aid debugging.
const model = computed(() =>
import.meta.env.DEV ? generation.value?.model : null
);
const searchQuery = computed(() => {
const step = generationPath.value.find(
s => s?.tool === 'search_documentation'
);
return step?.arguments?.query || '';
});
const hasUsedCitation = computed(() => citations.value.some(c => c.used));
const sourcesSummary = computed(() => {
const summary = t('CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY', {
count: citations.value.length,
});
if (!searchQuery.value) return summary;
const searched = t('CONVERSATION.CAPTAIN_GENERATION.SEARCHED_FOR', {
query: searchQuery.value,
});
return `${summary} · ${searched}`;
});
// Surface the FAQ(s) Captain actually used in the reply ahead of the rest.
const sortedCitations = computed(() =>
[...citations.value].sort((a, b) => Number(b.used) - Number(a.used))
);
const hasDetails = computed(
() =>
Boolean(reasoning.value) ||
citations.value.length > 0 ||
tools.value.length > 0
);
const rowAlignClass = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'justify-start' : 'justify-end'
);
const fetchGeneration = async () => {
if (hasFetched.value) return;
isLoading.value = true;
try {
const { data } = await MessageGenerationsAPI.show(props.messageId);
generation.value = useCamelCase(data, { deep: true });
} catch (error) {
generation.value = null;
} finally {
hasFetched.value = true;
isLoading.value = false;
}
};
const toggle = () => {
isExpanded.value = !isExpanded.value;
if (isExpanded.value) fetchGeneration();
};
</script>
<template>
<div class="flex flex-col gap-2">
<Transition
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isExpanded"
class="flex flex-col gap-3 p-3 text-xs rounded-lg bg-n-alpha-black1"
>
<span v-if="isLoading">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!hasDetails">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="reasoning" class="flex flex-col gap-1">
<span class="font-medium opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 whitespace-pre-line">{{ reasoning }}</p>
</div>
<div v-if="citations.length" class="flex flex-col gap-1.5">
<span class="font-medium opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<p class="m-0 opacity-70">{{ sourcesSummary }}</p>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="(citation, index) in sortedCitations"
:key="index"
:class="[
{ 'opacity-50': hasUsedCitation && !citation.used },
citation.used ? 'font-medium' : '',
]"
>
<a
v-if="citation.source"
:href="citation.source"
target="_blank"
rel="noopener noreferrer"
class="text-n-blue-11 hover:underline"
>
{{ citation.title || citation.source }}
</a>
<span v-else>{{ citation.title }}</span>
<span
v-if="citation.used"
class="px-1 ml-1 rounded bg-n-alpha-2 text-n-teal-10"
>
{{ t('CONVERSATION.CAPTAIN_GENERATION.USED') }}
</span>
</li>
</ul>
</div>
<div v-if="tools.length" class="flex flex-col gap-1">
<span class="font-medium opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TOOLS') }}
</span>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li v-for="(tool, index) in tools" :key="index">{{ tool }}</li>
</ul>
</div>
<span v-if="model" class="opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.MODEL', { model }) }}
</span>
</template>
</div>
</Transition>
<div class="flex items-center gap-1.5" :class="rowAlignClass">
<slot name="meta" />
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center justify-center bg-transparent border-0 cursor-pointer text-n-slate-10 hover:text-n-slate-11"
:class="isExpanded ? 'text-n-slate-11' : ''"
@click="toggle"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
</button>
</div>
</div>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const {
variant,
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
</div>
<slot />
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
<template v-if="shouldShowMeta">
<CaptainGenerationDetails
v-if="isCaptainMessage"
:message-id="id"
class="mt-2"
>
<template #meta>
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
</template>
</CaptainGenerationDetails>
<MessageMeta
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
</div>
</template>
@@ -75,23 +75,6 @@ const hasConversationUnreadCounts = computed(() => {
);
});
const hasFilteredUnreadCounts = computed(() => {
return (
hasConversationUnreadCounts.value &&
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS
)
);
});
const hasDataImport = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.DATA_IMPORT
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -216,18 +199,6 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -255,22 +226,14 @@ watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const hasUnreadCountsForSection = section => {
if (section === SIDEBAR_SORT_SECTIONS.FOLDERS) {
return hasFilteredUnreadCounts.value;
}
return hasConversationUnreadCounts.value;
};
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasUnreadCountsForSection(section),
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasUnreadCountsForSection(section),
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const updateSortPreference = (section, sortBy) => {
@@ -290,7 +253,6 @@ const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
unreadCountKey: view => getFolderUnreadCount.value(view.id),
})
);
@@ -380,9 +342,6 @@ const menuItems = computed(() => {
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
badgeCount: hasFilteredUnreadCounts.value
? mentionsUnreadCount.value
: 0,
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
@@ -390,9 +349,6 @@ const menuItems = computed(() => {
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
badgeCount: hasFilteredUnreadCounts.value
? participatingUnreadCount.value
: 0,
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -401,9 +357,6 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
badgeCount: hasFilteredUnreadCounts.value
? unattendedUnreadCount.value
: 0,
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -417,9 +370,6 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
badgeCount: hasFilteredUnreadCounts.value
? getFolderUnreadCount.value(view.id)
: 0,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -490,14 +440,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Overview',
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
activeOn: ['captain_assistants_overview_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_overview_index',
}),
},
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
@@ -863,16 +805,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-blocks',
to: accountScopedRoute('settings_applications'),
},
...(hasDataImport.value
? [
{
name: 'Settings Data',
label: t('SIDEBAR.DATA'),
icon: 'i-lucide-database',
to: accountScopedRoute('settings_data_imports'),
},
]
: []),
{
name: 'Settings Audit Logs',
label: t('SIDEBAR.AUDIT_LOGS'),
@@ -44,12 +44,6 @@ const showChatSupport = computed(() => {
);
});
const toggleChatSupport = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const menuItems = computed(() => {
return [
{
@@ -57,7 +51,9 @@ const menuItems = computed(() => {
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: toggleChatSupport,
click: () => {
window.$chatwoot.toggle();
},
},
{
show: true,
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
const { modalType, closeOnBackdropClick, onClose } = defineProps({
closeOnBackdropClick: { type: Boolean, default: true },
showCloseButton: { type: Boolean, default: true },
onClose: { type: Function, default: null },
onClose: { type: Function, required: true },
fullWidth: { type: Boolean, default: false },
modalType: { type: String, default: 'centered' },
size: { type: String, default: '' },
@@ -35,7 +35,7 @@ const handleMouseDown = () => {
const close = () => {
show.value = false;
emit('close');
onClose?.();
onClose();
};
const onMouseUp = () => {
@@ -52,10 +52,18 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice' || key === 'whatsapp_call') {
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [
'website',
'twilio',
@@ -62,7 +62,6 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
createVariableInputRule,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -307,10 +306,6 @@ const plugins = computed(() => {
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createVariableInputRule({
isPrivate: () => props.isPrivate,
getVariables: () => props.variables,
}),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
@@ -62,10 +62,7 @@ export default {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', {
inboxIds: [inboxId],
includeAgentBots: true,
});
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
},
@@ -2,7 +2,6 @@
import { computed, ref, watch } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
@@ -58,10 +57,6 @@ const showMetaSection = computed(() => {
);
});
const isAgentBotAssignee = computed(
() => props.chat?.meta?.assignee_type === 'AgentBot'
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
@@ -164,15 +159,10 @@ watch(
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center gap-px truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<Icon
:icon="
isAgentBotAssignee ? 'i-lucide-bot' : 'i-lucide-user-round'
"
class="size-3 text-n-slate-11 flex-shrink-0"
/>
<span class="truncate">{{ assignee.name }}</span>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<CardPriorityIcon
:priority="chat.priority"
@@ -123,13 +123,7 @@ export default {
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div
v-if="
currentChat.meta.assignee &&
currentChat.meta.assignee_type !== 'AgentBot'
"
class="flex items-center gap-2"
>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<input
id="assignee"
v-model="selectedType"
@@ -33,9 +33,7 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -95,7 +93,6 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -173,12 +170,6 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
@@ -464,15 +455,7 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -48,8 +48,6 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
getAgentVariables,
getContactVariables,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -395,13 +393,7 @@ export default {
contact: this.currentContact,
inbox: this.inbox,
});
// Match the backend drops: names are Ruby-capitalized and
// {{agent.*}} is the message sender, not the assignee.
return {
...variables,
...getContactVariables(this.currentContact),
...getAgentVariables(this.currentUser),
};
return variables;
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
@@ -33,19 +33,10 @@ defineProps({
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:icon-name="option.iconName"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
>
<template v-if="option.iconName && option.thumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2" />
</div>
</template>
</Avatar>
/>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
@@ -1,120 +0,0 @@
import { effectScope } from 'vue';
import { useAbortableRequest } from '../useAbortableRequest';
// Resolves when the request "completes", rejects like axios does when the
// signal is aborted mid-flight.
const abortableRunner =
(value, { fail = false } = {}) =>
signal =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
const error = new Error('canceled');
error.name = 'CanceledError';
reject(error);
});
// Defer so a follow-up `run`/`abort` can supersede this one first.
Promise.resolve().then(() => {
if (signal.aborted) return;
if (fail) {
reject(new Error('boom'));
return;
}
resolve(value);
});
});
describe('useAbortableRequest', () => {
it('passes a fresh signal to the runner and returns its result', async () => {
const { run } = useAbortableRequest();
let received = null;
const result = await run(signal => {
received = signal;
return Promise.resolve('ok');
});
expect(received).toBeInstanceOf(AbortSignal);
expect(received.aborted).toBe(false);
expect(result).toBe('ok');
});
it('toggles isPending around the request', async () => {
const { run, isPending } = useAbortableRequest();
expect(isPending.value).toBe(false);
const pending = run(() => Promise.resolve('done'));
expect(isPending.value).toBe(true);
await pending;
expect(isPending.value).toBe(false);
});
it('aborts the previous request when a new one starts', async () => {
const { run } = useAbortableRequest();
const first = run(abortableRunner('first'));
const second = run(abortableRunner('second'));
await expect(first).resolves.toBeUndefined();
await expect(second).resolves.toBe('second');
});
it('returns the onAbort value when a request is superseded', async () => {
const { run } = useAbortableRequest();
const first = run(abortableRunner('first'), { onAbort: null });
const second = run(abortableRunner('second'));
await expect(first).resolves.toBeNull();
await expect(second).resolves.toBe('second');
});
it('abort cancels the in-flight request and clears isPending', async () => {
const { run, abort, isPending } = useAbortableRequest();
const pending = run(abortableRunner('value'));
expect(isPending.value).toBe(true);
abort();
await expect(pending).resolves.toBeUndefined();
expect(isPending.value).toBe(false);
});
it('rethrows non-abort errors and clears isPending', async () => {
const { run, isPending } = useAbortableRequest();
await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow(
'boom'
);
expect(isPending.value).toBe(false);
});
it('aborts the in-flight request when its scope is disposed', async () => {
const scope = effectScope();
let request;
scope.run(() => {
request = useAbortableRequest();
});
const pending = request.run(abortableRunner('value'));
expect(request.isPending.value).toBe(true);
scope.stop();
await expect(pending).resolves.toBeUndefined();
expect(request.isPending.value).toBe(false);
});
it('keeps separate controllers per instance', async () => {
const a = useAbortableRequest();
const b = useAbortableRequest();
const first = a.run(abortableRunner('a'));
// Starting b's request must not abort a's.
const second = b.run(abortableRunner('b'));
await expect(first).resolves.toBe('a');
await expect(second).resolves.toBe('b');
});
});
@@ -26,12 +26,11 @@ const mockNoneAgent = {
};
const mockUseMapGetter = (overrides = {}) => {
const getAssignableAgents = vi.fn(() => allAgentsData);
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -54,24 +53,6 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: false });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('requests agent bots when explicitly included', () => {
const { agentsList, assignableAgents } = useAgentsList(true, {
includeAgentBots: true,
});
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: true });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
@@ -1,62 +0,0 @@
import { getCurrentScope, onScopeDispose, ref } from 'vue';
export const isAbortError = error =>
error?.name === 'AbortError' ||
error?.name === 'CanceledError' ||
error?.code === 'ERR_CANCELED';
/**
* Keeps only the latest request alive. Starting a new `run` (or calling
* `abort`) cancels the previous request through its `AbortSignal`, so
* out-of-order responses can never overwrite fresher data.
*
* @example
* const { run, abort, isPending } = useAbortableRequest();
* const results = await run(signal => api.search(query, { signal }));
*
* @returns {{
* run: (runner: (signal: AbortSignal) => Promise<any>, options?: { onAbort?: any }) => Promise<any>,
* abort: () => void,
* isPending: import('vue').Ref<boolean>,
* }}
* `run` resolves with the runner's value, or `options.onAbort` (default
* `undefined`) when the request was superseded. Non-abort errors are rethrown.
*/
export function useAbortableRequest() {
const isPending = ref(false);
let controller = null;
const abort = () => {
controller?.abort();
controller = null;
isPending.value = false;
};
const run = async (runner, { onAbort } = {}) => {
controller?.abort();
const currentController = new AbortController();
controller = currentController;
isPending.value = true;
try {
return await runner(currentController.signal);
} catch (error) {
if (currentController.signal.aborted || isAbortError(error))
return onAbort;
throw error;
} finally {
// Only the latest run owns the shared state; a superseded run leaves it
// for the run that replaced it.
if (controller === currentController) {
controller = null;
isPending.value = false;
}
}
};
// Cancel any in-flight request when the owning scope is disposed.
// Guarded so the composable can also be used outside an effect scope.
if (getCurrentScope()) onScopeDispose(abort);
return { run, abort, isPending };
}
@@ -10,14 +10,9 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {Object} [options] - Options for the assignable agents list.
* @param {boolean} [options.includeAgentBots=false] - Whether to include AgentBot assignees. Only pass this from surfaces that thread `assignee_type` through the assignment request.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(
includeNoneAgent = true,
{ includeAgentBots = false } = {}
) {
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -44,9 +39,7 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value
? assignable.value(inboxId.value, { includeAgentBots })
: [];
return inboxId.value ? assignable.value(inboxId.value) : [];
});
/**

Some files were not shown because too many files have changed in this diff Show More