Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53c2dd687 | ||
|
|
5bf5c88075 | ||
|
|
2f6da8545f | ||
|
|
8852e87a87 | ||
|
|
b0d11b5c1d | ||
|
|
1206cbb08c | ||
|
|
aead51cbde | ||
|
|
93ea9ecc9f | ||
|
|
a36dd4bcda | ||
|
|
fc83fd39ca | ||
|
|
2cacb0868c | ||
|
|
b8d35e8749 | ||
|
|
3a99d7fad6 | ||
|
|
d210f61855 | ||
|
|
b15f60755d | ||
|
|
e5229802df | ||
|
|
8c0655d618 | ||
|
|
9f345c8d48 |
@@ -1,65 +0,0 @@
|
||||
---
|
||||
:position: before
|
||||
:position_in_additional_file_patterns: before
|
||||
:position_in_class: before
|
||||
:position_in_factory: before
|
||||
:position_in_fixture: before
|
||||
:position_in_routes: before
|
||||
:position_in_serializer: before
|
||||
:position_in_test: before
|
||||
:classified_sort: true
|
||||
:exclude_controllers: true
|
||||
:exclude_factories: true
|
||||
:exclude_fixtures: true
|
||||
:exclude_helpers: true
|
||||
:exclude_scaffolds: true
|
||||
:exclude_serializers: true
|
||||
:exclude_sti_subclasses: false
|
||||
:exclude_tests: true
|
||||
:force: false
|
||||
:format_markdown: false
|
||||
:format_rdoc: false
|
||||
:format_yard: false
|
||||
:frozen: false
|
||||
:grouped_polymorphic: false
|
||||
:ignore_model_sub_dir: false
|
||||
:ignore_unknown_models: false
|
||||
:include_version: false
|
||||
:show_check_constraints: false
|
||||
:show_complete_foreign_keys: false
|
||||
:show_foreign_keys: true
|
||||
:show_indexes: true
|
||||
:show_indexes_include: false
|
||||
:simple_indexes: false
|
||||
:sort: false
|
||||
:timestamp: false
|
||||
:trace: false
|
||||
:with_comment: true
|
||||
:with_column_comments: true
|
||||
:with_table_comments: true
|
||||
:position_of_column_comment: :with_name
|
||||
:active_admin: false
|
||||
:command:
|
||||
:debug: false
|
||||
:hide_default_column_types: json,jsonb,hstore
|
||||
:hide_limit_column_types: integer,bigint,boolean
|
||||
:timestamp_columns:
|
||||
- created_at
|
||||
- updated_at
|
||||
:ignore_columns:
|
||||
:ignore_routes:
|
||||
:models: true
|
||||
:routes: false
|
||||
:skip_on_db_migrate: false
|
||||
:target_action: :do_annotations
|
||||
:wrapper:
|
||||
:wrapper_close:
|
||||
:wrapper_open:
|
||||
:classes_default_to_s: []
|
||||
:additional_file_patterns: []
|
||||
:model_dir:
|
||||
- app/models
|
||||
- enterprise/app/models
|
||||
:require: []
|
||||
:root_dir:
|
||||
- ''
|
||||
@@ -1,9 +1,3 @@
|
||||
---
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
||||
- CVE-2026-33658
|
||||
|
||||
@@ -40,8 +40,6 @@ gem 'json_refs'
|
||||
gem 'rack-attack', '>= 6.7.0'
|
||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||
gem 'down'
|
||||
# SSRF-safe URL fetching
|
||||
gem 'ssrf_filter', '~> 1.5'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||
|
||||
+2
-4
@@ -166,7 +166,7 @@ GEM
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
@@ -465,7 +465,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.2)
|
||||
json (2.18.1)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -942,7 +942,6 @@ GEM
|
||||
activesupport (>= 5.2)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.7.2)
|
||||
ssrf_filter (1.5.0)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (18.0.1)
|
||||
@@ -1159,7 +1158,6 @@ DEPENDENCIES
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
ssrf_filter (~> 1.5)
|
||||
stackprof
|
||||
stripe (~> 18.0)
|
||||
telephone_number
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.13.0
|
||||
4.11.2
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Email::BaseBuilder
|
||||
include EmailAddressParseable
|
||||
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
private
|
||||
@@ -49,4 +47,8 @@ class Email::BaseBuilder
|
||||
# can save it in the format "Name <email@domain.com>"
|
||||
parse_email(account.support_email)
|
||||
end
|
||||
|
||||
def parse_email(email_string)
|
||||
Mail::Address.new(email_string).address
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,10 +34,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
@agent_bot.reload
|
||||
end
|
||||
|
||||
def reset_secret
|
||||
@agent_bot.reset_secret!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_bot
|
||||
|
||||
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
||||
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
||||
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
||||
# Visiting a conversation should clear any unread inbox notifications for this conversation.
|
||||
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
|
||||
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
||||
|
||||
|
||||
@@ -66,12 +66,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reset_secret
|
||||
return head :not_found unless @inbox.api?
|
||||
|
||||
@inbox.channel.reset_secret!
|
||||
end
|
||||
|
||||
def destroy
|
||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
|
||||
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
return unless @hook&.access_token
|
||||
|
||||
begin
|
||||
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
|
||||
linear_client = Linear.new(@hook.access_token)
|
||||
linear_client.revoke_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||
|
||||
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
elsif params[:external_url].present?
|
||||
create_from_url
|
||||
else
|
||||
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
|
||||
render_error('No file or URL provided', :unprocessable_entity)
|
||||
end
|
||||
|
||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||
@@ -19,21 +19,35 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create_from_url
|
||||
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||
uri = parse_uri(params[:external_url])
|
||||
return if performed?
|
||||
|
||||
fetch_and_process_file_from_uri(uri)
|
||||
end
|
||||
|
||||
def parse_uri(url)
|
||||
uri = URI.parse(url)
|
||||
validate_uri(uri)
|
||||
uri
|
||||
rescue URI::InvalidURIError, SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_uri(uri)
|
||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def fetch_and_process_file_from_uri(uri)
|
||||
uri.open do |file|
|
||||
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
||||
end
|
||||
rescue SafeFetch::HttpError => e
|
||||
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||
rescue SafeFetch::FetchError
|
||||
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
|
||||
rescue SafeFetch::FileTooLargeError
|
||||
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
|
||||
rescue SafeFetch::UnsupportedContentTypeError
|
||||
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
|
||||
rescue SafeFetch::Error
|
||||
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
|
||||
rescue OpenURI::HTTPError => e
|
||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
||||
rescue SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
rescue StandardError
|
||||
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
|
||||
render_error('An unexpected error occurred', :internal_server_error)
|
||||
end
|
||||
|
||||
def create_and_save_blob(io, filename, content_type)
|
||||
|
||||
@@ -30,20 +30,9 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
locale: account_params[:locale],
|
||||
user: current_user
|
||||
).perform
|
||||
enqueue_branding_enrichment
|
||||
if @user
|
||||
# Authenticated users (dashboard "add account") and api_only signups
|
||||
# need the full response with account_id. API-only deployments have no
|
||||
# frontend to handle the email confirmation flow, so they need auth
|
||||
# tokens to proceed.
|
||||
# Unauthenticated web signup returns only the email — no session is
|
||||
# created until the user confirms via the email link.
|
||||
if current_user || api_only_signup?
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
else
|
||||
render json: { email: @user.email }
|
||||
end
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
else
|
||||
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
||||
end
|
||||
@@ -70,16 +59,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def ensure_account_name
|
||||
# ensure that account_name and user_full_name is present
|
||||
# this is becuase the account builder and the models validations are not triggered
|
||||
@@ -124,15 +103,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def api_only_signup?
|
||||
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
|
||||
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
|
||||
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
|
||||
# typecasts it to boolean, coercing 'api_only' to true.
|
||||
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
|
||||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
end
|
||||
|
||||
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
return unless conversation.nil?
|
||||
|
||||
@conversation = create_conversation
|
||||
apply_labels if permitted_params[:labels].present?
|
||||
end
|
||||
|
||||
def apply_labels
|
||||
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
|
||||
@conversation.update_labels(valid_labels) if valid_labels.present?
|
||||
@conversation = create_conversation if conversation.nil?
|
||||
end
|
||||
|
||||
def message_finder_params
|
||||
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
# custom_attributes and labels are applied when a new conversation is created alongside the first message
|
||||
params.permit(
|
||||
:id, :before, :after, :website_token,
|
||||
contact: [:name, :email],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
|
||||
custom_attributes: {},
|
||||
labels: []
|
||||
)
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Unauthenticated endpoint for resending confirmation emails during signup.
|
||||
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
|
||||
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
|
||||
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
|
||||
# Inherits from ActionController::API to avoid both issues entirely.
|
||||
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
|
||||
class Auth::ResendConfirmationsController < ActionController::API
|
||||
def create
|
||||
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
|
||||
email = params[:email]
|
||||
return head(:ok) unless email.is_a?(String)
|
||||
|
||||
user = User.from_email(email.strip.downcase)
|
||||
user&.send_confirmation_instructions unless user&.confirmed?
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
private
|
||||
|
||||
def sign_in_user
|
||||
# Capture before skip_confirmation! sets confirmed_at, which would
|
||||
# make oauth_user_needs_password_reset? return false and skip the
|
||||
# password reset for persisted unconfirmed users.
|
||||
needs_password_reset = oauth_user_needs_password_reset?
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
# See comment in sign_in_user for why this is captured before skip_confirmation!
|
||||
needs_password_reset = oauth_user_needs_password_reset?
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
create_account_for_user
|
||||
set_random_password_if_oauth_user
|
||||
token = @resource.send(:set_reset_password_token)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||
@@ -90,15 +81,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||
end
|
||||
|
||||
def oauth_user_needs_password_reset?
|
||||
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
|
||||
end
|
||||
|
||||
def set_random_password_if_oauth_user
|
||||
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
|
||||
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
|
||||
end
|
||||
|
||||
def default_devise_mapping
|
||||
'user'
|
||||
end
|
||||
|
||||
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
|
||||
include Linear::IntegrationHelper
|
||||
|
||||
def show
|
||||
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/linear/callback"
|
||||
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
|
||||
handle_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to safe_linear_redirect_uri
|
||||
redirect_to linear_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def handle_response
|
||||
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
|
||||
|
||||
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
|
||||
hook.assign_attributes(
|
||||
hook = account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
settings: merged_integration_settings(hook.settings)
|
||||
app_id: 'linear',
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
)
|
||||
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
|
||||
hook.save!
|
||||
redirect_to linear_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to safe_linear_redirect_uri
|
||||
redirect_to linear_redirect_uri
|
||||
end
|
||||
|
||||
def account
|
||||
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def account_id
|
||||
return @account_id if instance_variable_defined?(:@account_id)
|
||||
return unless params[:state]
|
||||
|
||||
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
|
||||
verify_linear_token(params[:state])
|
||||
end
|
||||
|
||||
def linear_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
||||
end
|
||||
|
||||
def safe_linear_redirect_uri
|
||||
return base_url if account_id.blank?
|
||||
|
||||
linear_redirect_uri
|
||||
rescue StandardError
|
||||
base_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def integration_settings
|
||||
{
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
expires_on: expires_on,
|
||||
scope: parsed_body['scope'],
|
||||
refresh_token: parsed_body['refresh_token']
|
||||
}.compact
|
||||
end
|
||||
|
||||
def merged_integration_settings(existing_settings)
|
||||
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
|
||||
end
|
||||
|
||||
def expires_on
|
||||
return if parsed_body['expires_in'].blank?
|
||||
|
||||
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
|
||||
before_action :set_account
|
||||
before_action :validate_account_permissible
|
||||
before_action :validate_feature_flag
|
||||
before_action :validate_params
|
||||
|
||||
def create
|
||||
results = migrate_email_channels
|
||||
render json: { results: results }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def validate_account_permissible
|
||||
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
|
||||
|
||||
render json: { error: 'Non permissible resource' }, status: :unauthorized
|
||||
end
|
||||
|
||||
def validate_feature_flag
|
||||
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
|
||||
|
||||
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
def validate_params
|
||||
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
|
||||
|
||||
return unless migration_params.size > MAX_MIGRATIONS
|
||||
|
||||
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def migrate_email_channels
|
||||
migration_params.map { |entry| migrate_single(entry) }
|
||||
end
|
||||
|
||||
MAX_MIGRATIONS = 25
|
||||
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
|
||||
|
||||
def migrate_single(entry)
|
||||
validate_provider!(entry[:provider])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel(entry)
|
||||
inbox = create_inbox(channel, entry)
|
||||
|
||||
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
|
||||
end
|
||||
rescue StandardError => e
|
||||
{ email: entry[:email], status: 'error', message: e.message }
|
||||
end
|
||||
|
||||
def create_channel(entry)
|
||||
Channel::Email.create!(
|
||||
account_id: @account.id,
|
||||
email: entry[:email],
|
||||
provider: entry[:provider],
|
||||
provider_config: entry[:provider_config]&.to_h,
|
||||
imap_enabled: entry.fetch(:imap_enabled, true),
|
||||
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
|
||||
imap_port: entry[:imap_port] || 993,
|
||||
imap_login: entry[:imap_login] || entry[:email],
|
||||
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
|
||||
)
|
||||
end
|
||||
|
||||
def create_inbox(channel, entry)
|
||||
@account.inboxes.create!(
|
||||
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def validate_provider!(provider)
|
||||
return if SUPPORTED_PROVIDERS.include?(provider)
|
||||
|
||||
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
|
||||
end
|
||||
|
||||
def default_imap_address(provider)
|
||||
case provider
|
||||
when 'google' then 'imap.gmail.com'
|
||||
when 'microsoft' then 'outlook.office365.com'
|
||||
else ''
|
||||
end
|
||||
end
|
||||
|
||||
def migration_params
|
||||
params.permit(migrations: [
|
||||
:email, :provider, :inbox_name,
|
||||
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
|
||||
{ provider_config: {} }
|
||||
])[:migrations]
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,11 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@search_query = list_params[:query]
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
|
||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||
@@ -62,7 +60,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@parsed_content = render_article_content(@article.content.to_s)
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
end
|
||||
|
||||
def set_category
|
||||
@@ -75,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
|
||||
permitted[:query] = permitted[:query].to_s.strip.presence
|
||||
end
|
||||
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show]
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@@ -25,7 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
def redirect_to_portal_with_locale
|
||||
return if params[:locale].present?
|
||||
|
||||
portal
|
||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
|
||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||
}, status: :unauthorized and return
|
||||
end
|
||||
|
||||
def ensure_portal_feature_enabled
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
return if @portal.account.feature_enabled?('help_center')
|
||||
|
||||
render 'public/api/v1/portals/not_active', status: :payment_required
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
|
||||
if @web_widget.allowed_domains.blank?
|
||||
response.headers.delete('X-Frame-Options')
|
||||
else
|
||||
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
||||
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
||||
end
|
||||
end
|
||||
|
||||
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
|
||||
# which cannot match any domain in frame-ancestors. When the per-inbox flag
|
||||
# is enabled, skip frame-ancestors for these requests.
|
||||
def embedded_from_non_web_origin?
|
||||
return false unless @web_widget.allow_mobile_webview?
|
||||
|
||||
origin = request.headers['Origin']
|
||||
origin.blank? || origin == 'null' || origin&.start_with?('file://')
|
||||
end
|
||||
end
|
||||
|
||||
WidgetsController.prepend_mod_with('WidgetsController')
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -98,9 +98,7 @@ export default {
|
||||
mql.onchange = e => setColorTheme(e.matches);
|
||||
},
|
||||
setLocale(locale) {
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
|
||||
@@ -25,10 +25,6 @@ class AgentBotsAPI extends ApiClient {
|
||||
resetAccessToken(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||
}
|
||||
|
||||
resetSecret(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -31,12 +31,6 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
@@ -48,10 +48,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
resetSecret(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -106,10 +106,6 @@ select {
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
|
||||
option:not(:disabled) {
|
||||
@apply bg-n-solid-2 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||
default: null,
|
||||
});
|
||||
|
||||
// Duration limits: 1 minute to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 1;
|
||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 10;
|
||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
+1
-2
@@ -27,7 +27,7 @@ const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||
const MIN_CONVERSATION_LIMIT = 0;
|
||||
const MIN_CONVERSATION_LIMIT = 1;
|
||||
const MAX_CONVERSATION_LIMIT = 100000;
|
||||
|
||||
const selectedInboxIds = computed(
|
||||
@@ -42,7 +42,6 @@ const availableInboxes = computed(() =>
|
||||
|
||||
const isLimitValid = limit => {
|
||||
return (
|
||||
Number.isInteger(limit.conversationLimit) &&
|
||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||
);
|
||||
|
||||
@@ -103,7 +103,6 @@ const showPagination = computed(() => {
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
class="mb-1"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
|
||||
+195
-51
@@ -1,63 +1,207 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
||||
};
|
||||
|
||||
const priorityLabels = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.priority && icons[props.priority]) {
|
||||
return icons[props.priority];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
||||
});
|
||||
|
||||
const tooltipContent = computed(() => {
|
||||
if (props.priority && priorityLabels[props.priority]) {
|
||||
return t(priorityLabels[props.priority]);
|
||||
}
|
||||
|
||||
if (props.showEmpty) {
|
||||
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: tooltipContent,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
|
||||
<template>
|
||||
<ButtonGroup
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
|
||||
+2
-18
@@ -1,22 +1,8 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -33,8 +19,6 @@ const locales = [
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -17,10 +17,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isDraft: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -41,28 +37,11 @@ const { t } = useI18n();
|
||||
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
||||
|
||||
const localeMenuLabels = computed(() => ({
|
||||
'change-default': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
||||
),
|
||||
'move-to-draft': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
||||
),
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
buildLocaleMenuItems({
|
||||
isDefault: props.isDefault,
|
||||
isDraft: props.isDraft,
|
||||
}).map(item => ({
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
...item,
|
||||
label: localeMenuLabels.value[item.action],
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -77,7 +56,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ localeLabel }}
|
||||
{{ locale }} ({{ localeCode }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
@@ -85,12 +64,6 @@ const handleAction = ({ action, value }) => {
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isDraft"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -113,7 +86,6 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="localeMenuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
|
||||
+37
-37
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
@@ -27,50 +27,58 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'saveArticle',
|
||||
'saveArticleAsync',
|
||||
'goBack',
|
||||
'setAuthor',
|
||||
'setCategory',
|
||||
'previewArticle',
|
||||
'createArticle',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isNewArticle = computed(() => !props.article?.id);
|
||||
|
||||
const localTitle = ref(props.article?.title ?? '');
|
||||
const localContent = ref(props.article?.content ?? '');
|
||||
const saveAndSync = value => {
|
||||
emit('saveArticle', value);
|
||||
};
|
||||
|
||||
// Sync local state when navigating to a different article or on initial fetch
|
||||
watch(
|
||||
() => props.article?.id,
|
||||
newId => {
|
||||
if (newId) {
|
||||
localTitle.value = props.article?.title ?? '';
|
||||
localContent.value = props.article?.content ?? '';
|
||||
}
|
||||
}
|
||||
// this will only send the data to the backend
|
||||
// but will not update the local state preventing unnecessary re-renders
|
||||
// since the data is already saved and we keep the editor text as the source of truth
|
||||
const quickSave = debounce(
|
||||
value => emit('saveArticleAsync', value),
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
||||
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
|
||||
// so we can save the data to the backend and retrieve the updated data
|
||||
// this will update the local state with response data
|
||||
// Only use to save for existing articles
|
||||
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
|
||||
|
||||
// Debounced save for new articles
|
||||
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
|
||||
|
||||
const handleSave = value => {
|
||||
if (isNewArticle.value) return;
|
||||
debouncedSave(value);
|
||||
if (isNewArticle.value) {
|
||||
quickSaveNewArticle(value);
|
||||
} else {
|
||||
quickSave(value);
|
||||
saveAndSyncDebounced(value);
|
||||
}
|
||||
};
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => localTitle.value,
|
||||
get: () => props.article.title,
|
||||
set: value => {
|
||||
localTitle.value = value;
|
||||
handleSave({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => localContent.value,
|
||||
get: () => props.article.content,
|
||||
set: content => {
|
||||
localContent.value = content;
|
||||
handleSave({ content });
|
||||
},
|
||||
});
|
||||
@@ -90,14 +98,6 @@ const setCategoryId = categoryId => {
|
||||
const previewArticle = () => {
|
||||
emit('previewArticle');
|
||||
};
|
||||
|
||||
const handleCreateArticle = event => {
|
||||
if (!isNewArticle.value) return;
|
||||
const title = event?.target?.value || '';
|
||||
if (title.trim()) {
|
||||
emit('createArticle', { title, content: localContent.value });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,11 +122,10 @@ const handleCreateArticle = event => {
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
:article="article"
|
||||
@save-article="values => emit('saveArticle', values)"
|
||||
@save-article="saveAndSync"
|
||||
@set-author="setAuthorId"
|
||||
@set-category="setCategoryId"
|
||||
/>
|
||||
@@ -161,12 +160,8 @@ const handleCreateArticle = event => {
|
||||
}
|
||||
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
@@ -174,10 +169,15 @@ const handleCreateArticle = event => {
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
@apply p-0 mt-0 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-48
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -24,20 +24,12 @@ const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
const localeStatus = ref('published');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const draftedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales
|
||||
.filter(locale => locale.draft)
|
||||
.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
@@ -49,44 +41,17 @@ const locales = computed(() => {
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{
|
||||
value: 'published',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
||||
},
|
||||
{
|
||||
value: 'draft',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
localeStatus.value = 'published';
|
||||
};
|
||||
|
||||
watch(localeStatus, value => {
|
||||
if (!value) {
|
||||
localeStatus.value = 'published';
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
const updatedDraftLocales =
|
||||
localeStatus.value === 'draft'
|
||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
||||
: draftedLocales.value;
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: {
|
||||
allowed_locales: updatedLocales,
|
||||
draft_locales: updatedDraftLocales,
|
||||
default_locale: props.portal?.meta?.default_locale,
|
||||
},
|
||||
});
|
||||
@@ -97,7 +62,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
resetForm();
|
||||
selectedLocale.value = '';
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
@@ -122,7 +87,6 @@ defineExpose({ dialogRef });
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@close="resetForm"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -134,16 +98,6 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="localeStatus"
|
||||
:options="statusOptions"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,6 @@ const isLocaleDefault = code => {
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
@@ -40,7 +39,6 @@ const updatePortalLocales = async ({
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
draft_locales: newDraftLocales,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,12 +53,8 @@ const updatePortalLocales = async ({
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const newDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
@@ -87,15 +81,11 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
const updatedDraftLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
newDraftLocales: updatedDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
@@ -108,46 +98,9 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const currentDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
const newDraftLocales = shouldDraft
|
||||
? [...new Set([...currentDraftLocales, localeCode])]
|
||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: props.portal.meta.default_locale,
|
||||
messageKey,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: true,
|
||||
messageKey: 'DRAFT_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const publishLocale = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: false,
|
||||
messageKey: 'PUBLISH_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'move-to-draft') {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -161,7 +114,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
|
||||
-12
@@ -4,49 +4,37 @@ import LocalesPage from './LocalesPage.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
code: 'en_GB',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
code: 'ms',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
code: 'ml',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
code: 'hi',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
|
||||
@@ -14,10 +14,6 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({ type: Boolean, default: false });
|
||||
@@ -32,8 +28,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -30,40 +30,23 @@ const modelValue = defineModel({
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => modelValue.value.size);
|
||||
const visibleItemIds = computed(() => props.allItems.map(item => item.id));
|
||||
const visibleItemCount = computed(() => visibleItemIds.value.length);
|
||||
const selectedVisibleCount = computed(
|
||||
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
|
||||
);
|
||||
const totalCount = computed(() => props.allItems.length);
|
||||
|
||||
const hasSelected = computed(() => selectedCount.value > 0);
|
||||
const isIndeterminate = computed(
|
||||
() =>
|
||||
selectedVisibleCount.value > 0 &&
|
||||
selectedVisibleCount.value < visibleItemCount.value
|
||||
() => hasSelected.value && selectedCount.value < totalCount.value
|
||||
);
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
visibleItemCount.value > 0 &&
|
||||
selectedVisibleCount.value === visibleItemCount.value
|
||||
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
|
||||
|
||||
const bulkCheckboxState = computed({
|
||||
get: () => allSelected.value,
|
||||
set: shouldSelectAll => {
|
||||
if (!visibleItemCount.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSelection = new Set(modelValue.value);
|
||||
if (shouldSelectAll) {
|
||||
visibleItemIds.value.forEach(id => updatedSelection.add(id));
|
||||
} else {
|
||||
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
|
||||
}
|
||||
modelValue.value = updatedSelection;
|
||||
const newSelectedIds = shouldSelectAll
|
||||
? new Set(props.allItems.map(item => item.id))
|
||||
: new Set();
|
||||
modelValue.value = newSelectedIds;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -80,7 +63,7 @@ const bulkCheckboxState = computed({
|
||||
v-if="hasSelected"
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
@@ -95,23 +78,21 @@ const bulkCheckboxState = computed({
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<slot name="secondary-actions" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot v-if="hasSecondaryActions" name="secondary-actions" />
|
||||
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -35,34 +34,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMenu: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'select', 'hover']);
|
||||
const emit = defineEmits(['action']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
@@ -100,23 +79,12 @@ const handleAction = ({ action, value }) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<CardLayout>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</span>
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
|
||||
+5
-11
@@ -21,22 +21,16 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => {
|
||||
const i18nTypeMap = {
|
||||
AssistantResponse: 'RESPONSES',
|
||||
AssistantDocument: 'DOCUMENTS',
|
||||
};
|
||||
return i18nTypeMap[props.type];
|
||||
});
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
|
||||
const handleBulkDelete = async ids => {
|
||||
if (!ids) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
||||
ids: Array.from(props.bulkIds),
|
||||
type: props.type,
|
||||
});
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkDelete',
|
||||
Array.from(props.bulkIds)
|
||||
);
|
||||
|
||||
emit('deleteSuccess');
|
||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||
|
||||
@@ -6,13 +6,6 @@ import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
defineProps({
|
||||
featurePrefix: {
|
||||
type: String,
|
||||
default: 'CAPTAIN',
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -38,7 +31,7 @@ const openBilling = () => {
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
:feature-prefix="featurePrefix"
|
||||
feature-prefix="CAPTAIN"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
|
||||
-7
@@ -27,7 +27,6 @@ const initialState = {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
citations: false,
|
||||
contactAttributes: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,7 +59,6 @@ const updateStateFromAssistant = assistant => {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
citations: config.feature_citation || false,
|
||||
contactAttributes: config.feature_contact_attributes || false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,7 +79,6 @@ const handleBasicInfoUpdate = async () => {
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
feature_citation: state.features.citations,
|
||||
feature_contact_attributes: state.features.contactAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -141,10 +138,6 @@ watch(
|
||||
<input v-model="state.features.citations" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.contactAttributes" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+6
-3
@@ -101,9 +101,12 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+6
-80
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -73,12 +72,8 @@ const DEFAULT_PARAM = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
||||
const MAX_TOOL_NAME_LENGTH = 55;
|
||||
|
||||
const validationRules = {
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
title: { required },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -108,15 +103,9 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
||||
if (failedRule === 'maxLength') {
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
||||
max: MAX_TOOL_NAME_LENGTH,
|
||||
});
|
||||
}
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
@@ -151,30 +140,6 @@ const handleSubmit = async () => {
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref(null);
|
||||
const isTestDisabled = computed(
|
||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!state.endpoint_url) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const { data } = await CustomToolsAPI.test(state);
|
||||
const isOk = data.status >= 200 && data.status < 300;
|
||||
testResult.value = { success: isOk, status: data.status };
|
||||
} catch (e) {
|
||||
const message =
|
||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
||||
testResult.value = { success: false, message };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -283,45 +248,6 @@ const handleTest = async () => {
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-play"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
||||
:is-loading="isTesting"
|
||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{
|
||||
testResult.status
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
||||
status: testResult.status,
|
||||
})
|
||||
: testResult.message
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
-12
@@ -1,11 +1,8 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -13,15 +10,6 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -36,13 +36,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'enterPress',
|
||||
'escapePress',
|
||||
'input',
|
||||
'blur',
|
||||
'focus',
|
||||
]);
|
||||
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: [String, Number],
|
||||
@@ -55,10 +49,6 @@ const onEnterPress = () => {
|
||||
emit('enterPress');
|
||||
};
|
||||
|
||||
const onEscapePress = () => {
|
||||
emit('escapePress');
|
||||
};
|
||||
|
||||
const handleInput = event => {
|
||||
emit('input', event.target.value);
|
||||
modelValue.value = event.target.value;
|
||||
@@ -112,7 +102,6 @@ defineExpose({
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown.enter.prevent="onEnterPress"
|
||||
@keydown.escape.prevent="onEscapePress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -226,10 +226,4 @@ const handleSeeOriginal = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Email clients (Gmail, Outlook) hardcode dir="ltr" on wrapper elements.
|
||||
// In RTL apps this forces email content LTR regardless of actual text.
|
||||
[dir='rtl'] .letter-render [dir='ltr'] {
|
||||
direction: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -250,12 +250,6 @@ const menuItems = computed(() => {
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Participating',
|
||||
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||
activeOn: ['conversation_through_participating'],
|
||||
to: accountScopedRoute('conversation_participating'),
|
||||
},
|
||||
{
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
|
||||
@@ -158,11 +158,9 @@ const activeChild = computed(() => {
|
||||
return rankedPage ?? activeOnPages[0];
|
||||
}
|
||||
|
||||
return navigableChildren.value.find(child => {
|
||||
if (!child.to) return false;
|
||||
const childPath = resolvePath(child.to);
|
||||
return route.path === childPath || route.path.startsWith(`${childPath}/`);
|
||||
});
|
||||
return navigableChildren.value.find(
|
||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||
);
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => {
|
||||
|
||||
@@ -56,7 +56,6 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
|
||||
import { conversationListPageURL } from '../helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnParticipatingView,
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import {
|
||||
@@ -114,7 +113,6 @@ const chatLists = useMapGetter('getFilteredConversations');
|
||||
const mineChatsList = useMapGetter('getMineChats');
|
||||
const allChatList = useMapGetter('getAllStatusChats');
|
||||
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
||||
const participatingChatsList = useMapGetter('getParticipatingChats');
|
||||
const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||
@@ -298,15 +296,13 @@ const pageTitle = computed(() => {
|
||||
if (props.label) {
|
||||
return `#${props.label}`;
|
||||
}
|
||||
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
|
||||
if (props.conversationType === 'mention') {
|
||||
return t('CHAT_LIST.MENTION_HEADING');
|
||||
}
|
||||
if (
|
||||
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||||
) {
|
||||
if (props.conversationType === 'participating') {
|
||||
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
|
||||
}
|
||||
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
|
||||
if (props.conversationType === 'unattended') {
|
||||
return t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
if (hasActiveFolders.value) {
|
||||
@@ -315,30 +311,12 @@ const pageTitle = computed(() => {
|
||||
return t('CHAT_LIST.TAB_HEADING');
|
||||
});
|
||||
|
||||
function filterByAssigneeTab(conversations) {
|
||||
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
|
||||
return conversations.filter(
|
||||
c => c.meta?.assignee?.id === currentUser.value?.id
|
||||
);
|
||||
}
|
||||
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
|
||||
return conversations.filter(c => !c.meta?.assignee);
|
||||
}
|
||||
return [...conversations];
|
||||
}
|
||||
|
||||
const conversationList = computed(() => {
|
||||
let localConversationList = [];
|
||||
|
||||
if (!hasAppliedFiltersOrActiveFolders.value) {
|
||||
const filters = conversationFilters.value;
|
||||
if (
|
||||
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||||
) {
|
||||
localConversationList = filterByAssigneeTab(
|
||||
participatingChatsList.value(filters)
|
||||
);
|
||||
} else if (activeAssigneeTab.value === 'me') {
|
||||
if (activeAssigneeTab.value === 'me') {
|
||||
localConversationList = [...mineChatsList.value(filters)];
|
||||
} else if (activeAssigneeTab.value === 'unassigned') {
|
||||
localConversationList = [...unAssignedChatsList.value(filters)];
|
||||
@@ -659,11 +637,9 @@ function redirectToConversationList() {
|
||||
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
|
||||
} else if (isOnParticipatingView({ route: { name } })) {
|
||||
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
|
||||
conversationType = 'mention';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
router.push(
|
||||
conversationListPageURL({
|
||||
|
||||
@@ -27,6 +27,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -204,7 +208,10 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="overflow-y-auto max-h-56">
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
||||
>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
||||
|
||||
@@ -313,12 +313,7 @@ const plugins = computed(() => {
|
||||
const sendWithSignature = computed(() => {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
if (
|
||||
props.allowSignature &&
|
||||
!props.isPrivate &&
|
||||
props.channelType &&
|
||||
!props.disabled
|
||||
) {
|
||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
}
|
||||
|
||||
@@ -441,7 +436,6 @@ function reloadState(content = props.modelValue) {
|
||||
}
|
||||
|
||||
function addSignature() {
|
||||
if (props.disabled) return;
|
||||
let content = props.modelValue;
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
@@ -460,7 +454,6 @@ function addSignature() {
|
||||
}
|
||||
|
||||
function removeSignature() {
|
||||
if (props.disabled) return;
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(
|
||||
@@ -813,7 +806,7 @@ watch(
|
||||
|
||||
watch(sendWithSignature, newValue => {
|
||||
// see if the allowSignature flag is true
|
||||
if (props.allowSignature && !props.disabled) {
|
||||
if (props.allowSignature) {
|
||||
toggleSignatureInEditor(newValue);
|
||||
}
|
||||
});
|
||||
@@ -835,8 +828,6 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focusEditorInputField });
|
||||
|
||||
// BUS Event to insert text or markdown into the editor at the
|
||||
// current cursor position.
|
||||
// Components using this
|
||||
@@ -984,32 +975,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
@apply overflow-auto;
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style:not(
|
||||
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
|
||||
) {
|
||||
@apply min-h-[5rem] max-h-[7.5rem];
|
||||
}
|
||||
|
||||
// Resizable editor wrapper styles
|
||||
.resizable-editor-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
min-height: clamp(
|
||||
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
||||
var(--editor-height, var(--editor-min-height, 5rem)),
|
||||
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
||||
);
|
||||
max-height: clamp(
|
||||
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
||||
var(--editor-height, var(--editor-min-height, 5rem)),
|
||||
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
||||
);
|
||||
transition:
|
||||
min-height var(--editor-height-transition, 180ms ease),
|
||||
max-height var(--editor-height-transition, 180ms ease);
|
||||
}
|
||||
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-backdrop::backdrop {
|
||||
|
||||
@@ -8,22 +8,13 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
triggerCharacters,
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
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 { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import SlashCommandMenu from './SlashCommandMenu.vue';
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const SLASH_MENU_OFFSET = 4;
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
@@ -49,7 +40,6 @@ let editorView = null;
|
||||
let state;
|
||||
|
||||
export default {
|
||||
components: { SlashCommandMenu },
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -72,15 +62,8 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
plugins: [
|
||||
imagePastePlugin(this.handleImageUpload),
|
||||
this.createSlashPlugin(),
|
||||
],
|
||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
||||
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
showSlashMenu: false,
|
||||
slashSearchTerm: '',
|
||||
slashRange: null,
|
||||
slashMenuPosition: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -96,7 +79,7 @@ export default {
|
||||
|
||||
created() {
|
||||
state = createState(
|
||||
this.modelValue || '',
|
||||
this.modelValue,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
@@ -112,126 +95,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createSlashPlugin() {
|
||||
return suggestionsPlugin({
|
||||
matcher: triggerCharacters('/', 0),
|
||||
suggestionClass: '',
|
||||
onEnter: args => {
|
||||
this.showSlashMenu = true;
|
||||
this.slashRange = args.range;
|
||||
this.slashSearchTerm = args.text || '';
|
||||
this.updateSlashMenuPosition(args.range.from);
|
||||
return false;
|
||||
},
|
||||
onChange: args => {
|
||||
this.slashRange = args.range;
|
||||
this.slashSearchTerm = args.text;
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
this.slashSearchTerm = '';
|
||||
this.showSlashMenu = false;
|
||||
this.slashMenuPosition = null;
|
||||
return false;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
return (
|
||||
event.keyCode === 13 &&
|
||||
this.showSlashMenu &&
|
||||
this.$refs.slashMenu?.hasItems
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
updateSlashMenuPosition(pos) {
|
||||
if (!editorView) return;
|
||||
const coords = editorView.coordsAtPos(pos);
|
||||
const editorRect = this.$refs.editor.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl';
|
||||
this.slashMenuPosition = {
|
||||
top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET,
|
||||
...(isRtl
|
||||
? { right: editorRect.right - coords.right }
|
||||
: { left: coords.left - editorRect.left }),
|
||||
};
|
||||
},
|
||||
removeSlashTriggerText() {
|
||||
if (!editorView || !this.slashRange) return;
|
||||
const { from, to } = this.slashRange;
|
||||
editorView.dispatch(editorView.state.tr.delete(from, to));
|
||||
state = editorView.state;
|
||||
},
|
||||
executeSlashCommand(actionKey) {
|
||||
if (!editorView) return;
|
||||
|
||||
this.removeSlashTriggerText();
|
||||
|
||||
const { schema } = editorView.state;
|
||||
const commandMap = {
|
||||
strong: () =>
|
||||
toggleMark(schema.marks.strong)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
em: () =>
|
||||
toggleMark(schema.marks.em)(editorView.state, editorView.dispatch),
|
||||
strike: () =>
|
||||
toggleMark(schema.marks.strike)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
code: () =>
|
||||
toggleMark(schema.marks.code)(editorView.state, editorView.dispatch),
|
||||
h1: () =>
|
||||
toggleBlockType(schema.nodes.heading, { level: 1 })(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
h2: () =>
|
||||
toggleBlockType(schema.nodes.heading, { level: 2 })(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
h3: () =>
|
||||
toggleBlockType(schema.nodes.heading, { level: 3 })(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
bulletList: () =>
|
||||
wrapInList(schema.nodes.bullet_list)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
orderedList: () =>
|
||||
wrapInList(schema.nodes.ordered_list)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
insertTable: () => {
|
||||
const { table, table_row, table_header, table_cell, paragraph } =
|
||||
schema.nodes;
|
||||
const headerCells = [0, 1, 2].map(() =>
|
||||
table_header.createAndFill(null, paragraph.create())
|
||||
);
|
||||
const dataCells = [0, 1, 2].map(() =>
|
||||
table_cell.createAndFill(null, paragraph.create())
|
||||
);
|
||||
const headerRow = table_row.create(null, headerCells);
|
||||
const dataRow = table_row.create(null, dataCells);
|
||||
const tableNode = table.create(null, [headerRow, dataRow]);
|
||||
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
|
||||
editorView.dispatch(tr.scrollIntoView());
|
||||
},
|
||||
};
|
||||
|
||||
const command = commandMap[actionKey];
|
||||
if (command) {
|
||||
command();
|
||||
state = editorView.state;
|
||||
this.emitOnChange();
|
||||
editorView.focus();
|
||||
}
|
||||
},
|
||||
contentFromEditor() {
|
||||
if (editorView) {
|
||||
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
|
||||
@@ -307,7 +170,7 @@ export default {
|
||||
},
|
||||
reloadState() {
|
||||
state = createState(
|
||||
this.modelValue || '',
|
||||
this.modelValue,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
@@ -399,8 +262,7 @@ export default {
|
||||
|
||||
// Get the editor's width
|
||||
const editorWidth = editor.offsetWidth;
|
||||
const menubar = editor.querySelector('.ProseMirror-menubar');
|
||||
const menubarWidth = menubar ? menubar.scrollWidth : 480;
|
||||
const menubarWidth = 480; // Menubar width (adjust as needed (px))
|
||||
|
||||
// Get the end position of the selection
|
||||
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
|
||||
@@ -428,15 +290,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="editor-root editor--article relative">
|
||||
<SlashCommandMenu
|
||||
v-if="showSlashMenu"
|
||||
ref="slashMenu"
|
||||
:search-key="slashSearchTerm"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:position="slashMenuPosition"
|
||||
@select-action="executeSlashCommand"
|
||||
/>
|
||||
<div class="editor-root editor--article">
|
||||
<input
|
||||
ref="imageUploadInput"
|
||||
type="file"
|
||||
|
||||
@@ -128,6 +128,7 @@ export default {
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
@@ -276,6 +277,9 @@ export default {
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
},
|
||||
replaceText(text) {
|
||||
this.$emit('replaceText', text);
|
||||
},
|
||||
toggleInsertArticle() {
|
||||
this.$emit('toggleInsertArticle');
|
||||
},
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
class="text-n-slate-11"
|
||||
sm
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('toggleEditorSize')"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
enabledMenuOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const EDITOR_ACTIONS = [
|
||||
{
|
||||
value: 'h1',
|
||||
labelKey: 'SLASH_COMMANDS.HEADING_1',
|
||||
icon: 'i-lucide-heading-1',
|
||||
menuKey: 'h1',
|
||||
},
|
||||
{
|
||||
value: 'h2',
|
||||
labelKey: 'SLASH_COMMANDS.HEADING_2',
|
||||
icon: 'i-lucide-heading-2',
|
||||
menuKey: 'h2',
|
||||
},
|
||||
{
|
||||
value: 'h3',
|
||||
labelKey: 'SLASH_COMMANDS.HEADING_3',
|
||||
icon: 'i-lucide-heading-3',
|
||||
menuKey: 'h3',
|
||||
},
|
||||
{
|
||||
value: 'strong',
|
||||
labelKey: 'SLASH_COMMANDS.BOLD',
|
||||
icon: 'i-lucide-bold',
|
||||
menuKey: 'strong',
|
||||
},
|
||||
{
|
||||
value: 'em',
|
||||
labelKey: 'SLASH_COMMANDS.ITALIC',
|
||||
icon: 'i-lucide-italic',
|
||||
menuKey: 'em',
|
||||
},
|
||||
{
|
||||
value: 'insertTable',
|
||||
labelKey: 'SLASH_COMMANDS.TABLE',
|
||||
icon: 'i-lucide-table',
|
||||
menuKey: 'insertTable',
|
||||
},
|
||||
{
|
||||
value: 'strike',
|
||||
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
|
||||
icon: 'i-lucide-strikethrough',
|
||||
menuKey: 'strike',
|
||||
},
|
||||
{
|
||||
value: 'code',
|
||||
labelKey: 'SLASH_COMMANDS.CODE',
|
||||
icon: 'i-lucide-code',
|
||||
menuKey: 'code',
|
||||
},
|
||||
{
|
||||
value: 'bulletList',
|
||||
labelKey: 'SLASH_COMMANDS.BULLET_LIST',
|
||||
icon: 'i-lucide-list',
|
||||
menuKey: 'bulletList',
|
||||
},
|
||||
{
|
||||
value: 'orderedList',
|
||||
labelKey: 'SLASH_COMMANDS.ORDERED_LIST',
|
||||
icon: 'i-lucide-list-ordered',
|
||||
menuKey: 'orderedList',
|
||||
},
|
||||
];
|
||||
|
||||
const listContainerRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const items = computed(() => {
|
||||
const search = props.searchKey.toLowerCase();
|
||||
return EDITOR_ACTIONS.filter(action => {
|
||||
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
|
||||
if (!search) return true;
|
||||
return t(action.labelKey).toLowerCase().includes(search);
|
||||
});
|
||||
});
|
||||
|
||||
const hasItems = computed(() => items.value.length > 0);
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
if (!props.position) return {};
|
||||
const style = { top: `${props.position.top}px` };
|
||||
if (props.position.right != null) {
|
||||
style.right = `${props.position.right}px`;
|
||||
} else {
|
||||
style.left = `${props.position.left}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
const container = listContainerRef.value;
|
||||
if (!container) return;
|
||||
const el = container.querySelector(`#slash-item-${selectedIndex.value}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
const item = items.value[selectedIndex.value];
|
||||
if (item) emit('selectAction', item.value);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
// Reset selection when filtered items change
|
||||
watch(items, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
const onHover = index => {
|
||||
selectedIndex.value = index;
|
||||
};
|
||||
|
||||
const onItemClick = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
|
||||
defineExpose({ hasItems });
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div
|
||||
v-if="hasItems"
|
||||
ref="listContainerRef"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[10rem] shadow-lg p-2 overflow-auto max-h-[15rem]"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:id="`slash-item-${index}`"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 border-0 rounded-lg text-n-slate-12 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
|
||||
}"
|
||||
@mouseover="onHover(index)"
|
||||
@click="onItemClick(index)"
|
||||
>
|
||||
<Icon :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||
<span class="min-w-0 text-sm truncate">
|
||||
{{ t(item.labelKey) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@ import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
@@ -305,7 +305,7 @@ const deleteConversation = () => {
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
@@ -317,10 +317,7 @@ const deleteConversation = () => {
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
|
||||
@@ -45,7 +45,6 @@ const backButtonUrl = computed(() => {
|
||||
|
||||
const conversationTypeMap = {
|
||||
conversation_through_mentions: 'mention',
|
||||
conversation_through_participating: 'participating',
|
||||
conversation_through_unattended: 'unattended',
|
||||
};
|
||||
return conversationListPageURL({
|
||||
|
||||
@@ -16,6 +16,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -65,6 +69,7 @@ const onSend = () => {
|
||||
:generated-content="generatedContent"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
:is-popout="isPopout"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { ref, provide, useTemplateRef } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { ref, provide } from 'vue';
|
||||
// composable
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
@@ -11,7 +11,6 @@ import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
|
||||
|
||||
// stores and apis
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -44,16 +43,21 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
ResizableEditorWrapper,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
const resizableEditorWrapperRef = ref(null);
|
||||
const messagesViewRef = useTemplateRef('messagesViewRef');
|
||||
const topBannerRef = useTemplateRef('topBannerRef');
|
||||
const { height: containerHeight } = useElementSize(messagesViewRef);
|
||||
const { height: topBannerHeight } = useElementSize(topBannerRef);
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const {
|
||||
captainTasksEnabled,
|
||||
@@ -64,15 +68,11 @@ export default {
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isPopOutReplyBox,
|
||||
captainTasksEnabled,
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
conversationPanelRef,
|
||||
resizableEditorWrapperRef,
|
||||
messagesViewRef,
|
||||
topBannerRef,
|
||||
containerHeight,
|
||||
topBannerHeight,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -254,7 +254,6 @@ export default {
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
this.resetReplyEditorHeight();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -438,37 +437,26 @@ export default {
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
},
|
||||
toggleReplyEditorSize() {
|
||||
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
|
||||
},
|
||||
resetReplyEditorHeight() {
|
||||
this.resizableEditorWrapperRef?.resetEditorHeight?.();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="messagesViewRef"
|
||||
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<MessageList
|
||||
ref="conversationPanelRef"
|
||||
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
|
||||
@@ -510,7 +498,13 @@ export default {
|
||||
/>
|
||||
</template>
|
||||
</MessageList>
|
||||
<div class="flex relative flex-col bg-n-surface-1">
|
||||
<div
|
||||
class="flex relative flex-col"
|
||||
:class="{
|
||||
'modal-mask': isPopOutReplyBox,
|
||||
'bg-n-surface-1': !isPopOutReplyBox,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
class="absolute flex items-center w-full h-0 -top-7"
|
||||
@@ -526,12 +520,42 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizableEditorWrapper
|
||||
ref="resizableEditorWrapperRef"
|
||||
:container-height="Math.max(0, containerHeight - topBannerHeight)"
|
||||
>
|
||||
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
|
||||
</ResizableEditorWrapper>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-mask {
|
||||
@apply fixed;
|
||||
|
||||
&::v-deep {
|
||||
.ProseMirror-woot-style {
|
||||
@apply max-h-[25rem];
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
@apply border border-n-weak max-w-[75rem] w-[70%];
|
||||
|
||||
&.is-private {
|
||||
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-box .reply-box__top {
|
||||
@apply relative min-h-[27.5rem];
|
||||
}
|
||||
|
||||
.reply-box__top .input {
|
||||
@apply min-h-[27.5rem];
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'PriorityMark',
|
||||
props: {
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
validate: value =>
|
||||
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONVERSATION_PRIORITY,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tooltipText() {
|
||||
return this.$t(
|
||||
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
|
||||
);
|
||||
},
|
||||
isUrgent() {
|
||||
return this.priority === CONVERSATION_PRIORITY.URGENT;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<span
|
||||
v-if="priority"
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
|
||||
'bg-n-slate-4 text-n-slate-11': !isUrgent,
|
||||
}"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="`priority-${priority.toLowerCase()}`"
|
||||
:size="isUrgent ? 12 : 14"
|
||||
class="flex-shrink-0"
|
||||
view-box="0 0 14 14"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -27,6 +27,7 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import {
|
||||
getMessageVariables,
|
||||
getUndefinedVariablesInMessage,
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
@@ -81,7 +82,13 @@ export default {
|
||||
CopilotReplyBottomPanel,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
emits: ['toggleEditorSize'],
|
||||
props: {
|
||||
popOutReplyBox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:popOutReplyBox'],
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
@@ -92,7 +99,6 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const messageEditor = useTemplateRef('messageEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
@@ -103,7 +109,6 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
messageEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
@@ -246,9 +251,6 @@ export default {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
||||
}
|
||||
if (this.isATiktokChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||
}
|
||||
@@ -505,7 +507,7 @@ export default {
|
||||
);
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
|
||||
// A hacky fix to solve the drag and drop
|
||||
// Is showing on top of new conversation modal drag and drop
|
||||
@@ -520,7 +522,7 @@ export default {
|
||||
unmounted() {
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
||||
emitter.off(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
@@ -541,10 +543,7 @@ export default {
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(
|
||||
message || '',
|
||||
this.maxLength
|
||||
);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
@@ -602,7 +601,7 @@ export default {
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '', this.maxLength);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
key,
|
||||
@@ -629,17 +628,10 @@ export default {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
|
||||
// still normalize stale signatures out of drafts when signature is off.
|
||||
if (this.isEditorDisabled && this.sendWithSignature) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
@@ -791,6 +783,7 @@ export default {
|
||||
|
||||
this.clearMessage();
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
@@ -910,6 +903,32 @@ export default {
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message,
|
||||
variables: this.messageVariables,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = updatedMessage;
|
||||
}, 100);
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
@@ -1172,15 +1191,6 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onReplyToMessage() {
|
||||
this.fetchAndSetReplyTo();
|
||||
if (this.inReplyTo) {
|
||||
this.$nextTick(() => {
|
||||
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
|
||||
this.messageEditor?.focusEditorInputField(pos);
|
||||
});
|
||||
}
|
||||
},
|
||||
resetReplyToMessage() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
@@ -1210,9 +1220,8 @@ export default {
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
toggleEditorSize() {
|
||||
this.$emit('toggleEditorSize');
|
||||
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
|
||||
togglePopout() {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
@@ -1238,8 +1247,9 @@ export default {
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-editor-size="toggleEditorSize"
|
||||
@toggle-popout="togglePopout"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
@@ -1268,7 +1278,7 @@ export default {
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout,
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
@@ -1292,6 +1302,7 @@ export default {
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:is-popout="popOutReplyBox"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@@ -1302,7 +1313,6 @@ export default {
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
ref="messageEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
@@ -1407,6 +1417,7 @@ export default {
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,6 @@ const emit = defineEmits(['dismiss']);
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const props = defineProps({
|
||||
containerHeight: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT = 120;
|
||||
const MIN_HEIGHT = 80;
|
||||
const MIN_MESSAGES_HEIGHT = 200;
|
||||
const EXPAND_RATIO = 0.5;
|
||||
const RESET_DELAY_MS = 120;
|
||||
|
||||
const wrapperRef = useTemplateRef('wrapperRef');
|
||||
const surroundingHeight = ref(0);
|
||||
const editorHeight = ref(DEFAULT_HEIGHT);
|
||||
const isResizing = ref(false);
|
||||
const startY = ref(0);
|
||||
const startHeight = ref(0);
|
||||
let resetTimeoutId = null;
|
||||
|
||||
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
|
||||
|
||||
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
|
||||
const measureSurroundingHeight = () => {
|
||||
if (wrapperRef.value) {
|
||||
surroundingHeight.value = Math.max(
|
||||
0,
|
||||
wrapperRef.value.offsetHeight - editorHeight.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isContainerReady = computed(() => props.containerHeight > 0);
|
||||
|
||||
const sizeBounds = computed(() => {
|
||||
const h = props.containerHeight;
|
||||
const s = surroundingHeight.value;
|
||||
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
|
||||
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
|
||||
return {
|
||||
min: MIN_HEIGHT,
|
||||
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
|
||||
expanded,
|
||||
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
|
||||
};
|
||||
});
|
||||
|
||||
const clampToBounds = val =>
|
||||
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
|
||||
|
||||
const clearDragStyles = () => {
|
||||
Object.assign(document.body.style, { cursor: '', userSelect: '' });
|
||||
};
|
||||
|
||||
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
|
||||
|
||||
const onResizeStart = event => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
isResizing.value = true;
|
||||
startY.value = getClientY(event);
|
||||
startHeight.value = clampToBounds(editorHeight.value);
|
||||
editorHeight.value = startHeight.value;
|
||||
Object.assign(document.body.style, {
|
||||
cursor: 'row-resize',
|
||||
userSelect: 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const onResizeMove = event => {
|
||||
if (!isResizing.value) return;
|
||||
if (event.touches) event.preventDefault();
|
||||
editorHeight.value = clampToBounds(
|
||||
startHeight.value + startY.value - getClientY(event)
|
||||
);
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return;
|
||||
isResizing.value = false;
|
||||
clearDragStyles();
|
||||
};
|
||||
|
||||
const resetEditorHeight = () => {
|
||||
editorHeight.value = sizeBounds.value.default;
|
||||
};
|
||||
|
||||
const toggleEditorExpand = () => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
const { expanded, max, default: defaultHeight } = sizeBounds.value;
|
||||
const isExpanded = editorHeight.value > defaultHeight;
|
||||
// If expanded is too close to default, use max so the toggle is always noticeable
|
||||
const target = expanded - defaultHeight < 100 ? max : expanded;
|
||||
editorHeight.value = isExpanded ? defaultHeight : target;
|
||||
};
|
||||
|
||||
const handleMessageSent = () => {
|
||||
clearTimeout(resetTimeoutId);
|
||||
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
|
||||
clearTimeout(resetTimeoutId);
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false;
|
||||
clearDragStyles();
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener(document, 'mousemove', onResizeMove);
|
||||
useEventListener(document, 'mouseup', onResizeEnd);
|
||||
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
|
||||
useEventListener(document, 'touchend', onResizeEnd);
|
||||
useEventListener(document, 'touchcancel', onResizeEnd);
|
||||
useEventListener(window, 'blur', onResizeEnd);
|
||||
|
||||
defineExpose({ toggleEditorExpand, resetEditorHeight });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="relative resizable-editor-wrapper"
|
||||
:style="{
|
||||
'--editor-height': editorHeight + 'px',
|
||||
'--editor-min-allowed': sizeBounds.min + 'px',
|
||||
'--editor-max-allowed': sizeBounds.max + 'px',
|
||||
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
|
||||
@mousedown="onResizeStart"
|
||||
@touchstart.prevent="onResizeStart"
|
||||
@dblclick="resetEditorHeight"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
|
||||
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
agents,
|
||||
teams,
|
||||
labels,
|
||||
booleanFilterOptions,
|
||||
statusFilterOptions,
|
||||
messageTypeOptions,
|
||||
priorityOptions,
|
||||
@@ -74,8 +73,6 @@ describe('useAutomation', () => {
|
||||
return countries;
|
||||
case 'message_type':
|
||||
return messageTypeOptions;
|
||||
case 'private_note':
|
||||
return booleanFilterOptions;
|
||||
case 'priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
@@ -92,9 +89,7 @@ describe('useAutomation', () => {
|
||||
case 'assign_team':
|
||||
return teams;
|
||||
case 'assign_agent':
|
||||
return options.addNoneToListFn
|
||||
? options.addNoneToListFn(options.agents)
|
||||
: options.agents;
|
||||
return agents;
|
||||
case 'send_email_to_team':
|
||||
return teams;
|
||||
case 'send_message':
|
||||
@@ -231,9 +226,6 @@ describe('useAutomation', () => {
|
||||
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||
messageTypeOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('private_note')).toEqual(
|
||||
booleanFilterOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
@@ -242,11 +234,7 @@ describe('useAutomation', () => {
|
||||
|
||||
expect(getActionDropdownValues('add_label')).toEqual(labels);
|
||||
expect(getActionDropdownValues('assign_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('assign_agent')).toEqual([
|
||||
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
|
||||
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
|
||||
...agents,
|
||||
]);
|
||||
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
|
||||
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEditableAutomation } from '../useEditableAutomation';
|
||||
import useAutomationValues from '../useAutomationValues';
|
||||
|
||||
vi.mock('../useAutomationValues');
|
||||
|
||||
describe('useEditableAutomation', () => {
|
||||
beforeEach(() => {
|
||||
useAutomationValues.mockReturnValue({
|
||||
getConditionDropdownValues: vi.fn(attributeKey => {
|
||||
if (attributeKey === 'private_note') {
|
||||
return [
|
||||
{ id: true, name: 'True' },
|
||||
{ id: false, name: 'False' },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
getActionDropdownValues: vi.fn(actionName => {
|
||||
if (actionName === 'assign_agent') {
|
||||
return [
|
||||
{ id: 'nil', name: 'None' },
|
||||
{
|
||||
id: 'last_responding_agent',
|
||||
name: 'Last Responding Agent',
|
||||
},
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rehydrates boolean conditions as a single selected option', () => {
|
||||
const automation = {
|
||||
event_name: 'message_created',
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'private_note',
|
||||
filter_operator: 'equal_to',
|
||||
values: [false],
|
||||
query_operator: null,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
};
|
||||
const automationTypes = {
|
||||
message_created: {
|
||||
conditions: [{ key: 'private_note', inputType: 'search_select' }],
|
||||
},
|
||||
};
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
const result = formatAutomation(automation, [], automationTypes, []);
|
||||
|
||||
expect(result.conditions).toEqual([
|
||||
{
|
||||
attribute_key: 'private_note',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: false, name: 'False' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rehydrates last responding agent as a selected action option', () => {
|
||||
const automation = {
|
||||
event_name: 'conversation_created',
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: ['last_responding_agent'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const automationActionTypes = [
|
||||
{ key: 'assign_agent', inputType: 'search_select' },
|
||||
];
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
const result = formatAutomation(automation, [], {}, automationActionTypes);
|
||||
|
||||
expect(result.actions).toEqual([
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: [
|
||||
{
|
||||
id: 'last_responding_agent',
|
||||
name: 'Last Responding Agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -119,30 +119,24 @@ describe('useMacros', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
|
||||
expect(getMacroDropdownValues('assign_team')).toHaveLength(
|
||||
mockTeams.length + 1
|
||||
); // +1 for "None"
|
||||
mockTeams.length
|
||||
);
|
||||
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
|
||||
mockAgents.length + 2
|
||||
); // +2 for "None" and "Self"
|
||||
mockAgents.length + 1
|
||||
); // +1 for "Self"
|
||||
});
|
||||
|
||||
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
|
||||
it('returns teams for assign_team and send_email_to_team types', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const assignTeamResult = getMacroDropdownValues('assign_team');
|
||||
expect(assignTeamResult[0]).toEqual({
|
||||
id: 'nil',
|
||||
name: 'AUTOMATION.NONE_OPTION',
|
||||
});
|
||||
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
|
||||
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
|
||||
});
|
||||
|
||||
it('returns agents with "None" and "Self" options for assign_agent type', () => {
|
||||
it('returns agents with "Self" option for assign_agent type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const result = getMacroDropdownValues('assign_agent');
|
||||
expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
|
||||
expect(result[1]).toEqual({ id: 'self', name: 'Self' });
|
||||
expect(result.slice(2)).toEqual(mockAgents);
|
||||
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
|
||||
expect(result.slice(1)).toEqual(mockAgents);
|
||||
});
|
||||
|
||||
it('returns formatted labels for add_label and remove_label types', () => {
|
||||
@@ -178,11 +172,8 @@ describe('useMacros', () => {
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('add_label')).toEqual([]);
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual([
|
||||
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
|
||||
]);
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual([]);
|
||||
expect(getMacroDropdownValues('assign_agent')).toEqual([
|
||||
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
|
||||
{ id: 'self', name: 'Self' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -121,19 +121,8 @@ export default function useAutomationValues() {
|
||||
* @returns {Array} An array of action dropdown values.
|
||||
*/
|
||||
const getActionDropdownValues = type => {
|
||||
let agentsList = agents.value;
|
||||
if (type === 'assign_agent') {
|
||||
agentsList = [
|
||||
{
|
||||
id: 'last_responding_agent',
|
||||
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
|
||||
},
|
||||
...agentsList,
|
||||
];
|
||||
}
|
||||
|
||||
return getActionOptions({
|
||||
agents: agentsList,
|
||||
agents: agents.value,
|
||||
labels: labels.value,
|
||||
teams: teams.value,
|
||||
slaPolicies: slaPolicies.value,
|
||||
|
||||
@@ -46,26 +46,11 @@ export function useEditableAutomation() {
|
||||
if (inputType === 'comma_separated_plain_text') {
|
||||
return { ...condition, values: condition.values.join(',') };
|
||||
}
|
||||
const dropdownValues = getConditionDropdownValues(
|
||||
condition.attribute_key
|
||||
);
|
||||
const hasBooleanOptions =
|
||||
inputType === 'search_select' &&
|
||||
dropdownValues.length &&
|
||||
dropdownValues.every(item => typeof item.id === 'boolean');
|
||||
|
||||
if (hasBooleanOptions) {
|
||||
return {
|
||||
...condition,
|
||||
query_operator: condition.query_operator || 'and',
|
||||
values: dropdownValues.find(item => item.id === condition.values[0]),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...condition,
|
||||
query_operator: condition.query_operator || 'and',
|
||||
values: [...dropdownValues].filter(item =>
|
||||
[...condition.values].includes(item.id)
|
||||
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
|
||||
item => [...condition.values].includes(item.id)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -12,14 +12,11 @@ import { useKeyboardEvents } from './useKeyboardEvents';
|
||||
|
||||
/**
|
||||
* Wrap the action in a function that calls the action and prevents the default event behavior.
|
||||
* Only prevents default when items are available to navigate.
|
||||
* @param {Function} action - The action to be called.
|
||||
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
|
||||
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
|
||||
*/
|
||||
const createAction = (action, items) => ({
|
||||
const createAction = action => ({
|
||||
action: e => {
|
||||
if (!items.value?.length) return;
|
||||
action();
|
||||
e.preventDefault();
|
||||
},
|
||||
@@ -41,14 +38,15 @@ const createKeyboardEvents = (
|
||||
items
|
||||
) => {
|
||||
const events = {
|
||||
ArrowUp: createAction(moveSelectionUp, items),
|
||||
'Control+KeyP': createAction(moveSelectionUp, items),
|
||||
ArrowDown: createAction(moveSelectionDown, items),
|
||||
'Control+KeyN': createAction(moveSelectionDown, items),
|
||||
ArrowUp: createAction(moveSelectionUp),
|
||||
'Control+KeyP': createAction(moveSelectionUp),
|
||||
ArrowDown: createAction(moveSelectionDown),
|
||||
'Control+KeyN': createAction(moveSelectionDown),
|
||||
};
|
||||
|
||||
// Adds an event handler for the Enter key if the onSelect function is provided.
|
||||
if (typeof onSelect === 'function') {
|
||||
events.Enter = createAction(onSelect, items);
|
||||
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
|
||||
}
|
||||
|
||||
return events;
|
||||
|
||||
@@ -15,11 +15,6 @@ export const useMacros = () => {
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
const withNoneOption = options => [
|
||||
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
|
||||
...(options || []),
|
||||
];
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
* @param {string} type - The type of dropdown values to retrieve
|
||||
@@ -28,15 +23,10 @@ export const useMacros = () => {
|
||||
const getMacroDropdownValues = type => {
|
||||
switch (type) {
|
||||
case 'assign_team':
|
||||
return withNoneOption(teams.value);
|
||||
case 'send_email_to_team':
|
||||
return teams.value;
|
||||
case 'assign_agent':
|
||||
return [
|
||||
...withNoneOption(),
|
||||
{ id: 'self', name: 'Self' },
|
||||
...agents.value,
|
||||
];
|
||||
return [{ id: 'self', name: 'Self' }, ...agents.value];
|
||||
case 'add_label':
|
||||
case 'remove_label':
|
||||
return labels.value.map(i => ({
|
||||
|
||||
@@ -172,7 +172,6 @@ export const FORMATTING = {
|
||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'strike',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
@@ -183,7 +182,6 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'h3',
|
||||
'imageUpload',
|
||||
'code',
|
||||
'insertTable',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,11 +12,6 @@ export default {
|
||||
SNOOZED: 'snoozed',
|
||||
ALL: 'all',
|
||||
},
|
||||
CONVERSATION_TYPE: {
|
||||
MENTION: 'mention',
|
||||
PARTICIPATING: 'participating',
|
||||
UNATTENDED: 'unattended',
|
||||
},
|
||||
SORT_BY_TYPE: {
|
||||
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
|
||||
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
|
||||
|
||||
@@ -37,7 +37,6 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
@@ -50,7 +49,6 @@ export const FEATURE_FLAGS = {
|
||||
export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.SLA,
|
||||
FEATURE_FLAGS.CAPTAIN,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
|
||||
@@ -51,7 +51,6 @@ export const conversationListPageURL = ({
|
||||
} else if (conversationType) {
|
||||
const urlMap = {
|
||||
mention: 'mentions/conversations',
|
||||
participating: 'participating/conversations',
|
||||
unattended: 'unattended/conversations',
|
||||
};
|
||||
url = `accounts/${accountId}/${urlMap[conversationType]}`;
|
||||
|
||||
@@ -150,7 +150,6 @@ export const getConditionOptions = ({
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: messageTypeOptions,
|
||||
private_note: booleanFilterOptions,
|
||||
priority: priorityOptions,
|
||||
labels: generateConditionOptions(labels, 'title'),
|
||||
};
|
||||
|
||||
@@ -32,25 +32,6 @@ export function extractTextFromMarkdown(markdown) {
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes inline base64 markdown images from signature content.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
|
||||
*/
|
||||
export function stripInlineBase64Images(content) {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { sanitizedContent: content || '', hasInlineImages: false };
|
||||
}
|
||||
|
||||
const markdownInlineBase64ImageRegex =
|
||||
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
|
||||
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
|
||||
const hasInlineImages = sanitizedContent !== content;
|
||||
|
||||
return { sanitizedContent, hasInlineImages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
* Uses MARKDOWN_PATTERNS from editor constants.
|
||||
|
||||
@@ -133,55 +133,20 @@ export const ARTICLE_TABS_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCALE_MENU_ITEMS = {
|
||||
makeDefault: {
|
||||
export const LOCALE_MENU_ITEMS = [
|
||||
{
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
|
||||
action: 'change-default',
|
||||
value: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
},
|
||||
moveToDraft: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
|
||||
action: 'move-to-draft',
|
||||
value: 'draft',
|
||||
icon: 'i-lucide-eye-off',
|
||||
},
|
||||
publishLocale: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
|
||||
action: 'publish-locale',
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
delete: {
|
||||
{
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
};
|
||||
|
||||
const disableLocaleMenuItems = menuItems =>
|
||||
menuItems.map(item => ({ ...item, disabled: true }));
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
];
|
||||
|
||||
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
|
||||
published: ['archive', 'draft'],
|
||||
|
||||
@@ -166,8 +166,6 @@ const TOD_TO_MERIDIEM = {
|
||||
evening: 'pm',
|
||||
night: 'pm',
|
||||
};
|
||||
const CJK_CHAR_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -280,13 +278,8 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const substituteLocalTokens = (text, pairs) => {
|
||||
let r = text;
|
||||
pairs.forEach(([local, en]) => {
|
||||
if (CJK_CHAR_RE.test(local)) {
|
||||
const re = new RegExp(escapeRegex(local), 'g');
|
||||
r = r.replace(re, ` ${en} `);
|
||||
} else {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
}
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
@@ -82,9 +82,6 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||
const RELATIVE_DURATION_AFTER_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
|
||||
);
|
||||
const DURATION_FROM_NOW_RE = new RegExp(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
@@ -92,9 +89,6 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
|
||||
);
|
||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
@@ -251,7 +245,6 @@ const matchDuration = (text, now) => {
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
@@ -310,13 +303,6 @@ const matchRelativeDay = (text, now) => {
|
||||
);
|
||||
}
|
||||
|
||||
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
|
||||
if (dayMeridiemMatch) {
|
||||
const [, dayKey, meridiem] = dayMeridiemMatch;
|
||||
const hours = meridiem === 'am' ? 9 : 14;
|
||||
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
|
||||
}
|
||||
|
||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||
if (dayAtTimeMatch) {
|
||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||
|
||||
@@ -40,15 +40,6 @@ describe('#URL Helpers', () => {
|
||||
'/app/accounts/1/custom_view/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return url to participating conversations', () => {
|
||||
expect(
|
||||
conversationListPageURL({
|
||||
accountId: 1,
|
||||
conversationType: 'participating',
|
||||
})
|
||||
).toBe('/app/accounts/1/participating/conversations');
|
||||
});
|
||||
});
|
||||
describe('conversationUrl', () => {
|
||||
it('should return direct conversation URL if activeInbox is nil', () => {
|
||||
|
||||
@@ -178,21 +178,6 @@ describe('getConditionOptions', () => {
|
||||
})
|
||||
).toEqual(testOptions);
|
||||
});
|
||||
|
||||
it('returns boolean options for private_note', () => {
|
||||
const booleanOptions = [
|
||||
{ id: true, name: 'True' },
|
||||
{ id: false, name: 'False' },
|
||||
];
|
||||
|
||||
expect(
|
||||
helpers.getConditionOptions({
|
||||
booleanFilterOptions: booleanOptions,
|
||||
customAttributes,
|
||||
type: 'private_note',
|
||||
})
|
||||
).toEqual(booleanOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileName', () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getMenuAnchor,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
@@ -424,36 +423,6 @@ describe('extractTextFromMarkdown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripInlineBase64Images', () => {
|
||||
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
|
||||
const content =
|
||||
'Hello\n\nWorld';
|
||||
const { sanitizedContent, hasInlineImages } =
|
||||
stripInlineBase64Images(content);
|
||||
|
||||
expect(hasInlineImages).toBe(true);
|
||||
expect(sanitizedContent).not.toContain('data:image/png;base64');
|
||||
expect(sanitizedContent).toContain('Hello');
|
||||
expect(sanitizedContent).toContain('World');
|
||||
});
|
||||
|
||||
it('leaves hosted image markdown unchanged', () => {
|
||||
const content = '';
|
||||
const { sanitizedContent, hasInlineImages } =
|
||||
stripInlineBase64Images(content);
|
||||
|
||||
expect(hasInlineImages).toBe(false);
|
||||
expect(sanitizedContent).toBe(content);
|
||||
});
|
||||
|
||||
it('returns empty hasInlineImages for empty input', () => {
|
||||
expect(stripInlineBase64Images('')).toEqual({
|
||||
sanitizedContent: '',
|
||||
hasInlineImages: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
@@ -45,10 +45,6 @@ describe('#resolveTeamIds', () => {
|
||||
const resolvedTeams = '⚙️ sales team, 🤷♂️ fayaz';
|
||||
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
|
||||
});
|
||||
|
||||
it('resolves nil as None', () => {
|
||||
expect(resolveTeamIds(teams, ['nil'])).toEqual('None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveLabels', () => {
|
||||
@@ -63,10 +59,6 @@ describe('#resolveAgents', () => {
|
||||
const resolvedAgents = 'John Doe';
|
||||
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
|
||||
});
|
||||
|
||||
it('resolves nil and self values', () => {
|
||||
expect(resolveAgents(agents, ['nil', 'self'])).toEqual('None, Self');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getFileName', () => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
buildLocaleMenuItems,
|
||||
buildPortalArticleURL,
|
||||
buildPortalURL,
|
||||
} from '../portalHelper';
|
||||
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
|
||||
|
||||
describe('PortalHelper', () => {
|
||||
describe('buildPortalURL', () => {
|
||||
@@ -72,39 +68,4 @@ describe('PortalHelper', () => {
|
||||
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1626,24 +1626,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const zhTWSnoozeTranslations = {
|
||||
UNITS: {
|
||||
HOUR: '小時',
|
||||
HOURS: '小時',
|
||||
DAY: '天',
|
||||
DAYS: '天',
|
||||
},
|
||||
HALF: '半',
|
||||
RELATIVE: {
|
||||
TOMORROW: '明天',
|
||||
},
|
||||
MERIDIEM: {
|
||||
AM: '上午',
|
||||
PM: '下午',
|
||||
},
|
||||
AFTER: '後',
|
||||
};
|
||||
|
||||
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
|
||||
it('Arabic "غد" does not produce half-duration suggestions', () => {
|
||||
const results = generateDateSuggestions('غد', now, {
|
||||
@@ -1739,37 +1721,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
||||
expect(results[0].date.getHours()).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh_TW compact CJK inputs', () => {
|
||||
const options = {
|
||||
translations: zhTWSnoozeTranslations,
|
||||
locale: 'zh-TW',
|
||||
};
|
||||
|
||||
it('parses "2小時後" (2 hours from now) without spaces', () => {
|
||||
const results = generateDateSuggestions('2小時後', now, options);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].date.getDate()).toBe(16);
|
||||
expect(results[0].date.getHours()).toBe(12);
|
||||
expect(results[0].date.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('parses "半天" (half day) without spaces', () => {
|
||||
const results = generateDateSuggestions('半天', now, options);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].date.getDate()).toBe(16);
|
||||
expect(results[0].date.getHours()).toBe(22);
|
||||
expect(results[0].date.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => {
|
||||
const results = generateDateSuggestions('明天 上午', now, options);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].date.getDate()).toBe(17);
|
||||
expect(results[0].date.getHours()).toBe(9);
|
||||
expect(results[0].date.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-space duration suggestions', () => {
|
||||
|
||||
@@ -125,7 +125,6 @@ const validateSingleAction = action => {
|
||||
'mute_conversation',
|
||||
'snooze_conversation',
|
||||
'resolve_conversation',
|
||||
'remove_assigned_agent',
|
||||
'remove_assigned_team',
|
||||
'open_conversation',
|
||||
'pending_conversation',
|
||||
|
||||
@@ -63,16 +63,6 @@
|
||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||
}
|
||||
},
|
||||
"SECRET": {
|
||||
"LABEL": "Webhook Secret",
|
||||
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
|
||||
"COPY_SUCCESS": "ምስጢሩ ወደ ክሊፕቦርድ ተቀድሷል",
|
||||
"TOGGLE": "የምስጢሩን ማየት አሳይ/ደብቅ",
|
||||
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
|
||||
"DONE": "ተጠናቀቀ",
|
||||
"RESET_SUCCESS": "Webhook secret regenerated successfully",
|
||||
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"DESCRIPTION": "Copy the access token and save it securely",
|
||||
|
||||
@@ -140,8 +140,6 @@
|
||||
"ACTIONS": {
|
||||
"ASSIGN_AGENT": "Assign to Agent",
|
||||
"ASSIGN_TEAM": "Assign a Team",
|
||||
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
|
||||
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
|
||||
"ADD_LABEL": "Add a Label",
|
||||
"REMOVE_LABEL": "Remove a Label",
|
||||
"SEND_EMAIL_TO_TEAM": "Send an Email to Team",
|
||||
@@ -171,7 +169,6 @@
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
"PRIVATE_NOTE": "የግል ማስታወሻ",
|
||||
"MESSAGE_CONTAINS": "Message Contains",
|
||||
"EMAIL": "Email",
|
||||
"INBOX": "Inbox",
|
||||
|
||||
@@ -52,17 +52,5 @@
|
||||
},
|
||||
"CHANNEL_SELECTOR": {
|
||||
"COMING_SOON": "Coming Soon!"
|
||||
},
|
||||
"SLASH_COMMANDS": {
|
||||
"HEADING_1": "Heading 1",
|
||||
"HEADING_2": "Heading 2",
|
||||
"HEADING_3": "Heading 3",
|
||||
"BOLD": "Bold",
|
||||
"ITALIC": "Italic",
|
||||
"STRIKETHROUGH": "Strikethrough",
|
||||
"CODE": "Code",
|
||||
"BULLET_LIST": "Bullet List",
|
||||
"ORDERED_LIST": "Ordered List",
|
||||
"TABLE": "Table"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@
|
||||
"SWITCH_VIEW_LAYOUT": "Switch the layout",
|
||||
"DASHBOARD_APP_TAB_MESSAGES": "Messages",
|
||||
"UNVERIFIED_SESSION": "The identity of this user is not verified",
|
||||
"NO_MESSAGE_1": "የደንበኞች መልእክቶች በኢንቦክስዎ አልተገኙም።",
|
||||
"NO_MESSAGE_2": " ወደ ገፅዎ መልእክት ለመላክ!",
|
||||
"NO_INBOX_1": "እሺ! አሁን ምንም ኢንቦክስ አልጨመሩም።",
|
||||
"NO_INBOX_2": " ለመጀመር",
|
||||
"NO_INBOX_AGENT": "ወይ! ምንም ኢንቦክስ አባል አይደለህም። እባክዎ አስተዳዳሪዎን ያነጋግሩ",
|
||||
"SEARCH_MESSAGES": "መልእክቶችን በውይይቶች ውስጥ ይፈልጉ",
|
||||
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
|
||||
"NO_MESSAGE_2": " to send a message to your page!",
|
||||
"NO_INBOX_1": "Hola! Looks like you haven't added any inboxes yet.",
|
||||
"NO_INBOX_2": " to get started",
|
||||
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator",
|
||||
"SEARCH_MESSAGES": "Search for messages in conversations",
|
||||
"VIEW_ORIGINAL": "View original",
|
||||
"VIEW_TRANSLATED": "View translated",
|
||||
"EMPTY_STATE": {
|
||||
@@ -19,19 +19,19 @@
|
||||
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "መልእክቶችን ይፈልጉ",
|
||||
"TITLE": "Search messages",
|
||||
"RESULT_TITLE": "Search Results",
|
||||
"LOADING_MESSAGE": "መረጃ በማስተናገድ ላይ...",
|
||||
"LOADING_MESSAGE": "Crunching data...",
|
||||
"PLACEHOLDER": "Type any text to search messages",
|
||||
"NO_MATCHING_RESULTS": "No results found."
|
||||
},
|
||||
"UNREAD_MESSAGES": "Unread Messages",
|
||||
"UNREAD_MESSAGE": "Unread Message",
|
||||
"CLICK_HERE": "እዚህ ጠቅ ያድርጉ",
|
||||
"LOADING_INBOXES": "ኢንቦክሶች በመጫን ላይ",
|
||||
"LOADING_CONVERSATIONS": "ውይይቶች በመጫን ላይ",
|
||||
"CANNOT_REPLY": "ምክንያቱን በመነሳት መልስ ማድረግ አይችሉም",
|
||||
"24_HOURS_WINDOW": "የ24 ሰዓት መልእክት ጊዜ ገደብ",
|
||||
"CLICK_HERE": "Click here",
|
||||
"LOADING_INBOXES": "Loading inboxes",
|
||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||
"CANNOT_REPLY": "You cannot reply due to",
|
||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"48_HOURS_WINDOW": "48 hour message window restriction",
|
||||
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
@@ -44,9 +44,9 @@
|
||||
"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 ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
|
||||
"REPLYING_TO": "ለዚህ ትመልሳለህ፦",
|
||||
"REMOVE_SELECTION": "ምርጫ አስወግድ",
|
||||
"DOWNLOAD": "አውርድ",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
"UNKNOWN_FILE_TYPE": "Unknown File",
|
||||
"SAVE_CONTACT": "Save Contact",
|
||||
"NO_CONTENT": "No content to display",
|
||||
@@ -85,13 +85,13 @@
|
||||
"YOU_ANSWERED": "You answered"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "ተፈትኗል",
|
||||
"REOPEN_ACTION": "እንደገና ክፈት",
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
"REOPEN_ACTION": "Reopen",
|
||||
"OPEN_ACTION": "Open",
|
||||
"MORE_ACTIONS": "ተጨማሪ እርምጃዎች",
|
||||
"OPEN": "ተጨማሪ",
|
||||
"CLOSE": "ዝጋ",
|
||||
"DETAILS": "ዝርዝሮች",
|
||||
"OPEN": "More",
|
||||
"CLOSE": "Close",
|
||||
"DETAILS": "details",
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
@@ -188,21 +188,21 @@
|
||||
"MESSAGE_SIGN_TOOLTIP": "Message signature",
|
||||
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MSG_INPUT": "አዲስ መስመር ለማስገባት Shift + enter ይጠቀሙ። '/' በመጀመር የተዘጋጀ ምላሽ ይምረጡ።",
|
||||
"PRIVATE_MSG_INPUT": "አዲስ መስመር ለማስገባት Shift + enter ይጠቀሙ። ይህ ለወኪሎች ብቻ ይታያል",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "ኮፒሎት ተጨማሪ እባብነቶች ስጡው, ወይም ሌላ ማንኛውንም ጥያቄ ያቀርቡ... ተከትሎ ለማስተላለፊያ ኤንተር ይጫኑ።",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
"REPLYBOX": {
|
||||
"REPLY": "መልስ",
|
||||
"PRIVATE_NOTE": "የግል ማስታወሻ",
|
||||
"SEND": "ላክ",
|
||||
"CREATE": "ማስታወሻ አክል",
|
||||
"REPLY": "Reply",
|
||||
"PRIVATE_NOTE": "Private Note",
|
||||
"SEND": "Send",
|
||||
"CREATE": "Add Note",
|
||||
"INSERT_READ_MORE": "Read more",
|
||||
"DISMISS_REPLY": "Dismiss reply",
|
||||
"REPLYING_TO": "Replying to:",
|
||||
@@ -214,7 +214,7 @@
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
@@ -245,10 +245,10 @@
|
||||
"EXPAND": "Expand preview"
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "የግል ማስታወሻ፡ ለአንተና ቡድንህ ብቻ ይታያል",
|
||||
"CHANGE_STATUS": "የውይይቱ ሁኔታ ተቀይሯል",
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
"CHANGE_STATUS": "Conversation status changed",
|
||||
"CHANGE_STATUS_FAILED": "Conversation status change failed",
|
||||
"CHANGE_AGENT": "የውይይቱ ተመድብ ተቀይሯል",
|
||||
"CHANGE_AGENT": "Conversation Assignee changed",
|
||||
"CHANGE_AGENT_FAILED": "Assignee change failed",
|
||||
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
|
||||
"ASSIGN_LABEL_FAILED": "Label assignment failed",
|
||||
@@ -300,20 +300,20 @@
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"TITLE": "የውይይት ጽሑፍ ላክ",
|
||||
"DESC": "የውይይቱን ጽሑፍ ቅጂ ወደ ተጠቃሚው ኢሜይል ላክ",
|
||||
"SUBMIT": "አስገባ",
|
||||
"CANCEL": "ይቅር",
|
||||
"SEND_EMAIL_SUCCESS": "የቻት አጭር መግለጫው በተሳካ ሁኔታ ተልኳል",
|
||||
"SEND_EMAIL_ERROR": "ስህተት ተፈጥሯል፣ እባክዎ ደግመው ይሞክሩ",
|
||||
"TITLE": "Send conversation transcript",
|
||||
"DESC": "Send a copy of the conversation transcript to the specified email address",
|
||||
"SUBMIT": "Submit",
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "መግለጫውን ለደንበኛው ይላኩ",
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
"SEND_TO_OTHER_EMAIL_ADDRESS": "መግለጫውን ወደ ሌላ ኢሜይል አድራሻ ይላኩ",
|
||||
"SEND_TO_OTHER_EMAIL_ADDRESS": "Send the transcript to another email address",
|
||||
"EMAIL": {
|
||||
"PLACEHOLDER": "ኢሜይል አድራሻ ያስገቡ",
|
||||
"ERROR": "ትክክለኛ ኢሜይል አድራሻ ያስገቡ"
|
||||
"PLACEHOLDER": "Enter an email address",
|
||||
"ERROR": "Please enter a valid email address"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -93,7 +93,6 @@
|
||||
"ASSIGN_AGENT": "Assign an Agent",
|
||||
"ADD_LABEL": "Add a Label",
|
||||
"REMOVE_LABEL": "Remove a Label",
|
||||
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
|
||||
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
|
||||
"SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript",
|
||||
"MUTE_CONVERSATION": "Mute Conversation",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user