Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2750321830 | ||
|
|
af6389bdd8 | ||
|
|
bb37face88 | ||
|
|
078c85e067 | ||
|
|
e19799d473 | ||
|
|
ddb0535a93 | ||
|
|
f2e2d24ce6 | ||
|
|
42cbf7d3b9 | ||
|
|
887897ea98 | ||
|
|
8aee518149 | ||
|
|
5733b822e3 | ||
|
|
1e52d23d7a | ||
|
|
fbb3479263 | ||
|
|
166a41c31c | ||
|
|
e65e18e9c5 | ||
|
|
89b83c65c8 | ||
|
|
ed30ff9c22 | ||
|
|
7d2f01e402 | ||
|
|
8c013415b8 | ||
|
|
2144de92f2 | ||
|
|
0e376f4fe2 | ||
|
|
67cab7171d | ||
|
|
7a5385cc32 | ||
|
|
920a98ccf4 | ||
|
|
ae49af354d | ||
|
|
d1fa8d8c2f | ||
|
|
c1ef2c3039 | ||
|
|
26b8650f92 | ||
|
|
71fffdd2b9 | ||
|
|
eae9841eb4 | ||
|
|
160732c07d | ||
|
|
7a299307b8 | ||
|
|
08f49f5896 | ||
|
|
bf0a10c780 | ||
|
|
a752e56765 | ||
|
|
ff6c068743 | ||
|
|
465763f256 | ||
|
|
e70bbfaaa5 | ||
|
|
a61a8bfbc6 | ||
|
|
4cb89d0de1 | ||
|
|
259187e1fd | ||
|
|
90861f8809 | ||
|
|
c420edce58 | ||
|
|
331875cdaa | ||
|
|
5af26e45a9 | ||
|
|
f948b0b8d9 | ||
|
|
081e08c7c6 | ||
|
|
dc3a4814aa | ||
|
|
5e811eab99 | ||
|
|
9749a3dc96 | ||
|
|
8dd0d08322 | ||
|
|
7e88d44fd9 | ||
|
|
2891a72cb9 | ||
|
|
522e3c4d3f | ||
|
|
0a25a0ef66 | ||
|
|
0fccb7dacd | ||
|
|
f1a07657e9 | ||
|
|
9cb6c501b5 | ||
|
|
ab01ab7853 | ||
|
|
6d74ff9477 | ||
|
|
365935c440 | ||
|
|
d96415c814 | ||
|
|
4f237add73 | ||
|
|
7bde6f23b9 | ||
|
|
99d4ad8f99 | ||
|
|
68f941df30 | ||
|
|
73054c6a94 | ||
|
|
3ace6f01eb | ||
|
|
5f70fd4454 | ||
|
|
f5a7d78180 |
@@ -85,8 +85,8 @@
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
- Only update `en.yml` and `en.json`
|
||||
- Other languages are handled by the community
|
||||
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
|
||||
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
|
||||
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
|
||||
- **Frontend**:
|
||||
- Use `components-next/` for message bubbles (the rest is being deprecated)
|
||||
|
||||
+16
-14
@@ -211,13 +211,14 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
datadog (2.38.0)
|
||||
cgi
|
||||
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
|
||||
libdatadog (~> 36.0.0.1.0)
|
||||
libddwaf (~> 1.30.0.0.0)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
datadog-ruby_core_source (3.5.3)
|
||||
date (3.5.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
@@ -522,15 +523,16 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
libdatadog (18.1.0.1.0)
|
||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3)
|
||||
libdatadog (36.0.0.1.0)
|
||||
libdatadog (36.0.0.1.0-arm64-darwin)
|
||||
libdatadog (36.0.0.1.0-x86_64-linux)
|
||||
libddwaf (1.30.0.0.2)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||
libddwaf (1.30.0.0.2-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||
libddwaf (1.30.0.0.2-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||
libddwaf (1.30.0.0.2-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
@@ -547,7 +549,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.23.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -740,8 +742,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2017-2024 Chatwoot Inc.
|
||||
Copyright (c) 2017-2026 Chatwoot Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.15.1
|
||||
4.16.0
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -14,15 +22,23 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
before_action :agent_bot, except: [:index, :create]
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
bulk_create_agents(emails)
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
clear_onboarding_step
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
@article.update!(article_params) if params[:article].present?
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
persist_article_changes if params[:article].present?
|
||||
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
# Draft-only autosaves must not bump the public-facing updated_at, so write
|
||||
# them with update_columns (which skips the timestamp). update_columns also
|
||||
# skips validations, so assign and validate first to avoid persisting content
|
||||
# that exceeds the column length limit.
|
||||
def persist_article_changes
|
||||
keys = article_params.to_h.keys
|
||||
if keys.any? && (keys - %w[draft_title draft_content]).empty?
|
||||
@article.assign_attributes(article_params)
|
||||
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
|
||||
else
|
||||
@article.update!(article_params)
|
||||
end
|
||||
end
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
:locale, :draft_title, :draft_content, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
|
||||
include SwitchLocale
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
around_action :switch_locale_using_account_locale
|
||||
|
||||
private
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def show
|
||||
set_branded_email_layout
|
||||
end
|
||||
|
||||
def update
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return
|
||||
end
|
||||
|
||||
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
|
||||
set_branded_email_layout
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_branded_email_layout
|
||||
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :authorize_account_update, only: [:update]
|
||||
|
||||
def show
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||
include RequestExceptionHandler
|
||||
include AccessTokenAuthHelper
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
|
||||
|
||||
around_action :handle_with_exception
|
||||
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
before_action :conversation
|
||||
|
||||
def create
|
||||
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
|
||||
|
||||
private
|
||||
|
||||
def authenticate_by_access_token?
|
||||
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
@@ -80,7 +80,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def toggle_status
|
||||
# FIXME: move this logic into a service object
|
||||
if pending_to_open_by_bot?
|
||||
if bot_handoff?
|
||||
@conversation.bot_handoff!
|
||||
elsif params[:status].present?
|
||||
set_conversation_status
|
||||
@@ -88,19 +88,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
else
|
||||
@status = @conversation.toggle_status
|
||||
end
|
||||
assign_conversation if should_assign_conversation?
|
||||
handle_human_open if @conversation.open? && Current.user.is_a?(User)
|
||||
end
|
||||
|
||||
def pending_to_open_by_bot?
|
||||
def bot_handoff?
|
||||
return false unless Current.user.is_a?(AgentBot)
|
||||
|
||||
@conversation.status == 'pending' && params[:status] == 'open'
|
||||
end
|
||||
|
||||
def should_assign_conversation?
|
||||
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
||||
end
|
||||
|
||||
def toggle_priority
|
||||
@conversation.toggle_priority(params[:priority])
|
||||
head :ok
|
||||
@@ -183,8 +179,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
end
|
||||
|
||||
def assign_conversation
|
||||
@conversation.assignee = current_user
|
||||
def handle_human_open
|
||||
@conversation.assignee_agent_bot = nil
|
||||
@conversation.assignee = Current.user if Current.user.agent?
|
||||
@conversation.save!
|
||||
end
|
||||
|
||||
|
||||
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
continue_update = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
continue_update = update_branded_email_layout
|
||||
raise ActiveRecord::Rollback unless continue_update
|
||||
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
return unless continue_update
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def update_branded_email_layout
|
||||
return true unless params.key?(:branded_email_layout)
|
||||
|
||||
branded_email_layout = normalized_branded_email_layout
|
||||
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return false
|
||||
end
|
||||
|
||||
unless @inbox.email?
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email layout is only supported for email inboxes')
|
||||
return false
|
||||
end
|
||||
|
||||
@inbox.update_branded_email_layout!(branded_email_layout)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
false
|
||||
end
|
||||
|
||||
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :fetch_label, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def popular_content_keys
|
||||
params.dig(:portal, :config, :popular_content)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_embedded_signup_enabled
|
||||
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
|
||||
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
@@ -16,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
|
||||
private
|
||||
|
||||
def ensure_embedded_signup_enabled
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
|
||||
|
||||
raise Pundit::NotAuthorizedError
|
||||
end
|
||||
|
||||
def process_embedded_signup
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
account: Current.account,
|
||||
@@ -31,7 +41,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
end
|
||||
|
||||
def validate_reauthorization_required
|
||||
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
||||
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
@@ -39,9 +49,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def can_upgrade_to_embedded_signup?
|
||||
def can_reconfigure_channel?
|
||||
channel = @inbox.channel
|
||||
return false unless channel.provider == 'whatsapp_cloud'
|
||||
return true if ChatwootApp.chatwoot_cloud?
|
||||
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
before_action :ensure_account_name, only: [:create]
|
||||
before_action :validate_captcha, only: [:create]
|
||||
before_action :fetch_account, except: [:create]
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
|
||||
before_action :check_authorization, except: [:create]
|
||||
|
||||
rescue_from CustomExceptions::Account::InvalidEmail,
|
||||
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if @account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||
end
|
||||
|
||||
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
else
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized'))
|
||||
end
|
||||
account
|
||||
end
|
||||
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
|
||||
@@ -4,17 +4,53 @@ module PortalHomeData
|
||||
private
|
||||
|
||||
def load_home_data
|
||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
load_recommended_content
|
||||
# The classic hero only needs the recommendations above; the rest is
|
||||
# documentation-layout home data (also used on custom-domain home pages).
|
||||
return unless @portal.layout == 'documentation'
|
||||
|
||||
@visible_categories = @portal.categories
|
||||
.where(locale: @locale)
|
||||
.joins(:articles).where(articles: { status: :published })
|
||||
.order(position: :asc)
|
||||
.group('categories.id')
|
||||
@popular_topics = @visible_categories.first(3)
|
||||
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
|
||||
@featured = base_articles.order_by_views.limit(6)
|
||||
@category_contributors = build_category_contributors(@visible_categories)
|
||||
end
|
||||
|
||||
def load_recommended_content
|
||||
@recommended_categories = recommended_categories
|
||||
@recommended_articles = recommended_articles
|
||||
end
|
||||
|
||||
def base_articles
|
||||
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
end
|
||||
|
||||
# Admin-recommended categories for the locale, in the chosen order. Unlike the
|
||||
# position-based fallback, published articles aren't required: the admin's pick wins.
|
||||
def recommended_categories
|
||||
ids = @portal.popular_category_ids(@locale)
|
||||
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
|
||||
end
|
||||
|
||||
# Admin-recommended articles for the locale, in the chosen order, limited to
|
||||
# published articles that still exist.
|
||||
def recommended_articles
|
||||
ids = @portal.popular_article_ids(@locale)
|
||||
ordered_by_ids(base_articles.where(id: ids), ids)
|
||||
end
|
||||
|
||||
# Loads the scope and returns its records ordered to match `ids`, dropping any
|
||||
# that no longer exist. Skips the query entirely when `ids` is blank.
|
||||
def ordered_by_ids(scope, ids)
|
||||
return [] if ids.blank?
|
||||
|
||||
by_id = scope.index_by(&:id)
|
||||
ids.filter_map { |id| by_id[id] }
|
||||
end
|
||||
|
||||
def build_category_contributors(categories)
|
||||
category_ids = categories.map(&:id)
|
||||
return {} if category_ids.empty?
|
||||
|
||||
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
|
||||
return unless @portal
|
||||
|
||||
@locale = @portal.default_locale
|
||||
if @portal.layout == 'documentation'
|
||||
request.variant = :documentation
|
||||
load_home_data
|
||||
end
|
||||
request.variant = :documentation if @portal.layout == 'documentation'
|
||||
load_home_data
|
||||
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
||||
end
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def set_view_variant
|
||||
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
|
||||
request.variant = if @is_plain_layout_enabled
|
||||
:plain
|
||||
elsif @portal_layout == 'documentation'
|
||||
:documentation
|
||||
end
|
||||
end
|
||||
|
||||
def portal
|
||||
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
|
||||
def render_404
|
||||
portal
|
||||
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
|
||||
set_view_variant
|
||||
render 'public/api/v1/portals/error/404', status: :not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
|
||||
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||
phone_number_id = metadata[:phone_number_id]
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
|
||||
def normalized_phone_number(phone_number)
|
||||
return if phone_number.blank?
|
||||
|
||||
phone_number = phone_number.to_s
|
||||
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class ConversationDrop < BaseDrop
|
||||
include MessageFormatHelper
|
||||
|
||||
def id
|
||||
@obj.try(:display_id)
|
||||
end
|
||||
|
||||
def display_id
|
||||
@obj.try(:display_id)
|
||||
end
|
||||
|
||||
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name)
|
||||
end
|
||||
|
||||
def business_name
|
||||
@obj.try(:sanitized_business_name)
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
@obj.try(:avatar_url)
|
||||
end
|
||||
|
||||
def email
|
||||
return unless @obj.try(:email?)
|
||||
|
||||
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(params = {}) {
|
||||
return axios.get(this.url, { params });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CallsAPI();
|
||||
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainAgentSessions extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/agent_sessions', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAgentSessions();
|
||||
@@ -26,15 +26,25 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getStats({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, {
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range }) {
|
||||
getFaqStats({ assistantId, signal }) {
|
||||
const requestConfig = {};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range, stats }) {
|
||||
return axios.get(`${this.url}/${assistantId}/summary`, {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
params: { range, timezone_offset: getTimezoneOffset(), stats },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||
}
|
||||
|
||||
initiate(conversationId, sdpOffer) {
|
||||
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
|
||||
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
|
||||
return axios
|
||||
.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
contact_id: contactId,
|
||||
inbox_id: inboxId,
|
||||
sdp_offer: sdpOffer,
|
||||
})
|
||||
.then(r => r.data);
|
||||
|
||||
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
signal,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
return axios.get(url);
|
||||
return axios.get(url, { signal });
|
||||
}
|
||||
|
||||
searchArticles({ portalSlug, query }) {
|
||||
|
||||
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
|
||||
authorId: '1',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||
import {
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import CallStatusBadge from './CallStatusBadge.vue';
|
||||
import { CALL_KIND, getCallKind } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
call: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const kind = computed(() => getCallKind(props.call));
|
||||
|
||||
const contactName = computed(() =>
|
||||
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
|
||||
/^\+/,
|
||||
''
|
||||
)
|
||||
);
|
||||
|
||||
const agentActionLabel = computed(() => {
|
||||
if (!props.call.agent) return '';
|
||||
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
|
||||
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
|
||||
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
|
||||
if (kind.value === CALL_KIND.ONGOING) {
|
||||
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
? t('CALLS_PAGE.ROW.DIALED_BY')
|
||||
: t('CALLS_PAGE.ROW.PICKED_BY');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const resultLabel = computed(() => {
|
||||
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
|
||||
if (kind.value === CALL_KIND.NO_REPLY) {
|
||||
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
|
||||
}
|
||||
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
|
||||
if (kind.value === CALL_KIND.ONGOING) {
|
||||
return props.call.status === VOICE_CALL_STATUS.RINGING
|
||||
? t('CALLS_PAGE.ROW.RINGING')
|
||||
: t('CALLS_PAGE.ROW.IN_PROGRESS');
|
||||
}
|
||||
return t('CALLS_PAGE.ROW.ANSWERED');
|
||||
});
|
||||
|
||||
const providerIcon = computed(() =>
|
||||
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||
);
|
||||
|
||||
const createdAtLabel = computed(() =>
|
||||
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
|
||||
);
|
||||
|
||||
const conversationRoute = computed(() => ({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
conversation_id: props.call.conversation.displayId,
|
||||
},
|
||||
query: { messageId: props.call.messageId },
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
|
||||
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
|
||||
>
|
||||
{{ contactName }}
|
||||
</span>
|
||||
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
|
||||
<RouterLink
|
||||
:to="conversationRoute"
|
||||
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
|
||||
>
|
||||
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
|
||||
{{ call.conversation.displayId }}
|
||||
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<template v-if="agentActionLabel">
|
||||
<span class="text-label-small text-n-slate-10 shrink-0">
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="call.agent.avatar"
|
||||
:name="call.agent.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-body-main truncate text-n-slate-12 min-w-0">
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
|
||||
{{ resultLabel }}
|
||||
</span>
|
||||
<span class="w-px h-3 bg-n-strong shrink-0" />
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!call.recordingUrl"
|
||||
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="call.recordingUrl"
|
||||
class="flex items-center gap-2 min-w-0 justify-between"
|
||||
>
|
||||
<AudioPlayer
|
||||
:src="call.recordingUrl"
|
||||
:fallback-duration="call.durationSeconds || 0"
|
||||
class="flex-1 sm:flex-[0.7] min-w-0"
|
||||
/>
|
||||
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
|
||||
class="text-heading-3 font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ contactName }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
|
||||
>
|
||||
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||
<template v-if="agentActionLabel">
|
||||
<span
|
||||
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||
>
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
|
||||
<Avatar
|
||||
:src="call.agent.avatar"
|
||||
:name="call.agent.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-tooltip.top="{
|
||||
content: call.agent.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-body-main truncate text-n-slate-12 min-w-0"
|
||||
>
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="resultLabel"
|
||||
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||
>
|
||||
{{ resultLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<AudioPlayer
|
||||
v-if="call.recordingUrl"
|
||||
:src="call.recordingUrl"
|
||||
:fallback-duration="call.durationSeconds || 0"
|
||||
class="w-auto min-w-44 shrink mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: call.inbox.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
|
||||
>
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="conversationRoute"
|
||||
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
|
||||
>
|
||||
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
|
||||
{{ call.conversation.displayId }}
|
||||
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<span
|
||||
v-tooltip.top="{
|
||||
content: createdAtLabel,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fallbackDuration: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const PLAYBACK_SPEEDS = [1, 1.5, 2];
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(props.fallbackDuration);
|
||||
const playbackSpeed = ref(1);
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const loadedDuration = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
|
||||
};
|
||||
|
||||
const formatTime = time => {
|
||||
if (!time || Number.isNaN(time)) return '00:00';
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
|
||||
|
||||
const displayedTime = computed(() =>
|
||||
formatTime(
|
||||
isPlaying.value || currentTime.value ? currentTime.value : duration.value
|
||||
)
|
||||
);
|
||||
|
||||
// Only one recording should play at a time across the list.
|
||||
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||
if (currentPlayingId !== uid && isPlaying.value) {
|
||||
audioPlayer.value?.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const playOrPause = () => {
|
||||
if (isPlaying.value) {
|
||||
audioPlayer.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
emitter.emit('pause_playing_audio', uid);
|
||||
audioPlayer.value.play();
|
||||
isPlaying.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
currentTime.value = audioPlayer.value?.currentTime;
|
||||
};
|
||||
|
||||
const seek = event => {
|
||||
const time = Number(event.target.value);
|
||||
audioPlayer.value.currentTime = time;
|
||||
currentTime.value = time;
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
};
|
||||
|
||||
const changePlaybackSpeed = () => {
|
||||
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
|
||||
playbackSpeed.value =
|
||||
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
};
|
||||
|
||||
const downloadRecording = () => {
|
||||
downloadFile({ url: props.src, type: 'audio' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="src" />
|
||||
</audio>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-12"
|
||||
@click="playOrPause"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:label="playbackSpeedLabel"
|
||||
class="!px-1 min-w-6 !text-n-slate-11"
|
||||
@click="changePlaybackSpeed"
|
||||
/>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-11"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { CALL_KIND } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
kind: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const KIND_CONFIG = {
|
||||
[CALL_KIND.ONGOING]: {
|
||||
icon: 'i-lucide-phone-call',
|
||||
class: 'bg-n-teal-3 text-n-teal-11',
|
||||
},
|
||||
[CALL_KIND.INCOMING]: {
|
||||
icon: 'i-lucide-phone-incoming',
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
[CALL_KIND.OUTGOING]: {
|
||||
icon: 'i-lucide-phone-outgoing',
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
[CALL_KIND.MISSED]: {
|
||||
icon: 'i-lucide-phone-missed',
|
||||
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
},
|
||||
[CALL_KIND.NO_REPLY]: {
|
||||
icon: 'i-lucide-phone-outgoing',
|
||||
class: 'bg-n-amber-3 text-n-amber-11',
|
||||
},
|
||||
[CALL_KIND.FAILED]: {
|
||||
icon: 'i-lucide-phone-off',
|
||||
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
},
|
||||
};
|
||||
|
||||
const config = computed(() => KIND_CONFIG[props.kind]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
|
||||
:class="config.class"
|
||||
>
|
||||
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
|
||||
<span class="truncate">{{
|
||||
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const setupVoiceChannel = () => {
|
||||
router.push({
|
||||
name: 'settings_inbox_new',
|
||||
params: { accountId: route.params.accountId },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
:title="t('CALLS_PAGE.SETUP.TITLE')"
|
||||
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="t('CALLS_PAGE.SETUP.ACTION')"
|
||||
icon="i-lucide-plus"
|
||||
@click="setupVoiceChannel"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Null while a fetch is in flight so stale counts are never shown.
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
agents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// Self-scoped viewers only ever see their own calls, so the assignee filter
|
||||
// is meaningless for them — only admins get it.
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const activity = defineModel('activity', { type: String, default: null });
|
||||
const assigneeId = defineModel('assigneeId', { type: Number, default: null });
|
||||
const inboxId = defineModel('inboxId', { type: Number, default: null });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const ACTIVITY_ICONS = {
|
||||
missed: 'i-lucide-phone-missed',
|
||||
no_reply: 'i-lucide-phone-outgoing',
|
||||
incoming: 'i-lucide-phone-incoming',
|
||||
outgoing: 'i-lucide-phone-outgoing',
|
||||
in_progress: 'i-lucide-phone-call',
|
||||
};
|
||||
|
||||
const BASE_ACTIVITIES = ['missed', 'no_reply'];
|
||||
const OTHER_ACTIVITIES = ['incoming', 'outgoing', 'in_progress'];
|
||||
|
||||
// A single open-menu identifier keeps the three dropdowns mutually exclusive:
|
||||
// opening one closes the others without any cross-wiring.
|
||||
const openMenu = ref(null); // 'activity' | 'assignee' | 'more' | null
|
||||
|
||||
const toggleMenu = name => {
|
||||
openMenu.value = openMenu.value === name ? null : name;
|
||||
};
|
||||
|
||||
// Each dropdown wrapper closes itself on outside clicks. The guard keeps the
|
||||
// other two wrappers (which the click is also outside of) from closing a
|
||||
// menu the user is interacting with.
|
||||
const closeOnOutside = name => {
|
||||
if (openMenu.value === name) openMenu.value = null;
|
||||
};
|
||||
|
||||
const activityLabel = value => t(`CALLS_PAGE.FILTERS.${value.toUpperCase()}`);
|
||||
|
||||
const activeChipLabel = computed(() => {
|
||||
const label = activityLabel(activity.value);
|
||||
return props.totalCount === null ? label : `${label} (${props.totalCount})`;
|
||||
});
|
||||
|
||||
const inactiveChips = computed(() =>
|
||||
BASE_ACTIVITIES.filter(value => value !== activity.value)
|
||||
);
|
||||
|
||||
const otherActivityItems = computed(() =>
|
||||
OTHER_ACTIVITIES.map(value => ({
|
||||
label: activityLabel(value),
|
||||
value,
|
||||
action: 'filter',
|
||||
icon: ACTIVITY_ICONS[value],
|
||||
isSelected: activity.value === value,
|
||||
}))
|
||||
);
|
||||
|
||||
const assigneeItems = computed(() => [
|
||||
{
|
||||
label: t('CALLS_PAGE.FILTERS.ALL_ASSIGNEES'),
|
||||
value: null,
|
||||
action: 'filter',
|
||||
isSelected: !assigneeId.value,
|
||||
},
|
||||
...props.agents.map(agent => ({
|
||||
label: agent.name,
|
||||
value: agent.id,
|
||||
action: 'filter',
|
||||
thumbnail: { name: agent.name, src: agent.thumbnail },
|
||||
isSelected: assigneeId.value === agent.id,
|
||||
})),
|
||||
]);
|
||||
|
||||
const moreFiltersSections = computed(() => [
|
||||
{
|
||||
title: t('CALLS_PAGE.FILTERS.INBOX'),
|
||||
items: [
|
||||
{
|
||||
label: t('CALLS_PAGE.FILTERS.ALL_INBOXES'),
|
||||
value: null,
|
||||
action: 'inbox',
|
||||
isSelected: !inboxId.value,
|
||||
},
|
||||
...props.inboxes.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
action: 'inbox',
|
||||
isSelected: inboxId.value === inbox.id,
|
||||
})),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedAssigneeLabel = computed(
|
||||
() =>
|
||||
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||
|
||||
const setActivity = value => {
|
||||
openMenu.value = null;
|
||||
activity.value = value;
|
||||
};
|
||||
|
||||
const setAssignee = ({ value }) => {
|
||||
openMenu.value = null;
|
||||
assigneeId.value = value;
|
||||
};
|
||||
|
||||
const applyMoreFilter = ({ action, value }) => {
|
||||
openMenu.value = null;
|
||||
if (action === 'inbox') inboxId.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||
{{
|
||||
totalCount === null
|
||||
? t('CALLS_PAGE.ALL_CALLS')
|
||||
: t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
v-else
|
||||
variant="outline"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[activity]"
|
||||
class="shrink-0"
|
||||
@click="setActivity(null)"
|
||||
>
|
||||
{{ activeChipLabel }}
|
||||
<Icon icon="i-lucide-x" />
|
||||
</Button>
|
||||
<div class="w-px h-4 bg-n-strong shrink-0" />
|
||||
<Button
|
||||
v-for="chip in inactiveChips"
|
||||
:key="chip"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[chip]"
|
||||
:label="activityLabel(chip)"
|
||||
class="shrink-0 text-n-slate-12"
|
||||
@click="setActivity(chip)"
|
||||
/>
|
||||
<OnClickOutside
|
||||
class="relative shrink-0"
|
||||
@trigger="closeOnOutside('activity')"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
class="text-n-slate-12"
|
||||
@click="toggleMenu('activity')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === 'activity'"
|
||||
:menu-items="otherActivityItems"
|
||||
class="mt-1 start-0 top-full w-44"
|
||||
@action="setActivity($event.value)"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<OnClickOutside
|
||||
v-if="showAssignee"
|
||||
class="relative"
|
||||
@trigger="closeOnOutside('assignee')"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-user-round-cog"
|
||||
class="max-w-52 text-n-slate-12"
|
||||
@click="toggleMenu('assignee')"
|
||||
>
|
||||
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === 'assignee'"
|
||||
:menu-items="assigneeItems"
|
||||
show-search
|
||||
class="mt-1 end-0 top-full w-56 max-h-72"
|
||||
@action="setAssignee"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<OnClickOutside class="relative" @trigger="closeOnOutside('more')">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list-filter"
|
||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||
@click="toggleMenu('more')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === 'more'"
|
||||
:menu-sections="moreFiltersSections"
|
||||
class="mt-1 end-0 top-full w-56 max-h-80"
|
||||
@action="applyMoreFilter"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
export const CALL_KIND = {
|
||||
ONGOING: 'ongoing',
|
||||
INCOMING: 'incoming',
|
||||
OUTGOING: 'outgoing',
|
||||
MISSED: 'missed',
|
||||
NO_REPLY: 'no_reply',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
// The API returns display values: status (ringing/in-progress/completed/
|
||||
// no-answer/failed) and direction (inbound/outbound). The list UI presents
|
||||
// them as a single "kind" per row.
|
||||
export const getCallKind = call => {
|
||||
if (
|
||||
[VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
|
||||
call.status
|
||||
)
|
||||
) {
|
||||
return CALL_KIND.ONGOING;
|
||||
}
|
||||
if (
|
||||
[VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
|
||||
) {
|
||||
return CALL_KIND.FAILED;
|
||||
}
|
||||
const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
|
||||
if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
|
||||
return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
|
||||
}
|
||||
return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
|
||||
};
|
||||
|
||||
// Filter chips map to the status/direction params supported by CallFinder.
|
||||
export const CALL_ACTIVITY_PARAMS = {
|
||||
missed: {
|
||||
status: VOICE_CALL_STATUS.NO_ANSWER,
|
||||
direction: VOICE_CALL_DIRECTION.INBOUND,
|
||||
},
|
||||
no_reply: {
|
||||
status: VOICE_CALL_STATUS.NO_ANSWER,
|
||||
direction: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
},
|
||||
incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
|
||||
outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
|
||||
in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
|
||||
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
// Find the most recent open conversation for this contact in the picked inbox.
|
||||
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
|
||||
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
|
||||
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
|
||||
// across all inboxes would be treated as having no conversation.
|
||||
const findWhatsappConversationId = async inboxId => {
|
||||
const { data } = await ContactAPI.getConversations(props.contactId, {
|
||||
inboxId,
|
||||
});
|
||||
const conversations = data?.payload || [];
|
||||
const match = [...conversations].sort(
|
||||
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
|
||||
)[0];
|
||||
return match?.id || null;
|
||||
};
|
||||
|
||||
const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
// WhatsApp /initiate is conversation-scoped, so we must hand it a
|
||||
// conversation. Use the caller's hint when given (in-conversation flow);
|
||||
// otherwise pick the most recent one in the inbox.
|
||||
const conversationId =
|
||||
conversationIdHint || (await findWhatsappConversationId(inboxId));
|
||||
if (!conversationId) {
|
||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationIdHint
|
||||
? { conversationId: conversationIdHint }
|
||||
: { contactId: props.contactId, inboxId }
|
||||
);
|
||||
// The composable returns { status: 'locked' } when an init is already in
|
||||
// flight or a call is already active; treat that as a soft no-op rather than
|
||||
// claiming success.
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
|
||||
const conversationId = response?.conversation_id || conversationIdHint;
|
||||
if (!response?.id) {
|
||||
// Permission template path returns no call id. Mirror the header button and
|
||||
// surface whether the request was just sent or is already pending instead of
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
ARTICLE_MENU_ITEMS,
|
||||
ARTICLE_MENU_OPTIONS,
|
||||
ARTICLE_STATUSES,
|
||||
getArticleStatus,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
|
||||
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
@@ -53,6 +55,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasPendingChanges: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -68,12 +74,16 @@ const emit = defineEmits([
|
||||
'articleAction',
|
||||
'toggleSelect',
|
||||
'hover',
|
||||
'draftResolved',
|
||||
'draftFailed',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
@@ -105,7 +115,18 @@ const articleMenuItems = computed(() => {
|
||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||
.map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
const draftItems = props.hasPendingChanges
|
||||
? [
|
||||
{
|
||||
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
|
||||
value: 'discard-draft',
|
||||
action: 'discard-draft',
|
||||
icon: 'i-lucide-undo-2',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [...statusItems, ...draftItems, commonItems.delete];
|
||||
});
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
@@ -153,6 +174,12 @@ const lastUpdatedAt = computed(() => {
|
||||
|
||||
const handleArticleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
// Un-publishing an article with staged edits — confirm apply/discard first;
|
||||
// the popover applies the chosen status itself.
|
||||
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
|
||||
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
|
||||
return;
|
||||
}
|
||||
emit('articleAction', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
@@ -184,6 +211,18 @@ const handleClick = id => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="hasPendingChanges"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
|
||||
)
|
||||
"
|
||||
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
@@ -204,9 +243,15 @@ const handleClick = id => {
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
class="mt-1 end-0 top-full w-40"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
<ArticlePendingChangesPopover
|
||||
ref="pendingChangesPopoverRef"
|
||||
:article-id="id"
|
||||
@resolved="emit('draftResolved', $event)"
|
||||
@failed="emit('draftFailed', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
'select-popular-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import {
|
||||
renderInlineDiff,
|
||||
buildDiffBlocks,
|
||||
} from 'dashboard/helper/articleDiffHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = defineModel({ type: Boolean, default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const liveTitle = computed(() => props.article?.title ?? '');
|
||||
const liveContent = computed(() => props.article?.content ?? '');
|
||||
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
|
||||
const draftContent = computed(
|
||||
() => props.article?.draftContent ?? liveContent.value
|
||||
);
|
||||
|
||||
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
|
||||
const titleDiff = computed(() =>
|
||||
renderInlineDiff(liveTitle.value, draftTitle.value)
|
||||
);
|
||||
|
||||
const contentBlocks = computed(() =>
|
||||
buildDiffBlocks(liveContent.value, draftContent.value)
|
||||
);
|
||||
const contentChanged = computed(() =>
|
||||
contentBlocks.value.some(block => block.type !== 'equal')
|
||||
);
|
||||
|
||||
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
|
||||
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
|
||||
// unsized columns so they don't collapse.
|
||||
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
|
||||
const DEFAULT_COL_WIDTH = 50;
|
||||
|
||||
const applyColumnWidths = (html, widths) => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const table = doc.body.querySelector('table');
|
||||
if (!table) return html;
|
||||
|
||||
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
|
||||
const colgroup = doc.createElement('colgroup');
|
||||
sized.forEach(width => {
|
||||
const col = doc.createElement('col');
|
||||
col.style.width = `${width}px`;
|
||||
colgroup.appendChild(col);
|
||||
});
|
||||
table.insertBefore(colgroup, table.firstChild);
|
||||
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
|
||||
return doc.body.innerHTML;
|
||||
};
|
||||
|
||||
const renderMarkdown = markdown => {
|
||||
if (!markdown) return '';
|
||||
const html = new MessageFormatter(markdown).formattedMessage;
|
||||
const match = markdown.match(COLWIDTHS_RE);
|
||||
return match
|
||||
? applyColumnWidths(html, match[1].split(',').map(Number))
|
||||
: html;
|
||||
};
|
||||
|
||||
const blockClass = type => {
|
||||
if (type === 'added') {
|
||||
return 'border-n-teal-9 bg-n-teal-2';
|
||||
}
|
||||
if (type === 'removed') {
|
||||
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
|
||||
}
|
||||
return 'border-transparent';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
|
||||
|
||||
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<aside
|
||||
v-if="isOpen"
|
||||
v-on-click-outside="dismissOnClickOutside"
|
||||
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="shrink-0 hover:text-n-slate-11"
|
||||
@click="close"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="titleChanged"
|
||||
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
|
||||
</span>
|
||||
<h1
|
||||
class="text-lg font-semibold leading-snug text-n-slate-12"
|
||||
v-html="titleDiff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contentChanged"
|
||||
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
|
||||
>
|
||||
<div
|
||||
v-for="(block, index) in contentBlocks"
|
||||
:key="index"
|
||||
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
|
||||
:class="blockClass(block.type)"
|
||||
v-html="renderMarkdown(block.md)"
|
||||
/>
|
||||
</div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
+65
-17
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
|
||||
@@ -9,6 +9,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
||||
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
|
||||
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
|
||||
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
@@ -38,32 +39,75 @@ const { t } = useI18n();
|
||||
|
||||
const isNewArticle = computed(() => !props.article?.id);
|
||||
|
||||
const localTitle = ref(props.article?.title ?? '');
|
||||
const localContent = ref(props.article?.content ?? '');
|
||||
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
|
||||
// falling back to the live value.
|
||||
const effectiveTitle = () =>
|
||||
props.article?.draftTitle ?? props.article?.title ?? '';
|
||||
const effectiveContent = () =>
|
||||
props.article?.draftContent ?? props.article?.content ?? '';
|
||||
|
||||
// Sync local state when navigating to a different article or on initial fetch
|
||||
const hasPendingChanges = computed(
|
||||
() => props.article?.draftTitle != null || props.article?.draftContent != null
|
||||
);
|
||||
|
||||
const localTitle = ref(effectiveTitle());
|
||||
const localContent = ref(effectiveContent());
|
||||
|
||||
const isDiffPanelOpen = ref(false);
|
||||
|
||||
// Autosave 500ms after the last edit. It sends both title and content so an
|
||||
// edit to one never drops a recent edit to the other. `stop` cancels a queued
|
||||
// save; `isPending` tells the header to wait before allowing a publish.
|
||||
const {
|
||||
isPending: isSaving,
|
||||
start: debouncedSave,
|
||||
stop: cancelSave,
|
||||
} = useTimeoutFn(
|
||||
() =>
|
||||
emit('saveArticle', {
|
||||
title: localTitle.value,
|
||||
content: localContent.value,
|
||||
}),
|
||||
500,
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const syncLocalState = () => {
|
||||
cancelSave();
|
||||
localTitle.value = effectiveTitle();
|
||||
localContent.value = effectiveContent();
|
||||
};
|
||||
|
||||
// Reseed on article switch or once a draft is published/discarded; close the
|
||||
// diff panel in the latter case since there's nothing left to compare.
|
||||
watch(
|
||||
() => props.article?.id,
|
||||
newId => {
|
||||
if (newId) {
|
||||
localTitle.value = props.article?.title ?? '';
|
||||
localContent.value = props.article?.content ?? '';
|
||||
}
|
||||
[() => props.article?.id, hasPendingChanges],
|
||||
([id, pending], [prevId, prevPending]) => {
|
||||
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
|
||||
if (prevPending && !pending) isDiffPanelOpen.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
||||
|
||||
const handleSave = value => {
|
||||
const scheduleSave = () => {
|
||||
if (isNewArticle.value) return;
|
||||
debouncedSave(value);
|
||||
debouncedSave();
|
||||
};
|
||||
|
||||
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
|
||||
onBeforeUnmount(() => {
|
||||
if (isNewArticle.value || !isSaving.value) return;
|
||||
cancelSave();
|
||||
emit('saveArticle', {
|
||||
title: localTitle.value,
|
||||
content: localContent.value,
|
||||
});
|
||||
});
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => localTitle.value,
|
||||
set: value => {
|
||||
localTitle.value = value;
|
||||
handleSave({ title: value });
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,7 +115,7 @@ const articleContent = computed({
|
||||
get: () => localContent.value,
|
||||
set: content => {
|
||||
localContent.value = content;
|
||||
handleSave({ content });
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,9 +152,13 @@ const handleCreateArticle = event => {
|
||||
:is-saved="isSaved"
|
||||
:status="article.status"
|
||||
:article-id="article.id"
|
||||
:pending-changes="hasPendingChanges"
|
||||
:is-saving="isSaving"
|
||||
@go-back="onClickGoBack"
|
||||
@preview-article="previewArticle"
|
||||
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
|
||||
/>
|
||||
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
||||
|
||||
+165
-14
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
@@ -17,6 +17,7 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isUpdating: {
|
||||
@@ -35,9 +36,17 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
pendingChanges: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goBack', 'previewArticle']);
|
||||
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -49,9 +58,30 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const showArticleActionMenu = ref(false);
|
||||
|
||||
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
|
||||
|
||||
// Per-article update flag the store already maintains.
|
||||
const articleUiFlags = useMapGetter('articles/uiFlags');
|
||||
const isUpdatingArticle = computed(
|
||||
() => articleUiFlags.value(props.articleId).isUpdating
|
||||
);
|
||||
|
||||
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
|
||||
const blockedWhileSaving = () => {
|
||||
if (!props.isSaving && !isUpdatingArticle.value) return false;
|
||||
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
|
||||
|
||||
const hasPendingChanges = computed(
|
||||
() => isPublished.value && props.pendingChanges
|
||||
);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
|
||||
return statusOptions.map(option => {
|
||||
const items = statusOptions.map(option => {
|
||||
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
|
||||
return {
|
||||
label: t(label),
|
||||
@@ -60,6 +90,17 @@ const articleMenuItems = computed(() => {
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasPendingChanges.value) {
|
||||
items.push({
|
||||
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
|
||||
value: 'discard-draft',
|
||||
action: 'discard-draft',
|
||||
icon: 'i-lucide-undo-2',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const statusText = computed(() =>
|
||||
@@ -85,8 +126,9 @@ const getStatusMessage = (status, isSuccess) => {
|
||||
: '';
|
||||
};
|
||||
|
||||
const updateArticleStatus = async ({ value }) => {
|
||||
showArticleActionMenu.value = false;
|
||||
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
|
||||
// update; omit it for a plain status change.
|
||||
const performStatusUpdate = async (value, draftAction) => {
|
||||
const status = getArticleStatus(value);
|
||||
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
isArticlePublishing.value = true;
|
||||
@@ -94,7 +136,7 @@ const updateArticleStatus = async ({ value }) => {
|
||||
const { portalSlug } = route.params;
|
||||
|
||||
try {
|
||||
await store.dispatch('articles/update', {
|
||||
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
status,
|
||||
@@ -107,12 +149,100 @@ const updateArticleStatus = async ({ value }) => {
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
isArticlePublishing.value = false;
|
||||
} catch (error) {
|
||||
useAlert(error?.message ?? getStatusMessage(status, false));
|
||||
} finally {
|
||||
isArticlePublishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateArticleStatus = ({ value }) => {
|
||||
showArticleActionMenu.value = false;
|
||||
// Leaving published with unsaved draft edits — ask whether to apply or discard
|
||||
// first; the popover applies the status itself once resolved.
|
||||
if (hasPendingChanges.value) {
|
||||
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
|
||||
return;
|
||||
}
|
||||
performStatusUpdate(value);
|
||||
};
|
||||
|
||||
const publishDraftChanges = async () => {
|
||||
isArticlePublishing.value = true;
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
await store.dispatch('articles/publishDraft', {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ??
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||
);
|
||||
} finally {
|
||||
isArticlePublishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const discardDraftChanges = async () => {
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
await store.dispatch('articles/discardDraft', {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ??
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPrimaryAction = () => {
|
||||
if (blockedWhileSaving()) return;
|
||||
if (hasPendingChanges.value) {
|
||||
publishDraftChanges();
|
||||
} else if (props.pendingChanges) {
|
||||
// Promote leftover draft edits on publish instead of republishing stale content.
|
||||
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
|
||||
} else {
|
||||
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = event => {
|
||||
showArticleActionMenu.value = false;
|
||||
// Don't resolve a draft while an autosave is still in flight — it could land
|
||||
// after and recreate the draft we just discarded/applied.
|
||||
if (blockedWhileSaving()) return;
|
||||
if (event.action === 'discard-draft') {
|
||||
discardDraftChanges();
|
||||
} else {
|
||||
updateArticleStatus(event);
|
||||
}
|
||||
};
|
||||
|
||||
// The popover applies the draft + status itself; we just surface the outcome.
|
||||
const onDraftResolved = status => {
|
||||
useAlert(getStatusMessage(status, true));
|
||||
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
|
||||
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
};
|
||||
|
||||
const onDraftFailed = error => {
|
||||
useAlert(
|
||||
error?.message ??
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -127,13 +257,24 @@ const updateArticleStatus = async ({ value }) => {
|
||||
@click="onClickGoBack"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
v-if="hasPendingChanges"
|
||||
type="button"
|
||||
data-diff-toggle
|
||||
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
|
||||
@click="emit('showDiff')"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
|
||||
</button>
|
||||
<span
|
||||
v-if="isUpdating || isSaved"
|
||||
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
|
||||
>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
|
||||
color="slate"
|
||||
@@ -143,17 +284,21 @@ const updateArticleStatus = async ({ value }) => {
|
||||
/>
|
||||
<ButtonGroup class="flex items-center">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||
:label="
|
||||
hasPendingChanges
|
||||
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
|
||||
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
|
||||
"
|
||||
size="sm"
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||
no-animation
|
||||
:is-loading="isArticlePublishing"
|
||||
:disabled="
|
||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||
!articleId ||
|
||||
isArticlePublishing
|
||||
isArticlePublishing ||
|
||||
(isPublished && !hasPendingChanges)
|
||||
"
|
||||
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
|
||||
@click="onPrimaryAction"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="showArticleActionMenu = false">
|
||||
@@ -169,11 +314,17 @@ const updateArticleStatus = async ({ value }) => {
|
||||
v-if="showArticleActionMenu"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-2 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="updateArticleStatus($event)"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
<ArticlePendingChangesPopover
|
||||
ref="pendingChangesPopoverRef"
|
||||
:article-id="articleId"
|
||||
@resolved="onDraftResolved"
|
||||
@failed="onDraftFailed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articleId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['resolved', 'failed']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const requestedStatus = ref(null);
|
||||
// Which button is in flight, so only that one shows the spinner.
|
||||
const activeAction = ref(null);
|
||||
|
||||
const articleUiFlags = useMapGetter('articles/uiFlags');
|
||||
const isLoading = computed(
|
||||
() => articleUiFlags.value(props.articleId).isUpdating
|
||||
);
|
||||
|
||||
// Open the confirmation for a target status; resolving it also applies that status.
|
||||
const open = status => {
|
||||
requestedStatus.value = status;
|
||||
activeAction.value = null;
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Don't let a click-outside or Escape dismiss the popover mid-action.
|
||||
const dismiss = () => {
|
||||
if (!isLoading.value) close();
|
||||
};
|
||||
|
||||
const resolve = async draftAction => {
|
||||
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
|
||||
try {
|
||||
await store.dispatch(`articles/${draftAction}`, {
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleId: props.articleId,
|
||||
status: requestedStatus.value,
|
||||
});
|
||||
emit('resolved', requestedStatus.value);
|
||||
close();
|
||||
} catch (error) {
|
||||
emit('failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onApply = () => resolve('publishDraft');
|
||||
const onDiscard = () => resolve('discardDraft');
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (isOpen.value) dismiss();
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="isOpen"
|
||||
v-on-click-outside="dismiss"
|
||||
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="shrink-0 -me-1 -mt-1"
|
||||
:disabled="isLoading"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:is-loading="isLoading && activeAction === 'discard'"
|
||||
:disabled="isLoading"
|
||||
:label="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
|
||||
"
|
||||
@click="onDiscard"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
color="blue"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:is-loading="isLoading && activeAction === 'apply'"
|
||||
:disabled="isLoading"
|
||||
:label="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
|
||||
"
|
||||
@click="onApply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+45
-9
@@ -5,8 +5,12 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import {
|
||||
getArticleStatus,
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||
@@ -145,6 +149,24 @@ const updateArticlesMeta = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const refreshArticleMeta = async () => {
|
||||
await updateArticlesMeta();
|
||||
await updatePortalMeta();
|
||||
};
|
||||
|
||||
// The card's pending-changes popover applies the status itself; surface the result.
|
||||
const onDraftResolved = status => {
|
||||
useAlert(getStatusMessage(status, true));
|
||||
refreshArticleMeta();
|
||||
};
|
||||
|
||||
const onDraftFailed = error => {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||
);
|
||||
};
|
||||
|
||||
const handleArticleAction = async (action, { status, id }) => {
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
@@ -154,6 +176,14 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
articleId: id,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
|
||||
} else if (action === 'discard-draft') {
|
||||
await store.dispatch('articles/discardDraft', {
|
||||
portalSlug,
|
||||
articleId: id,
|
||||
});
|
||||
useAlert(
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
|
||||
);
|
||||
} else {
|
||||
await store.dispatch('articles/update', {
|
||||
portalSlug,
|
||||
@@ -168,15 +198,16 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
}
|
||||
await updateArticlesMeta();
|
||||
await updatePortalMeta();
|
||||
await refreshArticleMeta();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
(action === 'delete'
|
||||
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
|
||||
: getStatusMessage(status, false));
|
||||
useAlert(errorMessage);
|
||||
const fallbackMessage =
|
||||
{
|
||||
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
|
||||
'discard-draft': t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
|
||||
),
|
||||
}[action] ?? getStatusMessage(status, false);
|
||||
useAlert(error?.message || fallbackMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,10 +241,15 @@ const updateArticle = ({ action, value, id }) => {
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(item.id)"
|
||||
:has-pending-changes="
|
||||
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
|
||||
"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@draft-resolved="onDraftResolved"
|
||||
@draft-failed="onDraftFailed"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||
/>
|
||||
|
||||
+44
-7
@@ -3,10 +3,15 @@ import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import {
|
||||
ARTICLE_TABS,
|
||||
CATEGORY_ALL,
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
@@ -60,6 +65,7 @@ const emit = defineEmits([
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
@@ -227,15 +233,46 @@ const onBulkActionSuccess = message => {
|
||||
};
|
||||
|
||||
const bulkUpdateStatus = async status => {
|
||||
const selectedIds = [...selectedArticleIds.value];
|
||||
const { portalSlug } = route.params;
|
||||
|
||||
const pendingIds = props.articles
|
||||
.filter(
|
||||
article =>
|
||||
selectedIds.includes(article.id) &&
|
||||
article.status === ARTICLE_STATUSES.PUBLISHED &&
|
||||
hasPendingChanges(article)
|
||||
)
|
||||
.map(article => article.id);
|
||||
|
||||
// Publish promotes each pending draft; other status changes skip them.
|
||||
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
|
||||
const draftIds = isPublishing ? pendingIds : [];
|
||||
const skippedCount = isPublishing ? 0 : pendingIds.length;
|
||||
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
|
||||
|
||||
if (!articleIds.length && !draftIds.length) {
|
||||
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await articlesAPI.bulkUpdateStatus({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
status,
|
||||
});
|
||||
if (articleIds.length) {
|
||||
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
|
||||
}
|
||||
await Promise.all(
|
||||
draftIds.map(articleId =>
|
||||
store.dispatch('articles/publishDraft', { portalSlug, articleId })
|
||||
)
|
||||
);
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||
);
|
||||
if (skippedCount) {
|
||||
useAlert(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -26,6 +27,7 @@ const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
const popularContentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'select-popular-content') {
|
||||
popularContentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_CATEGORIES = 3;
|
||||
const MAX_ARTICLES = 6;
|
||||
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
// One flag per picker so each shows its skeleton until its own data loads.
|
||||
const categoriesLoading = ref(false);
|
||||
const { run: runArticleRequest, isPending: articlesLoading } =
|
||||
useAbortableRequest();
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
const selectedCategoryIds = ref([]);
|
||||
|
||||
// Articles are searched server-side, so cache options for selected items that
|
||||
// fall outside the current results.
|
||||
const articleResults = ref([]);
|
||||
const articleOptionById = ref({});
|
||||
const selectedArticleIds = ref([]);
|
||||
|
||||
const popularContent = computed(
|
||||
() => props.portal?.config?.popular_content || {}
|
||||
);
|
||||
|
||||
const articleOptions = computed(() => {
|
||||
const options = new Map();
|
||||
selectedArticleIds.value.forEach(id => {
|
||||
if (articleOptionById.value[id])
|
||||
options.set(id, articleOptionById.value[id]);
|
||||
});
|
||||
articleResults.value.forEach(option => options.set(option.value, option));
|
||||
return [...options.values()];
|
||||
});
|
||||
|
||||
const toArticleOption = article => ({
|
||||
value: article.id,
|
||||
label: article.title,
|
||||
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
|
||||
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchCategories = async localeCode => {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await categoriesAPI.get({
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: localeCode,
|
||||
});
|
||||
return payload.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
|
||||
count: category.meta?.articles_count || 0,
|
||||
}),
|
||||
icon: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}));
|
||||
};
|
||||
|
||||
const requestArticles = async (query, signal) => {
|
||||
const { data } = await articlesAPI.getArticles({
|
||||
pageNumber: 1,
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: activeLocale.value,
|
||||
status: 'published',
|
||||
query,
|
||||
signal,
|
||||
});
|
||||
articleResults.value = data.payload.map(article => {
|
||||
const option = toArticleOption(article);
|
||||
articleOptionById.value[article.id] = option;
|
||||
return option;
|
||||
});
|
||||
};
|
||||
|
||||
const searchArticles = (query = '') =>
|
||||
runArticleRequest(signal => requestArticles(query, signal));
|
||||
|
||||
const onArticleSearch = useDebounceFn(searchArticles, 300);
|
||||
|
||||
// Resolve options for pre-selected articles that aren't in the current results.
|
||||
const cacheSelectedArticleOptions = async () => {
|
||||
const unknownIds = selectedArticleIds.value.filter(
|
||||
id => !articleOptionById.value[id]
|
||||
);
|
||||
await Promise.all(
|
||||
unknownIds.map(async id => {
|
||||
try {
|
||||
const { data } = await articlesAPI.getArticle({
|
||||
id,
|
||||
portalSlug: props.portal?.slug,
|
||||
});
|
||||
articleOptionById.value[id] = toArticleOption(data.payload);
|
||||
} catch {
|
||||
// Deleted since it was picked; leave it for the id fallback.
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const loadCategories = async localeCode => {
|
||||
categoriesLoading.value = true;
|
||||
try {
|
||||
const options = await fetchCategories(localeCode);
|
||||
// Reopened for another locale mid-flight; drop the stale response.
|
||||
if (localeCode !== activeLocale.value) return;
|
||||
categoryOptions.value = options;
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
} finally {
|
||||
if (localeCode === activeLocale.value) categoriesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
try {
|
||||
await runArticleRequest(signal =>
|
||||
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = popularContent.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
selectedCategoryIds.value = [...(existing.category_ids || [])];
|
||||
selectedArticleIds.value = [...(existing.article_ids || [])];
|
||||
categoryOptions.value = [];
|
||||
articleResults.value = [];
|
||||
articleOptionById.value = {};
|
||||
dialogRef.value?.open();
|
||||
|
||||
loadCategories(localeCode);
|
||||
loadArticles();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const updated = { ...popularContent.value };
|
||||
const entry = {
|
||||
category_ids: selectedCategoryIds.value,
|
||||
article_ids: selectedArticleIds.value,
|
||||
};
|
||||
|
||||
if (entry.category_ids.length || entry.article_ids.length) {
|
||||
updated[activeLocale.value] = entry;
|
||||
} else {
|
||||
delete updated[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { popular_content: updated },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t(`${KEY}.TITLE`)"
|
||||
:description="t(`${KEY}.DESCRIPTION`)"
|
||||
:confirm-button-label="t(`${KEY}.CONFIRM`)"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedCategoryIds"
|
||||
:options="categoryOptions"
|
||||
:max="MAX_CATEGORIES"
|
||||
:label="t(`${KEY}.CATEGORIES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
fallback-icon="i-lucide-folder"
|
||||
:loading="categoriesLoading"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedArticleIds"
|
||||
server-search
|
||||
:options="articleOptions"
|
||||
:max="MAX_ARTICLES"
|
||||
:label="t(`${KEY}.ARTICLES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
:loading="articlesLoading"
|
||||
@search="onArticleSearch"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
:close-on-scroll="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fallbackDuration: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const PLAYBACK_SPEEDS = [1, 1.5, 2];
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(props.fallbackDuration);
|
||||
const playbackSpeed = ref(1);
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const loadedDuration = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
|
||||
};
|
||||
|
||||
const formatTime = time => {
|
||||
if (!time || Number.isNaN(time)) return '00:00';
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
|
||||
|
||||
const displayedTime = computed(() =>
|
||||
formatTime(
|
||||
isPlaying.value || currentTime.value ? currentTime.value : duration.value
|
||||
)
|
||||
);
|
||||
|
||||
// Only one recording should play at a time across the list.
|
||||
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||
if (currentPlayingId !== uid && isPlaying.value) {
|
||||
audioPlayer.value?.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const playOrPause = () => {
|
||||
if (isPlaying.value) {
|
||||
audioPlayer.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
emitter.emit('pause_playing_audio', uid);
|
||||
audioPlayer.value.play();
|
||||
isPlaying.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
currentTime.value = audioPlayer.value?.currentTime;
|
||||
};
|
||||
|
||||
const seek = event => {
|
||||
const time = Number(event.target.value);
|
||||
audioPlayer.value.currentTime = time;
|
||||
currentTime.value = time;
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
};
|
||||
|
||||
const changePlaybackSpeed = () => {
|
||||
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
|
||||
playbackSpeed.value =
|
||||
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
};
|
||||
|
||||
const downloadRecording = () => {
|
||||
downloadFile({ url: props.src, type: 'audio' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-8 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="src" />
|
||||
</audio>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-12"
|
||||
@click="playOrPause"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:label="playbackSpeedLabel"
|
||||
class="!px-1 min-w-6 !text-n-slate-11"
|
||||
@click="changePlaybackSpeed"
|
||||
/>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-11"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ const props = defineProps({
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -61,6 +62,7 @@ const handleClick = () => {
|
||||
:icon="icon"
|
||||
:trailing-icon="trailingIcon"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
@click="handleClick"
|
||||
@blur="resetConfirmMode"
|
||||
>
|
||||
|
||||
+6
-1
@@ -9,6 +9,7 @@ const props = defineProps({
|
||||
// null = neutral, true = good direction, false = bad direction
|
||||
trendGood: { type: Boolean, default: null },
|
||||
clickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -45,7 +46,11 @@ const onActivate = () => {
|
||||
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<div v-if="loading" class="flex items-end justify-between gap-2">
|
||||
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
|
||||
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<div v-else class="flex items-end justify-between gap-2">
|
||||
<span
|
||||
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
|
||||
>
|
||||
|
||||
+28
-5
@@ -9,6 +9,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '30',
|
||||
},
|
||||
stats: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
|
||||
const welcomeMarkdown = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Increments on every fetch so a slow response for a superseded
|
||||
// range/stats/assistant can't overwrite the latest request's state.
|
||||
let fetchToken = 0;
|
||||
|
||||
const fetchSummary = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
|
||||
if (!props.stats) {
|
||||
welcomeMarkdown.value = '';
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
let message = '';
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getSummary({
|
||||
assistantId: assistantId.value,
|
||||
range: props.range,
|
||||
stats: props.stats,
|
||||
});
|
||||
welcomeMarkdown.value = data.message ?? '';
|
||||
message = data.message ?? '';
|
||||
} catch {
|
||||
welcomeMarkdown.value = '';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
message = '';
|
||||
}
|
||||
|
||||
if (token !== fetchToken) return;
|
||||
welcomeMarkdown.value = message;
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
|
||||
watch([() => props.range, () => props.stats, 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
|
||||
|
||||
+5
-2
@@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
||||
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||
@@ -114,7 +114,10 @@ const openCreateAssistantDialog = () => {
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<div
|
||||
v-if="assistants.length > 0"
|
||||
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<Button
|
||||
v-for="assistant in assistants"
|
||||
:key="assistant.id"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -27,6 +29,10 @@ const props = defineProps({
|
||||
type: [String, Number, Array],
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'search']);
|
||||
@@ -63,13 +69,22 @@ defineExpose({
|
||||
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
|
||||
>
|
||||
<div class="relative border-b border-n-strong">
|
||||
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
|
||||
<Spinner
|
||||
v-if="loading"
|
||||
:size="16"
|
||||
class="absolute top-2.5 start-3 text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2.5 size-4 start-3"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
|
||||
class="reset-base w-full py-2 !ps-10 !pe-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
|
||||
@input="onInputSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
// Drag-reorderable multi-select capped at `max`. The model is the ordered list
|
||||
// of selected values.
|
||||
const props = defineProps({
|
||||
// { value, label, subtitle?, icon?, iconColor? } — icon is an emoji or an
|
||||
// icon-picker value, and falls back to `fallbackIcon`.
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
fallbackIcon: {
|
||||
type: String,
|
||||
default: 'i-lucide-file-text',
|
||||
},
|
||||
// Show skeleton rows while options load, so the row height stays stable.
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Let the parent filter `options` (via the `search` event) instead of locally.
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
const selectedIds = defineModel({ type: Array, default: () => [] });
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const dragIndex = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const optionsByValue = computed(
|
||||
() => new Map(props.options.map(option => [option.value, option]))
|
||||
);
|
||||
|
||||
const selectedRows = computed(() =>
|
||||
selectedIds.value.map(
|
||||
id => optionsByValue.value.get(id) || { value: id, label: String(id) }
|
||||
)
|
||||
);
|
||||
|
||||
const remaining = computed(() => props.max - selectedIds.value.length);
|
||||
|
||||
const canAddMore = computed(() => selectedIds.value.length < props.max);
|
||||
|
||||
const dropdownOptions = computed(() => {
|
||||
const available = props.options.filter(
|
||||
option => !selectedIds.value.includes(option.value)
|
||||
);
|
||||
if (props.serverSearch) return available;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return available.filter(option =>
|
||||
option.label?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const onSearch = value => {
|
||||
searchQuery.value = value;
|
||||
emit('search', value);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value) {
|
||||
onSearch('');
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = option => {
|
||||
if (!canAddMore.value) return;
|
||||
selectedIds.value = [...selectedIds.value, option.value];
|
||||
if (!canAddMore.value) isOpen.value = false;
|
||||
};
|
||||
|
||||
const removeItem = id => {
|
||||
selectedIds.value = selectedIds.value.filter(value => value !== id);
|
||||
};
|
||||
|
||||
const onDragStart = index => {
|
||||
dragIndex.value = index;
|
||||
};
|
||||
|
||||
const onDragOver = index => {
|
||||
if (dragIndex.value === null || dragIndex.value === index) return;
|
||||
const ids = [...selectedIds.value];
|
||||
const [moved] = ids.splice(dragIndex.value, 1);
|
||||
ids.splice(index, 0, moved);
|
||||
dragIndex.value = index;
|
||||
selectedIds.value = ids;
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragIndex.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="label || $slots.counter"
|
||||
class="flex items-center justify-between mb-1.5"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-12">{{ label }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
<slot name="counter" :remaining="remaining" :max="max" />
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-for="slot in max"
|
||||
:key="slot"
|
||||
class="w-4 h-1 rounded-full"
|
||||
:class="slot <= selectedIds.length ? 'bg-n-brand' : 'bg-n-slate-4'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<div
|
||||
class="flex flex-col gap-1 p-1 border rounded-xl border-n-weak bg-n-background"
|
||||
:class="{ 'opacity-50 pointer-events-none': disabled }"
|
||||
>
|
||||
<div
|
||||
v-if="loading && selectedIds.length && !isOpen"
|
||||
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-for="n in selectedIds.length"
|
||||
:key="n"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-n-alpha-2"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 opacity-40 i-lucide-grip-vertical size-4 text-n-slate-9"
|
||||
/>
|
||||
<div class="flex-shrink-0 rounded-md size-6 bg-n-alpha-3" />
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="mb-0 text-sm">
|
||||
<span
|
||||
class="inline-block w-32 h-2.5 align-middle rounded bg-n-alpha-3 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
<p class="mb-0 text-xs">
|
||||
<span
|
||||
class="inline-block w-20 h-2 align-middle rounded bg-n-alpha-3 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<span class="flex-shrink-0 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="selectedRows.length"
|
||||
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in selectedRows"
|
||||
:key="row.value"
|
||||
draggable="true"
|
||||
class="flex items-center gap-2 px-2 py-1.5 transition-colors rounded-lg cursor-grab group/row"
|
||||
:class="
|
||||
index === dragIndex
|
||||
? 'opacity-40 bg-n-alpha-3 ring-1 ring-inset ring-n-brand'
|
||||
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
|
||||
"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 transition-colors i-lucide-grip-vertical size-4 text-n-slate-9 group-hover/row:text-n-slate-11"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-md size-6 bg-n-alpha-3 text-n-slate-11"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="row.icon"
|
||||
:value="row.icon"
|
||||
:color="row.iconColor"
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<span v-else class="size-4" :class="fallbackIcon" />
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="mb-0 text-sm truncate text-n-slate-12">
|
||||
{{ row.label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="row.subtitle"
|
||||
class="mb-0 text-xs truncate text-n-slate-10"
|
||||
>
|
||||
{{ row.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
no-animation
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click="removeItem(row.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canAddMore" class="relative">
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
justify="start"
|
||||
:label="addLabel"
|
||||
:disabled="loading && !isOpen"
|
||||
class="w-full"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template #icon>
|
||||
<Spinner
|
||||
v-if="loading && !isOpen"
|
||||
:size="16"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-search"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<ComboBoxDropdown
|
||||
ref="dropdownRef"
|
||||
:open="isOpen"
|
||||
:options="dropdownOptions"
|
||||
:search-value="searchQuery"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-state="emptyState"
|
||||
:loading="loading"
|
||||
@update:search-value="onSearch"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OnClickOutside>
|
||||
|
||||
<p
|
||||
v-if="selectedIds.length && $slots.note"
|
||||
class="flex items-center gap-1.5 mt-1.5 mb-0 text-xs text-n-slate-11"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-teal-9" />
|
||||
<slot name="note" />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Getting started', subtitle: 'Guides' },
|
||||
{ value: 2, label: 'Billing', subtitle: 'Payments' },
|
||||
{ value: 3, label: 'Security' },
|
||||
{ value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
|
||||
];
|
||||
|
||||
// A findable dropdown stub that exposes the `focus()` the component calls on open.
|
||||
const ComboBoxDropdownStub = {
|
||||
name: 'ComboBoxDropdown',
|
||||
props: [
|
||||
'open',
|
||||
'options',
|
||||
'searchValue',
|
||||
'searchPlaceholder',
|
||||
'emptyState',
|
||||
'loading',
|
||||
],
|
||||
emits: ['select', 'update:searchValue'],
|
||||
methods: { focus() {} },
|
||||
template: '<div class="combo-dropdown" />',
|
||||
};
|
||||
|
||||
// Renders a real <button> so clicks reach the parent handlers; `data-icon`
|
||||
// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
props: ['label', 'icon', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" />{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountSelect = (props = {}, slots = {}) =>
|
||||
mount(ReorderableMultiSelect, {
|
||||
props: { options: OPTIONS, max: 3, ...props },
|
||||
slots,
|
||||
global: {
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
ComboBoxDropdown: ComboBoxDropdownStub,
|
||||
Spinner: true,
|
||||
Icon: true,
|
||||
EmojiIcon: true,
|
||||
OnClickOutside: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
|
||||
const addTrigger = wrapper =>
|
||||
wrapper.findAll('button').find(button => !button.attributes('data-icon'));
|
||||
const removeButtons = wrapper =>
|
||||
wrapper.findAll('button[data-icon="i-lucide-x"]');
|
||||
const rows = wrapper => wrapper.findAll('[draggable="true"]');
|
||||
const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
|
||||
|
||||
describe('ReorderableMultiSelect', () => {
|
||||
describe('rendering selected rows', () => {
|
||||
it('renders rows in model order with labels resolved from options', () => {
|
||||
const wrapper = mountSelect({ modelValue: [2, 1] });
|
||||
|
||||
const labels = rows(wrapper).map(row => row.find('p').text());
|
||||
expect(labels).toEqual(['Billing', 'Getting started']);
|
||||
});
|
||||
|
||||
it('falls back to the stringified id when an option is unknown', () => {
|
||||
const wrapper = mountSelect({ modelValue: [99] });
|
||||
|
||||
expect(rows(wrapper)[0].find('p').text()).toBe('99');
|
||||
});
|
||||
|
||||
it('renders the progress dots filled up to the selection count', () => {
|
||||
const wrapper = mountSelect({
|
||||
modelValue: [1, 2],
|
||||
max: 3,
|
||||
label: 'Tags',
|
||||
});
|
||||
|
||||
const filled = wrapper.findAll('.bg-n-brand').length;
|
||||
expect(filled).toBe(2);
|
||||
});
|
||||
|
||||
it('exposes remaining and max to the counter slot', () => {
|
||||
const wrapper = mountSelect(
|
||||
{ modelValue: [1], max: 3 },
|
||||
{ counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toContain('2/3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding options', () => {
|
||||
it('appends the chosen option to the model', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('hides the add trigger once the model reaches max', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
|
||||
|
||||
expect(addTrigger(wrapper)).toBeUndefined();
|
||||
expect(dropdown(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the dropdown when the last slot is filled', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1], max: 2 });
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
expect(dropdown(wrapper).props('open')).toBe(true);
|
||||
|
||||
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Reaching max removes the trigger (and its dropdown) entirely.
|
||||
expect(dropdown(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes already-selected options from the dropdown', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
const values = dropdown(wrapper)
|
||||
.props('options')
|
||||
.map(option => option.value);
|
||||
expect(values).toEqual([2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing options', () => {
|
||||
it('removes the clicked item from the model', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
|
||||
|
||||
await removeButtons(wrapper)[1].trigger('click');
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searching', () => {
|
||||
it('filters options locally by label', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [] });
|
||||
|
||||
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const values = dropdown(wrapper)
|
||||
.props('options')
|
||||
.map(option => option.value);
|
||||
expect(values).toEqual([2]);
|
||||
});
|
||||
|
||||
it('emits search and skips local filtering when serverSearch is set', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [], serverSearch: true });
|
||||
|
||||
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
|
||||
// All unselected options remain; the parent owns filtering.
|
||||
expect(dropdown(wrapper).props('options')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('emits an empty search when the trigger opens', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
|
||||
expect(wrapper.emitted('search').at(-1)).toEqual(['']);
|
||||
expect(dropdown(wrapper).props('open')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reordering', () => {
|
||||
it('moves a row to the dropped position within the model', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
|
||||
|
||||
await rows(wrapper)[0].trigger('dragstart');
|
||||
await rows(wrapper)[2].trigger('dragover');
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([2, 3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton rows when loading a non-empty, closed selection', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
|
||||
|
||||
const skeleton = wrapper.find('[aria-busy="true"]');
|
||||
expect(skeleton.exists()).toBe(true);
|
||||
expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not show skeletons when the selection is empty', () => {
|
||||
const wrapper = mountSelect({ modelValue: [], loading: true });
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
|
||||
// Open first (trigger is enabled), then a live search turns loading on.
|
||||
const wrapper = mountSelect({ modelValue: [1, 2] });
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
|
||||
await wrapper.setProps({ loading: true });
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
|
||||
expect(rows(wrapper)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwards loading to the dropdown and disables the closed trigger', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1], loading: true });
|
||||
|
||||
expect(dropdown(wrapper).props('loading')).toBe(true);
|
||||
expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-52
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
import {
|
||||
isTwilioComplete,
|
||||
isTwilioMediaTemplate,
|
||||
getTwilioMediaVariableKey,
|
||||
getTwilioMediaUrl,
|
||||
applyTwilioMediaFilename,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
// Media-template detection and variable extraction are shared with the mobile
|
||||
// app via @chatwoot/utils.
|
||||
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
const mediaVariableKey = computed(() =>
|
||||
getTwilioMediaVariableKey(props.template)
|
||||
);
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
const templateMediaUrl = computed(() =>
|
||||
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
|
||||
);
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// Completeness validation is shared with the mobile app via @chatwoot/utils.
|
||||
const isFormInvalid = computed(
|
||||
() => !isTwilioComplete(props.template, processedParams.value)
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
@@ -135,19 +117,11 @@ const sendMessage = () => {
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
// For media templates, reduce the media URL to a filename before sending.
|
||||
const processedParameters = applyTwilioMediaFilename(
|
||||
props.template,
|
||||
processedParams.value
|
||||
);
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useMessageContext } from './provider.js';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { orientation, variant, createdAt } = useMessageContext();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const showSparkle = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
|
||||
const session = computed(() =>
|
||||
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
|
||||
);
|
||||
const hasFetched = computed(() =>
|
||||
store.getters['captainAgentSessions/hasFetched'](props.messageId)
|
||||
);
|
||||
const isLoading = computed(
|
||||
() =>
|
||||
!hasFetched.value ||
|
||||
store.getters['captainAgentSessions/isFetching'](props.messageId)
|
||||
);
|
||||
|
||||
const citations = computed(() => session.value?.citations || []);
|
||||
|
||||
const scenarioTitles = computed(() =>
|
||||
(session.value?.scenarios || []).reduce((map, scenario) => {
|
||||
map[scenario.id] = scenario.title;
|
||||
return map;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// Fallback for agents without a matching scenario title:
|
||||
// "chatwoot_assistant" → "Chatwoot assistant",
|
||||
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
|
||||
const humanizeAgentName = agentName => {
|
||||
const label = agentName
|
||||
.replace(/^scenario_\d+_/, '')
|
||||
.replace(/_agent$/, '')
|
||||
.replaceAll('_', ' ')
|
||||
.trim();
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
const handoffLabel = agentName => {
|
||||
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
|
||||
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
|
||||
};
|
||||
|
||||
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
|
||||
|
||||
// Tool names arrive as RubyLLM identifiers like
|
||||
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
|
||||
// show "FAQ Lookup" / "Get Status Page Overview" instead.
|
||||
const humanizeToolName = name => {
|
||||
return (name || '')
|
||||
.split('--')
|
||||
.pop()
|
||||
.replace(/^custom_/, '')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(word =>
|
||||
ACRONYMS.includes(word)
|
||||
? word.toUpperCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1)
|
||||
)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
|
||||
const humanizeArgumentKey = key =>
|
||||
key
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const formatArguments = args => {
|
||||
if (!args || typeof args !== 'object') return '';
|
||||
return Object.entries(args)
|
||||
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// Timeline of what Captain did during the run: tool calls (with their
|
||||
// arguments) and scenario/agent handoffs. Message bodies and raw tool
|
||||
// results are intentionally not echoed here.
|
||||
const steps = computed(() => {
|
||||
const runContext = session.value?.runContext;
|
||||
const result = [];
|
||||
let currentAgent = null;
|
||||
|
||||
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
|
||||
if (entry?.role !== 'assistant') return;
|
||||
|
||||
const agentName = entry.agentName;
|
||||
if (agentName && agentName !== currentAgent) {
|
||||
if (currentAgent !== null) {
|
||||
result.push({ type: 'handoff', name: handoffLabel(agentName) });
|
||||
}
|
||||
currentAgent = agentName;
|
||||
}
|
||||
|
||||
(entry.toolCalls || []).forEach(call => {
|
||||
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
|
||||
// the agent_name change above already yields a handoff step for them.
|
||||
if (call.name?.startsWith('handoff_to_')) return;
|
||||
|
||||
result.push({
|
||||
type: 'tool',
|
||||
name: humanizeToolName(call.name),
|
||||
detail: formatArguments(call.arguments),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// The final assistant entry stores structured content ({response, reasoning});
|
||||
// surface the model's reasoning for the reply it produced.
|
||||
const reasoning = computed(() => {
|
||||
const runContext = session.value?.runContext;
|
||||
if (!Array.isArray(runContext)) return '';
|
||||
|
||||
const entry = [...runContext]
|
||||
.reverse()
|
||||
.find(item => item?.role === 'assistant' && item.content?.reasoning);
|
||||
return entry?.content?.reasoning || '';
|
||||
});
|
||||
|
||||
const STEP_ICONS = {
|
||||
tool: 'i-ph-wrench',
|
||||
handoff: 'i-ph-user-switch',
|
||||
};
|
||||
|
||||
const STEP_KEYPATHS = {
|
||||
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
|
||||
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
|
||||
};
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
|
||||
|
||||
// Model and credits are only surfaced to super admins and in development.
|
||||
const devDetails = computed(() => {
|
||||
if (!session.value) return null;
|
||||
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
|
||||
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
|
||||
model: session.value.llmModel,
|
||||
});
|
||||
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
|
||||
credits: session.value.creditsConsumed,
|
||||
});
|
||||
return `${model} · ${credits}`;
|
||||
});
|
||||
|
||||
// With the sparkle at the row start, the meta gets pushed to the opposite end;
|
||||
// without it, fall back to the message orientation.
|
||||
const rowLayoutClass = computed(() => {
|
||||
if (showSparkle.value) return 'justify-between';
|
||||
return orientation.value === ORIENTATION.LEFT
|
||||
? 'justify-start'
|
||||
: 'justify-end';
|
||||
});
|
||||
|
||||
// Blend the sparkle with the bubble background: amber on private notes,
|
||||
// slate everywhere else. Tokens adapt to dark mode on their own.
|
||||
const sparkleColorClass = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
|
||||
return isOpen.value
|
||||
? 'text-n-amber-12/80'
|
||||
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
|
||||
}
|
||||
return isOpen.value
|
||||
? 'text-n-slate-12'
|
||||
: 'text-n-slate-11/60 hover:text-n-slate-12';
|
||||
});
|
||||
|
||||
const popoverAlign = computed(() =>
|
||||
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
|
||||
);
|
||||
|
||||
const prefetch = () => {
|
||||
store.dispatch('captainAgentSessions/fetch', {
|
||||
messageId: props.messageId,
|
||||
createdAt: createdAt.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onPopoverShow = () => {
|
||||
isOpen.value = true;
|
||||
prefetch();
|
||||
};
|
||||
|
||||
const onPopoverHide = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
|
||||
<Popover
|
||||
v-if="showSparkle"
|
||||
:align="popoverAlign"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<button
|
||||
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
|
||||
:class="sparkleColorClass"
|
||||
@mouseenter="prefetch"
|
||||
@focus="prefetch"
|
||||
>
|
||||
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
|
||||
<span class="text-xs">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
|
||||
</span>
|
||||
</button>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4 p-4 w-80">
|
||||
<span v-if="isLoading" class="text-xs text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
|
||||
</span>
|
||||
<span v-else-if="!session" class="text-xs text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
|
||||
</span>
|
||||
<template v-else>
|
||||
<div v-if="steps.length" class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex gap-2.5"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="flex-1 w-px min-h-2 bg-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col min-w-0 gap-0.5"
|
||||
:class="index < steps.length - 1 ? 'pb-3' : ''"
|
||||
>
|
||||
<I18nT
|
||||
:keypath="STEP_KEYPATHS[step.type]"
|
||||
tag="span"
|
||||
class="text-xs leading-5 text-n-slate-11"
|
||||
>
|
||||
<template #name>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{ step.name }}
|
||||
</span>
|
||||
</template>
|
||||
</I18nT>
|
||||
<span
|
||||
v-if="step.detail"
|
||||
class="text-xs text-n-slate-11 break-words"
|
||||
>
|
||||
{{ step.detail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="citations.length" class="flex flex-col gap-2">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
t(
|
||||
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
|
||||
citations.length
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
|
||||
<li
|
||||
v-for="citation in citations"
|
||||
:key="citation.id"
|
||||
class="text-xs text-n-slate-12"
|
||||
>
|
||||
<a
|
||||
v-if="citation.link"
|
||||
:href="citation.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ citation.title || citation.link }}
|
||||
</a>
|
||||
<span v-else>{{ citation.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="reasoning" class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
|
||||
</span>
|
||||
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
|
||||
{{ reasoning }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="devDetails" class="text-xs text-n-slate-11">
|
||||
{{ devDetails }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MessageMeta from '../MessageMeta.vue';
|
||||
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
|
||||
|
||||
const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const {
|
||||
variant,
|
||||
orientation,
|
||||
inReplyTo,
|
||||
shouldGroupWithNext,
|
||||
id,
|
||||
sender,
|
||||
senderType,
|
||||
} = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isCaptainMessage = computed(
|
||||
() =>
|
||||
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
|
||||
);
|
||||
|
||||
const metaColorClass = computed(() =>
|
||||
variant.value === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11'
|
||||
);
|
||||
|
||||
const emailMetaClass = computed(() =>
|
||||
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
|
||||
);
|
||||
|
||||
const varaintBaseMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.PRIVATE]:
|
||||
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="shouldShowMeta"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
variant === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
class="mt-2"
|
||||
/>
|
||||
<template v-if="shouldShowMeta">
|
||||
<CaptainGenerationDetails
|
||||
v-if="isCaptainMessage"
|
||||
:message-id="id"
|
||||
class="mt-2"
|
||||
>
|
||||
<template #meta>
|
||||
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
|
||||
</template>
|
||||
</CaptainGenerationDetails>
|
||||
<MessageMeta
|
||||
v-else
|
||||
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
|
||||
class="mt-2"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
|
||||
if (!canCallBack.value || isInitiatingCall.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
const response = await whatsappCallSession.initiateOutboundCall({
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import {
|
||||
useBreakpoints,
|
||||
breakpointsTailwind,
|
||||
useEventListener,
|
||||
} from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
@@ -16,6 +20,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const SCROLL_CLOSE_THRESHOLD = 24;
|
||||
const triggerTopAtOpen = ref(0);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
@@ -56,6 +68,22 @@ const hide = () => {
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
// The teleported popover tracks its trigger while ancestors scroll; allow
|
||||
// small drift (trackpad inertia), but close once the trigger moves further.
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
event => {
|
||||
if (!props.closeOnScroll || !showPopover.value) return;
|
||||
if (popoverRef.value?.contains(event.target)) return;
|
||||
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
@@ -43,7 +44,14 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const store = useStore();
|
||||
|
||||
// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
|
||||
// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
|
||||
const isCallsAvailable = computed(
|
||||
() => isOnChatwootCloud.value || isEnterprise
|
||||
);
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCallsAvailable.value
|
||||
? [
|
||||
{
|
||||
name: 'Calls',
|
||||
label: t('SIDEBAR.CALLS'),
|
||||
icon: 'i-lucide-phone',
|
||||
to: accountScopedRoute('calls_dashboard_index'),
|
||||
activeOn: ['calls_dashboard_index'],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Contacts',
|
||||
label: t('SIDEBAR.CONTACTS'),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { isWhatsAppComplete } from '@chatwoot/utils';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import {
|
||||
buildTemplateParameters,
|
||||
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
|
||||
return replaceTemplateVariables(bodyText.value, processedParams.value);
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaHeader.value) return false;
|
||||
|
||||
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasVariables.value && processedParams.value.body) {
|
||||
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
|
||||
value => !value
|
||||
);
|
||||
if (hasEmptyBodyVariable) return true;
|
||||
}
|
||||
|
||||
if (processedParams.value.buttons) {
|
||||
const hasEmptyButtonParameter = processedParams.value.buttons.some(
|
||||
button => !button.parameter
|
||||
);
|
||||
if (hasEmptyButtonParameter) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// Completeness validation is shared with the mobile app via @chatwoot/utils.
|
||||
const isFormInvalid = computed(
|
||||
() => !isWhatsAppComplete(props.template, processedParams.value)
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
props.chat.id
|
||||
);
|
||||
const response = await whatsappCallSession.initiateOutboundCall({
|
||||
conversationId: props.chat.id,
|
||||
});
|
||||
|
||||
// Composable returns LOCKED when init is already in flight or a call is
|
||||
// active; soft no-op so a parallel click doesn't trigger a banner.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { effectScope } from 'vue';
|
||||
import { useAbortableRequest } from '../useAbortableRequest';
|
||||
|
||||
// Resolves when the request "completes", rejects like axios does when the
|
||||
// signal is aborted mid-flight.
|
||||
const abortableRunner =
|
||||
(value, { fail = false } = {}) =>
|
||||
signal =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('canceled');
|
||||
error.name = 'CanceledError';
|
||||
reject(error);
|
||||
});
|
||||
// Defer so a follow-up `run`/`abort` can supersede this one first.
|
||||
Promise.resolve().then(() => {
|
||||
if (signal.aborted) return;
|
||||
if (fail) {
|
||||
reject(new Error('boom'));
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAbortableRequest', () => {
|
||||
it('passes a fresh signal to the runner and returns its result', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
let received = null;
|
||||
|
||||
const result = await run(signal => {
|
||||
received = signal;
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
expect(received).toBeInstanceOf(AbortSignal);
|
||||
expect(received.aborted).toBe(false);
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('toggles isPending around the request', async () => {
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
expect(isPending.value).toBe(false);
|
||||
|
||||
const pending = run(() => Promise.resolve('done'));
|
||||
expect(isPending.value).toBe(true);
|
||||
|
||||
await pending;
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the previous request when a new one starts', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
|
||||
const first = run(abortableRunner('first'));
|
||||
const second = run(abortableRunner('second'));
|
||||
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await expect(second).resolves.toBe('second');
|
||||
});
|
||||
|
||||
it('returns the onAbort value when a request is superseded', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
|
||||
const first = run(abortableRunner('first'), { onAbort: null });
|
||||
const second = run(abortableRunner('second'));
|
||||
|
||||
await expect(first).resolves.toBeNull();
|
||||
await expect(second).resolves.toBe('second');
|
||||
});
|
||||
|
||||
it('abort cancels the in-flight request and clears isPending', async () => {
|
||||
const { run, abort, isPending } = useAbortableRequest();
|
||||
|
||||
const pending = run(abortableRunner('value'));
|
||||
expect(isPending.value).toBe(true);
|
||||
|
||||
abort();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows non-abort errors and clears isPending', async () => {
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
|
||||
await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow(
|
||||
'boom'
|
||||
);
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the in-flight request when its scope is disposed', async () => {
|
||||
const scope = effectScope();
|
||||
let request;
|
||||
scope.run(() => {
|
||||
request = useAbortableRequest();
|
||||
});
|
||||
|
||||
const pending = request.run(abortableRunner('value'));
|
||||
expect(request.isPending.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(request.isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps separate controllers per instance', async () => {
|
||||
const a = useAbortableRequest();
|
||||
const b = useAbortableRequest();
|
||||
|
||||
const first = a.run(abortableRunner('a'));
|
||||
// Starting b's request must not abort a's.
|
||||
const second = b.run(abortableRunner('b'));
|
||||
|
||||
await expect(first).resolves.toBe('a');
|
||||
await expect(second).resolves.toBe('b');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
||||
const namelessBot = {
|
||||
id: 91,
|
||||
name: null,
|
||||
assignee_type: 'AgentBot',
|
||||
availability_status: 'offline',
|
||||
};
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
||||
...allAgentsData,
|
||||
namelessBot,
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList();
|
||||
// access the computed to trigger evaluation
|
||||
expect(agentsList.value).toBeDefined();
|
||||
|
||||
const passedAgents =
|
||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
||||
expect(passedAgents).toContainEqual({
|
||||
...namelessBot,
|
||||
name: '-',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getCurrentScope, onScopeDispose, ref } from 'vue';
|
||||
|
||||
export const isAbortError = error =>
|
||||
error?.name === 'AbortError' ||
|
||||
error?.name === 'CanceledError' ||
|
||||
error?.code === 'ERR_CANCELED';
|
||||
|
||||
/**
|
||||
* Keeps only the latest request alive. Starting a new `run` (or calling
|
||||
* `abort`) cancels the previous request through its `AbortSignal`, so
|
||||
* out-of-order responses can never overwrite fresher data.
|
||||
*
|
||||
* @example
|
||||
* const { run, abort, isPending } = useAbortableRequest();
|
||||
* const results = await run(signal => api.search(query, { signal }));
|
||||
*
|
||||
* @returns {{
|
||||
* run: (runner: (signal: AbortSignal) => Promise<any>, options?: { onAbort?: any }) => Promise<any>,
|
||||
* abort: () => void,
|
||||
* isPending: import('vue').Ref<boolean>,
|
||||
* }}
|
||||
* `run` resolves with the runner's value, or `options.onAbort` (default
|
||||
* `undefined`) when the request was superseded. Non-abort errors are rethrown.
|
||||
*/
|
||||
export function useAbortableRequest() {
|
||||
const isPending = ref(false);
|
||||
let controller = null;
|
||||
|
||||
const abort = () => {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
isPending.value = false;
|
||||
};
|
||||
|
||||
const run = async (runner, { onAbort } = {}) => {
|
||||
controller?.abort();
|
||||
const currentController = new AbortController();
|
||||
controller = currentController;
|
||||
isPending.value = true;
|
||||
|
||||
try {
|
||||
return await runner(currentController.signal);
|
||||
} catch (error) {
|
||||
if (currentController.signal.aborted || isAbortError(error))
|
||||
return onAbort;
|
||||
throw error;
|
||||
} finally {
|
||||
// Only the latest run owns the shared state; a superseded run leaves it
|
||||
// for the run that replaced it.
|
||||
if (controller === currentController) {
|
||||
controller = null;
|
||||
isPending.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel any in-flight request when the owning scope is disposed.
|
||||
// Guarded so the composable can also be used outside an effect scope.
|
||||
if (getCurrentScope()) onScopeDispose(abort);
|
||||
|
||||
return { run, abort, isPending };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
@@ -53,7 +53,11 @@ export function useAgentsList(
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = (assignableAgents.value || []).map(agent =>
|
||||
!agent.name && agent.assignee_type === 'AgentBot'
|
||||
? { ...agent, name: '-' }
|
||||
: agent
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import {
|
||||
@@ -21,7 +22,6 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
@@ -78,10 +78,7 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -308,7 +308,8 @@ export function useWhatsappCallSession() {
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
// target: { conversationId } or { contactId, inboxId }
|
||||
const initiateOutboundCall = async target => {
|
||||
// Module-scoped lock + active-session guard so a second click — from the
|
||||
// same composable instance OR a different one (header vs contact panel)
|
||||
// OR while a call is already live — can't tear down the in-flight setup
|
||||
@@ -320,10 +321,7 @@ export function useWhatsappCallSession() {
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
|
||||
if (response?.id) {
|
||||
activeCallId = response.id;
|
||||
// A connect webhook that raced ahead of this response was buffered;
|
||||
@@ -354,7 +352,7 @@ export function useWhatsappCallSession() {
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
) {
|
||||
return { status: data.status };
|
||||
return { status: data.status, conversation_id: data.conversation_id };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -7,11 +7,14 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts filename from a URL
|
||||
* @param {string} url - The URL to extract filename from
|
||||
* @returns {string} - The extracted filename or original URL if extraction fails
|
||||
*/
|
||||
export const extractFilenameFromUrl = url => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const filename = pathname.split('/').pop();
|
||||
return filename || url;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, try to extract filename using regex
|
||||
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
};
|
||||
// Shared with the mobile app via @chatwoot/utils.
|
||||
export { extractFilenameFromUrl } from '@chatwoot/utils';
|
||||
|
||||
/**
|
||||
* Normalizes a comma/newline separated list of domains
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Powers the "unpublished changes" preview: marks what changed between the live
|
||||
// article and the draft — word by word in the title, block by block in the body.
|
||||
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
// Matches the public renderer (CommonMark, no typographer). True when two
|
||||
// markdown strings render the same — so blank-line/spacing-only edits don't count,
|
||||
// but real changes (code indentation, smart quotes, width markers) do.
|
||||
const commonmark = MarkdownIt('commonmark');
|
||||
export const rendersIdentically = (a, b) =>
|
||||
commonmark.render(a ?? '') === commonmark.render(b ?? '');
|
||||
|
||||
const INS_CLASS = '!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5';
|
||||
const DEL_CLASS = '!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5';
|
||||
|
||||
// Detailed compare gets slow on huge texts; past this, show all old as removed
|
||||
// and all new as added.
|
||||
const MAX_DIFF_TOKENS = 2000;
|
||||
|
||||
const tokenizeWords = value => (value || '').match(/\S+/g) || [];
|
||||
|
||||
const escapeHtml = value =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Compares two lists in order and reports what's the same (`equal`), removed
|
||||
// (`del`) or added (`ins`), keeping as much unchanged as possible. `keyOf` says
|
||||
// how to compare items (title passes words, body passes blocks).
|
||||
const diffSequence = (a, b, keyOf = item => item) => {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n > MAX_DIFF_TOKENS || m > MAX_DIFF_TOKENS) {
|
||||
return [
|
||||
...a.map(item => ({ type: 'del', item })),
|
||||
...b.map(item => ({ type: 'ins', item })),
|
||||
];
|
||||
}
|
||||
|
||||
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] =
|
||||
keyOf(a[i]) === keyOf(b[j])
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (keyOf(a[i]) === keyOf(b[j])) {
|
||||
ops.push({ type: 'equal', item: a[i] });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ type: 'del', item: a[i] });
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push({ type: 'ins', item: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops.push({ type: 'del', item: a[i] });
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
ops.push({ type: 'ins', item: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const wrapDiff = {
|
||||
ins: text => `<ins class="${INS_CLASS}">${text}</ins>`,
|
||||
del: text => `<del class="${DEL_CLASS}">${text}</del>`,
|
||||
};
|
||||
|
||||
// Builds the highlighted title. Compares whole words (not single spaces) so
|
||||
// repeated words/spaces don't make the highlights jump around, then rejoins
|
||||
// with single spaces — a run of added/removed words shares one <ins>/<del> tag.
|
||||
export const renderInlineDiff = (oldValue, newValue) => {
|
||||
const ops = diffSequence(tokenizeWords(oldValue), tokenizeWords(newValue));
|
||||
|
||||
const segments = [];
|
||||
let run = [];
|
||||
let runType = null;
|
||||
const flushRun = () => {
|
||||
if (!run.length) return;
|
||||
const text = run.map(escapeHtml).join(' ');
|
||||
segments.push(wrapDiff[runType] ? wrapDiff[runType](text) : text);
|
||||
run = [];
|
||||
};
|
||||
|
||||
ops.forEach(({ type, item }) => {
|
||||
if (type !== runType) flushRun();
|
||||
runType = type;
|
||||
run.push(item);
|
||||
});
|
||||
flushRun();
|
||||
|
||||
return segments.join(' ');
|
||||
};
|
||||
|
||||
// A fenced code block opener: ``` or ~~~, indented up to 3 spaces (CommonMark).
|
||||
const FENCE_RE = /^ {0,3}(```|~~~)/;
|
||||
// A list item marker: -, *, + or "1." / "1)", indented up to 3 spaces.
|
||||
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
||||
|
||||
// Split on blank lines so each paragraph, heading or list compares as one piece.
|
||||
// Blank lines inside a fenced code block, or between items of the same list, are
|
||||
// content — splitting there would tear a code block or list apart and render it
|
||||
// with broken structure (orphaned <li>/<p>), so we keep those together.
|
||||
const splitBlocks = text => {
|
||||
const lines = (text || '').split('\n');
|
||||
const blocks = [];
|
||||
let buffer = [];
|
||||
let fence = null;
|
||||
let inList = false;
|
||||
|
||||
const flush = () => {
|
||||
const block = buffer.join('\n');
|
||||
if (block.trim()) blocks.push(block);
|
||||
buffer = [];
|
||||
inList = false;
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const marker = line.match(FENCE_RE)?.[1];
|
||||
if (marker && !fence) fence = marker;
|
||||
else if (fence && line.trimStart().startsWith(fence)) fence = null;
|
||||
|
||||
if (fence) {
|
||||
buffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (LIST_ITEM_RE.test(line)) inList = true;
|
||||
|
||||
if (line.trim() !== '') {
|
||||
buffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Blank line: keep it when the current list continues on the next non-blank
|
||||
// line (another item or an indented continuation); otherwise end the block.
|
||||
const next = lines.slice(index + 1).find(other => other.trim() !== '');
|
||||
if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
|
||||
buffer.push(line);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
});
|
||||
|
||||
flush();
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
|
||||
|
||||
// Diffs the body block by block. Blocks match when they render to the same HTML
|
||||
// (the check staging uses), so only edits that change the page show as a diff.
|
||||
export const buildDiffBlocks = (oldText, newText) => {
|
||||
const toBlocks = text =>
|
||||
splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
|
||||
const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
|
||||
return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
|
||||
};
|
||||
|
||||
export const hasPendingChanges = article =>
|
||||
article?.draftTitle != null || article?.draftContent != null;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export const setDirectUploadAuthHeaders = xhr => {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData() || {};
|
||||
|
||||
if (!accessToken) return;
|
||||
|
||||
xhr.setRequestHeader('access-token', accessToken);
|
||||
xhr.setRequestHeader('token-type', tokenType);
|
||||
xhr.setRequestHeader('client', client);
|
||||
xhr.setRequestHeader('expiry', expiry);
|
||||
xhr.setRequestHeader('uid', uid);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -9,7 +11,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.
|
||||
|
||||
@@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
selectPopularContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT',
|
||||
action: 'select-popular-content',
|
||||
value: 'select-popular-content',
|
||||
icon: 'i-lucide-sparkles',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
@@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
@@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when an agent has a null name', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: null, availability_status: 'offline' },
|
||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
||||
];
|
||||
|
||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
||||
expect(
|
||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
renderInlineDiff,
|
||||
buildDiffBlocks,
|
||||
hasPendingChanges,
|
||||
rendersIdentically,
|
||||
} from '../articleDiffHelper';
|
||||
|
||||
describe('articleDiffHelper', () => {
|
||||
describe('renderInlineDiff', () => {
|
||||
it('returns the text unchanged when there is no difference', () => {
|
||||
const result = renderInlineDiff('hello world', 'hello world');
|
||||
expect(result).toBe('hello world');
|
||||
expect(result).not.toContain('<ins');
|
||||
expect(result).not.toContain('<del');
|
||||
});
|
||||
|
||||
it('wraps inserted words in <ins>', () => {
|
||||
const result = renderInlineDiff('hello', 'hello there');
|
||||
expect(result).toContain('hello');
|
||||
expect(result).toContain('<ins');
|
||||
expect(result).toContain('there');
|
||||
});
|
||||
|
||||
it('wraps removed words in <del>', () => {
|
||||
const result = renderInlineDiff('hello there', 'hello');
|
||||
expect(result).toContain('<del');
|
||||
expect(result).toContain('there');
|
||||
});
|
||||
|
||||
it('keeps a single removal contiguous when a word repeats', () => {
|
||||
const result = renderInlineDiff(
|
||||
'How to use Agent bots?',
|
||||
'How How to Agent bots?'
|
||||
);
|
||||
expect(result).toBe(
|
||||
'How <ins class="!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5">How</ins> to <del class="!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5">use</del> Agent bots?'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes markup when diffing plain text', () => {
|
||||
const result = renderInlineDiff('a', 'a <b>');
|
||||
expect(result).toContain('<b>');
|
||||
expect(result).not.toContain('<b>');
|
||||
});
|
||||
|
||||
it('treats a cleared empty string as a full deletion', () => {
|
||||
const result = renderInlineDiff('gone', '');
|
||||
expect(result).toContain('<del');
|
||||
expect(result).toContain('gone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDiffBlocks', () => {
|
||||
it('passes an unchanged block through as equal', () => {
|
||||
const blocks = buildDiffBlocks('same para', 'same para');
|
||||
expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
|
||||
});
|
||||
|
||||
it('marks an appended block as added', () => {
|
||||
const blocks = buildDiffBlocks('a', 'a\n\nb');
|
||||
expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
|
||||
expect(blocks).toContainEqual({ type: 'added', md: 'b' });
|
||||
});
|
||||
|
||||
it('marks a deleted block as removed', () => {
|
||||
const blocks = buildDiffBlocks('a\n\nb', 'a');
|
||||
expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
|
||||
});
|
||||
|
||||
it('emits the old block then the new block for a reworded section', () => {
|
||||
const blocks = buildDiffBlocks('hello world', 'hello there');
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'removed', md: 'hello world' },
|
||||
{ type: 'added', md: 'hello there' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a fenced code block whole when it contains blank lines', () => {
|
||||
const code = '```\nline one\n\nline two\n```';
|
||||
const blocks = buildDiffBlocks(code, code);
|
||||
expect(blocks).toEqual([{ type: 'equal', md: code }]);
|
||||
});
|
||||
|
||||
it('diffs an edited code block as one whole removed + added block', () => {
|
||||
const live = '```\ncode line\n```';
|
||||
const draft = '```\ncode line\n\nsd\n```';
|
||||
const blocks = buildDiffBlocks(live, draft);
|
||||
expect(blocks).toContainEqual({ type: 'removed', md: live });
|
||||
expect(blocks).toContainEqual({ type: 'added', md: draft });
|
||||
});
|
||||
|
||||
it('surfaces whitespace edits that change the rendered output', () => {
|
||||
expect(
|
||||
buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
|
||||
block => block.type !== 'equal'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildDiffBlocks('line one\nline two', 'line one \nline two').some(
|
||||
block => block.type !== 'equal'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces an indented code block turning into a paragraph', () => {
|
||||
const blocks = buildDiffBlocks(
|
||||
' curl example.com',
|
||||
'curl example.com'
|
||||
);
|
||||
expect(blocks).toContainEqual({
|
||||
type: 'removed',
|
||||
md: ' curl example.com',
|
||||
});
|
||||
expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
|
||||
});
|
||||
|
||||
it('keeps spacing the renderer ignores as equal', () => {
|
||||
const blocks = buildDiffBlocks('a\nb', 'a \nb');
|
||||
expect(blocks.every(block => block.type === 'equal')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a loose list with item descriptions as one block', () => {
|
||||
const list =
|
||||
'1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
|
||||
const blocks = buildDiffBlocks(list, list);
|
||||
expect(blocks).toEqual([{ type: 'equal', md: list }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendersIdentically', () => {
|
||||
it('ignores blank-line / empty-paragraph differences', () => {
|
||||
expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
|
||||
expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('counts code-block indentation changes', () => {
|
||||
expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts smart vs straight quotes (no typographer)', () => {
|
||||
expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts real text changes', () => {
|
||||
expect(rendersIdentically('hello world', 'hello there')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats nullish input as empty', () => {
|
||||
expect(rendersIdentically(null, '')).toBe(true);
|
||||
expect(rendersIdentically(undefined, 'x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPendingChanges', () => {
|
||||
it('is true when a draft title or content is staged', () => {
|
||||
expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
|
||||
expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a cleared empty-string draft as a pending change', () => {
|
||||
expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false with no draft columns', () => {
|
||||
expect(hasPendingChanges({ title: 'live' })).toBe(false);
|
||||
expect(hasPendingChanges({})).toBe(false);
|
||||
expect(hasPendingChanges(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { setDirectUploadAuthHeaders } from '../directUploadsHelper';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
vi.mock('dashboard/api/auth', () => ({
|
||||
default: { getAuthData: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('setDirectUploadAuthHeaders', () => {
|
||||
const buildXhr = () => ({ setRequestHeader: vi.fn() });
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets the five session auth headers from the auth cookie', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
'access-token': 'token-123',
|
||||
'token-type': 'Bearer',
|
||||
client: 'client-123',
|
||||
expiry: '9999',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledTimes(5);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'access-token',
|
||||
'token-123'
|
||||
);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('token-type', 'Bearer');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('client', 'client-123');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('expiry', '9999');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'uid',
|
||||
'agent@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set any header when there is no auth data', () => {
|
||||
Auth.getAuthData.mockReturnValue(false);
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set any header when the access token is missing', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
client: 'client-123',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -74,26 +74,34 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
it('disables other actions but keeps content actions enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
const enabledActions = ['customize-content', 'select-popular-content'];
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
enabledActions.forEach(action => {
|
||||
expect(
|
||||
items.find(item => item.action === action)?.disabled
|
||||
).toBeFalsy();
|
||||
});
|
||||
expect(
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.filter(item => !enabledActions.includes(item.action))
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, popular content, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
).toEqual([
|
||||
'publish-locale',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
@@ -106,6 +114,7 @@ describe('PortalHelper', () => {
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle templates with no variables', () => {
|
||||
it('should handle templates with no variables but a media header', () => {
|
||||
const emptyTemplate = templates.find(
|
||||
t => t.name === 'no_variable_template'
|
||||
);
|
||||
const result = buildTemplateParameters(emptyTemplate, false);
|
||||
expect(result).toEqual({});
|
||||
const result = buildTemplateParameters(emptyTemplate);
|
||||
// hasMediaHeader is derived from the template, so the document header is kept.
|
||||
expect(result.body).toBeUndefined();
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'document',
|
||||
media_name: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should build parameters for templates with multiple component types', () => {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Constants
|
||||
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
|
||||
|
||||
// Constants and pure template helpers are shared with the mobile app via
|
||||
// @chatwoot/utils so the logic lives in one place.
|
||||
export {
|
||||
MEDIA_FORMATS,
|
||||
COMPONENT_TYPES,
|
||||
findComponentByType,
|
||||
processVariable,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
export const DEFAULT_CATEGORY = 'UTILITY';
|
||||
export const COMPONENT_TYPES = {
|
||||
HEADER: 'HEADER',
|
||||
BODY: 'BODY',
|
||||
BUTTONS: 'BUTTONS',
|
||||
};
|
||||
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
|
||||
|
||||
export const findComponentByType = (template, type) =>
|
||||
template.components?.find(component => component.type === type);
|
||||
|
||||
export const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
|
||||
export const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
|
||||
const allVariables = {};
|
||||
|
||||
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
|
||||
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
|
||||
|
||||
if (!bodyComponent) return allVariables;
|
||||
|
||||
const templateString = bodyComponent.text;
|
||||
|
||||
// Process body variables
|
||||
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
|
||||
if (matchedVariables) {
|
||||
allVariables.body = {};
|
||||
matchedVariables.forEach(variable => {
|
||||
const key = processVariable(variable);
|
||||
allVariables.body[key] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaHeaderValue) {
|
||||
if (!allVariables.header) allVariables.header = {};
|
||||
allVariables.header.media_url = '';
|
||||
allVariables.header.media_type = headerComponent.format.toLowerCase();
|
||||
|
||||
// For document templates, include media_name field for filename support
|
||||
if (headerComponent.format.toLowerCase() === 'document') {
|
||||
allVariables.header.media_name = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Process button variables
|
||||
const buttonComponents = template.components.filter(
|
||||
component => component.type === COMPONENT_TYPES.BUTTONS
|
||||
);
|
||||
|
||||
buttonComponents.forEach(buttonComponent => {
|
||||
if (buttonComponent.buttons) {
|
||||
buttonComponent.buttons.forEach((button, index) => {
|
||||
// Handle URL buttons with variables
|
||||
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
|
||||
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
|
||||
if (buttonVars.length > 0) {
|
||||
if (!allVariables.buttons) allVariables.buttons = [];
|
||||
allVariables.buttons[index] = {
|
||||
type: 'url',
|
||||
parameter: '',
|
||||
url: button.url,
|
||||
variables: buttonVars.map(v => processVariable(v)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle copy code buttons
|
||||
if (button.type === 'COPY_CODE') {
|
||||
if (!allVariables.buttons) allVariables.buttons = [];
|
||||
allVariables.buttons[index] = {
|
||||
type: 'copy_code',
|
||||
parameter: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return allVariables;
|
||||
};
|
||||
// The media-header flag is derived from the template inside the shared helper;
|
||||
// the second argument is kept for backwards-compatible call sites.
|
||||
export const buildTemplateParameters = template =>
|
||||
buildWhatsAppProcessedParams(template);
|
||||
|
||||
@@ -34,6 +34,7 @@ import ta from './locale/ta';
|
||||
import th from './locale/th';
|
||||
import tr from './locale/tr';
|
||||
import uk from './locale/uk';
|
||||
import uz from './locale/uz';
|
||||
import vi from './locale/vi';
|
||||
import zh_CN from './locale/zh_CN';
|
||||
import zh_TW from './locale/zh_TW';
|
||||
@@ -77,6 +78,7 @@ export default {
|
||||
th,
|
||||
tr,
|
||||
uk,
|
||||
uz,
|
||||
vi,
|
||||
zh_CN,
|
||||
zh_TW,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
"equal_to": "Equal to",
|
||||
"not_equal_to": "Not equal to",
|
||||
@@ -49,6 +51,7 @@
|
||||
"ASSIGNEE_NAME": "Assignee name",
|
||||
"INBOX_NAME": "Inbox name",
|
||||
"TEAM_NAME": "Team name",
|
||||
"CONTACT": "እውቂያ",
|
||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||
"CAMPAIGN_NAME": "Campaign name",
|
||||
"LABELS": "Labels",
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
},
|
||||
"unread": {
|
||||
"TEXT": "Unread Count: Highest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"ACTIONS": {
|
||||
"CREATE": "Add company"
|
||||
},
|
||||
"SELECTOR": {
|
||||
"PLACEHOLDER": "Select company",
|
||||
"CREATE_OPTION": "Add \"{name}\""
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Add company details",
|
||||
"ACTIONS": {
|
||||
|
||||
@@ -510,6 +510,7 @@
|
||||
"ATTRIBUTES": "ባህሪያት",
|
||||
"HISTORY": "ታሪክ",
|
||||
"NOTES": "ማስታወሻዎች",
|
||||
"MEDIA": "Media",
|
||||
"MERGE": "አንድነት አድርግ"
|
||||
},
|
||||
"HISTORY": {
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"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": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
|
||||
"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": "ለዚህ ትመልሳለህ፦",
|
||||
"REMOVE_SELECTION": "ምርጫ አስወግድ",
|
||||
"DOWNLOAD": "አውርድ",
|
||||
@@ -233,6 +235,7 @@
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"IMAGE_UPLOAD_SUCCESS": "ምስል በተሳካ ሁኔታ ተሰብስቧል",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
|
||||
@@ -303,6 +306,25 @@
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"REPORT_MESSAGE": {
|
||||
"LABEL": "Report message",
|
||||
"TITLE": "Report Captain message",
|
||||
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
|
||||
"PROBLEM_TYPE": "Problem type",
|
||||
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
|
||||
"DESCRIPTION_LABEL": "Description",
|
||||
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
|
||||
"SUBMIT": "Report",
|
||||
"SUCCESS": "Thanks for reporting. Our team will take a look.",
|
||||
"ERROR": "Could not report this message. Please try again.",
|
||||
"REASONS": {
|
||||
"incorrect_information": "Incorrect information",
|
||||
"inappropriate_response": "Inappropriate response",
|
||||
"incomplete_response": "Incomplete response",
|
||||
"outdated_information": "Outdated information",
|
||||
"other": "Other"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
@@ -400,7 +422,8 @@
|
||||
"VIEW_ALL": "View all",
|
||||
"SHOW_LESS": "Show less",
|
||||
"MORE_COUNT": "+{count}",
|
||||
"UNTITLED_FILE": "Untitled file"
|
||||
"UNTITLED_FILE": "Untitled file",
|
||||
"JUMP_TO_MESSAGE": "Jump to message"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -3,5 +3,33 @@
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"REMOVE": "Remove"
|
||||
},
|
||||
"EMOJI_ICON_PICKER": {
|
||||
"TABS": {
|
||||
"ICONS": "Icons",
|
||||
"EMOJIS": "Emojis"
|
||||
},
|
||||
"SEARCH_EMOJI": "Search emoji…",
|
||||
"SEARCH_ICON": "Search icons…",
|
||||
"FREQUENTLY_USED": "Frequently used",
|
||||
"NO_EMOJI": "No emoji match your search",
|
||||
"NO_ICON": "No icons match your search",
|
||||
"REMOVE": "አስወግድ",
|
||||
"STYLE": {
|
||||
"OUTLINE": "Outline icons",
|
||||
"FILLED": "Filled icons"
|
||||
},
|
||||
"COLORS": {
|
||||
"SLATE": "Slate",
|
||||
"RED": "Red",
|
||||
"ORANGE": "Orange",
|
||||
"AMBER": "Amber",
|
||||
"GREEN": "Green",
|
||||
"TEAL": "Teal",
|
||||
"BLUE": "Blue",
|
||||
"INDIGO": "Indigo",
|
||||
"VIOLET": "Violet",
|
||||
"PINK": "Pink"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +533,8 @@
|
||||
"PUBLISHED": "ተለቀቀ",
|
||||
"ARCHIVED": "ተቀምጧል"
|
||||
},
|
||||
"PENDING_EDITS": "Unpublished edits",
|
||||
"PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
|
||||
"CATEGORY": {
|
||||
"UNCATEGORISED": "ያልተደራጀ"
|
||||
}
|
||||
@@ -552,6 +554,7 @@
|
||||
"LOCALE": {
|
||||
"ALL": "ሁሉም ቋንቋዎች"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "ጽሑፎችን ፈልግ...",
|
||||
"NEW_ARTICLE": "አዲስ ጽሑፍ"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
@@ -579,6 +582,10 @@
|
||||
"CATEGORY": {
|
||||
"TITLE": "በዚህ ምድብ ምንም ጽሑፎች የሉም",
|
||||
"SUBTITLE": "በዚህ ምድብ ያሉ ጽሑፎች እዚህ ይታያሉ"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "No matching articles",
|
||||
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
@@ -611,6 +618,8 @@
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
|
||||
"STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
|
||||
"CATEGORY_SUCCESS": "Articles moved successfully",
|
||||
"CATEGORY_ERROR": "Failed to move articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
@@ -624,6 +633,7 @@
|
||||
"CATEGORY_HEADER": {
|
||||
"NEW_CATEGORY": "አዲስ ምድብ",
|
||||
"EDIT_CATEGORY": "ምድብ አርትዕ",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"CATEGORIES_COUNT": "{n} ምድብ | {n} ምድቦች",
|
||||
"BREADCRUMB": {
|
||||
"CATEGORY_LOCALE": "ምድቦች ({localeCode})",
|
||||
@@ -634,6 +644,10 @@
|
||||
"TITLE": "ምድቦች አልተገኙም",
|
||||
"SUBTITLE": "ካተጎሪዎች እዚህ ይታያሉ። በ'አዲስ ካተጎሪ' አዝራር ቁልፍ በመጫን ካተጎሪ ማክሰኞ ይችላሉ።."
|
||||
},
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching categories",
|
||||
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
|
||||
},
|
||||
"CATEGORY_CARD": {
|
||||
"ARTICLES_COUNT": "{count} ጽሑፍ | {count} ጽሑፎች"
|
||||
},
|
||||
@@ -691,6 +705,11 @@
|
||||
"LOCALES_PAGE": {
|
||||
"LOCALES_COUNT": "ምንም ቋንቋ አልተገኘም | {n} ቋንቋ | {n} ቋንቋዎች",
|
||||
"NEW_LOCALE_BUTTON_TEXT": "አዲስ ቋንቋ",
|
||||
"SEARCH_PLACEHOLDER": "Search locales...",
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching locales",
|
||||
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
|
||||
},
|
||||
"LOCALE_CARD": {
|
||||
"ARTICLES_COUNT": "{count} ጽሑፍ | {count} ጽሑፎች",
|
||||
"CATEGORIES_COUNT": "{count} ምድብ | {count} ምድቦች",
|
||||
@@ -700,9 +719,51 @@
|
||||
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"CUSTOMIZE_CONTENT": "Localize content",
|
||||
"SELECT_POPULAR_CONTENT": "Select recommended content",
|
||||
"DELETE": "ሰርዝ"
|
||||
}
|
||||
},
|
||||
"POPULAR_CONTENT_DIALOG": {
|
||||
"TITLE": "Recommended content",
|
||||
"DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.",
|
||||
"SEARCH": "Search...",
|
||||
"EMPTY": "No matching results",
|
||||
"ADD_ANOTHER": "Add another...",
|
||||
"SLOTS_LEFT": "{count} slots left",
|
||||
"OVERRIDING_DEFAULTS": "Overriding defaults for this locale",
|
||||
"CONFIRM": "Save recommendations",
|
||||
"CATEGORIES": {
|
||||
"LABEL": "Recommended categories",
|
||||
"ARTICLES_COUNT": "No articles | {count} article | {count} articles"
|
||||
},
|
||||
"ARTICLES": {
|
||||
"LABEL": "Recommended articles",
|
||||
"IN_CATEGORY": "in {category}",
|
||||
"UNCATEGORIZED": "ያልተደራጀ"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Recommended content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update recommended content. Try again."
|
||||
}
|
||||
},
|
||||
"CONTENT_DIALOG": {
|
||||
"TITLE": "Localize content",
|
||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||
"NAME": {
|
||||
"LABEL": "Name"
|
||||
},
|
||||
"PAGE_TITLE": {
|
||||
"LABEL": "የገፅ ርዕስ"
|
||||
},
|
||||
"HEADER_TEXT": {
|
||||
"LABEL": "የራስጌ ጽሑፍ"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||
}
|
||||
},
|
||||
"ADD_LOCALE_DIALOG": {
|
||||
"TITLE": "አዲስ ቋንቋ አክል",
|
||||
"DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.",
|
||||
@@ -730,10 +791,30 @@
|
||||
},
|
||||
"PREVIEW": "ቅድመ እይታ",
|
||||
"PUBLISH": "ለማስታወቂያ",
|
||||
"PUBLISH_CHANGES": "Publish changes",
|
||||
"PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
|
||||
"PUBLISH_CHANGES_ERROR": "Could not publish changes",
|
||||
"SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
|
||||
"DISCARD_CHANGES": "Discard changes",
|
||||
"DISCARD_CHANGES_SUCCESS": "Changes discarded",
|
||||
"DISCARD_CHANGES_ERROR": "Could not discard changes",
|
||||
"PENDING_CHANGES": "Pending changes",
|
||||
"VIEW_CHANGES": "View unpublished changes",
|
||||
"DRAFT": "እቅድ",
|
||||
"ARCHIVE": "አርክቭ",
|
||||
"BACK_TO_ARTICLES": "ወደ ጽሑፎች ተመለስ"
|
||||
},
|
||||
"PENDING_CHANGES_POPOVER": {
|
||||
"TITLE": "Unpublished changes",
|
||||
"DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
|
||||
"APPLY": "Apply changes",
|
||||
"DISCARD": "Discard changes"
|
||||
},
|
||||
"DIFF_DIALOG": {
|
||||
"TITLE": "Unpublished changes",
|
||||
"DESCRIPTION": "Compare your draft against the version that's currently live.",
|
||||
"TITLE_LABEL": "Title"
|
||||
},
|
||||
"EDIT_ARTICLE": {
|
||||
"MORE_PROPERTIES": "ተጨማሪ ባህሪያት",
|
||||
"UNCATEGORIZED": "ያልተመደበ",
|
||||
|
||||
@@ -58,14 +58,17 @@
|
||||
"ERROR_MESSAGE": "Instagram ጋር ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
|
||||
"ERROR_AUTH": "Instagram ጋር ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
|
||||
"NEW_INBOX_SUGGESTION": "ይህ የInstagram መለያ ቀድሞ ወደ ሌላ ኢንቦክስ ተገናኝቷል እና አሁን ወደዚህ ተለዋዋጭ ሆኗል። አዲስ መልእክቶች ሁሉ እዚህ ይታያሉ። አሮጌው ኢንቦክስ ለዚህ መለያ መልእክት ማስተላለፍ እና መቀበል አይችልም።.",
|
||||
"DUPLICATE_INBOX_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ኢንቦክስ ተለዋዋጭ ሆኗል። ከዚህ ኢንቦክስ የInstagram መልእክቶችን መላክ/መቀበል አትችሉም።."
|
||||
"DUPLICATE_INBOX_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ኢንቦክስ ተለዋዋጭ ሆኗል። ከዚህ ኢንቦክስ የInstagram መልእክቶችን መላክ/መቀበል አትችሉም።.",
|
||||
"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"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "ከTikTok ጋር ቀጥሉ",
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "TikTok መገለጫዎን ያገናኙ",
|
||||
"HELP": "TikTok መገለጫዎን እንደ ቻናል ለመጨመር፣ “Continue with TikTok” በመጫን መለያዎን መረጋገጥ አለብዎት ",
|
||||
"ERROR_MESSAGE": "TikTok ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
|
||||
"ERROR_AUTH": "TikTok ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ"
|
||||
"ERROR_AUTH": "TikTok ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
|
||||
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "የTwitter መገለጫዎን እንደ ቻናል ለመጨመር በ\"Sign in with Twitter\" ላይ በመጫን የTwitter መገለጫዎን መረጋገጥ አለብዎት። ",
|
||||
@@ -653,6 +656,10 @@
|
||||
},
|
||||
"CREDENTIALS": {
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
},
|
||||
"INBOUND": {
|
||||
"LABEL": "Allow incoming calls",
|
||||
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
@@ -810,6 +817,15 @@
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "ይህ ኢንቦክስ በWhatsApp ተዋሰነ መመዝገቢያ ተገናኝቷል።.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "ይህን ኢንቦክስ ለWhatsApp ንግድ ቅንብሮች ለማዘመን መቀየር ይችላሉ።.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "እንደገና ያቀናብሩ",
|
||||
"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": "የንግድ አካውንት መለያ",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API ቁልፍ",
|
||||
"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": "ወደ WhatsApp ቢዝነስ ይገናኙ",
|
||||
"WHATSAPP_CONNECT_SUBHEADER": "ለቀላል አስተዳደር ወደ WhatsApp ተዋሰነ መመዝገቢያ ይዘምኑ።.",
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "ይህን ኢንቦክስ ወደ WhatsApp ንግድ ያገናኙ ለተጨማሪ ባህሪያትና ቀላል አስተዳደር።.",
|
||||
@@ -827,6 +843,70 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "ከWhatsApp መልእክት አብነቶች እጅግ በእጅ ማስተካከል ለእንደገና የሚገኙ አብነቶችን ያዘምኑ።.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "አብነቶችን ያዘምኑ",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "የአብነት ማስተካከያ በተሳካ ሁኔታ ተጀምሯል። ለማዘመን ጥቂት ደቂቃዎች ሊወስድ ይችላል።.",
|
||||
"WHATSAPP_MANUAL_MIGRATION": {
|
||||
"BANNER": {
|
||||
"TITLE": "Manual setup recommended",
|
||||
"DESCRIPTION": "This inbox connects through the shared Meta app used for embedded signup, which recent Meta restrictions have affected. To avoid similar issues in the future, we recommend reconnecting it with your own Meta app.",
|
||||
"START": "Start manual migration",
|
||||
"GUIDE": "View guide"
|
||||
},
|
||||
"DIALOG": {
|
||||
"EYEBROW": "WhatsApp manual migration",
|
||||
"TITLE": "Reconnect WhatsApp inbox",
|
||||
"CLOSE": "ዝጋ",
|
||||
"ACTION_REQUIRED_TITLE": "Reconnect with your own Meta app",
|
||||
"ACTION_REQUIRED_DESCRIPTION": "Inboxes connected through your own Meta app are not affected by restrictions on the shared embedded signup app. 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_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": "ስልክ ቁጥር",
|
||||
"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": "ቀጥል",
|
||||
"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