Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b759496f2 | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 | ||
|
|
6aeda0ddf6 | ||
|
|
fed32d5964 | ||
|
|
735bc73c96 | ||
|
|
5e79dd699e | ||
|
|
05dd31389e | ||
|
|
f7bbd40816 | ||
|
|
7820739f3a | ||
|
|
224556fd1b | ||
|
|
51eb626b88 | ||
|
|
b0aa844a32 | ||
|
|
c8e551820b | ||
|
|
035d2858f5 | ||
|
|
16b8693e1b | ||
|
|
279dd1876c | ||
|
|
2266eb493b | ||
|
|
0920a01e66 | ||
|
|
06467057be | ||
|
|
8faa5a74b1 | ||
|
|
a651949c33 | ||
|
|
2ada713f29 | ||
|
|
751c28d94d | ||
|
|
4959a1ff1e | ||
|
|
661608c0b1 | ||
|
|
c5fb8d73cc | ||
|
|
9a89e1f522 | ||
|
|
667cd1ba1f | ||
|
|
98fa5f60e9 | ||
|
|
ecdeb891ff | ||
|
|
7a8829e79e | ||
|
|
2182165201 | ||
|
|
ce34f93917 | ||
|
|
475db85318 | ||
|
|
f12118a3c0 | ||
|
|
ca66218cb9 | ||
|
|
6a9c44476e | ||
|
|
6928f14a76 | ||
|
|
d2625d8544 | ||
|
|
7d42edd17f | ||
|
|
437dd9d38c | ||
|
|
50720f2395 | ||
|
|
1de90ec136 | ||
|
|
3a4738729d | ||
|
|
787fcc4a95 | ||
|
|
6cbddbdb67 | ||
|
|
5e82f24be5 | ||
|
|
e123a4e500 | ||
|
|
135be52431 | ||
|
|
03c10ba147 | ||
|
|
aa2e8f99e4 | ||
|
|
aee979ee0b | ||
|
|
72b8a31f2d | ||
|
|
48533e2a5d | ||
|
|
b5264a2560 | ||
|
|
98cf1ce9f6 | ||
|
|
5eee331da3 | ||
|
|
edd0fc98db | ||
|
|
cc008951db | ||
|
|
97dae52841 | ||
|
|
5264de24b0 | ||
|
|
b96bf41234 | ||
|
|
3f9f054c43 | ||
|
|
8e5d4f4d23 | ||
|
|
64f6bfc811 | ||
|
|
72c9e1775b | ||
|
|
b7b6e67df7 | ||
|
|
288c1cb757 | ||
|
|
a8c8b38f51 | ||
|
|
f422c83c26 | ||
|
|
722e68eecb | ||
|
|
0592cccca9 | ||
|
|
45b6ea6b3f | ||
|
|
de0bd8e71b | ||
|
|
224b1f98b0 | ||
|
|
3190b29fe9 | ||
|
|
42163946eb | ||
|
|
f13f3ba446 | ||
|
|
f1da7b8afa | ||
|
|
bd14e96ed9 | ||
|
|
00837019b5 | ||
|
|
45124c3b41 | ||
|
|
699b12b1d3 | ||
|
|
e5107604a0 | ||
|
|
871f2f4d56 | ||
|
|
4f94ad4a75 | ||
|
|
fbe3560b7a | ||
|
|
118270d2e8 | ||
|
|
8c0c0fd32c | ||
|
|
50d6ebaaca | ||
|
|
95463230cb | ||
|
|
f4d66566d0 | ||
|
|
5fd3d5e036 | ||
|
|
6f5ad8f372 | ||
|
|
441fe4db11 | ||
|
|
b9b5a18767 | ||
|
|
b815eb9ce0 | ||
|
|
b3d0af84c4 | ||
|
|
d83beb2148 | ||
|
|
8daf6cf6cb | ||
|
|
211fb1102d | ||
|
|
7b09b033ef | ||
|
|
65867b8b36 | ||
|
|
4cce7f6ad8 | ||
|
|
f2cb23d6e9 | ||
|
|
8824efe0e1 | ||
|
|
5de7ae492c | ||
|
|
b4b5de9b46 | ||
|
|
1987ac3d97 | ||
|
|
0012fa2c35 | ||
|
|
b4ce59eea8 | ||
|
|
42441dbd28 | ||
|
|
b9f824b43b | ||
|
|
7651c18b48 | ||
|
|
04acc16609 | ||
|
|
44a7a13117 | ||
|
|
9efd554693 | ||
|
|
2b296c06fb | ||
|
|
4381be5f3e | ||
|
|
127ac0a6b2 | ||
|
|
5d9d754961 | ||
|
|
cac7438fff | ||
|
|
0b41d7f483 | ||
|
|
4517c50227 | ||
|
|
4c4b70da25 | ||
|
|
d84ef4cfd6 | ||
|
|
23786bcb52 | ||
|
|
e4c3f0ac2f | ||
|
|
742c5cc1f4 | ||
|
|
d9e732c005 | ||
|
|
e0e321b8e2 | ||
|
|
ecc66e064d | ||
|
|
7144d55334 | ||
|
|
250650dd7a | ||
|
|
608be1036b | ||
|
|
6ff643b045 | ||
|
|
bf397750f3 | ||
|
|
f253ea0abc | ||
|
|
3aea425abc | ||
|
|
eee307b36b |
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
: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:
|
||||||
|
- ''
|
||||||
@@ -2,3 +2,8 @@
|
|||||||
ignore:
|
ignore:
|
||||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
- 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+)
|
- 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,6 +40,8 @@ gem 'json_refs'
|
|||||||
gem 'rack-attack', '>= 6.7.0'
|
gem 'rack-attack', '>= 6.7.0'
|
||||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||||
gem 'down'
|
gem 'down'
|
||||||
|
# SSRF-safe URL fetching
|
||||||
|
gem 'ssrf_filter', '~> 1.5'
|
||||||
# authentication type to fetch and send mail over oauth2.0
|
# authentication type to fetch and send mail over oauth2.0
|
||||||
gem 'gmail_xoauth'
|
gem 'gmail_xoauth'
|
||||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||||
@@ -82,6 +84,7 @@ gem 'barnes'
|
|||||||
gem 'devise', '>= 4.9.4'
|
gem 'devise', '>= 4.9.4'
|
||||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||||
gem 'devise_token_auth', '>= 1.2.3'
|
gem 'devise_token_auth', '>= 1.2.3'
|
||||||
|
gem 'rails-i18n', '~> 7.0'
|
||||||
# two-factor authentication
|
# two-factor authentication
|
||||||
gem 'devise-two-factor', '>= 5.0.0'
|
gem 'devise-two-factor', '>= 5.0.0'
|
||||||
# authorization
|
# authorization
|
||||||
|
|||||||
@@ -727,6 +727,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.1)
|
rails-html-sanitizer (1.6.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
|
rails-i18n (7.0.10)
|
||||||
|
i18n (>= 0.7, < 2)
|
||||||
|
railties (>= 6.0.0, < 8)
|
||||||
railties (7.1.5.2)
|
railties (7.1.5.2)
|
||||||
actionpack (= 7.1.5.2)
|
actionpack (= 7.1.5.2)
|
||||||
activesupport (= 7.1.5.2)
|
activesupport (= 7.1.5.2)
|
||||||
@@ -942,6 +945,7 @@ GEM
|
|||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
|
ssrf_filter (1.5.0)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (18.0.1)
|
stripe (18.0.1)
|
||||||
@@ -1124,6 +1128,7 @@ DEPENDENCIES
|
|||||||
rack-mini-profiler (>= 3.2.0)
|
rack-mini-profiler (>= 3.2.0)
|
||||||
rack-timeout
|
rack-timeout
|
||||||
rails (~> 7.1)
|
rails (~> 7.1)
|
||||||
|
rails-i18n (~> 7.0)
|
||||||
redis
|
redis
|
||||||
redis-namespace
|
redis-namespace
|
||||||
responders (>= 3.1.1)
|
responders (>= 3.1.1)
|
||||||
@@ -1158,6 +1163,7 @@ DEPENDENCIES
|
|||||||
spring
|
spring
|
||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
|
ssrf_filter (~> 1.5)
|
||||||
stackprof
|
stackprof
|
||||||
stripe (~> 18.0)
|
stripe (~> 18.0)
|
||||||
telephone_number
|
telephone_number
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.12.0
|
4.13.0
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ class AccountBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_account
|
def create_account
|
||||||
@account = Account.create!(name: account_name, locale: I18n.locale)
|
@account = Account.create!(
|
||||||
|
name: account_name,
|
||||||
|
locale: I18n.locale,
|
||||||
|
custom_attributes: { 'onboarding_step' => 'account_details' }
|
||||||
|
)
|
||||||
Current.account = @account
|
Current.account = @account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ class AgentBuilder
|
|||||||
user = User.from_email(email)
|
user = User.from_email(email)
|
||||||
return user if user
|
return user if user
|
||||||
|
|
||||||
|
@name = email.split('@').first if @name.blank?
|
||||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Checks if the user needs confirmation.
|
# Checks if the user needs confirmation.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Email::BaseBuilder
|
class Email::BaseBuilder
|
||||||
|
include EmailAddressParseable
|
||||||
|
|
||||||
pattr_initialize [:inbox!]
|
pattr_initialize [:inbox!]
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -39,7 +41,7 @@ class Email::BaseBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def business_name
|
def business_name
|
||||||
inbox.business_name || inbox.sanitized_name
|
inbox.sanitized_business_name
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_support_email
|
def account_support_email
|
||||||
@@ -47,8 +49,4 @@ class Email::BaseBuilder
|
|||||||
# can save it in the format "Name <email@domain.com>"
|
# can save it in the format "Name <email@domain.com>"
|
||||||
parse_email(account.support_email)
|
parse_email(account.support_email)
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_email(email_string)
|
|
||||||
Mail::Address.new(email_string).address
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class V2::ReportBuilder
|
class V2::ReportBuilder
|
||||||
include DateRangeHelper
|
include DateRangeHelper
|
||||||
include ReportHelper
|
include ReportHelper
|
||||||
|
|
||||||
attr_reader :account, :params
|
attr_reader :account, :params
|
||||||
|
|
||||||
DEFAULT_GROUP_BY = 'day'.freeze
|
DEFAULT_GROUP_BY = 'day'.freeze
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
|||||||
attr_reader :conversations_count, :resolved_count,
|
attr_reader :conversations_count, :resolved_count,
|
||||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||||
|
|
||||||
def fetch_conversations_count
|
|
||||||
account.conversations.where(created_at: range).group('assignee_id').count
|
|
||||||
end
|
|
||||||
|
|
||||||
def prepare_report
|
def prepare_report
|
||||||
account.account_users.map do |account_user|
|
account.account_users.map do |account_user|
|
||||||
build_agent_stats(account_user)
|
build_agent_stats(account_user)
|
||||||
|
|||||||
@@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def load_data
|
def load_data
|
||||||
@conversations_count = fetch_conversations_count
|
results = data_source.summary
|
||||||
load_reporting_events_data
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_reporting_events_data
|
@conversations_count = results.transform_values { |data| data[:conversations_count] }
|
||||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
|
||||||
index_key = group_by_key.to_s.split('.').last
|
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
|
||||||
|
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
|
||||||
results = reporting_events
|
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
|
||||||
.select(
|
|
||||||
"#{group_by_key} as #{index_key}",
|
|
||||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
|
||||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
|
||||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
|
||||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
|
||||||
)
|
|
||||||
.group(group_by_key)
|
|
||||||
.index_by { |record| record.public_send(index_key) }
|
|
||||||
|
|
||||||
@resolved_count = results.transform_values(&:resolved_count)
|
|
||||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
|
||||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
|
||||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
|
||||||
end
|
|
||||||
|
|
||||||
def reporting_events
|
|
||||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_conversations_count
|
|
||||||
# Override this method
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def group_by_key
|
def group_by_key
|
||||||
@@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder
|
|||||||
# Override this method
|
# Override this method
|
||||||
end
|
end
|
||||||
|
|
||||||
def average_value_key
|
def data_source
|
||||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
@data_source ||= Reports::DataSource.for(
|
||||||
|
account: account,
|
||||||
|
metric: nil,
|
||||||
|
dimension_type: summary_dimension_type,
|
||||||
|
dimension_id: nil,
|
||||||
|
scope: nil,
|
||||||
|
range: range,
|
||||||
|
group_by: 'day',
|
||||||
|
timezone_offset: params[:timezone_offset],
|
||||||
|
business_hours: params[:business_hours]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary_dimension_type
|
||||||
|
{
|
||||||
|
'account_id' => 'account',
|
||||||
|
'user_id' => 'agent',
|
||||||
|
'inbox_id' => 'inbox',
|
||||||
|
'conversations.team_id' => 'team'
|
||||||
|
}.fetch(group_by_key.to_s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
|
||||||
COUNT_METRICS = %w[
|
|
||||||
conversations_count
|
|
||||||
incoming_messages_count
|
|
||||||
outgoing_messages_count
|
|
||||||
resolutions_count
|
|
||||||
bot_resolutions_count
|
|
||||||
bot_handoffs_count
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
def builder_class(metric)
|
def builder_class(metric)
|
||||||
case metric
|
return unless Reports::ReportMetricRegistry.supported?(metric)
|
||||||
when *AVG_METRICS
|
|
||||||
V2::Reports::Timeseries::AverageReportBuilder
|
V2::Reports::Timeseries::ReportBuilder
|
||||||
when *COUNT_METRICS
|
|
||||||
V2::Reports::Timeseries::CountReportBuilder
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def log_invalid_metric
|
def log_invalid_metric
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
|||||||
attr_reader :conversations_count, :resolved_count,
|
attr_reader :conversations_count, :resolved_count,
|
||||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||||
|
|
||||||
def load_data
|
|
||||||
@conversations_count = fetch_conversations_count
|
|
||||||
load_reporting_events_data
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_conversations_count
|
|
||||||
account.conversations.where(created_at: range).group(group_by_key).count
|
|
||||||
end
|
|
||||||
|
|
||||||
def prepare_report
|
def prepare_report
|
||||||
account.inboxes.map do |inbox|
|
account.inboxes.map do |inbox|
|
||||||
build_inbox_stats(inbox)
|
build_inbox_stats(inbox)
|
||||||
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
|||||||
def group_by_key
|
def group_by_key
|
||||||
:inbox_id
|
:inbox_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def average_value_key
|
|
||||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
|||||||
attr_reader :conversations_count, :resolved_count,
|
attr_reader :conversations_count, :resolved_count,
|
||||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||||
|
|
||||||
def fetch_conversations_count
|
|
||||||
account.conversations.where(created_at: range).group(:team_id).count
|
|
||||||
end
|
|
||||||
|
|
||||||
def reporting_events
|
|
||||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
|
||||||
end
|
|
||||||
|
|
||||||
def prepare_report
|
def prepare_report
|
||||||
account.teams.map do |team|
|
account.teams.map do |team|
|
||||||
build_team_stats(team)
|
build_team_stats(team)
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
|
||||||
def timeseries
|
|
||||||
grouped_average_time = reporting_events.average(average_value_key)
|
|
||||||
grouped_event_count = reporting_events.count
|
|
||||||
grouped_average_time.each_with_object([]) do |element, arr|
|
|
||||||
event_date, average_time = element
|
|
||||||
arr << {
|
|
||||||
value: average_time,
|
|
||||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
|
||||||
count: grouped_event_count[event_date]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def aggregate_value
|
|
||||||
object_scope.average(average_value_key)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def event_name
|
|
||||||
metric_to_event_name = {
|
|
||||||
avg_first_response_time: :first_response,
|
|
||||||
avg_resolution_time: :conversation_resolved,
|
|
||||||
reply_time: :reply_time
|
|
||||||
}
|
|
||||||
metric_to_event_name[params[:metric].to_sym]
|
|
||||||
end
|
|
||||||
|
|
||||||
def object_scope
|
|
||||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def reporting_events
|
|
||||||
@grouped_values = object_scope.group_by_period(
|
|
||||||
group_by,
|
|
||||||
:created_at,
|
|
||||||
default_value: 0,
|
|
||||||
range: range,
|
|
||||||
permit: %w[day week month year hour],
|
|
||||||
time_zone: timezone
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def average_value_key
|
|
||||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||||
include TimezoneHelper
|
include TimezoneHelper
|
||||||
include DateRangeHelper
|
include DateRangeHelper
|
||||||
|
|
||||||
DEFAULT_GROUP_BY = 'day'.freeze
|
DEFAULT_GROUP_BY = 'day'.freeze
|
||||||
|
|
||||||
pattr_initialize :account, :params
|
pattr_initialize :account, :params
|
||||||
|
|
||||||
def scope
|
def scope
|
||||||
case params[:type].to_sym
|
case dimension_type.to_sym
|
||||||
when :account
|
when :account
|
||||||
account
|
account
|
||||||
when :inbox
|
when :inbox
|
||||||
@@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def data_source
|
||||||
|
@data_source ||= Reports::DataSource.for(
|
||||||
|
account: account,
|
||||||
|
metric: params[:metric],
|
||||||
|
dimension_type: dimension_type,
|
||||||
|
dimension_id: params[:id],
|
||||||
|
scope: scope,
|
||||||
|
range: range,
|
||||||
|
group_by: group_by,
|
||||||
|
timezone_offset: params[:timezone_offset],
|
||||||
|
business_hours: params[:business_hours]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def inbox
|
def inbox
|
||||||
@inbox ||= account.inboxes.find(params[:id])
|
@inbox ||= account.inboxes.find(params[:id])
|
||||||
end
|
end
|
||||||
@@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
|||||||
def timezone
|
def timezone
|
||||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def dimension_type
|
||||||
|
(params[:type].presence || 'account').to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
|
||||||
def timeseries
|
|
||||||
grouped_count.each_with_object([]) do |element, arr|
|
|
||||||
event_date, event_count = element
|
|
||||||
|
|
||||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
|
||||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
|
||||||
# because it converts the date to 12:00 AM server timezone.
|
|
||||||
# The desired output should be 12:00 AM in the specified timezone.
|
|
||||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def aggregate_value
|
|
||||||
object_scope.count
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def metric
|
|
||||||
@metric ||= params[:metric]
|
|
||||||
end
|
|
||||||
|
|
||||||
def object_scope
|
|
||||||
send("scope_for_#{metric}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def scope_for_conversations_count
|
|
||||||
scope.conversations.where(account_id: account.id, created_at: range)
|
|
||||||
end
|
|
||||||
|
|
||||||
def scope_for_incoming_messages_count
|
|
||||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
|
||||||
end
|
|
||||||
|
|
||||||
def scope_for_outgoing_messages_count
|
|
||||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
|
||||||
end
|
|
||||||
|
|
||||||
def scope_for_resolutions_count
|
|
||||||
scope.reporting_events.where(
|
|
||||||
name: :conversation_resolved,
|
|
||||||
account_id: account.id,
|
|
||||||
created_at: range
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def scope_for_bot_resolutions_count
|
|
||||||
scope.reporting_events.where(
|
|
||||||
name: :conversation_bot_resolved,
|
|
||||||
account_id: account.id,
|
|
||||||
created_at: range
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def scope_for_bot_handoffs_count
|
|
||||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
|
||||||
name: :conversation_bot_handoff,
|
|
||||||
account_id: account.id,
|
|
||||||
created_at: range
|
|
||||||
).distinct
|
|
||||||
end
|
|
||||||
|
|
||||||
def grouped_count
|
|
||||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
|
||||||
# It converts timestamps to the target timezone before grouping, which means
|
|
||||||
# the same event can fall into different day buckets depending on timezone
|
|
||||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
|
||||||
@grouped_values = object_scope.group_by_period(
|
|
||||||
group_by,
|
|
||||||
:created_at,
|
|
||||||
default_value: 0,
|
|
||||||
range: range,
|
|
||||||
permit: %w[day week month year hour],
|
|
||||||
time_zone: timezone
|
|
||||||
).count
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||||
|
def timeseries
|
||||||
|
data_source.timeseries
|
||||||
|
end
|
||||||
|
|
||||||
|
def aggregate_value
|
||||||
|
data_source.aggregate
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
@agent_bot.reload
|
@agent_bot.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_secret
|
||||||
|
@agent_bot.reset_secret!
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :portal
|
||||||
|
before_action :check_authorization
|
||||||
|
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||||
|
|
||||||
|
def translate
|
||||||
|
head :not_implemented
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status
|
||||||
|
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||||
|
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@articles.find_each { |article| article.update!(status: params[:status]) }
|
||||||
|
end
|
||||||
|
head :ok
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_could_not_create_error(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_articles
|
||||||
|
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||||
|
|
||||||
|
@articles.destroy_all
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def portal
|
||||||
|
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_authorization
|
||||||
|
authorize(Article, :create?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_articles
|
||||||
|
@articles = @portal.articles.where(id: params[:ids])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
sort_on :phone_number, type: :string
|
sort_on :phone_number, type: :string
|
||||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||||
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
||||||
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||||
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
||||||
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
# 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.
|
# 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.
|
# 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, true) if assignee? && @conversation.assignee_unread_messages.any?
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_secret
|
||||||
|
return head :not_found unless @inbox.api?
|
||||||
|
|
||||||
|
@inbox.channel.reset_secret!
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||||
|
|||||||
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_attached_logo
|
def process_attached_logo
|
||||||
blob_id = params[:blob_id]
|
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
|
||||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
@portal.logo.attach(blob) if blob
|
||||||
@portal.logo.attach(blob)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
elsif params[:external_url].present?
|
elsif params[:external_url].present?
|
||||||
create_from_url
|
create_from_url
|
||||||
else
|
else
|
||||||
render_error('No file or URL provided', :unprocessable_entity)
|
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||||
@@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_from_url
|
def create_from_url
|
||||||
uri = parse_uri(params[:external_url])
|
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||||
return if performed?
|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||||
|
|
||||||
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
|
end
|
||||||
rescue OpenURI::HTTPError => e
|
rescue SafeFetch::HttpError => e
|
||||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||||
rescue SocketError
|
rescue SafeFetch::FetchError
|
||||||
render_error('Invalid URL provided', :unprocessable_entity)
|
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 StandardError
|
rescue StandardError
|
||||||
render_error('An unexpected error occurred', :internal_server_error)
|
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_and_save_blob(io, filename, content_type)
|
def create_and_save_blob(io, filename, content_type)
|
||||||
|
|||||||
@@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
locale: account_params[:locale],
|
locale: account_params[:locale],
|
||||||
user: current_user
|
user: current_user
|
||||||
).perform
|
).perform
|
||||||
|
enqueue_branding_enrichment
|
||||||
if @user
|
if @user
|
||||||
send_auth_headers(@user)
|
# Authenticated users (dashboard "add account") and api_only signups
|
||||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
# 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
|
||||||
else
|
else
|
||||||
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
||||||
end
|
end
|
||||||
@@ -47,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||||
@account.custom_attributes.merge!(custom_attributes_params)
|
@account.custom_attributes.merge!(custom_attributes_params)
|
||||||
@account.settings.merge!(settings_params)
|
@account.settings.merge!(settings_params)
|
||||||
|
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||||
@account.save!
|
@account.save!
|
||||||
end
|
end
|
||||||
@@ -59,6 +71,17 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def enqueue_branding_enrichment
|
||||||
|
email = account_params[:email].presence || @user&.email
|
||||||
|
return if email.blank?
|
||||||
|
|
||||||
|
Account::BrandingEnrichmentJob.perform_later(@account.id, 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
|
def ensure_account_name
|
||||||
# ensure that account_name and user_full_name is present
|
# ensure that account_name and user_full_name is present
|
||||||
# this is becuase the account builder and the models validations are not triggered
|
# this is becuase the account builder and the models validations are not triggered
|
||||||
@@ -88,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def custom_attributes_params
|
def custom_attributes_params
|
||||||
params.permit(:industry, :company_size, :timezone)
|
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
@@ -103,6 +126,15 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||||
end
|
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
|
def validate_captcha
|
||||||
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
|||||||
before_action :check_mfa_feature_available
|
before_action :check_mfa_feature_available
|
||||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
before_action :check_mfa_disabled, only: [:create, :verify]
|
||||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
|
||||||
before_action :validate_password, only: [:destroy]
|
before_action :validate_password, only: [:destroy]
|
||||||
|
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
|||||||
def validate_otp
|
def validate_otp
|
||||||
authenticated = Mfa::AuthenticationService.new(
|
authenticated = Mfa::AuthenticationService.new(
|
||||||
user: current_user,
|
user: current_user,
|
||||||
otp_code: mfa_params[:otp_code]
|
otp_code: mfa_params[:otp_code],
|
||||||
|
backup_code: mfa_params[:backup_code]
|
||||||
).authenticate
|
).authenticate
|
||||||
|
|
||||||
return if authenticated
|
return if authenticated
|
||||||
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mfa_params
|
def mfa_params
|
||||||
params.permit(:otp_code, :password)
|
params.permit(:otp_code, :backup_code, :password)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_conversation
|
def set_conversation
|
||||||
@conversation = create_conversation if conversation.nil?
|
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?
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_finder_params
|
def message_finder_params
|
||||||
@@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
# timestamp parameter is used in create conversation method
|
# timestamp parameter is used in create conversation method
|
||||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
# 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: []
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_message
|
def set_message
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||||
|
|
||||||
def agent
|
def agent
|
||||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||||
end
|
end
|
||||||
|
|
||||||
def team
|
def team
|
||||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox
|
def inbox
|
||||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||||
end
|
end
|
||||||
|
|
||||||
def label
|
def label
|
||||||
@@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_report_with(builder_class)
|
def render_report_with(builder_class, type: nil)
|
||||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||||
|
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||||
render json: builder.build
|
render json: builder.build
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
private
|
private
|
||||||
|
|
||||||
def sign_in_user
|
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?
|
@resource.skip_confirmation! if confirmable_enabled?
|
||||||
|
set_random_password_if_oauth_user if needs_password_reset
|
||||||
|
|
||||||
# once the resource is found and verified
|
# once the resource is found and verified
|
||||||
# we can just send them to the login page again with the SSO params
|
# we can just send them to the login page again with the SSO params
|
||||||
@@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sign_in_user_on_mobile
|
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?
|
@resource.skip_confirmation! if confirmable_enabled?
|
||||||
|
set_random_password_if_oauth_user if needs_password_reset
|
||||||
|
|
||||||
# once the resource is found and verified
|
# once the resource is found and verified
|
||||||
# we can just send them to the login page again with the SSO params
|
# we can just send them to the login page again with the SSO params
|
||||||
@@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||||
|
|
||||||
create_account_for_user
|
create_account_for_user
|
||||||
|
set_random_password_if_oauth_user
|
||||||
token = @resource.send(:set_reset_password_token)
|
token = @resource.send(:set_reset_password_token)
|
||||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||||
@@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||||
end
|
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
|
def default_devise_mapping
|
||||||
'user'
|
'user'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
|||||||
before_action :validate_platform_app_permissible, except: [:index, :create]
|
before_action :validate_platform_app_permissible, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
|
before_action :ensure_portal_feature_enabled
|
||||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
@@ -61,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def set_article
|
def set_article
|
||||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
@parsed_content = render_article_content(@article.content)
|
@parsed_content = render_article_content(@article.content.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_category
|
def set_category
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
|
before_action :ensure_portal_feature_enabled
|
||||||
before_action :set_category, only: [:show]
|
before_action :set_category, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show]
|
before_action :ensure_custom_domain_request, only: [:show]
|
||||||
before_action :portal
|
|
||||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||||
|
before_action :portal
|
||||||
|
before_action :ensure_portal_feature_enabled
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
def redirect_to_portal_with_locale
|
def redirect_to_portal_with_locale
|
||||||
return if params[:locale].present?
|
return if params[:locale].present?
|
||||||
|
|
||||||
|
portal
|
||||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
|
|||||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||||
}, status: :unauthorized and return
|
}, status: :unauthorized and return
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
|
||||||
|
def show
|
||||||
|
@query = params[:user_query].to_s.strip
|
||||||
|
@user = resolve_user(@query)
|
||||||
|
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
|
||||||
|
@results = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@user = User.find_by(id: params[:user_id])
|
||||||
|
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
|
||||||
|
|
||||||
|
ids = parsed_subscription_ids
|
||||||
|
if ids.empty?
|
||||||
|
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
|
||||||
|
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
|
||||||
|
end
|
||||||
|
|
||||||
|
run_test_and_render(ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_subscriptions
|
||||||
|
user = User.find_by(id: params[:user_id])
|
||||||
|
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
|
||||||
|
|
||||||
|
ids = parsed_subscription_ids
|
||||||
|
if ids.empty?
|
||||||
|
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
|
||||||
|
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
|
||||||
|
end
|
||||||
|
|
||||||
|
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
|
||||||
|
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
|
||||||
|
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
|
||||||
|
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def run_test_and_render(ids)
|
||||||
|
@query = @user.id.to_s
|
||||||
|
@subscriptions = @user.notification_subscriptions.order(:id)
|
||||||
|
@results = Notification::PushTestService.new(
|
||||||
|
user: @user, subscription_ids: ids,
|
||||||
|
title: params[:push_title], body: params[:push_body]
|
||||||
|
).perform
|
||||||
|
|
||||||
|
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
|
||||||
|
render :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_super_admin_action(message)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[SuperAdmin] push diagnostics #{message} " \
|
||||||
|
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_user(query)
|
||||||
|
return if query.blank?
|
||||||
|
|
||||||
|
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_subscription_ids
|
||||||
|
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,7 +7,7 @@ module EmailHelper
|
|||||||
def render_email_html(content)
|
def render_email_html(content)
|
||||||
return '' if content.blank?
|
return '' if content.blank?
|
||||||
|
|
||||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
# Raise a standard error if any email address is invalid
|
# Raise a standard error if any email address is invalid
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export default {
|
|||||||
isRTL: 'accounts/isRTL',
|
isRTL: 'accounts/isRTL',
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
|
||||||
}),
|
}),
|
||||||
hideOnOnboardingView() {
|
hideOnOnboardingView() {
|
||||||
return !isOnOnboardingView(this.$route);
|
return !isOnOnboardingView(this.$route);
|
||||||
@@ -98,15 +97,18 @@ export default {
|
|||||||
mql.onchange = e => setColorTheme(e.matches);
|
mql.onchange = e => setColorTheme(e.matches);
|
||||||
},
|
},
|
||||||
setLocale(locale) {
|
setLocale(locale) {
|
||||||
this.$root.$i18n.locale = locale;
|
if (locale) {
|
||||||
|
this.$root.$i18n.locale = locale;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async initializeAccount() {
|
async initializeAccount() {
|
||||||
await this.$store.dispatch('accounts/get');
|
await this.$store.dispatch('accounts/get');
|
||||||
this.$store.dispatch('setActiveAccount', {
|
this.$store.dispatch('setActiveAccount', {
|
||||||
accountId: this.currentAccountId,
|
accountId: this.currentAccountId,
|
||||||
});
|
});
|
||||||
|
const account = this.getAccount(this.currentAccountId);
|
||||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||||
this.getAccount(this.currentAccountId);
|
account;
|
||||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||||
// If user locale is set, use it; otherwise use account locale
|
// If user locale is set, use it; otherwise use account locale
|
||||||
this.setLocale(this.uiSettings?.locale || locale);
|
this.setLocale(this.uiSettings?.locale || locale);
|
||||||
@@ -129,7 +131,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
v-if="!authUIFlags.isFetching"
|
||||||
id="app"
|
id="app"
|
||||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
|
|||||||
resetAccessToken(botId) {
|
resetAccessToken(botId) {
|
||||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSecret(botId) {
|
||||||
|
return axios.post(`${this.url}/${botId}/reset_secret`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AgentBotsAPI();
|
export default new AgentBotsAPI();
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return axios.delete(`${this.url}/${id}`);
|
return axios.delete(`${this.url}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(data = {}) {
|
||||||
|
return axios.post(`${this.url}/test`, {
|
||||||
|
custom_tool: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainCustomTools();
|
export default new CaptainCustomTools();
|
||||||
|
|||||||
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
|
|||||||
category_slug: categorySlug,
|
category_slug: categorySlug,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
|
||||||
|
return axios.post(
|
||||||
|
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
|
||||||
|
{ ids: articleIds, locale, category_id: categoryId, force }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateStatus({ portalSlug, articleIds, status }) {
|
||||||
|
return axios.patch(
|
||||||
|
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
|
||||||
|
{ ids: articleIds, status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete({ portalSlug, articleIds }) {
|
||||||
|
return axios.delete(
|
||||||
|
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||||
|
{ data: { ids: articleIds } }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ArticlesAPI();
|
export default new ArticlesAPI();
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
template,
|
template,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSecret(inboxId) {
|
||||||
|
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
|||||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(password, otpCode) {
|
disable(password, { otpCode, backupCode } = {}) {
|
||||||
return axios.delete(this.url, {
|
return axios.delete(this.url, {
|
||||||
data: { password, otp_code: otpCode },
|
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ select {
|
|||||||
&[disabled] {
|
&[disabled] {
|
||||||
@apply field-disabled;
|
@apply field-disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
option:not(:disabled) {
|
||||||
|
@apply bg-n-solid-2 text-n-slate-12;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textarea
|
// Textarea
|
||||||
|
|||||||
+3
-3
@@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', {
|
|||||||
|
|
||||||
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 10,
|
default: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
// Duration limits: 1 minute to 999 days (in minutes)
|
||||||
const MIN_DURATION_MINUTES = 10;
|
const MIN_DURATION_MINUTES = 1;
|
||||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,7 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||||
const MIN_CONVERSATION_LIMIT = 1;
|
const MIN_CONVERSATION_LIMIT = 0;
|
||||||
const MAX_CONVERSATION_LIMIT = 100000;
|
const MAX_CONVERSATION_LIMIT = 100000;
|
||||||
|
|
||||||
const selectedInboxIds = computed(
|
const selectedInboxIds = computed(
|
||||||
@@ -42,6 +42,7 @@ const availableInboxes = computed(() =>
|
|||||||
|
|
||||||
const isLimitValid = limit => {
|
const isLimitValid = limit => {
|
||||||
return (
|
return (
|
||||||
|
Number.isInteger(limit.conversationLimit) &&
|
||||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
|||||||
|
|
||||||
const countriesMap = computed(() => {
|
const countriesMap = computed(() => {
|
||||||
return countries.reduce((acc, country) => {
|
return countries.reduce((acc, country) => {
|
||||||
acc[country.code] = country;
|
|
||||||
acc[country.id] = country;
|
acc[country.id] = country;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<ComposeConversation :contact-id="contactId">
|
<ComposeConversation :contact-id="contactId">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger>
|
||||||
<Button
|
<Button
|
||||||
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="toggle"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ const emit = defineEmits([
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-px h-4 bg-n-strong" />
|
<div class="w-px h-4 bg-n-strong" />
|
||||||
<ComposeConversation>
|
<ComposeConversation>
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger>
|
||||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
<Button :label="buttonLabel" size="sm" />
|
||||||
</template>
|
</template>
|
||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const showPagination = computed(() => {
|
|||||||
<ContactsActiveFiltersPreview
|
<ContactsActiveFiltersPreview
|
||||||
v-if="showActiveFiltersPreview"
|
v-if="showActiveFiltersPreview"
|
||||||
:active-segment="activeSegment"
|
:active-segment="activeSegment"
|
||||||
|
class="mb-1"
|
||||||
@clear-filters="emit('clearFilters')"
|
@clear-filters="emit('clearFilters')"
|
||||||
@open-filter="openFilter"
|
@open-filter="openFilter"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
|||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
contact: { type: Object, required: true },
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
enableSelection: { type: Boolean, default: true },
|
||||||
|
hideThumbnail: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['selectConversation']);
|
||||||
|
|
||||||
|
const hovered = ref(false);
|
||||||
|
|
||||||
|
const onThumbnailHover = () => {
|
||||||
|
hovered.value = !props.hideThumbnail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailLeave = () => {
|
||||||
|
hovered.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedModel = computed({
|
||||||
|
get: () => props.selected,
|
||||||
|
set: value => {
|
||||||
|
emit('selectConversation', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative flex items-center flex-shrink-0"
|
||||||
|
@mouseenter="onThumbnailHover"
|
||||||
|
@mouseleave="onThumbnailLeave"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
v-if="!hideThumbnail"
|
||||||
|
:name="contact.name"
|
||||||
|
:src="contact.thumbnail"
|
||||||
|
:size="24"
|
||||||
|
:status="contact.availability_status"
|
||||||
|
hide-offline-status
|
||||||
|
>
|
||||||
|
<template v-if="enableSelection" #overlay>
|
||||||
|
<div
|
||||||
|
v-if="hovered || selected"
|
||||||
|
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<Checkbox v-model="selectedModel" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import MessagePreview from './MessagePreview.vue';
|
||||||
|
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||||
|
import UnreadBadge from './UnreadBadge.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
lastMessage: { type: Object, default: null },
|
||||||
|
voiceCallStatus: { type: String, default: '' },
|
||||||
|
voiceCallDirection: { type: String, default: '' },
|
||||||
|
unreadCount: { type: Number, default: 0 },
|
||||||
|
showExpandedPreview: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||||
|
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||||
|
>
|
||||||
|
<VoiceCallStatus
|
||||||
|
v-if="voiceCallStatus"
|
||||||
|
key="voice-status-row"
|
||||||
|
:status="voiceCallStatus"
|
||||||
|
:direction="voiceCallDirection"
|
||||||
|
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||||
|
/>
|
||||||
|
<MessagePreview
|
||||||
|
v-else-if="lastMessage"
|
||||||
|
key="message-preview"
|
||||||
|
:message="lastMessage"
|
||||||
|
:multi-line="showExpandedPreview"
|
||||||
|
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
key="no-messages"
|
||||||
|
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||||
|
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||||
|
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useThrottleFn } from '@vueuse/core';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
disableToggle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const slots = useSlots();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const accountLabels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
|
const activeLabels = computed(() => {
|
||||||
|
return accountLabels.value.filter(({ title }) =>
|
||||||
|
props.labels.includes(title)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAllLabels = ref(false);
|
||||||
|
const showExpandLabelButton = ref(false);
|
||||||
|
const labelPosition = ref(-1);
|
||||||
|
const labelContainer = ref(null);
|
||||||
|
|
||||||
|
// Show if there are labels OR if before slot exists
|
||||||
|
const showSection = computed(
|
||||||
|
() => activeLabels.value.length > 0 || !!slots.before
|
||||||
|
);
|
||||||
|
|
||||||
|
const computeVisibleLabelPosition = () => {
|
||||||
|
const container = labelContainer.value;
|
||||||
|
if (!container || activeLabels.value.length === 0) {
|
||||||
|
showExpandLabelButton.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = container.querySelectorAll('[data-label]');
|
||||||
|
if (labels.length === 0) {
|
||||||
|
showExpandLabelButton.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early exit if all labels are visible
|
||||||
|
if (showAllLabels.value) return;
|
||||||
|
|
||||||
|
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||||
|
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||||
|
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||||
|
|
||||||
|
let totalWidth = 0;
|
||||||
|
const labelsArray = Array.from(labels);
|
||||||
|
|
||||||
|
// Find last visible label index using some() - stops early on overflow
|
||||||
|
const overflowIndex = labelsArray.findIndex(label => {
|
||||||
|
totalWidth += label.offsetWidth + 6;
|
||||||
|
return totalWidth > availableWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleIndex =
|
||||||
|
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||||
|
|
||||||
|
labelPosition.value = visibleIndex;
|
||||||
|
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||||
|
|
||||||
|
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||||
|
|
||||||
|
const hiddenLabelsCount = computed(() => {
|
||||||
|
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||||
|
return activeLabels.value.length - labelPosition.value - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if all labels are hidden (none visible)
|
||||||
|
const allLabelsHidden = computed(() => {
|
||||||
|
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label text for button when disableToggle is true and all labels are hidden
|
||||||
|
const labelsCountText = computed(() => {
|
||||||
|
if (props.disableToggle && allLabelsHidden.value) {
|
||||||
|
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||||
|
count: activeLabels.value.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||||
|
return hiddenLabelsCount.value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenLabelsTooltip = computed(() => {
|
||||||
|
if (!props.disableToggle) return '';
|
||||||
|
// When all labels are hidden, show all label titles
|
||||||
|
if (allLabelsHidden.value) {
|
||||||
|
return activeLabels.value.map(label => label.title).join(', ');
|
||||||
|
}
|
||||||
|
if (!showExpandLabelButton.value) return '';
|
||||||
|
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||||
|
return hiddenLabels.map(label => label.title).join(', ');
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipText = computed(() => {
|
||||||
|
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||||
|
return hiddenLabelsTooltip.value;
|
||||||
|
}
|
||||||
|
return showAllLabels.value
|
||||||
|
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||||
|
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||||
|
});
|
||||||
|
|
||||||
|
const onShowLabels = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (props.disableToggle) return;
|
||||||
|
showAllLabels.value = !showAllLabels.value;
|
||||||
|
nextTick(() => computeVisibleLabelPosition());
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="showSection"
|
||||||
|
ref="labelContainer"
|
||||||
|
v-bind="attrs"
|
||||||
|
v-resize="throttledCalculate"
|
||||||
|
data-labels-container
|
||||||
|
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||||
|
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||||
|
>
|
||||||
|
<slot name="before" />
|
||||||
|
|
||||||
|
<Label
|
||||||
|
v-for="(label, index) in activeLabels"
|
||||||
|
:key="label ? label.id : index"
|
||||||
|
data-label
|
||||||
|
:label="label"
|
||||||
|
compact
|
||||||
|
:class="{
|
||||||
|
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: tooltipText,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
:label="labelsCountText"
|
||||||
|
xs
|
||||||
|
slate
|
||||||
|
:no-animation="disableToggle"
|
||||||
|
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||||
|
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||||
|
:class="{ 'cursor-default': disableToggle }"
|
||||||
|
@click="onShowLabels"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
+51
-195
@@ -1,207 +1,63 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||||
|
|
||||||
defineProps({
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
priority: {
|
priority: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="inline-flex items-center justify-center rounded-md">
|
<Icon
|
||||||
<!-- Low Priority -->
|
v-tooltip.top="{
|
||||||
<svg
|
content: tooltipContent,
|
||||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
delay: { show: 500, hide: 0 },
|
||||||
width="20"
|
}"
|
||||||
height="20"
|
:icon="iconName"
|
||||||
viewBox="0 0 20 20"
|
class="size-4 text-n-slate-5"
|
||||||
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>
|
</template>
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
showEmpty: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||||
|
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||||
|
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||||
|
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (props.status && icons[props.status]) {
|
||||||
|
return icons[props.status];
|
||||||
|
}
|
||||||
|
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Icon
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: status,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
:icon="iconName"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
|
import CardAvatar from './CardAvatar.vue';
|
||||||
|
import CardContent from './CardContent.vue';
|
||||||
|
import CardLabels from './CardLabelsV5.vue';
|
||||||
|
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||||
|
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||||
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
|
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||||
|
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||||
|
import CardStatusIcon from './CardStatusIcon.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chat: { type: Object, required: true },
|
||||||
|
currentContact: { type: Object, required: true },
|
||||||
|
assignee: { type: Object, default: () => ({}) },
|
||||||
|
inbox: { type: Object, default: () => ({}) },
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
isActiveChat: { type: Boolean, default: false },
|
||||||
|
showAssignee: { type: Boolean, default: false },
|
||||||
|
showInboxName: { type: Boolean, default: false },
|
||||||
|
isInboxView: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'selectConversation',
|
||||||
|
'deSelectConversation',
|
||||||
|
'click',
|
||||||
|
'contextmenu',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||||
|
|
||||||
|
const voiceCallData = computed(() => ({
|
||||||
|
status: props.chat.additional_attributes?.call_status,
|
||||||
|
direction: props.chat.additional_attributes?.call_direction,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unreadCount = computed(() => props.chat.unread_count);
|
||||||
|
|
||||||
|
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||||
|
|
||||||
|
const hasSlaPolicyId = computed(
|
||||||
|
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModel = computed({
|
||||||
|
get: () => props.selected,
|
||||||
|
set: value => {
|
||||||
|
if (value) {
|
||||||
|
emit('selectConversation', value);
|
||||||
|
} else {
|
||||||
|
emit('deSelectConversation', value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||||
|
:class="{
|
||||||
|
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
|
||||||
|
isActiveChat,
|
||||||
|
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
|
||||||
|
'hover:bg-n-alpha-1': !isActiveChat && !selected,
|
||||||
|
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||||
|
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||||
|
}"
|
||||||
|
@click="$emit('click', $event)"
|
||||||
|
@contextmenu="$emit('contextmenu', $event)"
|
||||||
|
>
|
||||||
|
<!-- LEFT SECTION -->
|
||||||
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||||
|
<Checkbox v-model="selectedModel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||||
|
|
||||||
|
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||||
|
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Avatar
|
||||||
|
v-if="showAssignee && assignee.name"
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: assignee.name,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
:name="assignee.name"
|
||||||
|
:src="assignee.thumbnail"
|
||||||
|
:size="14"
|
||||||
|
:status="assignee.availability_status"
|
||||||
|
hide-offline-status
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
icon="i-woot-empty-assignee"
|
||||||
|
class="size-4 text-n-slate-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||||
|
<CardStatusIcon :status="chat.status" show-empty />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||||
|
|
||||||
|
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
|
||||||
|
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isInboxView && showInboxName"
|
||||||
|
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: chat.id,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-woot-hash"
|
||||||
|
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-body-main text-n-slate-11 truncate">
|
||||||
|
{{ chat.id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardAvatar
|
||||||
|
:contact="currentContact"
|
||||||
|
:selected="false"
|
||||||
|
:enable-selection="false"
|
||||||
|
:hide-thumbnail="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h4
|
||||||
|
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{{ currentContact.name }}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<CardContent
|
||||||
|
:last-message="lastMessageInChat"
|
||||||
|
:voice-call-status="voiceCallData.status"
|
||||||
|
:voice-call-direction="voiceCallData.direction"
|
||||||
|
:unread-count="unreadCount"
|
||||||
|
:show-expanded-preview="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT SECTION -->
|
||||||
|
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||||
|
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||||
|
<CardLabels
|
||||||
|
:labels="chat.labels"
|
||||||
|
disable-toggle
|
||||||
|
class="my-0 [&>div]:justify-end justify-end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||||
|
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||||
|
<TimeAgo
|
||||||
|
:conversation-id="chat.id"
|
||||||
|
:last-activity-timestamp="chat.timestamp"
|
||||||
|
:created-at-timestamp="chat.created_at"
|
||||||
|
class="font-440 !text-xs text-n-slate-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||||
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showMessageType: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
defaultEmptyMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
multiLine: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getPlainText } = useMessageFormatter();
|
||||||
|
|
||||||
|
const attachmentIcons = {
|
||||||
|
image: 'i-lucide-image',
|
||||||
|
audio: 'i-lucide-headphones',
|
||||||
|
video: 'i-lucide-video',
|
||||||
|
file: 'i-lucide-file',
|
||||||
|
location: 'i-lucide-map-pin',
|
||||||
|
fallback: 'i-lucide-link-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageByAgent = computed(() => {
|
||||||
|
const { message_type: messageType } = props.message;
|
||||||
|
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMessageAnActivity = computed(() => {
|
||||||
|
const { message_type: messageType } = props.message;
|
||||||
|
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMessagePrivate = computed(() => {
|
||||||
|
const { private: isPrivate } = props.message;
|
||||||
|
return isPrivate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedLastMessage = computed(() => {
|
||||||
|
const { content_attributes: contentAttributes } = props.message;
|
||||||
|
const { email: { subject } = {} } = contentAttributes || {};
|
||||||
|
return getPlainText(subject || props.message.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastMessageFileType = computed(() => {
|
||||||
|
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||||
|
return fileType;
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentIcon = computed(() => {
|
||||||
|
return attachmentIcons[lastMessageFileType.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentMessageContent = computed(() => {
|
||||||
|
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMessageSticker = computed(() => {
|
||||||
|
return props.message && props.message.content_type === 'sticker';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-w-0 text-sm"
|
||||||
|
:class="
|
||||||
|
multiLine
|
||||||
|
? 'flex items-start gap-1'
|
||||||
|
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template v-if="showMessageType && !multiLine">
|
||||||
|
<Icon
|
||||||
|
v-if="isMessagePrivate"
|
||||||
|
icon="i-lucide-lock-keyhole"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="messageByAgent"
|
||||||
|
icon="i-lucide-undo-2"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="isMessageAnActivity"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="min-w-0 text-body-main"
|
||||||
|
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||||
|
>
|
||||||
|
<!-- Case for previous and conversation conversation card -->
|
||||||
|
<template v-if="showMessageType && multiLine">
|
||||||
|
<Icon
|
||||||
|
v-if="isMessagePrivate"
|
||||||
|
icon="i-lucide-lock-keyhole"
|
||||||
|
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="messageByAgent"
|
||||||
|
icon="i-lucide-undo-2"
|
||||||
|
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="isMessageAnActivity"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="message.content && isMessageSticker"
|
||||||
|
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||||
|
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<template v-else-if="message.content">
|
||||||
|
{{ parsedLastMessage }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else-if="message.attachments"
|
||||||
|
class="inline-block align-middle truncate"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="attachmentIcon && showMessageType"
|
||||||
|
:icon="attachmentIcon"
|
||||||
|
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||||
|
/>
|
||||||
|
<span class="inline-block align-middle">
|
||||||
|
{{ $t(attachmentMessageContent) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
count: { type: Number, required: true },
|
||||||
|
alignBottom: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const displayCount = computed(() =>
|
||||||
|
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="count > 0"
|
||||||
|
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
|
||||||
|
:class="{
|
||||||
|
'mb-0.5': alignBottom,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ displayCount }}
|
||||||
|
</span>
|
||||||
|
<span v-else />
|
||||||
|
</template>
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
VOICE_CALL_STATUS,
|
||||||
|
VOICE_CALL_DIRECTION,
|
||||||
|
} from 'dashboard/components-next/message/constants';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: { type: String, default: '' },
|
||||||
|
direction: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const LABEL_KEYS = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOutbound = computed(
|
||||||
|
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
);
|
||||||
|
const isFailed = computed(() =>
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelKey = computed(() => {
|
||||||
|
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||||
|
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||||
|
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = computed(
|
||||||
|
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
|
||||||
|
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
|
||||||
|
<span class="truncate text-body-main" :class="statusColor">
|
||||||
|
{{ $t(labelKey) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
|
||||||
|
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
|
||||||
|
<span class="truncate text-body-main text-n-slate-11">
|
||||||
|
{{ inbox.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ButtonGroup
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = 60000;
|
||||||
|
|
||||||
|
const timer = ref(null);
|
||||||
|
const slaStatus = ref({
|
||||||
|
threshold: null,
|
||||||
|
isSlaMissed: false,
|
||||||
|
type: null,
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||||
|
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||||
|
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||||
|
|
||||||
|
const updateSlaStatus = () => {
|
||||||
|
slaStatus.value = evaluateSLAStatus({
|
||||||
|
appliedSla: appliedSLA.value || {},
|
||||||
|
chat: props.chat,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTimer = () => {
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
createTimer();
|
||||||
|
}, REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
createTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.chat, updateSlaStatus);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
hasSlaThreshold,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="hasSlaThreshold"
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
:label="slaStatus.threshold"
|
||||||
|
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||||
|
</template>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
@@ -9,11 +9,15 @@ import {
|
|||||||
ARTICLE_STATUSES,
|
ARTICLE_STATUSES,
|
||||||
} from 'dashboard/helper/portalHelper';
|
} from 'dashboard/helper/portalHelper';
|
||||||
|
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -44,14 +48,46 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showSelectionControl: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
const emit = defineEmits([
|
||||||
|
'openArticle',
|
||||||
|
'articleAction',
|
||||||
|
'toggleSelect',
|
||||||
|
'hover',
|
||||||
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
|
const isTranslationAvailable = computed(
|
||||||
|
() =>
|
||||||
|
isEnterprise &&
|
||||||
|
isFeatureEnabledonAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const articleMenuItems = computed(() => {
|
const articleMenuItems = computed(() => {
|
||||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||||
(acc, [key, item]) => {
|
(acc, [key, item]) => {
|
||||||
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
|
|||||||
const statusItems = (
|
const statusItems = (
|
||||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||||
).map(key => commonItems[key]);
|
)
|
||||||
|
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||||
|
.map(key => commonItems[key]);
|
||||||
|
|
||||||
return [...statusItems, commonItems.delete];
|
return [...statusItems, commonItems.delete];
|
||||||
});
|
});
|
||||||
@@ -123,14 +161,27 @@ const handleClick = id => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardLayout>
|
<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 :model-value="isSelected" @change="emit('toggleSelect', id)" />
|
||||||
|
</div>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<span
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
<span
|
||||||
@click="handleClick(id)"
|
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||||
>
|
@click="handleClick(id)"
|
||||||
{{ title }}
|
>
|
||||||
</span>
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||||
|
|||||||
+39
-39
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||||
@@ -27,58 +27,50 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'saveArticle',
|
'saveArticle',
|
||||||
'saveArticleAsync',
|
|
||||||
'goBack',
|
'goBack',
|
||||||
'setAuthor',
|
'setAuthor',
|
||||||
'setCategory',
|
'setCategory',
|
||||||
'previewArticle',
|
'previewArticle',
|
||||||
|
'createArticle',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const isNewArticle = computed(() => !props.article?.id);
|
const isNewArticle = computed(() => !props.article?.id);
|
||||||
|
|
||||||
const saveAndSync = value => {
|
const localTitle = ref(props.article?.title ?? '');
|
||||||
emit('saveArticle', value);
|
const localContent = ref(props.article?.content ?? '');
|
||||||
};
|
|
||||||
|
|
||||||
// this will only send the data to the backend
|
// Sync local state when navigating to a different article or on initial fetch
|
||||||
// but will not update the local state preventing unnecessary re-renders
|
watch(
|
||||||
// since the data is already saved and we keep the editor text as the source of truth
|
() => props.article?.id,
|
||||||
const quickSave = debounce(
|
newId => {
|
||||||
value => emit('saveArticleAsync', value),
|
if (newId) {
|
||||||
400,
|
localTitle.value = props.article?.title ?? '';
|
||||||
false
|
localContent.value = props.article?.content ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
|
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
||||||
// 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 => {
|
const handleSave = value => {
|
||||||
if (isNewArticle.value) {
|
if (isNewArticle.value) return;
|
||||||
quickSaveNewArticle(value);
|
debouncedSave(value);
|
||||||
} else {
|
|
||||||
quickSave(value);
|
|
||||||
saveAndSyncDebounced(value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const articleTitle = computed({
|
const articleTitle = computed({
|
||||||
get: () => props.article.title,
|
get: () => localTitle.value,
|
||||||
set: value => {
|
set: value => {
|
||||||
|
localTitle.value = value;
|
||||||
handleSave({ title: value });
|
handleSave({ title: value });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const articleContent = computed({
|
const articleContent = computed({
|
||||||
get: () => props.article.content,
|
get: () => localContent.value,
|
||||||
set: content => {
|
set: content => {
|
||||||
|
localContent.value = content;
|
||||||
handleSave({ content });
|
handleSave({ content });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -98,6 +90,14 @@ const setCategoryId = categoryId => {
|
|||||||
const previewArticle = () => {
|
const previewArticle = () => {
|
||||||
emit('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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -121,11 +121,12 @@ const previewArticle = () => {
|
|||||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
autofocus
|
:autofocus="isNewArticle"
|
||||||
|
@blur="handleCreateArticle"
|
||||||
/>
|
/>
|
||||||
<ArticleEditorControls
|
<ArticleEditorControls
|
||||||
:article="article"
|
:article="article"
|
||||||
@save-article="saveAndSync"
|
@save-article="values => emit('saveArticle', values)"
|
||||||
@set-author="setAuthorId"
|
@set-author="setAuthorId"
|
||||||
@set-category="setCategoryId"
|
@set-category="setCategoryId"
|
||||||
/>
|
/>
|
||||||
@@ -137,7 +138,7 @@ const previewArticle = () => {
|
|||||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||||
"
|
"
|
||||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||||
:autofocus="false"
|
:autofocus="!isNewArticle"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</HelpCenterLayout>
|
</HelpCenterLayout>
|
||||||
@@ -160,8 +161,12 @@ const previewArticle = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-root .has-selection {
|
.editor-root .has-selection {
|
||||||
|
.ProseMirror-menubar:not(:has(*)) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
.ProseMirror-menubar {
|
||||||
@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;
|
@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;
|
||||||
display: flex;
|
display: flex;
|
||||||
top: var(--selection-top, auto) !important;
|
top: var(--selection-top, auto) !important;
|
||||||
left: var(--selection-left, 0) !important;
|
left: var(--selection-left, 0) !important;
|
||||||
@@ -169,15 +174,10 @@ const previewArticle = () => {
|
|||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
|
|
||||||
.ProseMirror-menuitem {
|
.ProseMirror-menuitem {
|
||||||
@apply mr-0;
|
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||||
|
|
||||||
.ProseMirror-icon {
|
.ProseMirror-icon {
|
||||||
@apply p-0 mt-0 !mr-0;
|
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px !important;
|
|
||||||
height: 20px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-2
@@ -20,8 +20,14 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
selectedArticleIds: {
|
||||||
|
type: Set,
|
||||||
|
default: () => new Set(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||||
|
|
||||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,12 +36,26 @@ const store = useStore();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const localArticles = ref(props.articles);
|
const localArticles = ref(props.articles);
|
||||||
|
const hoveredArticleId = ref(null);
|
||||||
|
|
||||||
const dragEnabled = computed(() => {
|
const dragEnabled = computed(() => {
|
||||||
// Enable dragging only for category articles and when there's more than one article
|
return (
|
||||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
props.isCategoryArticles &&
|
||||||
|
localArticles.value?.length > 1 &&
|
||||||
|
props.selectedArticleIds.size === 0
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
|
||||||
|
|
||||||
|
const shouldShowSelectionControl = id => {
|
||||||
|
return hoveredArticleId.value === id || hasBulkSelection.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardHover = (isHovered, id) => {
|
||||||
|
hoveredArticleId.value = isHovered ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
const getCategoryById = useMapGetter('categories/categoryById');
|
const getCategoryById = useMapGetter('categories/categoryById');
|
||||||
|
|
||||||
const openArticle = id => {
|
const openArticle = id => {
|
||||||
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateArticle = ({ action, value, id }) => {
|
const updateArticle = ({ action, value, id }) => {
|
||||||
|
if (action === 'translate') {
|
||||||
|
emit('translateArticle', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||||
handleArticleAction(action, { status, id });
|
handleArticleAction(action, { status, id });
|
||||||
};
|
};
|
||||||
@@ -187,9 +211,14 @@ watch(
|
|||||||
:category="getCategory(element.category.id)"
|
:category="getCategory(element.category.id)"
|
||||||
:views="element.views || 0"
|
:views="element.views || 0"
|
||||||
:updated-at="element.updatedAt"
|
:updated-at="element.updatedAt"
|
||||||
|
:is-selected="selectedArticleIds.has(element.id)"
|
||||||
|
selectable
|
||||||
|
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||||
:class="{ 'cursor-grab': dragEnabled }"
|
:class="{ 'cursor-grab': dragEnabled }"
|
||||||
@open-article="openArticle"
|
@open-article="openArticle"
|
||||||
@article-action="updateArticle"
|
@article-action="updateArticle"
|
||||||
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
|
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+231
-7
@@ -1,9 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||||
|
|
||||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||||
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
|
|||||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||||
|
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
articles: {
|
articles: {
|
||||||
@@ -39,7 +47,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -47,6 +55,42 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||||
const isFetching = useMapGetter('articles/isFetching');
|
const isFetching = useMapGetter('articles/isFetching');
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedArticleIds = ref(new Set());
|
||||||
|
const deleteConfirmDialogRef = ref(null);
|
||||||
|
|
||||||
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
|
const isTranslationAvailable = computed(
|
||||||
|
() =>
|
||||||
|
isEnterprise &&
|
||||||
|
isFeatureEnabledonAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
|
||||||
|
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
|
||||||
|
|
||||||
|
const selectAllLabel = computed(() => {
|
||||||
|
if (!visibleArticleIds.value.length) return '';
|
||||||
|
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
|
||||||
|
count: visibleArticleIds.value.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCountLabel = computed(() =>
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
|
||||||
|
count: selectedArticleIds.value.size,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkTranslateDialogRef = ref(null);
|
||||||
|
|
||||||
const hasNoArticles = computed(
|
const hasNoArticles = computed(
|
||||||
() => !isFetching.value && !props.articles.length
|
() => !isFetching.value && !props.articles.length
|
||||||
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
|
|||||||
params: { categorySlug, locale },
|
params: { categorySlug, locale },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleSelect = articleId => {
|
||||||
|
const newSet = new Set(selectedArticleIds.value);
|
||||||
|
if (newSet.has(articleId)) {
|
||||||
|
newSet.delete(articleId);
|
||||||
|
} else {
|
||||||
|
newSet.add(articleId);
|
||||||
|
}
|
||||||
|
selectedArticleIds.value = newSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedArticleIds.value = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranslateArticle = articleId => {
|
||||||
|
selectedArticleIds.value = new Set([articleId]);
|
||||||
|
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTranslateDialog = () => {
|
||||||
|
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBulkActionSuccess = message => {
|
||||||
|
useAlert(message);
|
||||||
|
clearSelection();
|
||||||
|
emit('refreshArticles');
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkUpdateStatus = async status => {
|
||||||
|
try {
|
||||||
|
await articlesAPI.bulkUpdateStatus({
|
||||||
|
portalSlug: route.params.portalSlug,
|
||||||
|
articleIds: [...selectedArticleIds.value],
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
onBulkActionSuccess(
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBulkDelete = () => {
|
||||||
|
deleteConfirmDialogRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkDelete = async () => {
|
||||||
|
try {
|
||||||
|
await articlesAPI.bulkDelete({
|
||||||
|
portalSlug: route.params.portalSlug,
|
||||||
|
articleIds: [...selectedArticleIds.value],
|
||||||
|
});
|
||||||
|
deleteConfirmDialogRef.value?.close();
|
||||||
|
onBulkActionSuccess(
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
deleteConfirmDialogRef.value?.close();
|
||||||
|
useAlert(
|
||||||
|
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear selection when articles change (page change, filter change)
|
||||||
|
watch(
|
||||||
|
() => props.articles,
|
||||||
|
() => clearSelection()
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
|
|||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<ArticleList
|
<template v-else-if="!hasNoArticles">
|
||||||
v-else-if="!hasNoArticles"
|
<div
|
||||||
:articles="articles"
|
v-if="selectedArticleIds.size > 0"
|
||||||
:is-category-articles="isCategoryArticles"
|
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||||
/>
|
>
|
||||||
|
<BulkSelectBar
|
||||||
|
v-model="selectedArticleIds"
|
||||||
|
:all-items="allItems"
|
||||||
|
:select-all-label="selectAllLabel"
|
||||||
|
:selected-count-label="selectedCountLabel"
|
||||||
|
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||||
|
>
|
||||||
|
<template #secondary-actions>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
:label="
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
|
||||||
|
"
|
||||||
|
class="!px-1.5"
|
||||||
|
@click="clearSelection"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex items-center gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
icon="i-lucide-check"
|
||||||
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
|
||||||
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||||
|
@click="bulkUpdateStatus('published')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
|
||||||
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||||
|
@click="bulkUpdateStatus('draft')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
icon="i-lucide-archive-restore"
|
||||||
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
|
||||||
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||||
|
@click="bulkUpdateStatus('archived')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="isTranslationAvailable"
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
icon="i-lucide-languages"
|
||||||
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
|
||||||
|
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||||
|
@click="openTranslateDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
ruby
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
|
||||||
|
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||||
|
@click="confirmBulkDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BulkSelectBar>
|
||||||
|
</div>
|
||||||
|
<ArticleList
|
||||||
|
:articles="articles"
|
||||||
|
:is-category-articles="isCategoryArticles"
|
||||||
|
:selected-article-ids="selectedArticleIds"
|
||||||
|
class="relative z-0"
|
||||||
|
@translate-article="handleTranslateArticle"
|
||||||
|
@toggle-select="handleToggleSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<ArticleEmptyState
|
<ArticleEmptyState
|
||||||
v-else
|
v-else
|
||||||
class="pt-14"
|
class="pt-14"
|
||||||
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
|
|||||||
@click="navigateToNewArticlePage"
|
@click="navigateToNewArticlePage"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<BulkTranslateDialog
|
||||||
|
ref="bulkTranslateDialogRef"
|
||||||
|
:selected-article-ids="[...selectedArticleIds]"
|
||||||
|
:allowed-locales="allowedLocales"
|
||||||
|
@translate-started="clearSelection"
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
ref="deleteConfirmDialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
|
||||||
|
selectedArticleIds.size
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
|
||||||
|
selectedArticleIds.size
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:confirm-button-label="
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
|
||||||
|
"
|
||||||
|
@confirm="bulkDelete"
|
||||||
|
/>
|
||||||
</HelpCenterLayout>
|
</HelpCenterLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedArticleIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
allowedLocales: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['translateStarted']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const selectedLocale = ref('');
|
||||||
|
const selectedCategoryId = ref('');
|
||||||
|
const targetCategories = ref([]);
|
||||||
|
const isFetchingCategories = ref(false);
|
||||||
|
const duplicateArticles = ref([]);
|
||||||
|
|
||||||
|
const currentLocale = computed(() => route.params.locale);
|
||||||
|
|
||||||
|
const localeOptions = computed(() => {
|
||||||
|
return props.allowedLocales
|
||||||
|
.filter(locale => locale.code !== currentLocale.value)
|
||||||
|
.map(locale => ({
|
||||||
|
value: locale.code,
|
||||||
|
label: `${locale.name} (${locale.code})`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryOptions = computed(() => {
|
||||||
|
return targetCategories.value.map(category => ({
|
||||||
|
value: category.id,
|
||||||
|
label: category.name,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const articleCount = computed(() => props.selectedArticleIds.length);
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = computed(() =>
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
|
||||||
|
|
||||||
|
const confirmLabel = computed(() => {
|
||||||
|
if (hasDuplicates.value) {
|
||||||
|
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
|
||||||
|
}
|
||||||
|
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConfirmDisabled = computed(() => {
|
||||||
|
return !selectedLocale.value || isSubmitting.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const articleEditUrl = articleId => {
|
||||||
|
const { portalSlug, categorySlug, tab } = route.params;
|
||||||
|
const resolved = router.resolve({
|
||||||
|
name: 'portals_articles_edit',
|
||||||
|
params: {
|
||||||
|
portalSlug,
|
||||||
|
locale: selectedLocale.value,
|
||||||
|
categorySlug,
|
||||||
|
tab,
|
||||||
|
articleSlug: articleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return resolved.href;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategoriesForLocale = async locale => {
|
||||||
|
if (!locale) {
|
||||||
|
targetCategories.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFetchingCategories.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await categoriesAPI.get({
|
||||||
|
portalSlug: route.params.portalSlug,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
targetCategories.value = data.payload;
|
||||||
|
} catch {
|
||||||
|
targetCategories.value = [];
|
||||||
|
} finally {
|
||||||
|
isFetchingCategories.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(selectedLocale, newLocale => {
|
||||||
|
selectedCategoryId.value = '';
|
||||||
|
duplicateArticles.value = [];
|
||||||
|
fetchCategoriesForLocale(newLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
selectedLocale.value = '';
|
||||||
|
selectedCategoryId.value = '';
|
||||||
|
targetCategories.value = [];
|
||||||
|
duplicateArticles.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTranslation = async (force = false) => {
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await store.dispatch('articles/bulkTranslate', {
|
||||||
|
portalSlug: route.params.portalSlug,
|
||||||
|
articleIds: props.selectedArticleIds,
|
||||||
|
locale: selectedLocale.value,
|
||||||
|
categoryId: selectedCategoryId.value,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
|
||||||
|
resetForm();
|
||||||
|
dialogRef.value?.close();
|
||||||
|
emit('translateStarted');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
duplicateArticles.value = error.response.data.duplicate_articles;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useAlert(
|
||||||
|
error?.message ||
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
if (isConfirmDisabled.value) return;
|
||||||
|
submitTranslation(hasDuplicates.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
type="edit"
|
||||||
|
:title="dialogTitle"
|
||||||
|
:description="description"
|
||||||
|
:confirm-button-label="confirmLabel"
|
||||||
|
:disable-confirm-button="isConfirmDisabled"
|
||||||
|
:is-loading="isSubmitting"
|
||||||
|
@close="resetForm"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
|
||||||
|
</span>
|
||||||
|
<ComboBox
|
||||||
|
v-model="selectedLocale"
|
||||||
|
:options="localeOptions"
|
||||||
|
:placeholder="
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
|
||||||
|
<span class="text-n-slate-10 font-normal">
|
||||||
|
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ComboBox
|
||||||
|
v-model="selectedCategoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:disabled="!selectedLocale || isFetchingCategories"
|
||||||
|
:placeholder="
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hasDuplicates"
|
||||||
|
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-2 min-w-0">
|
||||||
|
<p class="text-sm text-n-amber-12 m-0">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
|
||||||
|
duplicateArticles.length
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a
|
||||||
|
v-for="article in duplicateArticles"
|
||||||
|
:key="article.id"
|
||||||
|
:href="articleEditUrl(article.id)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
|
||||||
|
>
|
||||||
|
{{ article.title }}
|
||||||
|
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-n-amber-11 m-0">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -2,13 +2,10 @@
|
|||||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWindowSize } from '@vueuse/core';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
@@ -18,22 +15,18 @@ import {
|
|||||||
processContactableInboxes,
|
processContactableInboxes,
|
||||||
mergeInboxDetails,
|
mergeInboxDetails,
|
||||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
|
||||||
|
|
||||||
|
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
alignPosition: {
|
|
||||||
type: String,
|
|
||||||
default: 'left',
|
|
||||||
},
|
|
||||||
contactId: {
|
contactId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
isModal: {
|
align: {
|
||||||
type: Boolean,
|
type: String,
|
||||||
default: false,
|
default: 'end',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
|
|||||||
const searchContacts = createContactSearcher();
|
const searchContacts = createContactSearcher();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { width: windowWidth } = useWindowSize();
|
|
||||||
|
|
||||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||||
|
|
||||||
const isSmallScreen = computed(
|
const popoverRef = ref(null);
|
||||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
|
||||||
);
|
|
||||||
|
|
||||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
|
||||||
|
|
||||||
const contacts = ref([]);
|
const contacts = ref([]);
|
||||||
const selectedContact = ref(null);
|
const selectedContact = ref(null);
|
||||||
const targetInbox = ref(null);
|
const targetInbox = ref(null);
|
||||||
const isCreatingContact = ref(false);
|
const isCreatingContact = ref(false);
|
||||||
const isFetchingInboxes = ref(false);
|
const isFetchingInboxes = ref(false);
|
||||||
const isSearching = ref(false);
|
const isSearching = ref(false);
|
||||||
const showComposeNewConversation = ref(false);
|
|
||||||
|
|
||||||
const formState = reactive({
|
const formState = reactive({
|
||||||
message: '',
|
message: '',
|
||||||
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
|
|||||||
|
|
||||||
const activeContact = computed(() => contactById.value(props.contactId));
|
const activeContact = computed(() => contactById.value(props.contactId));
|
||||||
|
|
||||||
const composePopoverClass = computed(() => {
|
|
||||||
if (viewInModal.value) return '';
|
|
||||||
|
|
||||||
return props.alignPosition === 'right'
|
|
||||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
|
||||||
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
|
|
||||||
});
|
|
||||||
|
|
||||||
const onContactSearch = debounce(
|
const onContactSearch = debounce(
|
||||||
async query => {
|
async query => {
|
||||||
isSearching.value = true;
|
isSearching.value = true;
|
||||||
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeCompose = () => {
|
const closeCompose = () => {
|
||||||
showComposeNewConversation.value = false;
|
popoverRef.value?.hide();
|
||||||
if (!props.contactId) {
|
if (!props.contactId) {
|
||||||
// If contactId is passed as prop
|
// If contactId is passed as prop
|
||||||
// Then don't allow to remove the selected contact
|
// Then don't allow to remove the selected contact
|
||||||
@@ -180,7 +158,6 @@ const closeCompose = () => {
|
|||||||
}
|
}
|
||||||
targetInbox.value = null;
|
targetInbox.value = null;
|
||||||
resetContacts();
|
resetContacts();
|
||||||
emit('close');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const discardCompose = () => {
|
const discardCompose = () => {
|
||||||
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggle = () => {
|
const onPopoverShow = () => {
|
||||||
showComposeNewConversation.value = !showComposeNewConversation.value;
|
// Flag to prevent triggering drag n drop,
|
||||||
|
// When compose modal is active
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPopoverHide = () => {
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||||
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -242,64 +226,23 @@ watch(
|
|||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClickOutside = () => {
|
|
||||||
if (!showComposeNewConversation.value) return;
|
|
||||||
|
|
||||||
showComposeNewConversation.value = false;
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const onModalBackdropClick = () => {
|
|
||||||
if (!viewInModal.value) return;
|
|
||||||
handleClickOutside();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => resetContacts());
|
onMounted(() => resetContacts());
|
||||||
|
|
||||||
const keyboardEvents = {
|
|
||||||
Escape: {
|
|
||||||
action: () => {
|
|
||||||
if (showComposeNewConversation.value) {
|
|
||||||
showComposeNewConversation.value = false;
|
|
||||||
emit('close');
|
|
||||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<Popover
|
||||||
v-on-click-outside="[
|
ref="popoverRef"
|
||||||
handleClickOutside,
|
:align="align"
|
||||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
:show-content-border="false"
|
||||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
@show="onPopoverShow"
|
||||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
@hide="onPopoverHide"
|
||||||
]"
|
|
||||||
class="relative"
|
|
||||||
:class="{
|
|
||||||
'z-50': showComposeNewConversation && !viewInModal,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<slot
|
<template #default="{ isOpen }">
|
||||||
name="trigger"
|
<slot name="trigger" :is-open="isOpen" />
|
||||||
:is-open="showComposeNewConversation"
|
</template>
|
||||||
:toggle="toggle"
|
<template #content>
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="showComposeNewConversation"
|
|
||||||
:class="{
|
|
||||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
|
||||||
viewInModal,
|
|
||||||
}"
|
|
||||||
@click.self="onModalBackdropClick"
|
|
||||||
>
|
|
||||||
<ComposeNewConversationForm
|
<ComposeNewConversationForm
|
||||||
:form-state="formState"
|
:form-state="formState"
|
||||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
:is-loading="isSearching"
|
:is-loading="isSearching"
|
||||||
@@ -321,6 +264,6 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@create-conversation="createConversation"
|
@create-conversation="createConversation"
|
||||||
@discard="discardCompose"
|
@discard="discardCompose"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+5
-8
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
|
|||||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||||
@@ -20,6 +18,7 @@ const props = defineProps({
|
|||||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||||
|
// eslint-disable-next-line vue/no-unused-properties
|
||||||
messageTemplates: { type: Array, default: () => [] },
|
messageTemplates: { type: Array, default: () => [] },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
@@ -29,6 +28,7 @@ const props = defineProps({
|
|||||||
isDropdownActive: { type: Boolean, default: false },
|
isDropdownActive: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
inboxId: { type: Number, default: null },
|
inboxId: { type: Number, default: null },
|
||||||
|
voiceEnabled: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -81,11 +81,9 @@ const isRegularMessageMode = computed(() => {
|
|||||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
|
||||||
|
|
||||||
const shouldShowSignatureButton = computed(() => {
|
const shouldShowSignatureButton = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +108,7 @@ watch(
|
|||||||
() => props.hasSelectedInbox,
|
() => props.hasSelectedInbox,
|
||||||
newValue => {
|
newValue => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (newValue && !isVoiceInbox.value) setSignature();
|
if (newValue && !props.voiceEnabled) setSignature();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -198,7 +196,6 @@ useEventListener(document, 'paste', onPaste);
|
|||||||
<WhatsAppOptions
|
<WhatsAppOptions
|
||||||
v-if="isWhatsappInbox"
|
v-if="isWhatsappInbox"
|
||||||
:inbox-id="inboxId"
|
:inbox-id="inboxId"
|
||||||
:message-templates="messageTemplates"
|
|
||||||
@send-message="emit('sendWhatsappMessage', $event)"
|
@send-message="emit('sendWhatsappMessage', $event)"
|
||||||
/>
|
/>
|
||||||
<ContentTemplateSelector
|
<ContentTemplateSelector
|
||||||
@@ -220,7 +217,7 @@ useEventListener(document, 'paste', onPaste);
|
|||||||
/>
|
/>
|
||||||
<EmojiInput
|
<EmojiInput
|
||||||
v-if="isEmojiPickerOpen"
|
v-if="isEmojiPickerOpen"
|
||||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
|
||||||
:on-click="onClickInsertEmoji"
|
:on-click="onClickInsertEmoji"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-2
@@ -2,7 +2,7 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, requiredIf } from '@vuelidate/validators';
|
import { required, requiredIf } from '@vuelidate/validators';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
|||||||
|
|
||||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||||
|
|
||||||
|
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
|
||||||
|
|
||||||
const effectiveChannelType = computed(() =>
|
const effectiveChannelType = computed(() =>
|
||||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||||
);
|
);
|
||||||
@@ -361,7 +363,7 @@ useKeyboardEvents({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||||
>
|
>
|
||||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||||
<ContactSelector
|
<ContactSelector
|
||||||
@@ -442,6 +444,7 @@ useKeyboardEvents({
|
|||||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||||
:message-templates="whatsappMessageTemplates"
|
:message-templates="whatsappMessageTemplates"
|
||||||
:channel-type="inboxChannelType"
|
:channel-type="inboxChannelType"
|
||||||
|
:voice-enabled="voiceCallEnabled"
|
||||||
:is-loading="isCreating"
|
:is-loading="isCreating"
|
||||||
:disable-send-button="isCreating"
|
:disable-send-button="isCreating"
|
||||||
:has-selected-inbox="!!targetInbox"
|
:has-selected-inbox="!!targetInbox"
|
||||||
|
|||||||
+1
-3
@@ -24,9 +24,7 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<ContentTemplateParser
|
<ContentTemplateParser
|
||||||
:template="template"
|
:template="template"
|
||||||
|
|||||||
+65
-55
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
|
|||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedTemplate = ref(null);
|
const selectedTemplate = ref(null);
|
||||||
const showTemplatesMenu = ref(false);
|
|
||||||
|
|
||||||
const contentTemplates = computed(() => {
|
const contentTemplates = computed(() => {
|
||||||
const inboxData = inbox.value(props.inboxId);
|
const inboxData = inbox.value(props.inboxId);
|
||||||
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleTriggerClick = () => {
|
const handlePopoverShow = () => {
|
||||||
searchQuery.value = '';
|
searchQuery.value = '';
|
||||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
selectedTemplate.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverHide = () => {
|
||||||
|
selectedTemplate.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplateClick = template => {
|
const handleTemplateClick = template => {
|
||||||
selectedTemplate.value = template;
|
selectedTemplate.value = template;
|
||||||
showTemplatesMenu.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
selectedTemplate.value = null;
|
selectedTemplate.value = null;
|
||||||
showTemplatesMenu.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = template => {
|
const handleSendMessage = (template, hide) => {
|
||||||
emit('sendMessage', template);
|
emit('sendMessage', template);
|
||||||
selectedTemplate.value = null;
|
hide();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<Popover
|
||||||
|
align="start"
|
||||||
|
disable-mobile-view
|
||||||
|
@show="handlePopoverShow"
|
||||||
|
@hide="handlePopoverHide"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-ph-whatsapp-logo"
|
icon="i-ph-whatsapp-logo"
|
||||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||||
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
:disabled="selectedTemplate"
|
:disabled="selectedTemplate"
|
||||||
class="!text-xs font-medium"
|
class="!text-xs font-medium"
|
||||||
@click="handleTriggerClick"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<template #content="{ hide }">
|
||||||
v-if="showTemplatesMenu"
|
|
||||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full">
|
|
||||||
<Input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="search"
|
|
||||||
:placeholder="
|
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
|
||||||
"
|
|
||||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-search"
|
|
||||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Input>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="template in filteredTemplates"
|
v-if="!selectedTemplate"
|
||||||
:key="template.content_sid"
|
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||||
tabindex="0"
|
|
||||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
|
||||||
@click="handleTemplateClick(template)"
|
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center">
|
<div class="w-full">
|
||||||
<span class="text-sm text-n-slate-12">{{
|
<Input
|
||||||
template.friendly_name
|
v-model="searchQuery"
|
||||||
}}</span>
|
type="search"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-search"
|
||||||
|
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
<div
|
||||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
v-for="template in filteredTemplates"
|
||||||
</p>
|
:key="template.content_sid"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||||
|
@click="handleTemplateClick(template)"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-n-slate-12">{{
|
||||||
|
template.friendly_name
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||||
|
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template v-if="filteredTemplates.length === 0">
|
||||||
|
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||||
|
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="filteredTemplates.length === 0">
|
<ContentTemplateForm
|
||||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
v-else
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
:template="selectedTemplate"
|
||||||
</p>
|
@send-message="payload => handleSendMessage(payload, hide)"
|
||||||
</template>
|
@back="handleBack"
|
||||||
</div>
|
/>
|
||||||
<ContentTemplateForm
|
</template>
|
||||||
v-if="selectedTemplate"
|
</Popover>
|
||||||
:template="selectedTemplate"
|
|
||||||
@send-message="handleSendMessage"
|
|
||||||
@back="handleBack"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+60
-51
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||||
import WhatsappTemplate from './WhatsappTemplate.vue';
|
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
|
|||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedTemplate = ref(null);
|
const selectedTemplate = ref(null);
|
||||||
|
|
||||||
const showTemplatesMenu = ref(false);
|
|
||||||
|
|
||||||
const whatsAppTemplateMessages = computed(() => {
|
const whatsAppTemplateMessages = computed(() => {
|
||||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||||
});
|
});
|
||||||
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
|
|||||||
return template.components.find(component => component.type === 'BODY').text;
|
return template.components.find(component => component.type === 'BODY').text;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTriggerClick = () => {
|
const handlePopoverShow = () => {
|
||||||
searchQuery.value = '';
|
searchQuery.value = '';
|
||||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
selectedTemplate.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverHide = () => {
|
||||||
|
selectedTemplate.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplateClick = template => {
|
const handleTemplateClick = template => {
|
||||||
selectedTemplate.value = template;
|
selectedTemplate.value = template;
|
||||||
showTemplatesMenu.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
selectedTemplate.value = null;
|
selectedTemplate.value = null;
|
||||||
showTemplatesMenu.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = template => {
|
const handleSendMessage = (template, hide) => {
|
||||||
emit('sendMessage', template);
|
emit('sendMessage', template);
|
||||||
selectedTemplate.value = null;
|
hide();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<Popover
|
||||||
|
align="start"
|
||||||
|
disable-mobile-view
|
||||||
|
@show="handlePopoverShow"
|
||||||
|
@hide="handlePopoverHide"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-ri-whatsapp-line"
|
icon="i-ri-whatsapp-line"
|
||||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
|
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
|
||||||
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
:disabled="selectedTemplate"
|
:disabled="selectedTemplate"
|
||||||
class="!text-xs font-medium"
|
class="!text-xs font-medium"
|
||||||
@click="handleTriggerClick"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<template #content="{ hide }">
|
||||||
v-if="showTemplatesMenu"
|
|
||||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="relative w-full">
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-search"
|
|
||||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="search"
|
|
||||||
:placeholder="
|
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="template in filteredTemplates"
|
v-if="!selectedTemplate"
|
||||||
:key="template.id"
|
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
|
||||||
@click="handleTemplateClick(template)"
|
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
<div class="relative w-full">
|
||||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
<Icon
|
||||||
{{ getTemplateBody(template) }}
|
icon="i-lucide-search"
|
||||||
</p>
|
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="template in filteredTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||||
|
@click="handleTemplateClick(template)"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||||
|
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||||
|
{{ getTemplateBody(template) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template v-if="filteredTemplates.length === 0">
|
||||||
|
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="filteredTemplates.length === 0">
|
<WhatsappTemplate
|
||||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
v-else
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
:template="selectedTemplate"
|
||||||
</p>
|
@send-message="payload => handleSendMessage(payload, hide)"
|
||||||
</template>
|
@back="handleBack"
|
||||||
</div>
|
/>
|
||||||
<WhatsappTemplate
|
</template>
|
||||||
v-if="selectedTemplate"
|
</Popover>
|
||||||
:template="selectedTemplate"
|
|
||||||
@send-message="handleSendMessage"
|
|
||||||
@back="handleBack"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+1
-3
@@ -24,9 +24,7 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<WhatsAppTemplateParser
|
<WhatsAppTemplateParser
|
||||||
:template="template"
|
:template="template"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, useSlots } from 'vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
@@ -30,23 +30,40 @@ const modelValue = defineModel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedCount = computed(() => modelValue.value.size);
|
const selectedCount = computed(() => modelValue.value.size);
|
||||||
const totalCount = computed(() => props.allItems.length);
|
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 hasSelected = computed(() => selectedCount.value > 0);
|
const hasSelected = computed(() => selectedCount.value > 0);
|
||||||
const isIndeterminate = computed(
|
const isIndeterminate = computed(
|
||||||
() => hasSelected.value && selectedCount.value < totalCount.value
|
() =>
|
||||||
|
selectedVisibleCount.value > 0 &&
|
||||||
|
selectedVisibleCount.value < visibleItemCount.value
|
||||||
);
|
);
|
||||||
const allSelected = computed(
|
const allSelected = computed(
|
||||||
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
() =>
|
||||||
|
visibleItemCount.value > 0 &&
|
||||||
|
selectedVisibleCount.value === visibleItemCount.value
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
|
||||||
|
|
||||||
const bulkCheckboxState = computed({
|
const bulkCheckboxState = computed({
|
||||||
get: () => allSelected.value,
|
get: () => allSelected.value,
|
||||||
set: shouldSelectAll => {
|
set: shouldSelectAll => {
|
||||||
const newSelectedIds = shouldSelectAll
|
if (!visibleItemCount.value) {
|
||||||
? new Set(props.allItems.map(item => item.id))
|
return;
|
||||||
: new Set();
|
}
|
||||||
modelValue.value = newSelectedIds;
|
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
|
|||||||
v-if="hasSelected"
|
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"
|
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">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="bulkCheckboxState"
|
v-model="bulkCheckboxState"
|
||||||
@@ -78,21 +95,23 @@ const bulkCheckboxState = computed({
|
|||||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||||
{{ selectedCountLabel }}
|
{{ selectedCountLabel }}
|
||||||
</span>
|
</span>
|
||||||
<div class="h-4 w-px bg-n-strong" />
|
|
||||||
<slot name="secondary-actions" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<slot name="actions" :selected-count="selectedCount">
|
<slot v-if="hasSecondaryActions" name="secondary-actions" />
|
||||||
<Button
|
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
|
||||||
:label="deleteLabel"
|
<div class="flex items-center gap-3">
|
||||||
sm
|
<slot name="actions" :selected-count="selectedCount">
|
||||||
ruby
|
<Button
|
||||||
ghost
|
:label="deleteLabel"
|
||||||
class="!px-1.5"
|
sm
|
||||||
icon="i-lucide-trash"
|
ruby
|
||||||
@click="emit('bulkDelete')"
|
ghost
|
||||||
/>
|
class="!px-1.5"
|
||||||
</slot>
|
icon="i-lucide-trash"
|
||||||
|
@click="emit('bulkDelete')"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center gap-3">
|
<div v-else class="flex items-center gap-3">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -34,14 +35,34 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
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']);
|
const emit = defineEmits(['action', 'select', 'hover']);
|
||||||
const { checkPermissions } = usePolicy();
|
const { checkPermissions } = usePolicy();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => props.isSelected,
|
||||||
|
set: () => emit('select', props.id),
|
||||||
|
});
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const allOptions = [
|
const allOptions = [
|
||||||
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardLayout>
|
<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>
|
||||||
<div class="flex gap-1 justify-between w-full">
|
<div class="flex gap-1 justify-between w-full">
|
||||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-2 items-center">
|
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
class="flex relative items-center group"
|
class="flex relative items-center group"
|
||||||
|
|||||||
+11
-5
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const bulkDeleteDialogRef = ref(null);
|
const bulkDeleteDialogRef = ref(null);
|
||||||
const i18nKey = computed(() => props.type.toUpperCase());
|
const i18nKey = computed(() => {
|
||||||
|
const i18nTypeMap = {
|
||||||
|
AssistantResponse: 'RESPONSES',
|
||||||
|
AssistantDocument: 'DOCUMENTS',
|
||||||
|
};
|
||||||
|
return i18nTypeMap[props.type];
|
||||||
|
});
|
||||||
|
|
||||||
const handleBulkDelete = async ids => {
|
const handleBulkDelete = async ids => {
|
||||||
if (!ids) return;
|
if (!ids) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.dispatch(
|
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
||||||
'captainBulkActions/handleBulkDelete',
|
ids: Array.from(props.bulkIds),
|
||||||
Array.from(props.bulkIds)
|
type: props.type,
|
||||||
);
|
});
|
||||||
|
|
||||||
emit('deleteSuccess');
|
emit('deleteSuccess');
|
||||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
|
|||||||
|
|
||||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
featurePrefix: {
|
||||||
|
type: String,
|
||||||
|
default: 'CAPTAIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ const openBilling = () => {
|
|||||||
>
|
>
|
||||||
<BasePaywallModal
|
<BasePaywallModal
|
||||||
class="mx-auto"
|
class="mx-auto"
|
||||||
feature-prefix="CAPTAIN"
|
:feature-prefix="featurePrefix"
|
||||||
:i18n-key="i18nKey"
|
:i18n-key="i18nKey"
|
||||||
:is-super-admin="isSuperAdmin"
|
:is-super-admin="isSuperAdmin"
|
||||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||||
|
|||||||
+3
-6
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
|
|||||||
</Policy>
|
</Policy>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between w-full gap-4">
|
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||||
<div class="flex items-center gap-3 flex-1">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<span
|
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||||
v-if="description"
|
|
||||||
class="text-sm truncate text-n-slate-11 flex-1"
|
|
||||||
>
|
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
+80
-6
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required } from '@vuelidate/validators';
|
import { required, maxLength } from '@vuelidate/validators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
|
|||||||
required: false,
|
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 = {
|
const validationRules = {
|
||||||
title: { required },
|
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||||
endpoint_url: { required },
|
endpoint_url: { required },
|
||||||
http_method: { required },
|
http_method: { required },
|
||||||
auth_type: { required },
|
auth_type: { required },
|
||||||
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getErrorMessage = (field, errorKey) => {
|
const getErrorMessage = (field, errorKey) => {
|
||||||
return v$.value[field].$error
|
if (!v$.value[field].$error) return '';
|
||||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
|
||||||
: '';
|
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`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
emit('submit', state);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
|
|||||||
class="[&_textarea]:font-mono"
|
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">
|
<div class="flex gap-3 justify-between items-center w-full">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+12
@@ -1,8 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
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';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
@@ -10,6 +13,15 @@ const onClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<EmptyStateLayout
|
||||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||||
|
|||||||
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
|
|||||||
filterOperators: containmentOperators.value,
|
filterOperators: containmentOperators.value,
|
||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||||
|
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||||
|
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||||
|
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||||
|
inputType: 'plainText',
|
||||||
|
dataType: 'text',
|
||||||
|
filterOperators: containmentOperators.value,
|
||||||
|
attributeModel: 'standard',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
|
|||||||
IDENTIFIER: 'identifier',
|
IDENTIFIER: 'identifier',
|
||||||
COUNTRY_CODE: 'country_code',
|
COUNTRY_CODE: 'country_code',
|
||||||
CITY: 'city',
|
CITY: 'city',
|
||||||
|
COMPANY_NAME: 'company_name',
|
||||||
CREATED_AT: 'created_at',
|
CREATED_AT: 'created_at',
|
||||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||||
REFERER: 'referer',
|
REFERER: 'referer',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
export function useChannelIcon(inbox) {
|
export function useChannelIcon(inbox) {
|
||||||
const channelTypeIconMap = {
|
const channelTypeIconMap = {
|
||||||
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
'Channel::Tiktok': 'i-woot-tiktok',
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
'Channel::Voice': 'i-woot-voice',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
|
|||||||
icon = 'i-woot-whatsapp';
|
icon = 'i-woot-whatsapp';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||||
|
if (isVoiceCallEnabled(inboxDetails)) {
|
||||||
|
icon = 'i-woot-voice';
|
||||||
|
}
|
||||||
|
|
||||||
return icon ?? 'i-ri-global-fill';
|
return icon ?? 'i-ri-global-fill';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-woot-whatsapp');
|
expect(icon).toBe('i-woot-whatsapp');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for Voice channel', () => {
|
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Voice' };
|
const inbox = {
|
||||||
|
channel_type: 'Channel::TwilioSms',
|
||||||
|
voice_enabled: true,
|
||||||
|
};
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
expect(icon).toBe('i-woot-voice');
|
expect(icon).toBe('i-woot-voice');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,13 +30,23 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
focusOnMount: {
|
focusOnMount: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
|
const emit = defineEmits([
|
||||||
|
'enterPress',
|
||||||
|
'escapePress',
|
||||||
|
'input',
|
||||||
|
'blur',
|
||||||
|
'focus',
|
||||||
|
]);
|
||||||
|
|
||||||
const modelValue = defineModel({
|
const modelValue = defineModel({
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@@ -49,6 +59,10 @@ const onEnterPress = () => {
|
|||||||
emit('enterPress');
|
emit('enterPress');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onEscapePress = () => {
|
||||||
|
emit('escapePress');
|
||||||
|
};
|
||||||
|
|
||||||
const handleInput = event => {
|
const handleInput = event => {
|
||||||
emit('input', event.target.value);
|
emit('input', event.target.value);
|
||||||
modelValue.value = event.target.value;
|
modelValue.value = event.target.value;
|
||||||
@@ -72,6 +86,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focus: () => inlineInputRef.value?.focus(),
|
focus: () => inlineInputRef.value?.focus(),
|
||||||
|
blur: () => inlineInputRef.value?.blur(),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,12 +111,14 @@ defineExpose({
|
|||||||
:type="type"
|
:type="type"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
:class="customInputClass"
|
:class="customInputClass"
|
||||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@keydown.enter.prevent="onEnterPress"
|
@keydown.enter.prevent="onEnterPress"
|
||||||
|
@keydown.escape.prevent="onEscapePress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const convertToMinutes = newValue => {
|
|||||||
|
|
||||||
const transformedValue = computed({
|
const transformedValue = computed({
|
||||||
get() {
|
get() {
|
||||||
|
if (duration.value == null) return null;
|
||||||
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
|
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
|
||||||
if (unit.value === DURATION_UNITS.HOURS)
|
if (unit.value === DURATION_UNITS.HOURS)
|
||||||
return Math.floor(duration.value / 60);
|
return Math.floor(duration.value / 60);
|
||||||
@@ -41,6 +42,10 @@ const transformedValue = computed({
|
|||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
set(newValue) {
|
set(newValue) {
|
||||||
|
if (newValue == null || newValue === '') {
|
||||||
|
duration.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let minuteValue = convertToMinutes(newValue);
|
let minuteValue = convertToMinutes(newValue);
|
||||||
|
|
||||||
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
||||||
@@ -53,6 +58,7 @@ const transformedValue = computed({
|
|||||||
// this might create some confusion, especially when saving
|
// this might create some confusion, especially when saving
|
||||||
// this watcher fixes it by rounding the duration basically, to the nearest unit value
|
// this watcher fixes it by rounding the duration basically, to the nearest unit value
|
||||||
watch(unit, () => {
|
watch(unit, () => {
|
||||||
|
if (duration.value == null) return;
|
||||||
let adjustedValue = convertToMinutes(transformedValue.value);
|
let adjustedValue = convertToMinutes(transformedValue.value);
|
||||||
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
|
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -226,4 +226,10 @@ 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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||||
|
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
align: {
|
||||||
|
type: String,
|
||||||
|
default: 'end',
|
||||||
|
validator: v => ['start', 'end'].includes(v),
|
||||||
|
},
|
||||||
|
disableMobileView: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showContentBorder: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['show', 'hide']);
|
||||||
|
|
||||||
|
const isActive = ref(false);
|
||||||
|
const triggerRef = ref(null);
|
||||||
|
const popoverRef = ref(null);
|
||||||
|
const mobileContentRef = ref(null);
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||||
|
const belowMd = breakpoints.smaller('md');
|
||||||
|
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
|
||||||
|
const showPopover = computed(() => isActive.value && !isMobile.value);
|
||||||
|
|
||||||
|
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||||
|
triggerRef,
|
||||||
|
popoverRef,
|
||||||
|
showPopover,
|
||||||
|
{ align: props.align }
|
||||||
|
);
|
||||||
|
|
||||||
|
const show = async () => {
|
||||||
|
isActive.value = true;
|
||||||
|
if (!isMobile.value) {
|
||||||
|
await nextTick();
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
emit('show');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
if (!isActive.value) return;
|
||||||
|
isActive.value = false;
|
||||||
|
emit('hide');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
if (isActive.value) hide();
|
||||||
|
else await show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalculate position when switching from mobile to desktop while open
|
||||||
|
watch(isMobile, async mobile => {
|
||||||
|
if (!isActive.value || mobile) return;
|
||||||
|
await nextTick();
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClickOutside = event => {
|
||||||
|
if (triggerRef.value?.contains(event.target)) return;
|
||||||
|
hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selectors for teleported elements that should not trigger close
|
||||||
|
const clickOutsideIgnore = [
|
||||||
|
'dialog.ProseMirror-prompt-backdrop',
|
||||||
|
'[data-popover-content]',
|
||||||
|
];
|
||||||
|
|
||||||
|
useKeyboardEvents({
|
||||||
|
Escape: {
|
||||||
|
action: () => isActive.value && hide(),
|
||||||
|
allowOnFocusedInput: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ show, hide, toggle });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="triggerRef" class="inline-flex" @click="toggle">
|
||||||
|
<slot :is-open="isActive" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<TeleportWithDirection to="body">
|
||||||
|
<!-- Mobile: centered modal with backdrop -->
|
||||||
|
<div
|
||||||
|
v-if="isActive && isMobile"
|
||||||
|
data-popover-backdrop
|
||||||
|
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="mobileContentRef"
|
||||||
|
v-on-click-outside="[
|
||||||
|
handleClickOutside,
|
||||||
|
{ ignore: clickOutsideIgnore },
|
||||||
|
]"
|
||||||
|
data-popover-content
|
||||||
|
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||||
|
>
|
||||||
|
<slot name="content" :hide="hide" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: fixed popover -->
|
||||||
|
<div
|
||||||
|
v-else-if="showPopover"
|
||||||
|
ref="popoverRef"
|
||||||
|
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
|
||||||
|
data-popover-content
|
||||||
|
:class="fixedPosition.class"
|
||||||
|
:style="fixedPosition.style"
|
||||||
|
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||||
|
:class="{ 'border border-n-strong': showContentBorder }"
|
||||||
|
>
|
||||||
|
<slot name="content" :hide="hide" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TeleportWithDirection>
|
||||||
|
</template>
|
||||||
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
|||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
|
|||||||
emit('closeMobileSidebar');
|
emit('closeMobileSidebar');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onComposeOpen = toggleFn => {
|
|
||||||
toggleFn();
|
|
||||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onComposeClose = () => {
|
|
||||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const newReportRoutes = () => [
|
const newReportRoutes = () => [
|
||||||
{
|
{
|
||||||
name: 'Reports Agent',
|
name: 'Reports Agent',
|
||||||
@@ -250,6 +239,12 @@ const menuItems = computed(() => {
|
|||||||
activeOn: ['conversation_through_mentions'],
|
activeOn: ['conversation_through_mentions'],
|
||||||
to: accountScopedRoute('conversation_mentions'),
|
to: accountScopedRoute('conversation_mentions'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Participating',
|
||||||
|
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||||
|
activeOn: ['conversation_through_participating'],
|
||||||
|
to: accountScopedRoute('conversation_participating'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Unattended',
|
name: 'Unattended',
|
||||||
activeOn: ['conversation_through_unattended'],
|
activeOn: ['conversation_through_unattended'],
|
||||||
@@ -728,7 +723,13 @@ const menuItems = computed(() => {
|
|||||||
<aside
|
<aside
|
||||||
v-on-click-outside="[
|
v-on-click-outside="[
|
||||||
closeMobileSidebar,
|
closeMobileSidebar,
|
||||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
{
|
||||||
|
ignore: [
|
||||||
|
'#mobile-sidebar-launcher',
|
||||||
|
'[data-popover-content]',
|
||||||
|
'[data-popover-backdrop]',
|
||||||
|
],
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -796,8 +797,8 @@ const menuItems = computed(() => {
|
|||||||
>
|
>
|
||||||
<span class="i-lucide-search size-4 text-n-slate-11" />
|
<span class="i-lucide-search size-4 text-n-slate-11" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
<ComposeConversation align="start">
|
||||||
<template #trigger="{ toggle, isOpen }">
|
<template #trigger="{ isOpen }">
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-pen-line"
|
icon="i-lucide-pen-line"
|
||||||
color="slate"
|
color="slate"
|
||||||
@@ -809,7 +810,6 @@ const menuItems = computed(() => {
|
|||||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||||
]"
|
]"
|
||||||
@click="onComposeOpen(toggle)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
|
|||||||
@@ -158,9 +158,11 @@ const activeChild = computed(() => {
|
|||||||
return rankedPage ?? activeOnPages[0];
|
return rankedPage ?? activeOnPages[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return navigableChildren.value.find(
|
return navigableChildren.value.find(child => {
|
||||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
if (!child.to) return false;
|
||||||
);
|
const childPath = resolvePath(child.to);
|
||||||
|
return route.path === childPath || route.path.startsWith(`${childPath}/`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasActiveChild = computed(() => {
|
const hasActiveChild = computed(() => {
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
|
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
|
||||||
// composables and components, useVirtualChatList, useChatlistFilters
|
|
||||||
import {
|
|
||||||
ref,
|
|
||||||
unref,
|
|
||||||
provide,
|
|
||||||
computed,
|
|
||||||
watch,
|
|
||||||
onMounted,
|
|
||||||
defineEmits,
|
|
||||||
} from 'vue';
|
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
@@ -17,23 +7,19 @@ import {
|
|||||||
useFunctionGetter,
|
useFunctionGetter,
|
||||||
} from 'dashboard/composables/store.js';
|
} from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import { Virtualizer } from 'virtua/vue';
|
|
||||||
import ChatListHeader from './ChatListHeader.vue';
|
import ChatListHeader from './ChatListHeader.vue';
|
||||||
|
import ConversationList from './ConversationList.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||||
import ConversationItem from './ConversationItem.vue';
|
|
||||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
||||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
|
||||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||||
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
|
||||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||||
import { useFilter } from 'shared/composables/useFilter';
|
import { useFilter } from 'shared/composables/useFilter';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
@@ -56,6 +42,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
|
|||||||
import { conversationListPageURL } from '../helper/URLHelper';
|
import { conversationListPageURL } from '../helper/URLHelper';
|
||||||
import {
|
import {
|
||||||
isOnMentionsView,
|
isOnMentionsView,
|
||||||
|
isOnParticipatingView,
|
||||||
isOnUnattendedView,
|
isOnUnattendedView,
|
||||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||||
import {
|
import {
|
||||||
@@ -84,10 +71,6 @@ const route = useRoute();
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
const resolveAttributesModalRef = ref(null);
|
const resolveAttributesModalRef = ref(null);
|
||||||
const conversationListRef = ref(null);
|
|
||||||
const virtualListRef = ref(null);
|
|
||||||
|
|
||||||
provide('contextMenuElementTarget', virtualListRef);
|
|
||||||
|
|
||||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||||
@@ -99,7 +82,6 @@ const chatsOnView = ref([]);
|
|||||||
const foldersQuery = ref({});
|
const foldersQuery = ref({});
|
||||||
const showAddFoldersModal = ref(false);
|
const showAddFoldersModal = ref(false);
|
||||||
const showDeleteFoldersModal = ref(false);
|
const showDeleteFoldersModal = ref(false);
|
||||||
const isContextMenuOpen = ref(false);
|
|
||||||
const appliedFilter = ref([]);
|
const appliedFilter = ref([]);
|
||||||
const advancedFilterTypes = ref(
|
const advancedFilterTypes = ref(
|
||||||
advancedFilterOptions.map(filter => ({
|
advancedFilterOptions.map(filter => ({
|
||||||
@@ -113,6 +95,7 @@ const chatLists = useMapGetter('getFilteredConversations');
|
|||||||
const mineChatsList = useMapGetter('getMineChats');
|
const mineChatsList = useMapGetter('getMineChats');
|
||||||
const allChatList = useMapGetter('getAllStatusChats');
|
const allChatList = useMapGetter('getAllStatusChats');
|
||||||
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
||||||
|
const participatingChatsList = useMapGetter('getParticipatingChats');
|
||||||
const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
||||||
const activeInbox = useMapGetter('getSelectedInbox');
|
const activeInbox = useMapGetter('getSelectedInbox');
|
||||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||||
@@ -128,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
|
|||||||
const getTeamFn = useMapGetter('teams/getTeam');
|
const getTeamFn = useMapGetter('teams/getTeam');
|
||||||
const getConversationById = useMapGetter('getConversationById');
|
const getConversationById = useMapGetter('getConversationById');
|
||||||
|
|
||||||
useChatListKeyboardEvents(conversationListRef);
|
|
||||||
const {
|
const {
|
||||||
selectedConversations,
|
selectedConversations,
|
||||||
selectedInboxes,
|
selectedInboxes,
|
||||||
@@ -296,13 +278,15 @@ const pageTitle = computed(() => {
|
|||||||
if (props.label) {
|
if (props.label) {
|
||||||
return `#${props.label}`;
|
return `#${props.label}`;
|
||||||
}
|
}
|
||||||
if (props.conversationType === 'mention') {
|
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
|
||||||
return t('CHAT_LIST.MENTION_HEADING');
|
return t('CHAT_LIST.MENTION_HEADING');
|
||||||
}
|
}
|
||||||
if (props.conversationType === 'participating') {
|
if (
|
||||||
|
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||||||
|
) {
|
||||||
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
|
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
|
||||||
}
|
}
|
||||||
if (props.conversationType === 'unattended') {
|
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
|
||||||
return t('CHAT_LIST.UNATTENDED_HEADING');
|
return t('CHAT_LIST.UNATTENDED_HEADING');
|
||||||
}
|
}
|
||||||
if (hasActiveFolders.value) {
|
if (hasActiveFolders.value) {
|
||||||
@@ -311,12 +295,30 @@ const pageTitle = computed(() => {
|
|||||||
return t('CHAT_LIST.TAB_HEADING');
|
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(() => {
|
const conversationList = computed(() => {
|
||||||
let localConversationList = [];
|
let localConversationList = [];
|
||||||
|
|
||||||
if (!hasAppliedFiltersOrActiveFolders.value) {
|
if (!hasAppliedFiltersOrActiveFolders.value) {
|
||||||
const filters = conversationFilters.value;
|
const filters = conversationFilters.value;
|
||||||
if (activeAssigneeTab.value === 'me') {
|
if (
|
||||||
|
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||||||
|
) {
|
||||||
|
localConversationList = filterByAssigneeTab(
|
||||||
|
participatingChatsList.value(filters)
|
||||||
|
);
|
||||||
|
} else if (activeAssigneeTab.value === 'me') {
|
||||||
localConversationList = [...mineChatsList.value(filters)];
|
localConversationList = [...mineChatsList.value(filters)];
|
||||||
} else if (activeAssigneeTab.value === 'unassigned') {
|
} else if (activeAssigneeTab.value === 'unassigned') {
|
||||||
localConversationList = [...unAssignedChatsList.value(filters)];
|
localConversationList = [...unAssignedChatsList.value(filters)];
|
||||||
@@ -338,7 +340,7 @@ const conversationList = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showEndOfListMessage = computed(() => {
|
const showEndOfListMessage = computed(() => {
|
||||||
return (
|
return !!(
|
||||||
conversationList.value.length &&
|
conversationList.value.length &&
|
||||||
hasCurrentPageEndReached.value &&
|
hasCurrentPageEndReached.value &&
|
||||||
!chatListLoading.value
|
!chatListLoading.value
|
||||||
@@ -583,14 +585,6 @@ function loadMoreConversations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
|
||||||
// If the list doesn’t fill the viewport, loading can stall.
|
|
||||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
|
||||||
const intersectionObserverOptions = computed(() => ({
|
|
||||||
root: conversationListRef.value,
|
|
||||||
rootMargin: '100px 0px 100px 0px',
|
|
||||||
}));
|
|
||||||
|
|
||||||
function updateAssigneeTab(selectedTab) {
|
function updateAssigneeTab(selectedTab) {
|
||||||
if (activeAssigneeTab.value !== selectedTab) {
|
if (activeAssigneeTab.value !== selectedTab) {
|
||||||
resetBulkActions();
|
resetBulkActions();
|
||||||
@@ -637,9 +631,11 @@ function redirectToConversationList() {
|
|||||||
|
|
||||||
let conversationType = '';
|
let conversationType = '';
|
||||||
if (isOnMentionsView({ route: { name } })) {
|
if (isOnMentionsView({ route: { name } })) {
|
||||||
conversationType = 'mention';
|
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
|
||||||
|
} else if (isOnParticipatingView({ route: { name } })) {
|
||||||
|
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
|
||||||
} else if (isOnUnattendedView({ route: { name } })) {
|
} else if (isOnUnattendedView({ route: { name } })) {
|
||||||
conversationType = 'unattended';
|
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
|
||||||
}
|
}
|
||||||
router.push(
|
router.push(
|
||||||
conversationListPageURL({
|
conversationListPageURL({
|
||||||
@@ -782,10 +778,6 @@ function allSelectedConversationsStatus(status) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContextMenuToggle(state) {
|
|
||||||
isContextMenuOpen.value = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll(check) {
|
function toggleSelectAll(check) {
|
||||||
selectAllConversations(check, conversationList);
|
selectAllConversations(check, conversationList);
|
||||||
}
|
}
|
||||||
@@ -833,7 +825,6 @@ provide('assignTeam', onAssignTeam);
|
|||||||
provide('assignLabels', onAssignLabels);
|
provide('assignLabels', onAssignLabels);
|
||||||
provide('removeLabels', onRemoveLabels);
|
provide('removeLabels', onRemoveLabels);
|
||||||
provide('updateConversationStatus', handleResolveConversation);
|
provide('updateConversationStatus', handleResolveConversation);
|
||||||
provide('toggleContextMenu', onContextMenuToggle);
|
|
||||||
provide('markAsUnread', markAsUnread);
|
provide('markAsUnread', markAsUnread);
|
||||||
provide('markAsRead', markAsRead);
|
provide('markAsRead', markAsRead);
|
||||||
provide('assignPriority', assignPriority);
|
provide('assignPriority', assignPriority);
|
||||||
@@ -946,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
@assign-labels="onAssignLabels"
|
@assign-labels="onAssignLabels"
|
||||||
@assign-team="onAssignTeamsForBulk"
|
@assign-team="onAssignTeamsForBulk"
|
||||||
/>
|
/>
|
||||||
<div
|
<ConversationList
|
||||||
ref="conversationListRef"
|
:conversation-list="conversationList"
|
||||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
:is-loading="chatListLoading"
|
||||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
:show-end-of-list-message="showEndOfListMessage"
|
||||||
>
|
:label="label"
|
||||||
<Virtualizer
|
:team-id="teamId"
|
||||||
ref="virtualListRef"
|
:folders-id="foldersId"
|
||||||
v-slot="{ item, index }"
|
:conversation-type="conversationType"
|
||||||
:data="conversationList"
|
:show-assignee="showAssigneeInConversationCard"
|
||||||
>
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
<ConversationItem
|
@load-more="loadMoreConversations"
|
||||||
:source="item"
|
/>
|
||||||
:label="label"
|
|
||||||
:team-id="teamId"
|
|
||||||
:folders-id="foldersId"
|
|
||||||
:conversation-type="conversationType"
|
|
||||||
:show-assignee="showAssigneeInConversationCard"
|
|
||||||
:data-index="index"
|
|
||||||
@select-conversation="selectConversation"
|
|
||||||
@de-select-conversation="deSelectConversation"
|
|
||||||
/>
|
|
||||||
</Virtualizer>
|
|
||||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
|
||||||
<Spinner class="text-n-brand" />
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-else-if="showEndOfListMessage"
|
|
||||||
class="p-4 text-center text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ $t('CHAT_LIST.EOF') }}
|
|
||||||
</p>
|
|
||||||
<IntersectionObserver
|
|
||||||
v-else
|
|
||||||
:options="intersectionObserverOptions"
|
|
||||||
@observed="loadMoreConversations"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="deleteConversationDialogRef"
|
ref="deleteConversationDialogRef"
|
||||||
type="alert"
|
type="alert"
|
||||||
|
|||||||
@@ -1,74 +1,244 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { computed, ref, watch, inject } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||||
export default {
|
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
|
||||||
components: {
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
ConversationCard,
|
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
|
||||||
},
|
|
||||||
inject: [
|
const props = defineProps({
|
||||||
'selectConversation',
|
source: { type: Object, required: true },
|
||||||
'deSelectConversation',
|
teamId: { type: [String, Number], default: 0 },
|
||||||
'assignAgent',
|
label: { type: String, default: '' },
|
||||||
'assignTeam',
|
conversationType: { type: String, default: '' },
|
||||||
'assignLabels',
|
foldersId: { type: [String, Number], default: 0 },
|
||||||
'removeLabels',
|
showAssignee: { type: Boolean, default: false },
|
||||||
'updateConversationStatus',
|
showExpanded: { type: Boolean, default: false },
|
||||||
'toggleContextMenu',
|
});
|
||||||
'markAsUnread',
|
|
||||||
'markAsRead',
|
const router = useRouter();
|
||||||
'assignPriority',
|
const store = useStore();
|
||||||
'isConversationSelected',
|
|
||||||
'deleteConversation',
|
const selectConversation = inject('selectConversation');
|
||||||
],
|
const deSelectConversation = inject('deSelectConversation');
|
||||||
props: {
|
const assignAgent = inject('assignAgent');
|
||||||
source: {
|
const assignTeam = inject('assignTeam');
|
||||||
type: Object,
|
const assignLabels = inject('assignLabels');
|
||||||
required: true,
|
const removeLabels = inject('removeLabels');
|
||||||
},
|
const updateConversationStatus = inject('updateConversationStatus');
|
||||||
teamId: {
|
const toggleContextMenu = inject('toggleContextMenu');
|
||||||
type: [String, Number],
|
const markAsUnread = inject('markAsUnread');
|
||||||
default: 0,
|
const markAsRead = inject('markAsRead');
|
||||||
},
|
const assignPriority = inject('assignPriority');
|
||||||
label: {
|
const isConversationSelected = inject('isConversationSelected');
|
||||||
type: String,
|
const deleteConversation = inject('deleteConversation');
|
||||||
default: '',
|
|
||||||
},
|
// --- Context menu state (shared by both layouts) ---
|
||||||
conversationType: {
|
const showContextMenu = ref(false);
|
||||||
type: String,
|
const contextMenu = ref({ x: null, y: null });
|
||||||
default: '',
|
|
||||||
},
|
// Reset context menu state when the row is recycled to a different conversation.
|
||||||
foldersId: {
|
watch(
|
||||||
type: [String, Number],
|
() => props.source.id,
|
||||||
default: 0,
|
() => {
|
||||||
},
|
if (showContextMenu.value) {
|
||||||
showAssignee: {
|
toggleContextMenu(false);
|
||||||
type: Boolean,
|
}
|
||||||
default: false,
|
showContextMenu.value = false;
|
||||||
},
|
contextMenu.value = { x: null, y: null };
|
||||||
},
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
const activeInbox = useMapGetter('getSelectedInbox');
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
|
||||||
|
const chatMetadata = computed(() => props.source.meta || {});
|
||||||
|
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||||
|
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||||
|
|
||||||
|
const currentContact = computed(() =>
|
||||||
|
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
|
||||||
|
|
||||||
|
const inbox = computed(() => {
|
||||||
|
const inboxId = props.source.inbox_id;
|
||||||
|
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const showInboxName = computed(
|
||||||
|
() => !activeInbox.value && inboxesList.value.length > 1
|
||||||
|
);
|
||||||
|
const isInboxView = computed(() => !!activeInbox.value);
|
||||||
|
const showAssigneeForExpandedCard = computed(
|
||||||
|
() => props.showExpanded || props.showAssignee
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversationPath = computed(() =>
|
||||||
|
frontendURL(
|
||||||
|
conversationUrl({
|
||||||
|
accountId: accountId.value,
|
||||||
|
activeInbox: activeInbox.value,
|
||||||
|
id: props.source.id,
|
||||||
|
label: props.label,
|
||||||
|
teamId: props.teamId,
|
||||||
|
conversationType: props.conversationType,
|
||||||
|
foldersId: props.foldersId,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCardClick = e => {
|
||||||
|
const path = conversationPath.value;
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(
|
||||||
|
`${window.chatwootConfig.hostURL}${path}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActiveChat.value) return;
|
||||||
|
router.push({ path });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExpandedSelect = checked => {
|
||||||
|
if (checked) {
|
||||||
|
selectConversation(props.source.id, inbox.value.id);
|
||||||
|
} else {
|
||||||
|
deSelectConversation(props.source.id, inbox.value.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openContextMenu = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleContextMenu(true);
|
||||||
|
contextMenu.value.x = e.pageX || e.clientX;
|
||||||
|
contextMenu.value.y = e.pageY || e.clientY;
|
||||||
|
showContextMenu.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
toggleContextMenu(false);
|
||||||
|
showContextMenu.value = false;
|
||||||
|
contextMenu.value.x = null;
|
||||||
|
contextMenu.value.y = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateConversation = (status, snoozedUntil) => {
|
||||||
|
closeContextMenu();
|
||||||
|
updateConversationStatus(props.source.id, status, snoozedUntil);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAssignAgent = agent => {
|
||||||
|
assignAgent(agent, [props.source.id]);
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAssignLabel = label => {
|
||||||
|
assignLabels([label.title], [props.source.id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemoveLabel = label => {
|
||||||
|
removeLabels([label.title], [props.source.id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAssignTeam = team => {
|
||||||
|
assignTeam(team, props.source.id);
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMarkAsUnread = () => {
|
||||||
|
markAsUnread(props.source.id);
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMarkAsRead = () => {
|
||||||
|
markAsRead(props.source.id);
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAssignPriority = priority => {
|
||||||
|
assignPriority(priority, props.source.id);
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteConversation = () => {
|
||||||
|
deleteConversation(props.source.id);
|
||||||
|
closeContextMenu();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ConversationCard
|
<!-- Expanded layout: wide screen + expanded setting -->
|
||||||
:active-label="label"
|
<ConversationCardExpanded
|
||||||
:team-id="teamId"
|
v-if="showExpanded"
|
||||||
:folders-id="foldersId"
|
|
||||||
:chat="source"
|
:chat="source"
|
||||||
:conversation-type="conversationType"
|
:current-contact="currentContact"
|
||||||
|
:assignee="assignee"
|
||||||
|
:inbox="inbox"
|
||||||
:selected="isConversationSelected(source.id)"
|
:selected="isConversationSelected(source.id)"
|
||||||
|
:is-active-chat="isActiveChat"
|
||||||
|
:show-assignee="showAssigneeForExpandedCard"
|
||||||
|
:show-inbox-name="showInboxName"
|
||||||
|
:is-inbox-view="isInboxView"
|
||||||
|
@select-conversation="onExpandedSelect"
|
||||||
|
@de-select-conversation="onExpandedSelect"
|
||||||
|
@click="onCardClick"
|
||||||
|
@contextmenu="openContextMenu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Default (condensed) layout -->
|
||||||
|
<ConversationCard
|
||||||
|
v-else
|
||||||
|
:chat="source"
|
||||||
|
:current-contact="currentContact"
|
||||||
|
:assignee="assignee"
|
||||||
|
:inbox="inbox"
|
||||||
|
:selected="isConversationSelected(source.id)"
|
||||||
|
:is-active-chat="isActiveChat"
|
||||||
:show-assignee="showAssignee"
|
:show-assignee="showAssignee"
|
||||||
enable-context-menu
|
:show-inbox-name="showInboxName"
|
||||||
|
@click="onCardClick"
|
||||||
|
@contextmenu="openContextMenu"
|
||||||
@select-conversation="selectConversation"
|
@select-conversation="selectConversation"
|
||||||
@de-select-conversation="deSelectConversation"
|
@de-select-conversation="deSelectConversation"
|
||||||
@assign-agent="assignAgent"
|
|
||||||
@assign-team="assignTeam"
|
|
||||||
@assign-label="assignLabels"
|
|
||||||
@remove-label="removeLabels"
|
|
||||||
@update-conversation-status="updateConversationStatus"
|
|
||||||
@context-menu-toggle="toggleContextMenu"
|
|
||||||
@mark-as-unread="markAsUnread"
|
|
||||||
@mark-as-read="markAsRead"
|
|
||||||
@assign-priority="assignPriority"
|
|
||||||
@delete-conversation="deleteConversation"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Shared context menu for both layouts -->
|
||||||
|
<ContextMenu
|
||||||
|
v-if="showContextMenu"
|
||||||
|
:x="contextMenu.x"
|
||||||
|
:y="contextMenu.y"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
>
|
||||||
|
<ConversationContextMenu
|
||||||
|
:status="source.status"
|
||||||
|
:inbox-id="inbox.id"
|
||||||
|
:priority="source.priority"
|
||||||
|
:chat-id="source.id"
|
||||||
|
:has-unread-messages="source.unread_count > 0"
|
||||||
|
:conversation-labels="source.labels"
|
||||||
|
:conversation-url="conversationPath"
|
||||||
|
@update-conversation="onUpdateConversation"
|
||||||
|
@assign-agent="onAssignAgent"
|
||||||
|
@assign-label="onAssignLabel"
|
||||||
|
@remove-label="onRemoveLabel"
|
||||||
|
@assign-team="onAssignTeam"
|
||||||
|
@mark-as-unread="onMarkAsUnread"
|
||||||
|
@mark-as-read="onMarkAsRead"
|
||||||
|
@assign-priority="onAssignPriority"
|
||||||
|
@delete-conversation="onDeleteConversation"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, provide } from 'vue';
|
||||||
|
import { Virtualizer } from 'virtua/vue';
|
||||||
|
import { useBreakpoints } from '@vueuse/core';
|
||||||
|
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||||
|
import ConversationItem from './ConversationItem.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||||
|
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
conversationList: { type: Array, default: () => [] },
|
||||||
|
isLoading: { type: Boolean, default: false },
|
||||||
|
showEndOfListMessage: { type: Boolean, default: false },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
teamId: { type: [String, Number], default: 0 },
|
||||||
|
foldersId: { type: [String, Number], default: 0 },
|
||||||
|
conversationType: { type: String, default: '' },
|
||||||
|
showAssignee: { type: Boolean, default: false },
|
||||||
|
isOnExpandedLayout: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['loadMore']);
|
||||||
|
|
||||||
|
const conversationListRef = ref(null);
|
||||||
|
const virtualListRef = ref(null);
|
||||||
|
const isContextMenuOpen = ref(false);
|
||||||
|
|
||||||
|
provide('contextMenuElementTarget', virtualListRef);
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints({
|
||||||
|
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||||
|
});
|
||||||
|
const isLgScreen = breakpoints.greaterOrEqual('lg');
|
||||||
|
const showExpandedCards = computed(
|
||||||
|
() => props.isOnExpandedLayout && isLgScreen.value
|
||||||
|
);
|
||||||
|
|
||||||
|
useChatListKeyboardEvents(conversationListRef);
|
||||||
|
|
||||||
|
const intersectionObserverOptions = computed(() => ({
|
||||||
|
root: conversationListRef.value,
|
||||||
|
rootMargin: '100px 0px 100px 0px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onContextMenuToggle = state => {
|
||||||
|
isContextMenuOpen.value = state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreConversations = () => {
|
||||||
|
emit('loadMore');
|
||||||
|
};
|
||||||
|
|
||||||
|
provide('toggleContextMenu', onContextMenuToggle);
|
||||||
|
|
||||||
|
defineExpose({ conversationListRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="conversationListRef"
|
||||||
|
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||||
|
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||||
|
>
|
||||||
|
<Virtualizer
|
||||||
|
ref="virtualListRef"
|
||||||
|
v-slot="{ item }"
|
||||||
|
:data="conversationList"
|
||||||
|
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
|
||||||
|
>
|
||||||
|
<ConversationItem
|
||||||
|
:source="item"
|
||||||
|
:label="label"
|
||||||
|
:team-id="teamId"
|
||||||
|
:folders-id="foldersId"
|
||||||
|
:conversation-type="conversationType"
|
||||||
|
:show-assignee="showAssignee"
|
||||||
|
:show-expanded="showExpandedCards"
|
||||||
|
/>
|
||||||
|
</Virtualizer>
|
||||||
|
<div v-if="isLoading" class="flex justify-center my-4">
|
||||||
|
<Spinner class="text-n-brand" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="showEndOfListMessage" class="p-4 text-center text-n-slate-11">
|
||||||
|
{{ $t('CHAT_LIST.EOF') }}
|
||||||
|
</p>
|
||||||
|
<IntersectionObserver
|
||||||
|
v-else
|
||||||
|
:options="intersectionObserverOptions"
|
||||||
|
@observed="loadMoreConversations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -27,10 +27,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
isPopout: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -208,10 +204,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2 mb-4">
|
||||||
<div
|
<div class="overflow-y-auto max-h-56">
|
||||||
class="overflow-y-auto"
|
|
||||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
|
collapseSelection,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
getContentNode,
|
getContentNode,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
@@ -66,6 +67,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
hasPressedEnterAndNotCmdOrShift,
|
hasPressedEnterAndNotCmdOrShift,
|
||||||
hasPressedCommandAndEnter,
|
hasPressedCommandAndEnter,
|
||||||
|
isEscape,
|
||||||
} from 'shared/helpers/KeyboardHelpers';
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
import { createTypingIndicator } from '@chatwoot/utils';
|
import { createTypingIndicator } from '@chatwoot/utils';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
@@ -313,7 +315,12 @@ const plugins = computed(() => {
|
|||||||
const sendWithSignature = computed(() => {
|
const sendWithSignature = computed(() => {
|
||||||
// this is considered the source of truth, we watch this property
|
// this is considered the source of truth, we watch this property
|
||||||
// on change, we toggle the signature in the editor
|
// on change, we toggle the signature in the editor
|
||||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
if (
|
||||||
|
props.allowSignature &&
|
||||||
|
!props.isPrivate &&
|
||||||
|
props.channelType &&
|
||||||
|
!props.disabled
|
||||||
|
) {
|
||||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +443,7 @@ function reloadState(content = props.modelValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addSignature() {
|
function addSignature() {
|
||||||
|
if (props.disabled) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
// see if the content is empty, if it is before appending the signature
|
// 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
|
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||||
@@ -454,6 +462,7 @@ function addSignature() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeSignature() {
|
function removeSignature() {
|
||||||
|
if (props.disabled) return;
|
||||||
if (!props.signature) return;
|
if (!props.signature) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
content = removeSignatureHelper(
|
content = removeSignatureHelper(
|
||||||
@@ -508,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
|
|||||||
|
|
||||||
function checkSelection(editorState) {
|
function checkSelection(editorState) {
|
||||||
showSelectionMenu.value = false;
|
showSelectionMenu.value = false;
|
||||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
const { selection } = editorState;
|
||||||
|
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||||
|
const hasSelection = !selection.empty && !selection.node;
|
||||||
if (hasSelection === isTextSelected.value) return;
|
if (hasSelection === isTextSelected.value) return;
|
||||||
|
|
||||||
isTextSelected.value = hasSelection;
|
isTextSelected.value = hasSelection;
|
||||||
@@ -704,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(event) {
|
function onKeydown(event) {
|
||||||
|
if (isEscape(event)) {
|
||||||
|
collapseSelection(editorView);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isEnterToSendEnabled()) {
|
if (isEnterToSendEnabled()) {
|
||||||
handleLineBreakWhenEnterToSendEnabled(event);
|
handleLineBreakWhenEnterToSendEnabled(event);
|
||||||
}
|
}
|
||||||
if (isCmdPlusEnterToSendEnabled()) {
|
if (isCmdPlusEnterToSendEnabled()) {
|
||||||
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEditorView() {
|
function createEditorView() {
|
||||||
@@ -737,6 +753,9 @@ function createEditorView() {
|
|||||||
blur: () => {
|
blur: () => {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
typingIndicator.stop();
|
typingIndicator.stop();
|
||||||
|
// PM keeps its selection on blur — clear the menu flags manually.
|
||||||
|
isTextSelected.value = false;
|
||||||
|
editorRoot.value?.classList.remove('has-selection');
|
||||||
emit('blur');
|
emit('blur');
|
||||||
},
|
},
|
||||||
paste: (view, event) => {
|
paste: (view, event) => {
|
||||||
@@ -806,7 +825,7 @@ watch(
|
|||||||
|
|
||||||
watch(sendWithSignature, newValue => {
|
watch(sendWithSignature, newValue => {
|
||||||
// see if the allowSignature flag is true
|
// see if the allowSignature flag is true
|
||||||
if (props.allowSignature) {
|
if (props.allowSignature && !props.disabled) {
|
||||||
toggleSignatureInEditor(newValue);
|
toggleSignatureInEditor(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -977,7 +996,32 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-woot-style {
|
.ProseMirror-woot-style {
|
||||||
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-prompt-backdrop::backdrop {
|
.ProseMirror-prompt-backdrop::backdrop {
|
||||||
|
|||||||
@@ -8,13 +8,24 @@ import {
|
|||||||
EditorState,
|
EditorState,
|
||||||
Selection,
|
Selection,
|
||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
|
import {
|
||||||
|
suggestionsPlugin,
|
||||||
|
triggerCharacters,
|
||||||
|
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||||
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
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 { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
|
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||||
|
import { collapseSelection } from 'dashboard/helper/editorHelper';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||||
|
import SlashCommandMenu from './SlashCommandMenu.vue';
|
||||||
|
|
||||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
|
const SLASH_MENU_OFFSET = 4;
|
||||||
const createState = (
|
const createState = (
|
||||||
content,
|
content,
|
||||||
placeholder,
|
placeholder,
|
||||||
@@ -40,6 +51,7 @@ let editorView = null;
|
|||||||
let state;
|
let state;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { SlashCommandMenu },
|
||||||
mixins: [keyboardEventListenerMixins],
|
mixins: [keyboardEventListenerMixins],
|
||||||
props: {
|
props: {
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -62,8 +74,15 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
plugins: [
|
||||||
|
imagePastePlugin(this.handleImageUpload),
|
||||||
|
this.createSlashPlugin(),
|
||||||
|
],
|
||||||
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||||
|
showSlashMenu: false,
|
||||||
|
slashSearchTerm: '',
|
||||||
|
slashRange: null,
|
||||||
|
slashMenuPosition: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -79,7 +98,7 @@ export default {
|
|||||||
|
|
||||||
created() {
|
created() {
|
||||||
state = createState(
|
state = createState(
|
||||||
this.modelValue,
|
this.modelValue || '',
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.plugins,
|
this.plugins,
|
||||||
{ onImageUpload: this.openFileBrowser },
|
{ onImageUpload: this.openFileBrowser },
|
||||||
@@ -95,6 +114,126 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
contentFromEditor() {
|
||||||
if (editorView) {
|
if (editorView) {
|
||||||
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
|
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
|
||||||
@@ -170,7 +309,7 @@ export default {
|
|||||||
},
|
},
|
||||||
reloadState() {
|
reloadState() {
|
||||||
state = createState(
|
state = createState(
|
||||||
this.modelValue,
|
this.modelValue || '',
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.plugins,
|
this.plugins,
|
||||||
{ onImageUpload: this.openFileBrowser },
|
{ onImageUpload: this.openFileBrowser },
|
||||||
@@ -225,19 +364,33 @@ export default {
|
|||||||
onKeyup() {
|
onKeyup() {
|
||||||
this.$emit('keyup');
|
this.$emit('keyup');
|
||||||
},
|
},
|
||||||
onKeydown() {
|
onKeydown(view, event) {
|
||||||
this.$emit('keydown');
|
this.$emit('keydown');
|
||||||
|
if (isEscape(event)) {
|
||||||
|
if (this.showSlashMenu) {
|
||||||
|
this.showSlashMenu = false;
|
||||||
|
this.slashSearchTerm = '';
|
||||||
|
this.slashMenuPosition = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
collapseSelection(editorView);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
onBlur() {
|
onBlur() {
|
||||||
|
// ProseMirror keeps its selection on blur — clear the menu flag manually.
|
||||||
|
this.isTextSelected = false;
|
||||||
|
this.$refs.editor?.classList.remove('has-selection');
|
||||||
this.$emit('blur');
|
this.$emit('blur');
|
||||||
},
|
},
|
||||||
onFocus() {
|
onFocus() {
|
||||||
this.$emit('focus');
|
this.$emit('focus');
|
||||||
},
|
},
|
||||||
checkSelection(editorState) {
|
checkSelection(editorState) {
|
||||||
const { from, to } = editorState.selection;
|
const { selection } = editorState;
|
||||||
// Check if there's a selection (from and to are different)
|
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||||
const hasSelection = from !== to;
|
const hasSelection = !selection.empty && !selection.node;
|
||||||
// If the selection state is the same as the previous state, do nothing
|
// If the selection state is the same as the previous state, do nothing
|
||||||
if (hasSelection === this.isTextSelected) return;
|
if (hasSelection === this.isTextSelected) return;
|
||||||
// Update the selection state
|
// Update the selection state
|
||||||
@@ -262,7 +415,8 @@ export default {
|
|||||||
|
|
||||||
// Get the editor's width
|
// Get the editor's width
|
||||||
const editorWidth = editor.offsetWidth;
|
const editorWidth = editor.offsetWidth;
|
||||||
const menubarWidth = 480; // Menubar width (adjust as needed (px))
|
const menubar = editor.querySelector('.ProseMirror-menubar');
|
||||||
|
const menubarWidth = menubar ? menubar.scrollWidth : 480;
|
||||||
|
|
||||||
// Get the end position of the selection
|
// Get the end position of the selection
|
||||||
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
|
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
|
||||||
@@ -290,7 +444,15 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="editor-root editor--article">
|
<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"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref="imageUploadInput"
|
ref="imageUploadInput"
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user