Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeff17dfd7 | ||
|
|
02d4de7357 | ||
|
|
afa2b49e45 | ||
|
|
6ba86788b2 | ||
|
|
3c4225cf57 | ||
|
|
d0e7753258 | ||
|
|
563619c95d | ||
|
|
f5c6d375ef | ||
|
|
da8ce630db | ||
|
|
c964f53288 | ||
|
|
b9e7030908 | ||
|
|
76dc037c1a | ||
|
|
67f55c5b7f | ||
|
|
fd6dc62780 | ||
|
|
b1f8792e5f | ||
|
|
c1eceb8f98 | ||
|
|
ba474a4348 | ||
|
|
741e536f89 | ||
|
|
67568f37ca | ||
|
|
f96c3609a4 | ||
|
|
cd1472b0ac | ||
|
|
806d15c13b | ||
|
|
963f182466 | ||
|
|
5b4ff80777 | ||
|
|
9f17fbe697 | ||
|
|
5c39d896d9 | ||
|
|
729aca7b64 | ||
|
|
d04a2c2f56 | ||
|
|
dca4c39900 | ||
|
|
6d6475232a | ||
|
|
4d95956900 | ||
|
|
33314768f4 | ||
|
|
9f5d55ae33 | ||
|
|
f0aacbf170 | ||
|
|
96d0757041 | ||
|
|
847bb13aa5 | ||
|
|
ef63752075 | ||
|
|
bb387dbd4a | ||
|
|
4dd630c683 | ||
|
|
1186aa26dc | ||
|
|
c86736ff70 | ||
|
|
6e31eefd35 | ||
|
|
3d066333eb | ||
|
|
65e5a9dbf1 | ||
|
|
5122536322 | ||
|
|
bf7a5792b2 | ||
|
|
84eb313f38 | ||
|
|
85cd87c410 |
@@ -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
|
||||
|
||||
+3
-3
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
|
||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||
|
||||
|
||||
# AI powered features (Captain)
|
||||
# The OpenAI API key and endpoint for Captain are not configured via .env.
|
||||
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
|
||||
# AI powered features
|
||||
## OpenAI key
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Housekeeping/Performance related configurations
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
|
||||
+1
-1
@@ -1030,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,213 +0,0 @@
|
||||
class V2::Reports::DrilldownBuilder
|
||||
include DateRangeHelper
|
||||
include TimezoneHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
DEFAULT_PAGE = 1
|
||||
DEFAULT_PER_PAGE = 25
|
||||
MAX_PER_PAGE = 100
|
||||
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
|
||||
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
|
||||
MESSAGE_METRICS = {
|
||||
'incoming_messages_count' => :incoming,
|
||||
'outgoing_messages_count' => :outgoing
|
||||
}.freeze
|
||||
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
|
||||
|
||||
def build
|
||||
records = paginated_records.to_a
|
||||
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def meta
|
||||
{
|
||||
metric: metric,
|
||||
record_type: record_type,
|
||||
bucket: {
|
||||
since: bucket_range.begin.to_i,
|
||||
until: bucket_range.end.to_i
|
||||
},
|
||||
current_page: current_page,
|
||||
per_page: per_page,
|
||||
total_count: paginated_records.total_count,
|
||||
conversation_count: conversation_count
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_count
|
||||
return paginated_records.total_count if conversation_metric?
|
||||
|
||||
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
||||
end
|
||||
|
||||
def paginated_records
|
||||
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
||||
end
|
||||
|
||||
def drilldown_scope
|
||||
if message_metric?
|
||||
message_scope
|
||||
elsif conversation_metric?
|
||||
conversation_scope
|
||||
else
|
||||
reporting_event_scope
|
||||
end
|
||||
end
|
||||
|
||||
def message_scope
|
||||
scope.messages
|
||||
.where(account_id: account.id, created_at: bucket_range)
|
||||
.public_send(MESSAGE_METRICS.fetch(metric))
|
||||
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
||||
.reorder(created_at: :desc)
|
||||
end
|
||||
|
||||
def conversation_scope
|
||||
scope.conversations
|
||||
.where(account_id: account.id, created_at: bucket_range)
|
||||
.includes(:assignee, :contact, :inbox)
|
||||
.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def reporting_event_scope
|
||||
events = scope.reporting_events
|
||||
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
|
||||
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
|
||||
.order(created_at: :desc)
|
||||
|
||||
if raw_count_strategy == :exclude_bot_handoffs
|
||||
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
elsif raw_count_strategy == :distinct_conversation
|
||||
events = events.where(id: distinct_conversation_event_ids(events))
|
||||
end
|
||||
|
||||
events
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
scope.reporting_events
|
||||
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
||||
.where.not(conversation_id: nil)
|
||||
.select(:conversation_id)
|
||||
end
|
||||
|
||||
def distinct_conversation_event_ids(events)
|
||||
events.reorder(nil)
|
||||
.where.not(conversation_id: nil)
|
||||
.select('MAX(reporting_events.id)')
|
||||
.group(:conversation_id)
|
||||
end
|
||||
|
||||
def record_serializer(records)
|
||||
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
|
||||
account,
|
||||
metric,
|
||||
use_business_hours?,
|
||||
records
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_range
|
||||
@bucket_range ||= begin
|
||||
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
|
||||
bucket_end = bucket_end_for(bucket_start)
|
||||
requested_start = Time.zone.at(params[:since].to_i)
|
||||
requested_end = Time.zone.at(params[:until].to_i)
|
||||
|
||||
[bucket_start, requested_start].max...[bucket_end, requested_end].min
|
||||
end
|
||||
end
|
||||
|
||||
def bucket_end_for(bucket_start)
|
||||
{
|
||||
'hour' => bucket_start + 1.hour,
|
||||
'day' => bucket_start + 1.day,
|
||||
'week' => bucket_start + 1.week,
|
||||
'month' => bucket_start + 1.month,
|
||||
'year' => bucket_start + 1.year
|
||||
}.fetch(group_by)
|
||||
end
|
||||
|
||||
def scope
|
||||
case dimension_type
|
||||
when 'account' then account
|
||||
when 'inbox' then inbox
|
||||
when 'agent' then user
|
||||
when 'label' then label
|
||||
when 'team' then team
|
||||
else
|
||||
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def inbox = @inbox ||= account.inboxes.find(params[:id])
|
||||
|
||||
def user = @user ||= account.users.find(params[:id])
|
||||
|
||||
def label = @label ||= account.labels.find(params[:id])
|
||||
|
||||
def team = @team ||= account.teams.find(params[:id])
|
||||
|
||||
def metric
|
||||
params[:metric].to_s
|
||||
end
|
||||
|
||||
def report_metric
|
||||
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||
end
|
||||
|
||||
def raw_event_name
|
||||
report_metric&.raw_event_name
|
||||
end
|
||||
|
||||
def raw_count_strategy
|
||||
report_metric&.raw_count_strategy
|
||||
end
|
||||
|
||||
def record_type
|
||||
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
|
||||
|
||||
'conversation'
|
||||
end
|
||||
|
||||
def message_metric?
|
||||
MESSAGE_METRICS.key?(metric)
|
||||
end
|
||||
|
||||
def conversation_metric?
|
||||
metric == 'conversations_count'
|
||||
end
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
|
||||
def group_by
|
||||
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
|
||||
def current_page
|
||||
[params[:page].to_i, DEFAULT_PAGE].max
|
||||
end
|
||||
|
||||
def per_page
|
||||
requested_per_page = params[:per_page].to_i
|
||||
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
|
||||
|
||||
[requested_per_page, MAX_PER_PAGE].min
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
end
|
||||
end
|
||||
@@ -1,199 +0,0 @@
|
||||
class V2::Reports::DrilldownRecordSerializer
|
||||
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||
|
||||
attr_reader :account, :metric, :use_business_hours, :records
|
||||
|
||||
def initialize(account, metric, use_business_hours, records = [])
|
||||
@account = account
|
||||
@metric = metric
|
||||
@use_business_hours = use_business_hours
|
||||
@records = records
|
||||
end
|
||||
|
||||
def serialize(record)
|
||||
return serialize_message(record) if record.is_a?(Message)
|
||||
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
|
||||
|
||||
serialize_conversation(record)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_message(message, metric_value: nil, occurred_at: nil)
|
||||
{
|
||||
record_type: 'message',
|
||||
conversation: conversation_attributes(message.conversation),
|
||||
message: message_attributes(message),
|
||||
metric_value: metric_value,
|
||||
occurred_at: (occurred_at || message.created_at).to_i
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_conversation_event(event)
|
||||
inferred_message = inferred_message_for(event)
|
||||
if inferred_message.present?
|
||||
return serialize_message(
|
||||
inferred_message,
|
||||
metric_value: event_metric_value(event),
|
||||
occurred_at: event_timestamp(event)
|
||||
)
|
||||
end
|
||||
|
||||
serialize_conversation(
|
||||
event.conversation,
|
||||
metric_value: event_metric_value(event),
|
||||
occurred_at: event_timestamp(event),
|
||||
event_name: event.name
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
|
||||
serialized_record = {
|
||||
record_type: 'conversation',
|
||||
conversation: conversation_attributes(conversation),
|
||||
message: nil,
|
||||
metric_value: metric_value,
|
||||
occurred_at: (occurred_at || conversation&.created_at)&.to_i
|
||||
}
|
||||
serialized_record[:event_name] = event_name if event_name.present?
|
||||
serialized_record
|
||||
end
|
||||
|
||||
def conversation_attributes(conversation)
|
||||
return {} if conversation.blank?
|
||||
|
||||
{
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id,
|
||||
contact_id: conversation.contact_id,
|
||||
contact_name: conversation.contact&.name,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox&.name,
|
||||
assignee_id: conversation.assignee_id,
|
||||
assignee_name: conversation.assignee&.name,
|
||||
status: conversation.status,
|
||||
created_at: conversation.created_at.to_i,
|
||||
last_activity_at: conversation.last_activity_at.to_i,
|
||||
last_message: last_message_attributes(conversation)
|
||||
}
|
||||
end
|
||||
|
||||
def message_attributes(message)
|
||||
{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
sender_name: message.sender&.try(:name),
|
||||
created_at: message.created_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def last_message_attributes(conversation)
|
||||
message = latest_messages_by_conversation_id[conversation.id]
|
||||
return if message.blank?
|
||||
|
||||
message_attributes(message)
|
||||
end
|
||||
|
||||
def inferred_message_for(event)
|
||||
return unless MESSAGE_EVENT_METRICS.include?(metric)
|
||||
return if event.conversation.blank? || event.event_end_time.blank?
|
||||
|
||||
inferred_messages_by_event_id[event.id]
|
||||
end
|
||||
|
||||
def first_response_event_with_user?(event)
|
||||
metric == 'avg_first_response_time' && event.user_id.present?
|
||||
end
|
||||
|
||||
def message_inference_range(event)
|
||||
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
|
||||
end
|
||||
|
||||
def event_metric_value(event)
|
||||
use_business_hours ? event.value_in_business_hours : event.value
|
||||
end
|
||||
|
||||
def event_timestamp(event)
|
||||
event.event_end_time || event.created_at
|
||||
end
|
||||
|
||||
def latest_messages_by_conversation_id
|
||||
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
|
||||
{}
|
||||
else
|
||||
latest_messages.index_by(&:conversation_id)
|
||||
end
|
||||
end
|
||||
|
||||
def latest_messages
|
||||
Message
|
||||
.where(account_id: account.id, conversation_id: conversation_ids)
|
||||
.where.not(message_type: :activity)
|
||||
.select('DISTINCT ON (messages.conversation_id) messages.*')
|
||||
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
|
||||
.includes(:sender)
|
||||
end
|
||||
|
||||
def inferred_messages_by_event_id
|
||||
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
|
||||
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
|
||||
message_matches_event?(message, event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def inferred_message_candidates
|
||||
@inferred_message_candidates ||= if inference_events.blank?
|
||||
[]
|
||||
else
|
||||
inferred_messages.to_a
|
||||
end
|
||||
end
|
||||
|
||||
def inferred_messages
|
||||
Message
|
||||
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
|
||||
.where(created_at: inference_time_range)
|
||||
.where(message_type: %i[outgoing template])
|
||||
.includes(:sender)
|
||||
.reorder(created_at: :desc, id: :desc)
|
||||
end
|
||||
|
||||
def message_matches_event?(message, event)
|
||||
message.conversation_id == event.conversation_id &&
|
||||
message.created_at.between?(
|
||||
message_inference_range(event).begin,
|
||||
message_inference_range(event).end
|
||||
) &&
|
||||
message_sender_matches_event?(message, event)
|
||||
end
|
||||
|
||||
def message_sender_matches_event?(message, event)
|
||||
return true unless first_response_event_with_user?(event)
|
||||
|
||||
message.sender_id == event.user_id && message.sender_type == 'User'
|
||||
end
|
||||
|
||||
def inference_time_range
|
||||
event_end_times = inference_events.map(&:event_end_time)
|
||||
|
||||
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
|
||||
end
|
||||
|
||||
def inference_events
|
||||
@inference_events ||= records.select do |record|
|
||||
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_ids
|
||||
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
|
||||
end
|
||||
|
||||
def conversation_id_for(record)
|
||||
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
|
||||
|
||||
record.id
|
||||
end
|
||||
end
|
||||
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
render json: { positions: positions }
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -124,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,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
|
||||
@@ -57,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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,13 +51,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
|
||||
end
|
||||
|
||||
def drilldown
|
||||
return head :unauthorized unless Current.account_user.administrator?
|
||||
return head :unprocessable_entity unless valid_drilldown_params?
|
||||
|
||||
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
|
||||
end
|
||||
|
||||
def conversations
|
||||
return head :unprocessable_entity if params[:type].blank?
|
||||
|
||||
@@ -140,22 +133,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
})
|
||||
end
|
||||
|
||||
def drilldown_params
|
||||
permitted_params = params.permit(
|
||||
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
|
||||
).to_h.symbolize_keys
|
||||
permitted_params.merge(
|
||||
type: (params[:type].presence || 'account').to_sym,
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
)
|
||||
end
|
||||
|
||||
def valid_drilldown_params?
|
||||
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
|
||||
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
|
||||
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,20 +37,6 @@ class CaptainAssistant extends ApiClient {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,42 +31,6 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getDrilldown({
|
||||
metric,
|
||||
bucketTimestamp,
|
||||
from,
|
||||
to,
|
||||
type = 'account',
|
||||
id,
|
||||
groupBy,
|
||||
businessHours,
|
||||
page,
|
||||
perPage,
|
||||
signal,
|
||||
}) {
|
||||
const requestConfig = {
|
||||
params: {
|
||||
metric,
|
||||
bucket_timestamp: bucketTimestamp,
|
||||
since: from,
|
||||
until: to,
|
||||
type,
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
page,
|
||||
per_page: perPage,
|
||||
},
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
requestConfig.signal = signal;
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/drilldown`, requestConfig);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
|
||||
@@ -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,8 +1,6 @@
|
||||
import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
describe('#Reports API', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
@@ -13,7 +11,6 @@ describe('#Reports API', () => {
|
||||
expect(reportsAPI).toHaveProperty('update');
|
||||
expect(reportsAPI).toHaveProperty('delete');
|
||||
expect(reportsAPI).toHaveProperty('getReports');
|
||||
expect(reportsAPI).toHaveProperty('getDrilldown');
|
||||
expect(reportsAPI).toHaveProperty('getSummary');
|
||||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||
expect(reportsAPI).toHaveProperty('getLabelReports');
|
||||
@@ -45,14 +42,11 @@ describe('#Reports API', () => {
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
|
||||
params: {
|
||||
business_hours: undefined,
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
metric: 'conversations_count',
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
timezone_offset: timezoneOffset(),
|
||||
timezone_offset: -0,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -65,70 +59,13 @@ describe('#Reports API', () => {
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
timezone_offset: timezoneOffset(),
|
||||
timezone_offset: -0,
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDrilldown', () => {
|
||||
reportsAPI.getDrilldown({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
page: 2,
|
||||
perPage: 25,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||
params: {
|
||||
metric: 'incoming_messages_count',
|
||||
bucket_timestamp: 1621103400,
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
group_by: 'day',
|
||||
business_hours: false,
|
||||
timezone_offset: timezoneOffset(),
|
||||
page: 2,
|
||||
per_page: 25,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDrilldown with abort signal', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
reportsAPI.getDrilldown({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||
params: {
|
||||
metric: 'incoming_messages_count',
|
||||
bucket_timestamp: 1621103400,
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
type: 'account',
|
||||
id: undefined,
|
||||
group_by: undefined,
|
||||
business_hours: undefined,
|
||||
timezone_offset: timezoneOffset(),
|
||||
page: undefined,
|
||||
per_page: undefined,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it('#getAgentReports', () => {
|
||||
reportsAPI.getAgentReports({
|
||||
from: 1621103400,
|
||||
|
||||
+1
-7
@@ -16,10 +16,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -53,9 +49,7 @@ const unreadMessagesCount = computed(() => {
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return (
|
||||
!props.contact?.blocked &&
|
||||
slaCardLabelRef.value?.hasSlaThreshold &&
|
||||
props.conversation?.appliedSla?.id
|
||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
-1
@@ -126,7 +126,6 @@ const onCardClick = e => {
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:contact="contact"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-3
@@ -51,9 +51,7 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() =>
|
||||
!props.currentContact?.blocked &&
|
||||
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -19,8 +19,16 @@ const slaStatus = ref({
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
@@ -33,9 +41,8 @@ const slaStatusText = computed(() => {
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
chat: props.conversation,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
@@ -27,7 +27,6 @@ defineOptions({
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
@@ -35,7 +34,6 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
-378
@@ -1,378 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
// Reorderable list with cross-page drag. It must stay mounted during a page
|
||||
// fetch — hide the list spinner while `dragging`, or the held item is lost.
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
itemKey: { type: String, default: 'id' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
currentPage: { type: Number, default: 1 },
|
||||
totalPages: { type: Number, default: 1 },
|
||||
});
|
||||
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
const EDGE_BAND = 72; // px from a side that arms a page turn
|
||||
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
|
||||
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
|
||||
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
|
||||
const PILL = 36; // px, the page-turn arrow button on each edge
|
||||
|
||||
const root = ref(null);
|
||||
const isDragging = ref(false);
|
||||
const dragged = ref(null);
|
||||
const pointer = ref({ x: 0, y: 0 });
|
||||
const insertBefore = ref(null); // key the gap sits before, null = end of list
|
||||
const activeEdge = ref(null);
|
||||
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
|
||||
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
|
||||
const dragHeight = ref(0);
|
||||
|
||||
let press = null; // pending press, before it becomes a drag
|
||||
let dwell = null; // timer that flips the page after hovering an edge
|
||||
let paging = false; // waiting for a flipped page to load
|
||||
let scroller = null; // scrollable ancestor, found when a drag begins
|
||||
let scrollDir = 0; // -1 up, +1 down, 0 idle
|
||||
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
|
||||
|
||||
const keyOf = item => String(item?.[props.itemKey]);
|
||||
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
|
||||
const isRTL = () =>
|
||||
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
|
||||
const canPage = dir =>
|
||||
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
|
||||
|
||||
// Key of the item after `key` in `list`, or null when it is the last one.
|
||||
const keyAfter = (list, key) => {
|
||||
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
|
||||
return next ? keyOf(next) : null;
|
||||
};
|
||||
|
||||
const edges = computed(() =>
|
||||
['prev', 'next'].filter(canPage).map(dir => {
|
||||
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
|
||||
return {
|
||||
dir,
|
||||
onLeft,
|
||||
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
|
||||
// the dragged row that follows the cursor and shrinks when aimed at an edge.
|
||||
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
|
||||
const bandStyle = onLeft => {
|
||||
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
|
||||
return {
|
||||
width: `${EDGE_BAND_W}px`,
|
||||
height: `${bounds.value.bottom - bounds.value.top}px`,
|
||||
transform: `translate(${x}px, ${bounds.value.top}px)`,
|
||||
};
|
||||
};
|
||||
const pillStyle = onLeft => {
|
||||
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
|
||||
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
|
||||
};
|
||||
const ghostStyle = computed(() => ({
|
||||
width: `${bounds.value.right - bounds.value.left}px`,
|
||||
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
|
||||
}));
|
||||
const scaleStyle = computed(() => ({
|
||||
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
|
||||
}));
|
||||
|
||||
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
|
||||
const displayItems = computed(() => {
|
||||
if (!isDragging.value || !dragged.value) return props.items;
|
||||
const rest = others(dragged.value);
|
||||
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
|
||||
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Move the gap when the cursor crosses the midpoint of the card it is over.
|
||||
const aim = () => {
|
||||
const card = document
|
||||
.elementFromPoint(pointer.value.x, pointer.value.y)
|
||||
?.closest('[data-drag-id]');
|
||||
const key = card?.dataset.dragId;
|
||||
if (!key || key === keyOf(dragged.value)) return;
|
||||
|
||||
const { top, height } = card.getBoundingClientRect();
|
||||
const above = pointer.value.y < top + height / 2;
|
||||
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
|
||||
};
|
||||
|
||||
const flip = dir => {
|
||||
if (paging || !isDragging.value) return;
|
||||
paging = true;
|
||||
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
|
||||
};
|
||||
|
||||
const aimEdge = x => {
|
||||
const rect = root.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
|
||||
// fade always sit on the visible edges, not the far ends of the full content.
|
||||
const view = scroller?.getBoundingClientRect();
|
||||
const viewTop = Math.max(view?.top ?? 0, 0);
|
||||
const viewBottom = Math.min(
|
||||
view?.bottom ?? window.innerHeight,
|
||||
window.innerHeight
|
||||
);
|
||||
bounds.value = {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: Math.max(rect.top, viewTop),
|
||||
bottom: Math.min(rect.bottom, viewBottom),
|
||||
};
|
||||
|
||||
let dir = null;
|
||||
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
|
||||
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
|
||||
if (dir && !canPage(dir)) dir = null;
|
||||
if (dir === activeEdge.value) return;
|
||||
|
||||
activeEdge.value = dir;
|
||||
clearTimeout(dwell);
|
||||
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
|
||||
};
|
||||
|
||||
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
|
||||
const scrollParent = () => {
|
||||
let el = root.value?.parentElement;
|
||||
while (el) {
|
||||
const { overflowY } = getComputedStyle(el);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
|
||||
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
|
||||
() => {
|
||||
if (!scroller || !scrollDir) return;
|
||||
scroller.scrollTop += scrollDir * SCROLL_STEP;
|
||||
aim();
|
||||
aimEdge(pointer.value.x);
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const updateAutoScroll = y => {
|
||||
if (!scroller) return;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const atTop = scroller.scrollTop <= 0;
|
||||
const atBottom =
|
||||
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
||||
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
|
||||
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
|
||||
else scrollDir = 0;
|
||||
if (scrollDir) resumeScroll();
|
||||
else pauseScroll();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
isDragging.value = false;
|
||||
dragged.value = null;
|
||||
insertBefore.value = null;
|
||||
activeEdge.value = null;
|
||||
clearTimeout(dwell);
|
||||
paging = false;
|
||||
scrollDir = 0;
|
||||
scroller = null;
|
||||
pauseScroll();
|
||||
document.body.classList.remove('select-none');
|
||||
emit('dragging', false);
|
||||
};
|
||||
|
||||
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
|
||||
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
|
||||
// target page's boundary row into the vacated slot. So at the leading edge after moving
|
||||
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
|
||||
// the item sorts onto the adjacent page and vanishes from view.
|
||||
const drop = (item, rawBefore) => {
|
||||
const pos = o => o?.position || 0;
|
||||
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
|
||||
const rest = others(item);
|
||||
// No other rows to position against — e.g. the lone article on a page dropped
|
||||
// without crossing to another page. Leave the order untouched.
|
||||
if (!rest.length) return;
|
||||
// A page flip can leave the aimed key pointing at a row that is no longer on
|
||||
// this page. Like displayItems, resolve an unknown key to null (end of list),
|
||||
// so what the user sees and what we save agree.
|
||||
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
|
||||
const movedDown = props.currentPage > sourcePage;
|
||||
const movedUp = props.currentPage < sourcePage;
|
||||
|
||||
let position;
|
||||
if (before === null) {
|
||||
position =
|
||||
movedUp && rest.length >= 2
|
||||
? mid(rest.at(-2), rest.at(-1))
|
||||
: pos(rest.at(-1)) + 5;
|
||||
} else {
|
||||
const i = rest.findIndex(o => keyOf(o) === before);
|
||||
if (i > 0) {
|
||||
position = mid(rest[i - 1], rest[i]);
|
||||
} else if (movedDown) {
|
||||
// Top-of-page after moving down: the old first row slid up into the source
|
||||
// page's gap, so land just after it to stay this page's first.
|
||||
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
|
||||
} else {
|
||||
position = pos(rest[0]) - 5;
|
||||
}
|
||||
}
|
||||
emit('reorder', { [item[props.itemKey]]: position });
|
||||
};
|
||||
|
||||
const startDrag = () => {
|
||||
isDragging.value = true;
|
||||
dragged.value = press.item;
|
||||
grab.value = { dx: press.dx, dy: press.dy };
|
||||
dragHeight.value = press.h;
|
||||
insertBefore.value = keyAfter(props.items, keyOf(press.item));
|
||||
sourcePage = props.currentPage;
|
||||
scroller = scrollParent();
|
||||
document.body.classList.add('select-none');
|
||||
emit('dragging', true);
|
||||
};
|
||||
|
||||
const onPointerDown = (item, e) => {
|
||||
if (e.button !== 0 || props.disabled) return;
|
||||
if (e.target.closest('button, a, input, [role="button"]')) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
press = {
|
||||
item,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
dx: e.clientX - rect.left,
|
||||
dy: e.clientY - rect.top,
|
||||
h: rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
useEventListener(window, 'pointermove', e => {
|
||||
if (!press) return;
|
||||
if (!isDragging.value) {
|
||||
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
|
||||
if (moved < DRAG_THRESHOLD) return;
|
||||
startDrag();
|
||||
}
|
||||
e.preventDefault();
|
||||
pointer.value = { x: e.clientX, y: e.clientY };
|
||||
aim();
|
||||
aimEdge(e.clientX);
|
||||
updateAutoScroll(e.clientY);
|
||||
});
|
||||
|
||||
useEventListener(window, 'pointerup', () => {
|
||||
if (!press) return;
|
||||
press = null;
|
||||
if (!isDragging.value) return; // a press without movement is a click
|
||||
const item = dragged.value;
|
||||
const before = insertBefore.value;
|
||||
const flipping = paging;
|
||||
reset();
|
||||
if (!flipping) drop(item, before);
|
||||
});
|
||||
|
||||
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (!isDragging.value || !paging) return;
|
||||
paging = false;
|
||||
nextTick(() => {
|
||||
if (!isDragging.value) return;
|
||||
aim();
|
||||
activeEdge.value = null;
|
||||
aimEdge(pointer.value.x);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (!isDragging.value) return;
|
||||
press = null;
|
||||
reset();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(dwell);
|
||||
document.body.classList.remove('select-none');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative w-full h-full">
|
||||
<ul class="w-full h-full space-y-4">
|
||||
<li
|
||||
v-for="(item, index) in displayItems"
|
||||
:key="keyOf(item)"
|
||||
:data-drag-id="keyOf(item)"
|
||||
class="relative list-none"
|
||||
:class="{ 'cursor-grab': !disabled && !isDragging }"
|
||||
@pointerdown="onPointerDown(item, $event)"
|
||||
@dragstart.prevent
|
||||
>
|
||||
<div
|
||||
v-if="isDragging && keyOf(item) === keyOf(dragged)"
|
||||
:style="{ height: `${dragHeight}px` }"
|
||||
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
|
||||
/>
|
||||
<slot v-else name="item" :item="item" :index="index" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport v-if="isDragging" to="body">
|
||||
<div
|
||||
v-for="edge in edges"
|
||||
:key="`band-${edge.dir}`"
|
||||
:style="bandStyle(edge.onLeft)"
|
||||
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
|
||||
:class="[
|
||||
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
|
||||
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="edge in edges"
|
||||
:key="edge.dir"
|
||||
:style="pillStyle(edge.onLeft)"
|
||||
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
|
||||
:class="
|
||||
activeEdge === edge.dir
|
||||
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
|
||||
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
:icon="edge.icon"
|
||||
class="size-4"
|
||||
:class="activeEdge === edge.dir && 'animate-pulse'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dragged"
|
||||
:style="ghostStyle"
|
||||
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
|
||||
>
|
||||
<div
|
||||
:style="scaleStyle"
|
||||
class="transition-transform duration-150 shadow-2xl rounded-2xl"
|
||||
:class="{ 'scale-50': activeEdge }"
|
||||
>
|
||||
<slot name="ghost" :item="dragged" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
-222
@@ -1,222 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h, nextTick } from 'vue';
|
||||
import DraggableReorderList from '../DraggableReorderList.vue';
|
||||
|
||||
// The component is pointer-driven, so we drive it through real pointer events on
|
||||
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
|
||||
// (which card is under the cursor) and getBoundingClientRect (its geometry).
|
||||
const elementAtPoint = { current: null };
|
||||
|
||||
const move = (clientX, clientY) =>
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
|
||||
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
|
||||
|
||||
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
|
||||
const stubGeometry = wrapper => {
|
||||
wrapper.element.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 600,
|
||||
});
|
||||
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
|
||||
const top = index * 50;
|
||||
li.element.getBoundingClientRect = () => ({
|
||||
top,
|
||||
height: 40,
|
||||
bottom: top + 40,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const mountList = (props = {}) =>
|
||||
mount(DraggableReorderList, {
|
||||
props: { items: [], ...props },
|
||||
slots: {
|
||||
item: scope => h('div', { class: 'card' }, scope.item.title),
|
||||
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
|
||||
},
|
||||
global: { stubs: { Icon: true, teleport: true } },
|
||||
});
|
||||
|
||||
describe('DraggableReorderList', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
elementAtPoint.current = null;
|
||||
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const startDragging = async id => {
|
||||
stubGeometry(wrapper);
|
||||
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
clientY: 20,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
it('renders each item through the item slot', () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
});
|
||||
|
||||
const cards = wrapper.findAll('.card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].text()).toBe('Alpha');
|
||||
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a grab affordance only when enabled', () => {
|
||||
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
|
||||
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
|
||||
'cursor-grab'
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
|
||||
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
|
||||
'cursor-grab'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start a drag when disabled', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
disabled: true,
|
||||
});
|
||||
await startDragging(1);
|
||||
move(250, 200);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits dragging true then false across a drag', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
});
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||
move(250, 60);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
|
||||
|
||||
release();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('emits the midpoint position when dropped between two rows', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
{ id: 3, title: 'Gamma', position: 30 },
|
||||
],
|
||||
});
|
||||
await startDragging(1);
|
||||
|
||||
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
|
||||
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
|
||||
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||
move(250, 85);
|
||||
await nextTick();
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
|
||||
});
|
||||
|
||||
it('does not reorder when the only row on a page is dropped in place', async () => {
|
||||
// P1: dragging the lone article on a later page and releasing without
|
||||
// crossing to another page must be a no-op, not move it to the top.
|
||||
wrapper = mountList({
|
||||
items: [{ id: 5, title: 'Solo', position: 260 }],
|
||||
currentPage: 2,
|
||||
totalPages: 2,
|
||||
});
|
||||
await startDragging(5);
|
||||
move(250, 300);
|
||||
await nextTick();
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
|
||||
expect(wrapper.emitted('reorder')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('turns the page after dwelling on a pageable edge', async () => {
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
],
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
});
|
||||
await startDragging(1);
|
||||
|
||||
// Drag to the right edge over blank space (no card) and hold.
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it('can still turn pages after releasing during a pending flip', async () => {
|
||||
// Releasing while a flip fetch is in flight must clear paging state, or every
|
||||
// later drag would be stuck unable to navigate.
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
],
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
});
|
||||
|
||||
// First drag: park at the edge to start a flip, then release before the new
|
||||
// page arrives (items never change here).
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
// Second drag must be able to flip again.
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
|
||||
});
|
||||
});
|
||||
+72
-68
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -9,7 +10,6 @@ import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -28,22 +28,9 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'translateArticle',
|
||||
'toggleSelect',
|
||||
'navigatePage',
|
||||
'dragging',
|
||||
]);
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
@@ -52,14 +39,14 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
const canReorder = props.articles?.length > 1 || props.totalPages > 1;
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
canReorder &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
});
|
||||
@@ -76,10 +63,6 @@ const handleCardHover = (isHovered, id) => {
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const openArticle = id => {
|
||||
const { tab, categorySlug, locale } = route.params;
|
||||
if (props.isCategoryArticles) {
|
||||
@@ -100,25 +83,38 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = async positionsHash => {
|
||||
const [movedId] = Object.keys(positionsHash);
|
||||
// A same-page reorder updates optimistically in the store, so it needs no
|
||||
// refetch. Only a cross-page drop must refresh, to pull the moved article
|
||||
// onto this page in its new spot.
|
||||
const isCrossPage = !props.articles.some(
|
||||
article => String(article.id) === movedId
|
||||
);
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup: positionsHash,
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
if (isCrossPage) emit('navigatePage', props.currentPage);
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
// Create a map of article IDs to their new positions
|
||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
||||
obj[key] = sortedArticlePositions[index];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
onReorder(reorderedGroup);
|
||||
};
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const getStatusMessage = (status, isSuccess) => {
|
||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||
const statusMap = {
|
||||
@@ -188,46 +184,54 @@ const updateArticle = ({ action, value, id }) => {
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
|
||||
// Watch for changes in the articles prop and update the localArticles ref
|
||||
watch(
|
||||
() => props.articles,
|
||||
newArticles => {
|
||||
localArticles.value = newArticles;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DraggableReorderList
|
||||
:items="articles"
|
||||
<Draggable
|
||||
v-model="localArticles"
|
||||
:disabled="!dragEnabled"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@reorder="onReorder"
|
||||
@navigate-page="page => emit('navigatePage', page)"
|
||||
@dragging="value => emit('dragging', value)"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
ghost-class="article-ghost-class"
|
||||
class="w-full h-full space-y-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<ArticleCard
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:status="item.status"
|
||||
:author="item.author"
|
||||
:category="getCategory(item.category.id)"
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(item.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||
/>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none rounded-2xl">
|
||||
<ArticleCard
|
||||
:id="element.id"
|
||||
:key="element.id"
|
||||
:title="element.title"
|
||||
:status="element.status"
|
||||
:author="element.author"
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
<template #ghost="{ item }">
|
||||
<ArticleCard
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:status="item.status"
|
||||
:author="item.author"
|
||||
:category="getCategory(item.category.id)"
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
/>
|
||||
</template>
|
||||
</DraggableReorderList>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-ghost-class {
|
||||
@apply opacity-50 bg-n-solid-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-8
@@ -70,7 +70,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const isArticleDragging = ref(false);
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
@@ -149,8 +148,6 @@ const articlesCount = computed(() => {
|
||||
return Number(countMap[tab] || countMap['']);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
|
||||
|
||||
const showArticleHeaderControls = computed(
|
||||
() => !props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
@@ -346,7 +343,7 @@ watch(
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading && !isArticleDragging"
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
@@ -456,13 +453,9 @@ watch(
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-pages="totalPages"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@navigate-page="handlePageChange"
|
||||
@dragging="isArticleDragging = $event"
|
||||
/>
|
||||
</template>
|
||||
<ArticleEmptyState
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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')
|
||||
"
|
||||
|
||||
-1
@@ -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"
|
||||
|
||||
-1
@@ -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"
|
||||
|
||||
-86
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
-374
@@ -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>
|
||||
+71
@@ -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>
|
||||
-234
@@ -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>
|
||||
+3
-11
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
@@ -19,16 +19,8 @@ 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 }
|
||||
const dismissed = ref(
|
||||
LocalStorage.getFlag(DISMISS_STORE, accountId.value, assistantId.value)
|
||||
);
|
||||
|
||||
// Thin coverage paired with a large review backlog: approving the pending FAQs
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
// Selected day-range, used only for the chart's start-axis label.
|
||||
range: { type: String, default: '30' },
|
||||
});
|
||||
|
||||
// Daily assistant credit usage (sample data) rendered as a lightweight bar
|
||||
// chart, so we avoid pulling in a chart library for placeholder visuals.
|
||||
const creditUsage = {
|
||||
total: '48,210',
|
||||
trend: '+18.2%',
|
||||
daily: [
|
||||
120, 145, 132, 160, 175, 158, 190, 210, 195, 230, 220, 245, 260, 240, 275,
|
||||
290, 270, 310, 295, 330, 350, 325, 360, 380, 355, 400, 420, 395, 440, 465,
|
||||
],
|
||||
};
|
||||
|
||||
// Bar heights as a percentage of the peak day, with a small floor so even the
|
||||
// lowest day stays visible.
|
||||
const bars = computed(() => {
|
||||
const max = Math.max(...creditUsage.daily);
|
||||
return creditUsage.daily.map((value, index) => ({
|
||||
key: index,
|
||||
value,
|
||||
height: Math.max(6, Math.round((value / max) * 100)),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ creditUsage.total }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
|
||||
</span>
|
||||
<span class="text-sm font-medium tabular-nums text-n-slate-11">
|
||||
{{ creditUsage.trend }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="rounded-full size-2.5 bg-n-brand" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 border rounded-xl bg-n-solid-1 border-n-weak">
|
||||
<div class="flex items-end gap-1 h-40">
|
||||
<div
|
||||
v-for="bar in bars"
|
||||
:key="bar.key"
|
||||
v-tooltip="`${bar.value} ${$t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`"
|
||||
class="flex-1 rounded-t transition-colors bg-n-brand/70 hover:bg-n-brand"
|
||||
:style="{ height: `${bar.height}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_START', { count: range }) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_END') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+1
-20
@@ -8,35 +8,16 @@ const props = defineProps({
|
||||
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 flex-col gap-3 p-5 group bg-n-solid-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
|
||||
<span
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ const handleAction = ({ value }) => {
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
:menu-sections="menuSections"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Flagged responses broken down by report reason, mapped from
|
||||
// captain_message_reports.report_reason (sample data).
|
||||
const flagReasons = [
|
||||
{
|
||||
key: 'incorrect',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCORRECT'),
|
||||
count: 11,
|
||||
},
|
||||
{
|
||||
key: 'incomplete',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCOMPLETE'),
|
||||
count: 7,
|
||||
},
|
||||
{
|
||||
key: 'outdated',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OUTDATED'),
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
key: 'inappropriate',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INAPPROPRIATE'),
|
||||
count: 2,
|
||||
},
|
||||
{ key: 'other', label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OTHER'), count: 3 },
|
||||
];
|
||||
|
||||
const total = computed(() =>
|
||||
flagReasons.reduce((sum, reason) => sum + reason.count, 0)
|
||||
);
|
||||
|
||||
// Flagged responses as a share of all Captain replies (sample data).
|
||||
const rate = '1.3%';
|
||||
</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.FLAGGED.TITLE') }}
|
||||
</span>
|
||||
<span class="text-sm tabular-nums text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TOTAL', { count: total, rate }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="reason in flagReasons"
|
||||
:key="reason.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="w-28 text-xs truncate text-n-slate-11 shrink-0">
|
||||
{{ reason.label }}
|
||||
</span>
|
||||
<div class="flex-1 h-2 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-n-amber-9"
|
||||
:style="{ width: `${Math.round((reason.count / total) * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="w-6 text-xs font-medium text-right tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ reason.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+46
-12
@@ -2,7 +2,6 @@
|
||||
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: {
|
||||
@@ -16,7 +15,7 @@ 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).
|
||||
// **bold** so we can highlight them.
|
||||
const welcomeMarkdown = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
@@ -37,11 +36,35 @@ const fetchSummary = async () => {
|
||||
|
||||
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
|
||||
// Split a line on **bold** runs so we can render emphasised numbers as
|
||||
// brand-highlighted spans with Tailwind (instead of styling raw HTML).
|
||||
const parseSegments = text => {
|
||||
const parts = [];
|
||||
const regex = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match = regex.exec(text);
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ text: match[1], bold: true });
|
||||
lastIndex = regex.lastIndex;
|
||||
match = regex.exec(text);
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ text: text.slice(lastIndex) });
|
||||
}
|
||||
return parts.map((part, index) => ({ ...part, key: index }));
|
||||
};
|
||||
|
||||
// Break the markdown into paragraphs on blank lines so newline breaks survive
|
||||
// (a single <p> would collapse them), each parsed into highlightable segments.
|
||||
const paragraphs = computed(() =>
|
||||
welcomeMarkdown.value
|
||||
.split(/\n{2,}/)
|
||||
.map(block => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block, index) => ({ key: index, segments: parseSegments(block) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -66,10 +89,21 @@ const formattedSummary = computed(
|
||||
</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"
|
||||
/>
|
||||
<template v-else>
|
||||
<p
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.key"
|
||||
class="text-lg leading-relaxed text-n-slate-12"
|
||||
>
|
||||
<template v-for="segment in paragraph.segments" :key="segment.key">
|
||||
<span
|
||||
v-if="segment.bold"
|
||||
class="font-bold tabular-nums text-n-brand"
|
||||
>{{ segment.text }}</span
|
||||
>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
},
|
||||
@@ -863,16 +813,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 = () => {
|
||||
@@ -123,10 +123,6 @@ onMounted(() => {
|
||||
a {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +57,7 @@ const showMetaSection = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const isAgentBotAssignee = computed(
|
||||
() => props.chat?.meta?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
|
||||
);
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const showLabelsSection = computed(() => {
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
@@ -164,15 +157,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"
|
||||
|
||||
@@ -93,9 +93,7 @@ const hasMultipleInboxes = computed(
|
||||
() => store.getters['inboxes/getInboxes'].length > 1
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.applied_sla?.id && !currentContact.value?.blocked
|
||||
);
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const copyConversationId = async () => {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-2
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -61,7 +61,6 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ConversationCard from '../ConversationCard.vue';
|
||||
|
||||
const defaultChat = {
|
||||
id: 1,
|
||||
labels: [],
|
||||
messages: [],
|
||||
priority: null,
|
||||
unread_count: 0,
|
||||
timestamp: 1700000000,
|
||||
created_at: 1700000000,
|
||||
};
|
||||
|
||||
const mountComponent = (chat, currentContact = {}) =>
|
||||
shallowMount(ConversationCard, {
|
||||
props: {
|
||||
chat: { ...defaultChat, ...chat },
|
||||
currentContact: {
|
||||
name: 'Jane Doe',
|
||||
thumbnail: '',
|
||||
availability_status: 'offline',
|
||||
...currentContact,
|
||||
},
|
||||
inbox: { id: 1 },
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
'fluent-icon': true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ConversationCard', () => {
|
||||
it('does not reserve the labels row when only a persisted SLA policy id is present', () => {
|
||||
const wrapper = mountComponent({ sla_policy_id: 1, applied_sla: null });
|
||||
|
||||
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the labels row when an active applied SLA is present', () => {
|
||||
const wrapper = mountComponent({
|
||||
sla_policy_id: 1,
|
||||
applied_sla: { id: 1 },
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reserve the labels row when the contact is blocked', () => {
|
||||
const wrapper = mountComponent(
|
||||
{
|
||||
sla_policy_id: 1,
|
||||
applied_sla: { id: 1 },
|
||||
},
|
||||
{ blocked: true }
|
||||
);
|
||||
|
||||
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) : [];
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -161,7 +161,6 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
markDismissed(callSid);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Single source of truth for billing currencies on the frontend.
|
||||
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
|
||||
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
|
||||
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
|
||||
|
||||
export const DEFAULT_BILLING_CURRENCY = 'usd';
|
||||
|
||||
// Order here drives the order of the currency toggle in the UI.
|
||||
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
|
||||
|
||||
export const BILLING_CURRENCY_CONFIG = {
|
||||
usd: {
|
||||
code: 'usd',
|
||||
intlLocale: 'en-US',
|
||||
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
|
||||
},
|
||||
brl: {
|
||||
code: 'brl',
|
||||
intlLocale: 'pt-BR',
|
||||
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
|
||||
},
|
||||
};
|
||||
|
||||
export const getCurrencyConfig = code =>
|
||||
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
|
||||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
|
||||
|
||||
export const formatCurrencyAmount = (amount, code, options = {}) => {
|
||||
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
|
||||
return new Intl.NumberFormat(intlLocale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode.toUpperCase(),
|
||||
...options,
|
||||
}).format(amount);
|
||||
};
|
||||
@@ -188,16 +188,6 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'insertTable',
|
||||
];
|
||||
|
||||
// [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer
|
||||
// escapes; the re-parse renders them literally, unescaping would crash it.
|
||||
const flattenLink = (_match, text, url) => {
|
||||
const cleanUrl = url
|
||||
.trim()
|
||||
.replace(/\s+["'(].*$/, '')
|
||||
.replace(/^<|>$/g, '');
|
||||
return text === cleanUrl ? cleanUrl : `${text}: ${cleanUrl}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown formatting patterns for stripping unsupported formatting.
|
||||
*
|
||||
@@ -274,12 +264,7 @@ export const MARKDOWN_PATTERNS = [
|
||||
{
|
||||
type: 'link', // PM: link
|
||||
patterns: [
|
||||
// Escape-aware label + URL captures so a \] or \) can't cut the match
|
||||
// short and leave link markup that crashes the re-parse.
|
||||
{
|
||||
pattern: /\[((?:\\.|[^\]\\])*)\]\(((?:\\.|[^)\\])*)\)/g,
|
||||
replacement: flattenLink,
|
||||
},
|
||||
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
|
||||
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
|
||||
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
|
||||
],
|
||||
|
||||
@@ -78,5 +78,3 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -7,11 +7,9 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
LABELS: 'labels',
|
||||
@@ -49,7 +47,6 @@ export const FEATURE_FLAGS = {
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
|
||||
UNREAD_COUNT_FOR_FILTERS: 'unread_count_for_filters',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -17,13 +17,6 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
|
||||
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
|
||||
const MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS =
|
||||
UNREAD_COUNTS_REFETCH_THROTTLE_MS;
|
||||
const getFilteredUnreadCountsRefreshRetryDelay = () =>
|
||||
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
|
||||
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
@@ -32,9 +25,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.CancelTyping = [];
|
||||
this.lastUnreadCountsFetchAt = null;
|
||||
this.unreadCountsFetchTimer = null;
|
||||
this.mentionUnreadCountsFetchTimer = null;
|
||||
this.mentionUnreadCountsRetryTimer = null;
|
||||
this.filteredUnreadCountsRetryTimer = null;
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
@@ -150,12 +140,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onConversationUnreadCountChanged = () => {
|
||||
this.refreshConversationUnreadCountsWithFilteredRetry();
|
||||
};
|
||||
|
||||
refreshConversationUnreadCountsWithFilteredRetry = () => {
|
||||
this.throttledFetchConversationUnreadCounts();
|
||||
this.scheduleFilteredUnreadCountsRetry();
|
||||
};
|
||||
|
||||
throttledFetchConversationUnreadCounts = () => {
|
||||
@@ -186,51 +171,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.unreadCountsFetchTimer = null;
|
||||
};
|
||||
|
||||
scheduleMentionUnreadCountsFetch = () => {
|
||||
if (!this.isFilteredUnreadCountsEnabled()) return;
|
||||
|
||||
// Mention invalidation runs through the async dispatcher, and stale snapshots
|
||||
// can be served until the filtered-count backend refresh window opens.
|
||||
this.scheduleUnreadCountsFetchAfter(
|
||||
'mentionUnreadCountsFetchTimer',
|
||||
MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS
|
||||
);
|
||||
this.scheduleUnreadCountsFetchAfter(
|
||||
'mentionUnreadCountsRetryTimer',
|
||||
getFilteredUnreadCountsRefreshRetryDelay(),
|
||||
{ reset: true }
|
||||
);
|
||||
};
|
||||
|
||||
scheduleFilteredUnreadCountsRetry = () => {
|
||||
if (!this.isFilteredUnreadCountsEnabled()) return;
|
||||
|
||||
// Filtered snapshots can intentionally stay stale until the backend
|
||||
// refresh window opens.
|
||||
this.scheduleUnreadCountsFetchAfter(
|
||||
'filteredUnreadCountsRetryTimer',
|
||||
getFilteredUnreadCountsRefreshRetryDelay(),
|
||||
{ reset: true }
|
||||
);
|
||||
};
|
||||
|
||||
scheduleUnreadCountsFetchAfter = (
|
||||
timerName,
|
||||
delay,
|
||||
{ reset = false } = {}
|
||||
) => {
|
||||
if (this[timerName]) {
|
||||
if (!reset) return;
|
||||
|
||||
clearTimeout(this[timerName]);
|
||||
}
|
||||
|
||||
this[timerName] = setTimeout(() => {
|
||||
this[timerName] = null;
|
||||
this.throttledFetchConversationUnreadCounts();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
fetchConversationUnreadCounts = () => {
|
||||
if (!this.isConversationUnreadCountsEnabled()) return;
|
||||
|
||||
@@ -249,17 +189,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
);
|
||||
};
|
||||
|
||||
isFilteredUnreadCountsEnabled = () => {
|
||||
const accountId = this.app.$store.getters.getCurrentAccountId;
|
||||
const isFeatureEnabled =
|
||||
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
|
||||
|
||||
return (
|
||||
isFeatureEnabled?.(accountId, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
|
||||
isFeatureEnabled?.(accountId, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
|
||||
);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
@@ -283,7 +212,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
onConversationMentioned = data => {
|
||||
this.app.$store.dispatch('addMentions', data);
|
||||
this.scheduleMentionUnreadCountsFetch();
|
||||
};
|
||||
|
||||
clearTimer = conversationId => {
|
||||
@@ -345,12 +273,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
|
||||
if (this.isFilteredUnreadCountsEnabled()) {
|
||||
// Inbox/team/label visibility changes can change the accessible set used
|
||||
// by filtered unread counts even when no conversation row changes.
|
||||
this.refreshConversationUnreadCountsWithFilteredRetry();
|
||||
}
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
|
||||
currentAccountId
|
||||
) => {
|
||||
const agentsWithDynamicPresenceUpdate = agents.map(item =>
|
||||
item.id === currentUser.id && (item.assignee_type || 'User') === 'User'
|
||||
item.id === currentUser.id
|
||||
? {
|
||||
...item,
|
||||
availability_status: currentUser.accounts.find(
|
||||
|
||||
@@ -9,7 +9,6 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
@@ -429,55 +428,6 @@ export function stripUnsupportedFormatting(content, schema) {
|
||||
* - emoji
|
||||
*/
|
||||
|
||||
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
|
||||
const LIQUID_SYNTAX = /\{\{|\{%/;
|
||||
|
||||
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
|
||||
export const resolveVariableText = (key, variables) => {
|
||||
const value = String(variables?.[key] ?? '');
|
||||
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
|
||||
};
|
||||
|
||||
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
|
||||
// name split on whitespace, each word Ruby-capitalized (rest downcased).
|
||||
const getNameVariables = (prefix, name) => {
|
||||
const names = (name || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
return {
|
||||
[`${prefix}.name`]: names.join(' '),
|
||||
[`${prefix}.first_name`]: names[0] || '',
|
||||
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
|
||||
};
|
||||
};
|
||||
|
||||
// {{agent.*}} values for the message sender.
|
||||
export const getAgentVariables = user => ({
|
||||
...getNameVariables('agent', user.name),
|
||||
'agent.email': user.email,
|
||||
});
|
||||
|
||||
// {{contact.*}} name values.
|
||||
export const getContactVariables = contact =>
|
||||
getNameVariables('contact', contact?.name);
|
||||
|
||||
// Resolves a manually typed {{variable}} to its value on the closing braces.
|
||||
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
|
||||
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
|
||||
const rule = new InputRule(
|
||||
/\{\{([^{}]+)\}\}$/,
|
||||
(editorState, match, from, to) => {
|
||||
if (isPrivate()) return null;
|
||||
const [, key] = match;
|
||||
const text = resolveVariableText(key, getVariables());
|
||||
if (text === `{{${key}}}`) return null;
|
||||
return editorState.tr.insertText(text, from, to);
|
||||
}
|
||||
);
|
||||
return inputRules({ rules: [rule] });
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
@@ -512,7 +462,7 @@ const createNode = (editorView, nodeType, content) => {
|
||||
);
|
||||
}
|
||||
case 'variable':
|
||||
return state.schema.text(content);
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
case 'emoji':
|
||||
return state.schema.text(content);
|
||||
case 'tool': {
|
||||
@@ -547,12 +497,8 @@ const nodeCreators = {
|
||||
to,
|
||||
};
|
||||
},
|
||||
variable: (editorView, content, from, to, variables) => ({
|
||||
node: createNode(
|
||||
editorView,
|
||||
'variable',
|
||||
resolveVariableText(content, variables)
|
||||
),
|
||||
variable: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'variable', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
|
||||
@@ -20,8 +20,6 @@ export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
/**
|
||||
* Formats seconds into a human-readable time string
|
||||
* @param {number} seconds - The time in seconds (can be negative for overdue)
|
||||
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
|
||||
*/
|
||||
const formatSLATime = seconds => {
|
||||
const absSeconds = Math.abs(seconds);
|
||||
|
||||
const units = {
|
||||
y: 31536000,
|
||||
mo: 2592000,
|
||||
d: 86400,
|
||||
h: 3600,
|
||||
m: 60,
|
||||
};
|
||||
|
||||
if (absSeconds < 60) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let remaining = absSeconds;
|
||||
|
||||
Object.entries(units).forEach(([unit, value]) => {
|
||||
if (parts.length >= 2) return;
|
||||
const count = Math.floor(remaining / value);
|
||||
if (count > 0) {
|
||||
parts.push(`${count}${unit}`);
|
||||
remaining -= count * value;
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const toUnixTimestamp = value => {
|
||||
if (!value || typeof value === 'number') return value;
|
||||
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isNaN(numericValue)) return numericValue;
|
||||
|
||||
const parsedTimestamp = Date.parse(value);
|
||||
return Number.isNaN(parsedTimestamp)
|
||||
? value
|
||||
: Math.floor(parsedTimestamp / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates SLA status using backend-computed due times
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
|
||||
* @param {Object} params.chat - The conversation object
|
||||
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
|
||||
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
|
||||
*/
|
||||
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
|
||||
if (!appliedSla || !chat) {
|
||||
return emptyStatus;
|
||||
}
|
||||
|
||||
const sla = useCamelCase(appliedSla);
|
||||
const conversation = useCamelCase(chat);
|
||||
const events = useCamelCase(slaEvents || []);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const slaStatuses = [];
|
||||
|
||||
const dueAtByType = {
|
||||
FRT: sla.slaFrtDueAt,
|
||||
RT: sla.slaRtDueAt,
|
||||
};
|
||||
const slaTypes = ['FRT', 'NRT', 'RT'];
|
||||
|
||||
events.forEach(event => {
|
||||
const type = event.eventType?.toUpperCase();
|
||||
if (!slaTypes.includes(type)) return;
|
||||
|
||||
const missedAt =
|
||||
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
|
||||
if (!missedAt) return;
|
||||
|
||||
slaStatuses.push({
|
||||
type,
|
||||
threshold: missedAt - currentTime,
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
|
||||
const shouldCheckFirstResponse =
|
||||
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||
|
||||
// Check FRT - until first reply is made on time
|
||||
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||
const threshold = sla.slaFrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'FRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check NRT - only if first reply made and waiting for response
|
||||
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||
const threshold = sla.slaNrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'NRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check RT - only if conversation is unresolved
|
||||
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
|
||||
const threshold = sla.slaRtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'RT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (slaStatuses.length === 0) {
|
||||
return emptyStatus;
|
||||
}
|
||||
|
||||
// Show existing breaches before upcoming deadlines, then pick the closest timer.
|
||||
slaStatuses.sort((a, b) => {
|
||||
if (a.isSlaMissed !== b.isSlaMissed) {
|
||||
return a.isSlaMissed ? -1 : 1;
|
||||
}
|
||||
|
||||
return Math.abs(a.threshold) - Math.abs(b.threshold);
|
||||
});
|
||||
const mostUrgent = slaStatuses[0];
|
||||
|
||||
return {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(mostUrgent.threshold),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import ActionCableConnector from '../actionCable';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
vi.mock('shared/helpers/mitt', () => ({
|
||||
emitter: {
|
||||
@@ -18,9 +17,6 @@ global.chatwootConfig = {
|
||||
websocketURL: 'wss://test.chatwoot.com',
|
||||
};
|
||||
|
||||
const mockRetryJitter = value =>
|
||||
vi.spyOn(Math, 'random').mockReturnValue(value);
|
||||
|
||||
describe('ActionCableConnector - Copilot Tests', () => {
|
||||
let store;
|
||||
let actionCable;
|
||||
@@ -43,8 +39,6 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
describe('copilot event handlers', () => {
|
||||
@@ -87,223 +81,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
});
|
||||
|
||||
it('should refetch unread counts when unread count changes', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
mockRetryJitter(0.5);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
||||
|
||||
vi.advanceTimersByTime(37499);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch).toHaveBeenLastCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry unread count changes when filtered counts are disabled', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockImplementation(
|
||||
(_, featureFlag) =>
|
||||
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(45000);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('delays unread count refetch when a conversation is mentioned', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
const conversation = { id: 1, account_id: 1 };
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.mentioned',
|
||||
data: conversation,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
||||
});
|
||||
|
||||
it('does not schedule mention unread count fetches when filtered counts are disabled', () => {
|
||||
vi.useFakeTimers();
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockImplementation(
|
||||
(_, featureFlag) =>
|
||||
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
|
||||
const conversation = { id: 1, account_id: 1 };
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.mentioned',
|
||||
data: conversation,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
|
||||
|
||||
vi.advanceTimersByTime(45000);
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries mentioned unread counts after the backend refresh window', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
mockRetryJitter(0.5);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.mentioned',
|
||||
data: { id: 1, account_id: 1 },
|
||||
});
|
||||
|
||||
const unreadCountFetches = () =>
|
||||
mockDispatch.mock.calls.filter(
|
||||
([action]) => action === 'conversationUnreadCounts/get'
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(unreadCountFetches()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(32499);
|
||||
expect(unreadCountFetches()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(unreadCountFetches()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reschedules mentioned unread count retries for later invalidations', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
mockRetryJitter(0);
|
||||
|
||||
const unreadCountFetches = () =>
|
||||
mockDispatch.mock.calls.filter(
|
||||
([action]) => action === 'conversationUnreadCounts/get'
|
||||
);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.mentioned',
|
||||
data: { id: 1, account_id: 1 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(unreadCountFetches()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.mentioned',
|
||||
data: { id: 1, account_id: 1 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(unreadCountFetches()).toHaveLength(2);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(unreadCountFetches()).toHaveLength(2);
|
||||
|
||||
vi.advanceTimersByTime(15000);
|
||||
expect(unreadCountFetches()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('refetches filtered unread counts after account cache invalidation', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
mockRetryJitter(0.5);
|
||||
|
||||
const cacheKeys = {
|
||||
label: 'label-key',
|
||||
inbox: 'inbox-key',
|
||||
team: 'team-key',
|
||||
};
|
||||
const unreadCountFetches = () =>
|
||||
mockDispatch.mock.calls.filter(
|
||||
([action]) => action === 'conversationUnreadCounts/get'
|
||||
);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'account.cache_invalidated',
|
||||
data: { account_id: 1, cache_keys: cacheKeys },
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('labels/revalidate', {
|
||||
newKey: cacheKeys.label,
|
||||
});
|
||||
expect(mockDispatch).toHaveBeenCalledWith('inboxes/revalidate', {
|
||||
newKey: cacheKeys.inbox,
|
||||
});
|
||||
expect(mockDispatch).toHaveBeenCalledWith('teams/revalidate', {
|
||||
newKey: cacheKeys.team,
|
||||
});
|
||||
expect(unreadCountFetches()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(37499);
|
||||
expect(unreadCountFetches()).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(unreadCountFetches()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not refetch unread counts after cache invalidation when filtered counts are disabled', () => {
|
||||
vi.useFakeTimers();
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockImplementation(
|
||||
(_, featureFlag) =>
|
||||
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'account.cache_invalidated',
|
||||
data: {
|
||||
account_id: 1,
|
||||
cache_keys: {
|
||||
label: 'label-key',
|
||||
inbox: 'inbox-key',
|
||||
team: 'team-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(45000);
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not refetch unread counts when unread count feature is disabled', () => {
|
||||
|
||||
@@ -94,56 +94,16 @@ describe('getContentNode', () => {
|
||||
});
|
||||
|
||||
describe('getVariableNode', () => {
|
||||
it('should render the resolved value directly when the variable has a value', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.name',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.name': 'John' }
|
||||
);
|
||||
it('should create a variable node', () => {
|
||||
const content = 'name';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'variable', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
|
||||
});
|
||||
|
||||
it('should resolve camelCase custom attributes and non-string values', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.custom_attribute.cloudCustomer',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.custom_attribute.cloudCustomer': true }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the variable has no value', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.email',
|
||||
{ from: 0, to: 10 },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith(
|
||||
'{{contact.email}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the value contains Liquid syntax', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.name',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.name': '{{agent.email}}' }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith(
|
||||
'{{contact.name}}'
|
||||
);
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
EditorState,
|
||||
EditorView,
|
||||
buildMessageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
import {
|
||||
@@ -11,12 +6,9 @@ import {
|
||||
calculateMenuPosition,
|
||||
cleanSignature,
|
||||
collapseSelection,
|
||||
createVariableInputRule,
|
||||
extractTextFromMarkdown,
|
||||
findNodeToInsertImage,
|
||||
findSignatureInBody,
|
||||
getAgentVariables,
|
||||
getContactVariables,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getMenuAnchor,
|
||||
@@ -986,77 +978,13 @@ describe('stripUnsupportedFormatting', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps link text and URL when schema does not support links', () => {
|
||||
it('strips links but keeps text', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [this link](https://example.com)',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check this link: https://example.com');
|
||||
});
|
||||
|
||||
it('drops the hidden link title when preserving the URL', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [docs](https://example.com "Docs")',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check docs: https://example.com');
|
||||
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [docs](<https://example.com> "Docs")',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check docs: https://example.com');
|
||||
});
|
||||
|
||||
// Output is re-parsed before sending, so assert the final text
|
||||
// (strip + re-parse); the re-parse turns serializer escapes into literals.
|
||||
describe('links round-trip through re-parse without crashing', () => {
|
||||
const smsSchema = buildMessageSchema([], []); // no marks, no nodes
|
||||
const sendAs = md =>
|
||||
new MessageMarkdownTransformer(smsSchema).parse(
|
||||
stripUnsupportedFormatting(md, smsSchema)
|
||||
).textContent;
|
||||
|
||||
it('keeps escaped parens/underscores anywhere in the URL', () => {
|
||||
expect(
|
||||
sendAs('See [wiki](https://en.wikipedia.org/wiki/Foo\\_\\(bar\\))')
|
||||
).toBe('See wiki: https://en.wikipedia.org/wiki/Foo_(bar)');
|
||||
expect(sendAs('See [wiki](https://host/a\\_\\(b\\)c)')).toBe(
|
||||
'See wiki: https://host/a_(b)c'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the label when it equals the URL even when escaped', () => {
|
||||
expect(
|
||||
sendAs(
|
||||
'[www.example.com/Foo\\_\\(bar\\)](www.example.com/Foo\\_\\(bar\\))'
|
||||
)
|
||||
).toBe('www.example.com/Foo_(bar)');
|
||||
});
|
||||
|
||||
it('does not reintroduce emphasis from an escaped label', () => {
|
||||
expect(sendAs('[Use \\_id\\_](https://example.com)')).toBe(
|
||||
'Use _id_: https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('flattens a label containing an escaped closing bracket', () => {
|
||||
expect(sendAs('[FAQ \\[v2\\]](https://example.com)')).toBe(
|
||||
'FAQ [v2]: https://example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves bare URLs untouched so channels can auto-link them', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('Visit www.example.com now', emptySchema)
|
||||
).toBe('Visit www.example.com now');
|
||||
expect(
|
||||
stripUnsupportedFormatting('Visit <https://example.com>', emptySchema)
|
||||
).toBe('Visit https://example.com');
|
||||
).toBe('Check this link');
|
||||
});
|
||||
|
||||
it('converts autolinks to plain URLs when schema does not support links', () => {
|
||||
@@ -1121,7 +1049,7 @@ describe('stripUnsupportedFormatting', () => {
|
||||
it('handles complex content with multiple formatting types', () => {
|
||||
const content =
|
||||
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
||||
const expected = 'Bold and italic with code and link: url\nlist item';
|
||||
const expected = 'Bold and italic with code and link\nlist item';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1231,149 +1159,3 @@ describe('Menu positioning helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentVariables', () => {
|
||||
it('builds agent variables from the user', () => {
|
||||
expect(
|
||||
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
|
||||
).toEqual({
|
||||
'agent.name': 'John Doe',
|
||||
'agent.first_name': 'John',
|
||||
'agent.last_name': 'Doe',
|
||||
'agent.email': 'john@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
|
||||
const variables = getAgentVariables({ name: 'JANE doE' });
|
||||
|
||||
expect(variables['agent.name']).toBe('Jane Doe');
|
||||
expect(variables['agent.first_name']).toBe('Jane');
|
||||
expect(variables['agent.last_name']).toBe('Doe');
|
||||
});
|
||||
|
||||
it('ignores extra whitespace between words', () => {
|
||||
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
|
||||
'John Doe'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves last_name empty for single-word names', () => {
|
||||
const variables = getAgentVariables({ name: 'john' });
|
||||
|
||||
expect(variables['agent.first_name']).toBe('John');
|
||||
expect(variables['agent.last_name']).toBe('');
|
||||
});
|
||||
|
||||
it('handles a missing name', () => {
|
||||
const variables = getAgentVariables({ email: 'john@example.com' });
|
||||
|
||||
expect(variables['agent.name']).toBe('');
|
||||
expect(variables['agent.first_name']).toBe('');
|
||||
expect(variables['agent.last_name']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactVariables', () => {
|
||||
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
|
||||
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
|
||||
'contact.name': 'Jane Doe',
|
||||
'contact.first_name': 'Jane',
|
||||
'contact.last_name': 'Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves last_name empty for single-word names', () => {
|
||||
const variables = getContactVariables({ name: 'john' });
|
||||
|
||||
expect(variables['contact.first_name']).toBe('John');
|
||||
expect(variables['contact.last_name']).toBe('');
|
||||
});
|
||||
|
||||
it('handles a missing contact', () => {
|
||||
expect(getContactVariables(undefined)['contact.name']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVariableInputRule', () => {
|
||||
// Editor holding `{{key}` so we can simulate typing the final `}`.
|
||||
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
|
||||
const plugin = createVariableInputRule({
|
||||
isPrivate: () => isPrivate,
|
||||
getVariables: () => variables,
|
||||
});
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc: schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [schema.text(typed)]),
|
||||
]),
|
||||
plugins: [plugin],
|
||||
});
|
||||
return new EditorView(document.body, { state });
|
||||
};
|
||||
|
||||
// Types the closing `}`; when the rule declines, insert it like the browser would.
|
||||
const typeClosingBrace = view => {
|
||||
const end = view.state.doc.content.size - 1;
|
||||
const handled = view.someProp('handleTextInput', fn =>
|
||||
fn(view, end, end, '}')
|
||||
);
|
||||
if (!handled) {
|
||||
view.dispatch(view.state.tr.insertText('}', end, end));
|
||||
}
|
||||
};
|
||||
|
||||
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
variables: { 'contact.name': 'John' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('John');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('resolves boolean/non-string values', () => {
|
||||
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
|
||||
variables: { 'contact.custom_attribute.cloudCustomer': true },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('true');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('keeps the placeholder when the variable has no value', () => {
|
||||
const view = buildView('{{contact.email}', { variables: {} });
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.email}}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
variables: { 'contact.name': '{{agent.email}}' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.name}}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('does not resolve inside a private note', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
isPrivate: true,
|
||||
variables: { 'contact.name': 'John' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.name}}');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('#normalizeSidebarSortPreferences', () => {
|
||||
|
||||
it('falls back to defaults for unsupported preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: 'unsupported_sort',
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
|
||||
@@ -171,15 +171,6 @@ describe('#getSidebarSortOptions', () => {
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
});
|
||||
|
||||
it('keeps folder unread count options when filtered unread counts are enabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
|
||||
hasUnreadCounts: true,
|
||||
});
|
||||
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
});
|
||||
|
||||
it('removes unread count options when unread counts are disabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: false,
|
||||
@@ -189,16 +180,6 @@ describe('#getSidebarSortOptions', () => {
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
|
||||
it('removes folder unread count options when filtered unread counts are disabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
|
||||
hasUnreadCounts: false,
|
||||
});
|
||||
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveSidebarSort', () => {
|
||||
@@ -221,14 +202,4 @@ describe('#resolveSidebarSort', () => {
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
|
||||
it('falls back to alphabetical sort for folders when filtered unread counts are disabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.FOLDERS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: false }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
import { evaluateSLAStatus } from '../slaHelper';
|
||||
|
||||
describe('#SLA Helpers', () => {
|
||||
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(currentTimestamp * 1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('evaluateSLAStatus', () => {
|
||||
describe('when inputs are invalid', () => {
|
||||
it('returns empty status when appliedSla is null', () => {
|
||||
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty status when chat is null', () => {
|
||||
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FRT (First Response Time)', () => {
|
||||
it('returns FRT status when first reply not made and within threshold', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns missed FRT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return FRT when first reply was made before due time', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 1000,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('FRT');
|
||||
});
|
||||
|
||||
it('does not return FRT when first reply was made at due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
});
|
||||
|
||||
it('returns missed FRT when first reply was made after due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 300,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('10m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: new Date(
|
||||
(currentTimestamp - 300) * 1000
|
||||
).toISOString(),
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('10m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the due time for a missed FRT event created after the deadline', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NRT (Next Response Time)', () => {
|
||||
it('returns NRT status when waiting for response and within threshold', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns missed NRT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 2700,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('15m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return NRT when not waiting for response', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('NRT');
|
||||
});
|
||||
|
||||
it('returns missed NRT when a recorded NRT miss exists after response', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: null,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('15m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
|
||||
const appliedSla = {
|
||||
sla_nrt_due_at: currentTimestamp + 300,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 60,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return NRT when first reply not made', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('NRT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RT (Resolution Time)', () => {
|
||||
it('returns RT status when conversation is open and within threshold', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['pending', 'snoozed'])(
|
||||
'returns RT status when conversation is %s and within threshold',
|
||||
status => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status,
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns missed RT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the due time for a missed RT event created after the deadline', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return RT when conversation is resolved', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status: 'resolved',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority selection', () => {
|
||||
it('returns most urgent SLA when multiple are active', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
|
||||
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
|
||||
sla_rt_due_at: currentTimestamp + 3600, // 1h
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
// FRT is selected because first_reply_created_at is null
|
||||
// NRT is not checked when first_reply_created_at is null
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
});
|
||||
|
||||
it('returns most urgent missed SLA over upcoming SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
|
||||
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 2100,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an existing missed SLA over a closer upcoming SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
|
||||
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time formatting', () => {
|
||||
it('formats time in days and hours', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1d 1h');
|
||||
});
|
||||
|
||||
it('formats time less than a minute as 1m', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1m');
|
||||
});
|
||||
|
||||
it('formats months correctly', () => {
|
||||
const appliedSla = {
|
||||
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
|
||||
}; // 1 month + 1 day
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1mo 1d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty status scenarios', () => {
|
||||
it('returns empty when no SLA thresholds are set', () => {
|
||||
const appliedSla = {};
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty when all conditions are met', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp + 3600,
|
||||
sla_nrt_due_at: currentTimestamp + 1800,
|
||||
sla_rt_due_at: currentTimestamp + 7200,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
|
||||
waiting_since: null, // Not waiting, so NRT not applicable
|
||||
status: 'resolved', // RT not applicable
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
CONTENT_TYPES,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import types from 'dashboard/store/mutation-types';
|
||||
@@ -104,7 +101,6 @@ export function handleVoiceCallCreated(
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
inboxId,
|
||||
@@ -112,11 +108,6 @@ export function handleVoiceCallCreated(
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
// A voice_call message can be created already terminal when the caller hangs
|
||||
// up before connect. Only ring while the call is actually ringing; mirrors the
|
||||
// guard in seedCallsFromHydratedMessages.
|
||||
if (status !== VOICE_CALL_STATUS.RINGING) return;
|
||||
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
|
||||
@@ -44,8 +44,6 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
|
||||
@@ -58,10 +58,7 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
@@ -323,8 +320,6 @@
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "manual setup flow",
|
||||
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. We’ll restore support as soon as possible.",
|
||||
"STATUS_LINK": "View status update",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
@@ -820,15 +815,6 @@
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
|
||||
"WHATSAPP_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
|
||||
"WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
|
||||
"WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
|
||||
"WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "Business Account ID",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API Key",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
|
||||
"WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
|
||||
"WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
|
||||
"WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
|
||||
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
|
||||
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
||||
@@ -846,70 +832,6 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_MANUAL_MIGRATION": {
|
||||
"BANNER": {
|
||||
"TITLE": "WhatsApp setup action required",
|
||||
"DESCRIPTION": "Meta restrictions are affecting WhatsApp setup and management features. Reconnect this inbox manually to keep your WhatsApp configuration up to date.",
|
||||
"START": "Start manual migration",
|
||||
"GUIDE": "View guide"
|
||||
},
|
||||
"DIALOG": {
|
||||
"EYEBROW": "WhatsApp manual migration",
|
||||
"TITLE": "Reconnect WhatsApp inbox",
|
||||
"CLOSE": "Close",
|
||||
"ACTION_REQUIRED_TITLE": "Action required for this WhatsApp inbox",
|
||||
"ACTION_REQUIRED_DESCRIPTION": "Meta restrictions are affecting setup and management features. This guided flow updates the WhatsApp API connection without creating a new inbox.",
|
||||
"GUIDE_LINK": "Open the manual setup guide",
|
||||
"PRESERVED_TITLE": "Preserved",
|
||||
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
|
||||
"UPDATED_TITLE": "Updated",
|
||||
"UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
|
||||
"WABA_ID": "WABA ID",
|
||||
"WABA_PLACEHOLDER": "Enter WABA ID",
|
||||
"WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
|
||||
"PHONE_NUMBER_ID": "Phone Number ID",
|
||||
"PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
|
||||
"PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
|
||||
"DISPLAY_PHONE_NUMBER": "Display phone number",
|
||||
"DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
|
||||
"DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
|
||||
"ACCESS_TOKEN": "Permanent access token or system user token",
|
||||
"ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
|
||||
"TOKEN_HELP_PREFIX": "The token must include",
|
||||
"TOKEN_HELP_MIDDLE": "Add",
|
||||
"TOKEN_HELP_SUFFIX": "for template sync and template management.",
|
||||
"MESSAGING_PERMISSION": "whatsapp_business_messaging.",
|
||||
"MANAGEMENT_PERMISSION": "whatsapp_business_management",
|
||||
"REVIEW_TITLE": "Review before reconnecting",
|
||||
"INBOX": "Inbox",
|
||||
"PHONE_NUMBER": "Phone number",
|
||||
"NOT_ENTERED": "Not entered",
|
||||
"VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
|
||||
"BACK": "Back",
|
||||
"CANCEL": "Cancel",
|
||||
"CONTINUE": "Continue",
|
||||
"REVIEW_MIGRATION": "Review migration",
|
||||
"RECONNECT": "Reconnect WhatsApp inbox"
|
||||
},
|
||||
"STEPS": {
|
||||
"BEFORE_YOU_START": {
|
||||
"TITLE": "Before you start",
|
||||
"DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
|
||||
},
|
||||
"BUSINESS_DETAILS": {
|
||||
"TITLE": "Business details",
|
||||
"DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access token",
|
||||
"DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
|
||||
},
|
||||
"REVIEW_MIGRATION": {
|
||||
"TITLE": "Review migration",
|
||||
"DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user