Compare commits

..
Author SHA1 Message Date
Muhsin Keloth 6e90600c03 chore: add packages 2024-04-30 09:13:36 +05:30
Muhsin Keloth 19410dfa3d feat: add linear 2024-04-29 13:27:18 +05:30
919 changed files with 6582 additions and 16054 deletions
+6 -8
View File
@@ -1,9 +1,4 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
@@ -28,9 +23,6 @@ FORCE_SSL=false
ENABLE_ACCOUNT_SIGNUP=false
# Redis config
# specify the configs via single URL or individual variables
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
# You can also use the following format for the URL: redis://:password@host:port/db_number
REDIS_URL=redis://redis:6379
# If you are using docker-compose, set this variable's value to be any string,
# which will be the password for the redis service running inside the docker-compose
@@ -189,6 +181,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# SENTRY_DSN=
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
+5 -5
View File
@@ -71,13 +71,13 @@ gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4'
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'
# authorization
gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
@@ -113,10 +113,10 @@ gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.2.4'
gem 'sidekiq', '>= 7.2.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
@@ -166,7 +166,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
## Gems for reponse bot
# adds cosine similarity to postgres using vector extension
+83 -87
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
actioncable (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionmailbox (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.3)
actionpack (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionmailer (7.0.8.1)
actionpack (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activesupport (= 7.0.8.1)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.3)
actionview (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionpack (7.0.8.1)
actionview (= 7.0.8.1)
activesupport (= 7.0.8.1)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.3)
actionpack (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
actiontext (7.0.8.1)
actionpack (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.3)
activesupport (= 7.0.8.3)
actionview (7.0.8.1)
activesupport (= 7.0.8.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.3)
activesupport (= 7.0.8.3)
activejob (7.0.8.1)
activesupport (= 7.0.8.1)
globalid (>= 0.3.6)
activemodel (7.0.8.3)
activesupport (= 7.0.8.3)
activerecord (7.0.8.3)
activemodel (= 7.0.8.3)
activesupport (= 7.0.8.3)
activemodel (7.0.8.1)
activesupport (= 7.0.8.1)
activerecord (7.0.8.1)
activemodel (= 7.0.8.1)
activesupport (= 7.0.8.1)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activesupport (= 7.0.8.3)
activestorage (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activesupport (= 7.0.8.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.3)
activesupport (7.0.8.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.3)
administrate-field-active_storage (1.0.1)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -148,9 +148,8 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.2.0)
base64 (0.1.1)
bcrypt (3.1.20)
bigdecimal (3.1.7)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -200,10 +199,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_token_auth (1.2.3)
devise_token_auth (1.2.1)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
rails (>= 4.2.0, < 7.1)
diff-lcs (1.5.0)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
@@ -369,7 +368,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.5)
i18n (1.14.4)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -448,20 +447,20 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
method_source (1.0.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.6)
minitest (5.23.0)
minitest (5.22.3)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -474,7 +473,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.11)
net-imap (0.4.9)
date
net-protocol
net-pop (0.1.2)
@@ -489,15 +488,15 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.5)
nio4r (2.7.0)
nokogiri (1.16.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.5-arm64-darwin)
nokogiri (1.16.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-darwin)
nokogiri (1.16.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
nokogiri (1.16.4-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -524,7 +523,7 @@ GEM
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.2)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
@@ -555,7 +554,7 @@ GEM
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.0)
rack (>= 2.0.0)
@@ -569,20 +568,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.3)
actioncable (= 7.0.8.3)
actionmailbox (= 7.0.8.3)
actionmailer (= 7.0.8.3)
actionpack (= 7.0.8.3)
actiontext (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activemodel (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
rails (7.0.8.1)
actioncable (= 7.0.8.1)
actionmailbox (= 7.0.8.1)
actionmailer (= 7.0.8.1)
actionpack (= 7.0.8.1)
actiontext (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activemodel (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
bundler (>= 1.15.0)
railties (= 7.0.8.3)
railties (= 7.0.8.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -590,9 +589,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
railties (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -604,7 +603,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.1)
redis-client (0.19.1)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -628,8 +627,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.2.8)
strscan (>= 3.0.9)
rexml (3.2.5)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
@@ -704,19 +702,18 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.17.3)
sentry-rails (5.14.0)
railties (>= 5.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
bigdecimal
sentry-ruby (~> 5.14.0)
sentry-ruby (5.14.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.14.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.2.4)
sidekiq (7.2.1)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
@@ -759,7 +756,6 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
@@ -821,7 +817,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.14)
zeitwerk (2.6.13)
PLATFORMS
arm64-darwin-20
@@ -839,7 +835,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-active_storage (>= 1.0.1)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -862,7 +858,7 @@ DEPENDENCIES
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
devise_token_auth (>= 1.2.3)
devise_token_auth
dotenv-rails
down
elastic-apm
@@ -906,7 +902,7 @@ DEPENDENCIES
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-rails_csrf_protection (~> 1.0)
pg
pg_search
pgvector
@@ -935,9 +931,9 @@ DEPENDENCIES
seed_dump
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.15.0)
sentry-sidekiq (>= 5.14.0)
shoulda-matchers
sidekiq (>= 7.2.4)
sidekiq (>= 7.2.1)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
+1 -1
View File
@@ -55,7 +55,7 @@
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
"plan": "heroku-postgresql:mini"
}
],
"stack": "heroku-20",
@@ -19,9 +19,9 @@ class ContactInboxWithContactBuilder
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
private
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::BaseReportBuilder
pattr_initialize :account, :params
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)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end
@@ -1,21 +0,0 @@
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end
@@ -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)
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,46 +0,0 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def group_by
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
end
@@ -1,71 +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.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@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
@@ -21,6 +21,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
end
def permitted_params
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
params.permit(:type, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end
end
@@ -46,8 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export
column_names = params['column_names']
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] }
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
head :ok, message: I18n.t('errors.contacts.export.success')
end
@@ -62,7 +61,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end
def filter
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts(contacts)
@@ -1,28 +0,0 @@
class Api::V1::Accounts::Conversations::DraftMessagesController < Api::V1::Accounts::Conversations::BaseController
def show
render json: { has_draft: false } and return unless Redis::Alfred.exists?(draft_redis_key)
draft_message = Redis::Alfred.get(draft_redis_key)
render json: { has_draft: true, message: draft_message }
end
def update
Redis::Alfred.set(draft_redis_key, draft_message_params)
head :ok
end
def destroy
Redis::Alfred.delete(draft_redis_key)
head :ok
end
private
def draft_redis_key
format(Redis::Alfred::CONVERSATION_DRAFT_MESSAGE, id: @conversation.id)
end
def draft_message_params
params.dig(:draft_message, :message) || ''
end
end
@@ -1,93 +0,0 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
def teams
teams = linear_processor_service.teams
if teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams[:data], status: :ok
end
end
def team_entities
team_id = permitted_params[:team_id]
team_entities = linear_processor_service.team_entities(team_id)
if team_entities[:error]
render json: { error: team_entities[:error] }, status: :unprocessable_entity
else
render json: team_entities[:data], status: :ok
end
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def linked_issues
issues = linear_processor_service.linked_issues(conversation_link)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
def search_issue
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
term = params[:q]
issues = linear_processor_service.search_issue(term)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
private
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
end
def fetch_conversation
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
end
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end
@@ -5,17 +5,19 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.timeseries
builder = V2::ReportBuilder.new(Current.account, report_params)
data = builder.build
render json: data
end
def summary
render json: build_summary(:summary)
render json: summary_metrics
end
def bot_summary
render json: build_summary(:bot_summary)
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
render json: summary
end
def agents
@@ -124,11 +126,10 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
}
end
def build_summary(method)
builder = V2::Reports::Conversations::MetricBuilder
current_summary = builder.new(Current.account, current_summary_params).send(method)
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
current_summary.merge(previous: previous_summary)
def summary_metrics
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
end
def conversation_metrics
@@ -2,10 +2,7 @@ module MicrosoftConcern
extend ActiveSupport::Concern
def microsoft_client
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret,
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
{
site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+1 -6
View File
@@ -3,7 +3,6 @@ class DashboardController < ActionController::Base
before_action :set_application_pack
before_action :set_global_config
before_action :set_dashboard_scripts
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
@@ -36,10 +35,6 @@ class DashboardController < ActionController::Base
).merge(app_config)
end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end
@@ -63,7 +58,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
}
end
@@ -7,14 +7,9 @@ class Microsoft::CallbacksController < ApplicationController
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox, already_exists = find_or_create_inbox
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
if already_exists
redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -45,17 +40,9 @@ class Microsoft::CallbacksController < ApplicationController
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
channel_email.inbox
end
# Fallback name, for when name field is missing from users_data
+1 -1
View File
@@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController
end
def microsoft_indentity
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
end
end
@@ -35,8 +35,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
+2 -3
View File
@@ -10,15 +10,14 @@ class AsyncDispatcher < BaseDispatcher
def listeners
[
AutomationRuleListener.instance,
CampaignListener.instance,
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance
WebhookListener.instance,
AutomationRuleListener.instance
]
end
end
+5 -11
View File
@@ -1,7 +1,7 @@
module Api::V2::Accounts::ReportsHelper
def generate_agents_report
Current.account.users.map do |agent|
agent_report = report_builder({ type: :agent, id: agent.id }).summary
agent_report = generate_report({ type: :agent, id: agent.id })
[agent.name] + generate_readable_report_metrics(agent_report)
end
end
@@ -15,7 +15,7 @@ module Api::V2::Accounts::ReportsHelper
def generate_teams_report
Current.account.teams.map do |team|
team_report = report_builder({ type: :team, id: team.id }).summary
team_report = generate_report({ type: :team, id: team.id })
[team.name] + generate_readable_report_metrics(team_report)
end
end
@@ -27,7 +27,7 @@ module Api::V2::Accounts::ReportsHelper
end
end
def report_builder(report_params)
def generate_report(report_params)
V2::ReportBuilder.new(
Current.account,
report_params.merge(
@@ -37,11 +37,7 @@ module Api::V2::Accounts::ReportsHelper
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
)
end
def generate_report(report_params)
report_builder(report_params).short_summary
).short_summary
end
private
@@ -50,9 +46,7 @@ module Api::V2::Accounts::ReportsHelper
[
report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
report_metric[:resolutions_count]
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format
]
end
end
-19
View File
@@ -1,19 +0,0 @@
module TimezoneHelper
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
#
# https://github.com/rails/rails/pull/22243
# https://github.com/rails/rails/issues/21501
# https://github.com/rails/rails/issues/7297
def timezone_name_from_offset(offset)
return 'UTC' if offset.blank?
offset_in_seconds = offset.to_f * 3600
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
zone.now.utc_offset == offset_in_seconds
end
return matching_zone.name if matching_zone
end
end
+2 -2
View File
@@ -77,8 +77,8 @@ class ContactAPI extends ApiClient {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
exportContacts(queryPayload) {
return axios.post(`${this.url}/export`, queryPayload);
exportContacts() {
return axios.get(`${this.url}/export`);
}
}
@@ -15,7 +15,6 @@ class ConversationApi extends ApiClient {
teamId,
conversationType,
sortBy,
updatedWithin,
}) {
return axios.get(this.url, {
params: {
@@ -27,7 +26,6 @@ class ConversationApi extends ApiClient {
labels,
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
},
});
}
@@ -1,47 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId, title) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
title: title,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();
@@ -46,7 +46,6 @@ describe('#ConversationAPI', () => {
page: 1,
labels: [],
teamId: 1,
updatedWithin: 20,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
params: {
@@ -56,7 +55,6 @@ describe('#ConversationAPI', () => {
assignee_type: 'me',
page: 1,
labels: [],
updated_within: 20,
},
});
});
@@ -1,202 +0,0 @@
import LinearAPIClient from '../../integrations/linear';
import ApiClient from '../../ApiClient';
describe('#linearAPI', () => {
it('creates correct instance', () => {
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
expect(LinearAPIClient).toHaveProperty('getTeams');
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
expect(LinearAPIClient).toHaveProperty('createIssue');
expect(LinearAPIClient).toHaveProperty('link_issue');
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
expect(LinearAPIClient).toHaveProperty('searchIssues');
});
describe('getTeams', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeams();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/teams'
);
});
});
describe('getTeamEntities', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeamEntities(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/team_entities?team_id=1'
);
});
});
describe('createIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.link_issue(1, 2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 2,
conversation_id: 1,
}
);
});
});
describe('getLinkedIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getLinkedIssue(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
);
});
});
describe('unlinkIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
}
);
});
});
describe('searchIssues', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.searchIssues('query');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/search_issue?q=query'
);
});
});
});
@@ -12,6 +12,7 @@ h6 {
p {
text-rendering: optimizeLegibility;
word-spacing: 0.12em;
@apply mb-2 leading-[1.65] text-sm;
@@ -1,9 +1,9 @@
<template>
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
class="sm:w-[50%] md:w-1/3 lg:w-1/4 bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<img :src="src" :alt="title" class="w-[50%] my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
+100 -110
View File
@@ -7,17 +7,79 @@
]"
>
<slot />
<chat-list-header
:page-title="pageTitle"
:has-applied-filters="hasAppliedFilters"
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
@basic-filter-change="onBasicFilterChange"
/>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
<add-custom-views
v-if="showAddFoldersModal"
@@ -111,15 +173,6 @@
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
@@ -127,8 +180,8 @@
import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
@@ -152,15 +205,10 @@ import {
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getUnixTime } from 'date-fns';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
ChatListHeader,
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
@@ -168,9 +216,9 @@ export default {
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
CustomSnoozeModal,
},
mixins: [
timeMixin,
@@ -240,6 +288,7 @@ export default {
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
isContextMenuOpen: false,
appliedFilter: [],
@@ -247,7 +296,6 @@ export default {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
showCustomSnoozeModal: false,
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
@@ -268,7 +316,6 @@ export default {
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
chatListFilters: 'getChatListFilters',
unAssignedChatsList: 'getUnAssignedChats',
chatListLoading: 'getChatListLoadingStatus',
currentUserID: 'getCurrentUserID',
@@ -282,18 +329,23 @@ export default {
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
selectedConversations: 'bulkActions/getSelectedConversationIds',
contextMenuChatId: 'getContextMenuChatId',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
},
hasActiveFolders() {
return Boolean(this.activeFolder && this.foldersId !== 0);
return this.activeFolder && this.foldersId !== 0;
},
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
},
savedFoldersValue() {
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
return {};
},
showEndOfListMessage() {
return (
this.conversationList.length &&
@@ -369,6 +421,7 @@ export default {
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
conversationListPagination() {
@@ -481,13 +534,7 @@ export default {
this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
},
activeFolder(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch(
'customViews/setActiveConversationFolder',
newVal || null
);
}
activeFolder() {
this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
},
@@ -497,14 +544,8 @@ export default {
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
conversationFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('updateChatListFilters', newVal);
}
},
},
mounted() {
this.$store.dispatch('setChatListFilters', this.conversationFilters);
this.setFiltersFromUISettings();
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
@@ -514,14 +555,9 @@ export default {
this.$store.dispatch('campaigns/get');
}
this.$emitter.on('fetch_conversation_stats', () => {
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
updateVirtualListProps(key, value) {
@@ -700,9 +736,8 @@ export default {
this.fetchConversations();
},
fetchConversations() {
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
this.$store
.dispatch('fetchAllConversations')
.dispatch('fetchAllConversations', this.conversationFilters)
.then(this.emitConversationLoaded);
},
loadMoreConversations() {
@@ -742,7 +777,7 @@ export default {
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
this.$emitter.emit('clearSearchInput');
bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
this.fetchConversations();
@@ -764,7 +799,7 @@ export default {
});
},
resetBulkActions() {
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedConversations = [];
this.selectedInboxes = [];
},
onBasicFilterChange(value, type) {
@@ -795,16 +830,12 @@ export default {
return this.selectedConversations.includes(id);
},
selectConversation(conversationId, inboxId) {
this.$store.dispatch(
'bulkActions/setSelectedConversationIds',
conversationId
);
this.selectedConversations.push(conversationId);
this.selectedInboxes.push(inboxId);
},
deSelectConversation(conversationId, inboxId) {
this.$store.dispatch(
'bulkActions/removeSelectedConversationIds',
conversationId
this.selectedConversations = this.selectedConversations.filter(
item => item !== conversationId
);
this.selectedInboxes = this.selectedInboxes.filter(
item => item !== inboxId
@@ -812,10 +843,7 @@ export default {
},
selectAllConversations(check) {
if (check) {
this.$store.dispatch(
'bulkActions/setSelectedConversationIds',
this.conversationList.map(item => item.id)
);
this.selectedConversations = this.conversationList.map(item => item.id);
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
} else {
this.resetBulkActions();
@@ -831,7 +859,7 @@ export default {
assignee_id: agent.id,
},
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedConversations = [];
if (conversationId) {
this.showAlert(
this.$t(
@@ -929,7 +957,7 @@ export default {
add: labels,
},
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedConversations = [];
if (conversationId) {
this.showAlert(
this.$t(
@@ -956,13 +984,13 @@ export default {
team_id: team.id,
},
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status, snoozedUntil) {
async onUpdateConversations(status) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
@@ -970,9 +998,8 @@ export default {
fields: {
status,
},
snoozed_until: snoozedUntil,
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
@@ -993,49 +1020,12 @@ export default {
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item)?.status === status;
return this.$store.getters.getConversationById(item).status === status;
});
},
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and then the custom snooze modal is open
// Then if the custom snooze modal is closed and set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
},
};
</script>
@@ -1,115 +0,0 @@
<script setup>
import { computed } from 'vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
});
const emits = defineEmits([
'add-folders',
'delete-folders',
'reset-filters',
'basic-filter-change',
'filters-modal',
]);
const onBasicFilterChange = (value, type) => {
emits('basic-filter-change', value, type);
};
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
</script>
<template>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emits('add-folders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emits('reset-filters')"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emits('filters-modal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emits('delete-folders')"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emits('filters-modal')"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
</template>
+1 -3
View File
@@ -25,10 +25,8 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
script: {
type: String,
@@ -61,7 +59,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.script);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
@@ -12,7 +12,7 @@
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
class="w-full font-medium text-sm mb-0"
:class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
@@ -20,14 +20,9 @@
"
>
{{ label }}
<helper-text-popup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<woot-button
v-if="showActions && value"
v-if="showCopyAndDeleteButton"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
@@ -46,7 +41,7 @@
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
class="!h-8 ltr:rounded-r-none rtl:rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: $v.editedValue.$error }"
@blur="$v.editedValue.$touch"
@@ -90,7 +85,7 @@
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showActions && value"
v-if="showCopyAndDeleteButton"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
@@ -100,7 +95,7 @@
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-if="showEditButton"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
@@ -135,25 +130,22 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin],
mixins: [customAttributeMixin, clickaway],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false },
@@ -174,6 +166,12 @@ export default {
};
},
computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
@@ -270,10 +268,10 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
@@ -1,31 +0,0 @@
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-ash-900">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-ash-200" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-ash-900">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
@@ -18,10 +18,8 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
value: {
type: String,
@@ -37,7 +35,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
toggleMasked() {
this.masked = !this.masked;
@@ -99,9 +99,7 @@ export default {
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
if (this.closeOnBackdropClick) {
this.onClose();
}
this.onClose();
}
},
},
@@ -55,7 +55,7 @@ export default {
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
this.$emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
@@ -21,7 +21,7 @@ export default {
},
methods: {
onMenuItemClick() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU);
},
},
};
@@ -15,13 +15,11 @@
<script>
import WootSnackbar from './Snackbar.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
WootSnackbar,
},
mixins: [alertMixin],
props: {
duration: {
type: Number,
@@ -36,10 +34,10 @@ export default {
},
mounted() {
this.$emitter.on('newToastMessage', this.onNewToastMessage);
bus.$on('newToastMessage', this.onNewToastMessage);
},
beforeDestroy() {
this.$emitter.off('newToastMessage', this.onNewToastMessage);
bus.$off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage(message, action) {
@@ -1,5 +1,5 @@
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div class="resolve-actions relative flex items-center justify-end">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -73,13 +73,26 @@
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -87,20 +100,23 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SNOOZE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [alertMixin, keyboardEventListenerMixins],
mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
};
},
computed: {
@@ -128,12 +144,14 @@ export default {
},
},
mounted() {
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
@@ -184,6 +202,28 @@ export default {
// error
}
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
@@ -24,7 +24,6 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
@@ -52,7 +51,6 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
@@ -47,6 +47,7 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -66,7 +67,7 @@ export default {
AvailabilityStatusBadge,
},
mixins: [alertMixin],
mixins: [clickaway, alertMixin],
data() {
return {
@@ -7,7 +7,7 @@
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="px-8 py-4">
<div class="px-8 pt-4 pb-8">
<div
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
@@ -45,10 +45,10 @@
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="flex justify-end items-center px-8 pb-8 pt-4 gap-2"
class="flex justify-end items-center p-8 gap-2"
>
<button
class="button success large expanded nice w-full"
class="button success large expanded nice"
@click="$emit('show-create-account-modal')"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
@@ -106,6 +106,7 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
@@ -118,6 +119,7 @@ export default {
WootDropdownItem,
AvailabilityStatus,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,
@@ -65,11 +65,11 @@
</div>
<span
v-if="warningIcon"
class="inline-flex mr-1 bg-red-50 dark:bg-red-900 p-0.5 rounded-sm"
class="inline-flex rounded-sm mr-1 bg-slate-100"
>
<fluent-icon
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="text-xxs text-red-500 dark:text-red-300"
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
class="text-xxs"
:icon="warningIcon"
size="12"
/>
@@ -1,304 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from './helpers/DatePickerHelper';
import {
isValid,
startOfMonth,
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
setMonth,
setYear,
isAfter,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
import CalendarYear from './components/CalendarYear.vue';
import CalendarMonth from './components/CalendarMonth.vue';
import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
);
const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS);
const hoveredEndDate = ref(null);
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
const emit = defineEmits(['change']);
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// Watcher will set the input values based on the selected start and end dates
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
}
},
{ immediate: true }
);
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
watch(
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
},
{ immediate: true, deep: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
};
const moveCalendar = (calendar, direction, period = MONTH) => {
const { start, end } = moveCalendarDate(
calendar,
startCurrentDate.value,
endCurrentDate.value,
direction,
period
);
startCurrentDate.value = start;
endCurrentDate.value = end;
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
selectingEndDate.value = true;
} else {
selectedEndDate.value = day;
selectingEndDate.value = false;
}
};
const setViewMode = (calendar, mode) => {
selectedRange.value = CUSTOM_RANGE;
calendarViews.value[calendar] = mode;
};
const openCalendar = (index, calendarType, period = MONTH) => {
const current =
calendarType === START_CALENDAR
? startCurrentDate.value
: endCurrentDate.value;
const newDate =
period === MONTH
? setMonth(startOfMonth(current), index)
: setYear(current, index);
if (calendarType === START_CALENDAR) {
startCurrentDate.value = newDate;
} else {
endCurrentDate.value = newDate;
}
setViewMode(calendarType, period === MONTH ? WEEK : MONTH);
};
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = newDate;
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = newDate;
}
selectingEndDate.value = false;
};
const handleManualInputError = message => {
useAlert(message);
};
const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
// Reset view modes if they are being used to toggle between different calendar views
calendarViews.value = { start: WEEK, end: WEEK };
};
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
useAlert('Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
</script>
<template>
<div class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
@open="showDatePicker = !showDatePicker"
/>
<div
v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
<div
v-for="calendar in [START_CALENDAR, END_CALENDAR]"
:key="`${calendar}-calendar`"
class="flex flex-col items-center"
>
<CalendarDateInput
:calendar-type="calendar"
:date-value="
calendar === START_CALENDAR ? manualStartDate : manualEndDate
"
:compare-date="
calendar === START_CALENDAR ? manualEndDate : manualStartDate
"
:is-disabled="selectedRange !== CUSTOM_RANGE"
@update="
calendar === START_CALENDAR
? (manualStartDate = $event)
: (manualEndDate = $event)
"
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
"
>
<CalendarYear
v-if="calendarViews[calendar] === YEAR"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-year="openCalendar($event, calendar, YEAR)"
/>
<CalendarMonth
v-else-if="calendarViews[calendar] === MONTH"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-month="openCalendar($event, calendar)"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev', YEAR)"
@next="moveCalendar(calendar, 'next', YEAR)"
/>
<CalendarWeek
v-else-if="calendarViews[calendar] === WEEK"
:calendar-type="calendar"
:current-date="currentDate"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selecting-end-date="selectingEndDate"
:hovered-end-date="hoveredEndDate"
@update-hovered-end-date="hoveredEndDate = $event"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev')"
@next="moveCalendar(calendar, 'next')"
/>
</div>
</div>
</div>
</div>
<CalendarFooter @change="emitDateRange" @clear="resetDatePicker" />
</div>
</div>
</div>
</template>
@@ -1,79 +0,0 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
defineProps({
calendarType: {
type: String,
default: 'start',
},
firstButtonLabel: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
viewMode: {
type: String,
default: '',
},
});
const emit = defineEmits(['prev', 'next', 'set-view']);
const { YEAR } = CALENDAR_PERIODS;
const onClickPrev = type => {
emit('prev', type);
};
const onClickNext = type => {
emit('next', type);
};
const onClickSetView = (type, mode) => {
emit('set-view', type, mode);
};
</script>
<template>
<div class="flex items-start justify-between w-full h-9">
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickPrev(calendarType)"
>
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickNext(calendarType)"
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div>
</template>
@@ -1,74 +0,0 @@
<script setup>
import { computed } from 'vue';
import { parse, isValid, isAfter, isBefore } from 'date-fns';
import {
getIntlDateFormatForLocale,
CALENDAR_TYPES,
} from '../helpers/DatePickerHelper';
const props = defineProps({
calendarType: {
type: String,
default: '',
},
dateValue: Date,
compareDate: Date,
isDisabled: Boolean,
});
const emit = defineEmits(['update', 'validate', 'error']);
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const dateFormat = computed(() => getIntlDateFormatForLocale()?.toUpperCase());
const localDateValue = computed({
get: () => props.dateValue?.toLocaleDateString(navigator.language) || '',
set: newValue => {
const format = getIntlDateFormatForLocale();
const parsedDate = parse(newValue, format, new Date());
if (isValid(parsedDate)) {
emit('update', parsedDate);
}
},
});
const validateDate = () => {
if (!isValid(props.dateValue)) {
emit('error', `Please enter the date in valid format: ${dateFormat.value}`);
return;
}
const { calendarType, compareDate, dateValue } = props;
const isStartCalendar = calendarType === START_CALENDAR;
const isEndCalendar = calendarType === END_CALENDAR;
if (compareDate && isStartCalendar && isAfter(dateValue, compareDate)) {
emit('error', 'Start date must be before the end date.');
} else if (compareDate && isEndCalendar && isBefore(dateValue, compareDate)) {
emit('error', 'End date must be after the start date.');
} else {
emit('validate', dateValue);
}
};
</script>
<template>
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{
calendarType === START_CALENDAR
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
: $t('DATE_PICKER.DATE_RANGE_INPUT.END')
}}
</span>
<input
v-model="localDateValue"
type="text"
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
/>
</div>
</template>
@@ -1,41 +0,0 @@
<script setup>
import { dateRanges } from '../helpers/DatePickerHelper';
defineProps({
selectedRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['set-range']);
const setDateRange = range => {
emit('set-range', range);
};
</script>
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
:class="
range.value === selectedRange
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</div>
</div>
</template>
@@ -1,28 +0,0 @@
<script setup>
const emit = defineEmits(['clear', 'apply']);
const onClickClear = () => {
emit('clear');
};
const onClickApply = () => {
emit('change');
};
</script>
<template>
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
@click="onClickClear"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
@click="onClickApply"
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { computed } from 'vue';
import { format, getMonth, setMonth, startOfMonth } from 'date-fns';
import {
yearName,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH, YEAR } = CALENDAR_PERIODS;
const months = Array.from({ length: 12 }, (_, index) =>
format(setMonth(startOfMonth(new Date()), index), 'MMM')
);
const activeMonthIndex = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getMonth(date);
});
const emit = defineEmits(['select-month', 'prev', 'next', 'set-view']);
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const selectMonth = index => {
emit('select-month', index);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="YEAR"
:calendar-type="calendarType"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate,
MONTH
)
"
@set-view="setViewMode"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid w-full grid-cols-3 gap-x-3 gap-y-2 auto-rows-[61px]">
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex,
}"
@click="selectMonth(index)"
>
{{ month }}
</button>
</div>
</div>
</template>
@@ -1,172 +0,0 @@
<script setup>
import {
monthName,
yearName,
getWeeksForMonth,
isToday,
dayIsInRange,
isCurrentMonth,
isLastDayOfMonth,
isHoveringDayInRange,
isHoveringNextDayInRange,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarWeekLabel from './CalendarWeekLabel.vue';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
currentDate: Date,
startCurrentDate: Date,
endCurrentDate: Date,
selectedStartDate: Date,
selectingEndDate: Boolean,
selectedEndDate: Date,
hoveredEndDate: Date,
});
const emit = defineEmits([
'update-hovered-end-date',
'select-date',
'prev',
'next',
'set-view',
]);
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH } = CALENDAR_PERIODS;
const emitHoveredEndDate = day => {
emit('update-hovered-end-date', day);
};
const emitSelectDate = day => {
emit('select-date', day);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const weeks = calendarType => {
return getWeeksForMonth(
calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isSelectedStartOrEndDate = day => {
return (
dayIsInRange(day, props.selectedStartDate, props.selectedStartDate) ||
dayIsInRange(day, props.selectedEndDate, props.selectedEndDate)
);
};
const isInRange = day => {
return dayIsInRange(day, props.selectedStartDate, props.selectedEndDate);
};
const isInCurrentMonth = day => {
return isCurrentMonth(
day,
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isHoveringInRange = day => {
return isHoveringDayInRange(
day,
props.selectedStartDate,
props.selectingEndDate,
props.hoveredEndDate
);
};
const isNextDayInRange = day => {
return isHoveringNextDayInRange(
day,
props.selectedStartDate,
props.selectedEndDate,
props.hoveredEndDate
);
};
const dayClasses = day => ({
'text-slate-500 dark:text-slate-400 pointer-events-none':
!isInCurrentMonth(day),
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
isInCurrentMonth(day),
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="MONTH"
:calendar-type="calendarType"
:first-button-label="
monthName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
@prev="onClickPrev"
@next="onClickNext"
@set-view="setViewMode"
/>
<CalendarWeekLabel />
<div
v-for="week in weeks(calendarType)"
:key="week[0].getTime()"
class="grid max-w-md grid-cols-7 gap-2 mx-auto overflow-hidden rounded-lg"
>
<div
v-for="day in week"
:key="day.getTime()"
class="flex relative items-center justify-center w-9 h-8 py-1.5 px-2 font-medium text-sm rounded-lg cursor-pointer"
:class="dayClasses(day)"
@mouseenter="emitHoveredEndDate(day)"
@mouseleave="emitHoveredEndDate(null)"
@click="emitSelectDate(day)"
>
{{ day.getDate() }}
<span
v-if="
(isInRange(day) || isHoveringInRange(day)) &&
isNextDayInRange(day) &&
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/>
</div>
</div>
</div>
</template>
@@ -1,15 +0,0 @@
<script setup>
import { calendarWeeks } from '../helpers/DatePickerHelper';
</script>
<template>
<div class="max-w-md mx-auto grid grid-cols-7 gap-2">
<div
v-for="day in calendarWeeks"
:key="day.id"
class="flex items-center justify-center font-medium text-sm w-9 h-7 py-1.5 px-2"
>
{{ day.label }}
</div>
</div>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { getYear, addYears, subYears } from 'date-fns';
import { CALENDAR_TYPES } from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const calculateStartYear = date => {
const year = getYear(date);
return year - (year % 10); // Align with the beginning of a decade
};
const startYear = ref(
calculateStartYear(
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
)
);
const years = computed(() =>
Array.from({ length: 10 }, (_, i) => startYear.value + i)
);
const firstYear = computed(() => years.value[0]);
const lastYear = computed(() => years.value[years.value.length - 1]);
const activeYear = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getYear(date);
});
const onClickPrev = () => {
startYear.value = subYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const onClickNext = () => {
startYear.value = addYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const emit = defineEmits(['select-year']);
const selectYear = year => {
emit('select-year', year);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:calendar-type="calendarType"
:button-label="`${firstYear} - ${lastYear}`"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 w-full auto-rows-[47px]">
<button
v-for="year in years"
:key="year"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
}"
@click="selectYear(year)"
>
{{ year }}
</button>
</div>
</div>
</template>
@@ -1,71 +0,0 @@
<script setup>
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
const props = defineProps({
selectedStartDate: Date,
selectedEndDate: Date,
selectedRange: {
type: String,
default: '',
},
});
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
const endDate = props.selectedEndDate;
if (!isValid(startDate) || !isValid(endDate)) {
return 'Select a date range';
}
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
}
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format(
endDate,
formatString
)}`;
});
const activeDateRange = computed(
() => dateRanges.find(range => range.value === props.selectedRange).label
);
const emit = defineEmits(['open']);
const openDatePicker = () => {
emit('open');
};
</script>
<template>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="openDatePicker"
>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="calendar"
size="16"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-200">
{{ formatDateRange }}
</span>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="chevron-down"
size="14"
/>
</button>
</template>
@@ -1,219 +0,0 @@
import {
startOfDay,
subDays,
endOfDay,
subMonths,
addMonths,
subYears,
addYears,
startOfMonth,
isSameMonth,
format,
startOfWeek,
addDays,
eachDayOfInterval,
endOfMonth,
isSameDay,
isWithinInterval,
} from 'date-fns';
// Constants for calendar and date ranges
export const calendarWeeks = [
{ id: 1, label: 'M' },
{ id: 2, label: 'T' },
{ id: 3, label: 'W' },
{ id: 4, label: 'T' },
{ id: 5, label: 'F' },
{ id: 6, label: 'S' },
{ id: 7, label: 'S' },
];
export const dateRanges = [
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_7_DAYS', value: 'last7days' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_30_DAYS', value: 'last30days' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
];
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last7days',
LAST_30_DAYS: 'last30days',
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
CUSTOM_RANGE: 'custom',
};
export const CALENDAR_TYPES = {
START_CALENDAR: 'start',
END_CALENDAR: 'end',
};
export const CALENDAR_PERIODS = {
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
// Utility functions for date operations
export const monthName = currentDate => format(currentDate, 'MMMM');
export const yearName = currentDate => format(currentDate, 'yyyy');
export const getIntlDateFormatForLocale = () => {
const year = 2222;
const month = 12;
const day = 15;
const date = new Date(year, month - 1, day);
const formattedDate = new Intl.DateTimeFormat(navigator.language).format(
date
);
return formattedDate
.replace(`${year}`, 'yyyy')
.replace(`${month}`, 'MM')
.replace(`${day}`, 'dd');
};
// Utility functions for calendar operations
export const chunk = (array, size) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, index) =>
array.slice(index * size, index * size + size)
);
export const getWeeksForMonth = (date, weekStartsOn = 1) => {
const startOfTheMonth = startOfMonth(date);
const startOfTheFirstWeek = startOfWeek(startOfTheMonth, { weekStartsOn });
const endOfTheLastWeek = addDays(startOfTheFirstWeek, 41); // Covering six weeks to fill the calendar
return chunk(
eachDayOfInterval({ start: startOfTheFirstWeek, end: endOfTheLastWeek }),
7
);
};
export const moveCalendarDate = (
calendar,
startCurrentDate,
endCurrentDate,
direction,
period
) => {
const adjustFunctions = {
month: { prev: subMonths, next: addMonths },
year: { prev: subYears, next: addYears },
};
const adjust = adjustFunctions[period][direction];
if (calendar === 'start') {
const newStart = adjust(startCurrentDate, 1);
return { start: newStart, end: endCurrentDate };
}
const newEnd = adjust(endCurrentDate, 1);
return { start: startCurrentDate, end: newEnd };
};
// Date comparison functions
export const isToday = (currentDate, date) =>
date.getDate() === currentDate.getDate() &&
date.getMonth() === currentDate.getMonth() &&
date.getFullYear() === currentDate.getFullYear();
export const isCurrentMonth = (day, referenceDate) =>
isSameMonth(day, referenceDate);
export const isLastDayOfMonth = day => {
const lastDay = endOfMonth(day);
return day.getDate() === lastDay.getDate();
};
export const dayIsInRange = (date, startDate, endDate) => {
if (!startDate || !endDate) {
return false;
}
// Normalize dates to ignore time differences
let startOfDayStart = startOfDay(startDate);
let startOfDayEnd = startOfDay(endDate);
// Swap if start is greater than end
if (startOfDayStart > startOfDayEnd) {
[startOfDayStart, startOfDayEnd] = [startOfDayEnd, startOfDayStart];
}
// Check if the date is within the interval or is the same as the start date
return (
isSameDay(date, startOfDayStart) ||
isWithinInterval(date, {
start: startOfDayStart,
end: startOfDayEnd,
})
);
};
// Handling hovering states in date range pickers
export const isHoveringDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (endDate && hoveredEndDate && startDate <= hoveredEndDate) {
// Ensure the start date is not after the hovered end date
return isWithinInterval(day, { start: startDate, end: hoveredEndDate });
}
return false;
};
export const isHoveringNextDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (startDate && !endDate && hoveredEndDate) {
// If a start date is selected, and we're hovering (but haven't clicked an end date yet)
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: hoveredEndDate });
}
if (startDate && endDate) {
// Normal range checking between selected start and end dates
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: endDate });
}
return false;
};
// Helper func to determine active date ranges based on user selection
export const getActiveDateRange = (range, currentDate) => {
const ranges = {
last7days: () => ({
start: startOfDay(subDays(currentDate, 6)),
end: endOfDay(currentDate),
}),
last30days: () => ({
start: startOfDay(subDays(currentDate, 29)),
end: endOfDay(currentDate),
}),
last3months: () => ({
start: startOfDay(subMonths(currentDate, 3)),
end: endOfDay(currentDate),
}),
last6months: () => ({
start: startOfDay(subMonths(currentDate, 6)),
end: endOfDay(currentDate),
}),
lastYear: () => ({
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
return (
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};
@@ -1,309 +0,0 @@
import {
monthName,
yearName,
getIntlDateFormatForLocale,
getWeeksForMonth,
isToday,
isCurrentMonth,
isLastDayOfMonth,
dayIsInRange,
getActiveDateRange,
isHoveringDayInRange,
isHoveringNextDayInRange,
moveCalendarDate,
chunk,
} from '../DatePickerHelper';
describe('Date formatting functions', () => {
const testDate = new Date(2020, 4, 15); // May 15, 2020
beforeEach(() => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns the correct month name from a date', () => {
expect(monthName(testDate)).toBe('May');
});
it('returns the correct year from a date', () => {
expect(yearName(testDate)).toBe('2020');
});
it('returns the correct date format for the current locale en-US', () => {
const expected = 'MM/dd/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
it('returns the correct date format for the current locale en-IN', () => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-IN');
const expected = 'dd/MM/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
});
describe('chunk', () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
it('correctly chunks an array into smaller arrays of given size', () => {
const expected = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
expect(chunk(array, 3)).toEqual(expected);
});
it('handles arrays that do not divide evenly by the chunk size', () => {
const expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9]];
expect(chunk(array, 2)).toEqual(expected);
});
});
describe('getWeeksForMonth', () => {
it('returns the correct weeks array for a month starting on Monday', () => {
const date = new Date(2020, 3, 1); // April 2020
const weeks = getWeeksForMonth(date, 1);
expect(weeks.length).toBe(6);
expect(weeks[0][0]).toEqual(new Date(2020, 2, 30)); // Check if first day of the first week is correct
});
});
describe('moveCalendarDate', () => {
it('handles the year transition when moving the start date backward by one month from January', () => {
const startDate = new Date(2021, 0, 1);
const endDate = new Date(2021, 0, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'prev',
'month'
);
const expectedStartDate = new Date(2020, 11, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the start date forward by one month from January', () => {
const startDate = new Date(2020, 0, 1);
const endDate = new Date(2020, 1, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'next',
'month'
);
const expectedStartDate = new Date(2020, 1, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the end date forward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'next', 'month');
const expectedEndDate = new Date(2022, 0, 31);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
it('handles the year transition when moving the end date backward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'prev', 'month');
const expectedEndDate = new Date(2021, 10, 30);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
});
describe('isToday', () => {
it('returns true if the dates are the same', () => {
const today = new Date();
const alsoToday = new Date(today);
expect(isToday(today, alsoToday)).toBeTruthy();
});
it('returns false if the dates are not the same', () => {
const today = new Date();
const notToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 1
);
expect(isToday(today, notToday)).toBeFalsy();
});
});
describe('isCurrentMonth', () => {
const referenceDate = new Date(2020, 6, 15); // July 15, 2020
it('returns true if the day is in the same month as the reference date', () => {
const testDay = new Date(2020, 6, 1);
expect(isCurrentMonth(testDay, referenceDate)).toBeTruthy();
});
it('returns false if the day is not in the same month as the reference date', () => {
const testDay = new Date(2020, 5, 30);
expect(isCurrentMonth(testDay, referenceDate)).toBeFalsy();
});
});
describe('isLastDayOfMonth', () => {
it('returns true if the day is the last day of the month', () => {
const testDay = new Date(2020, 6, 31); // July 31, 2020
expect(isLastDayOfMonth(testDay)).toBeTruthy();
});
it('returns false if the day is not the last day of the month', () => {
const testDay = new Date(2020, 6, 30); // July 30, 2020
expect(isLastDayOfMonth(testDay)).toBeFalsy();
});
});
describe('dayIsInRange', () => {
it('returns true if the date is within the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 15);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns true if the date is the same as the start date', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 10);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns false if the date is outside the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 9);
expect(dayIsInRange(testDate, start, end)).toBeFalsy();
});
});
describe('isHoveringDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const endDate = new Date(2020, 6, 20);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 12);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the day is outside the hovered date range', () => {
const testDay = new Date(2020, 6, 16);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeFalsy();
});
});
describe('isHoveringNextDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the next day after the given day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 14);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the next day is outside the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 15);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeFalsy();
});
});
describe('getActiveDateRange', () => {
const currentDate = new Date(2020, 5, 15, 12, 0); // May 15, 2020, at noon
beforeAll(() => {
// Mocking the current date to ensure consistency in tests
jest.useFakeTimers().setSystemTime(currentDate.getTime());
});
afterAll(() => {
jest.useRealTimers();
});
it('returns the correct range for "last7days"', () => {
const range = getActiveDateRange('last7days', new Date());
const expectedStart = new Date(2020, 5, 9);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last30days"', () => {
const range = getActiveDateRange('last30days', new Date());
const expectedStart = new Date(2020, 4, 17);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last3months"', () => {
const range = getActiveDateRange('last3months', new Date());
const expectedStart = new Date(2020, 2, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last6months"', () => {
const range = getActiveDateRange('last6months', new Date());
const expectedStart = new Date(2019, 11, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "lastYear"', () => {
const range = getActiveDateRange('lastYear', new Date());
const expectedStart = new Date(2019, 5, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "custom date range"', () => {
const range = getActiveDateRange('custom', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
it('handles an unknown range label gracefully', () => {
const range = getActiveDateRange('unknown', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
});
@@ -1,15 +0,0 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -1,24 +0,0 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="relative group w-[inherit] whitespace-normal z-20">
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-slate-600 absolute dark:text-slate-400"
/>
<div
class="bg-white dark:bg-slate-900 w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-slate-75 dark:border-slate-800 rounded-lg shadow-md"
>
<p class="text-slate-800 dark:text-slate-75 mb-0 text-xs">
{{ message }}
</p>
</div>
</div>
</template>
@@ -81,7 +81,7 @@ export default {
},
mounted() {
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
bus.$on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage;
},
@@ -18,11 +18,13 @@
<script>
import { Chrome } from 'vue-color';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Chrome,
},
mixins: [clickaway],
props: {
value: {
type: String,
@@ -33,6 +33,7 @@
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
export default {
@@ -41,7 +42,7 @@ export default {
LabelDropdown,
},
mixins: [adminMixin, keyboardEventListenerMixins],
mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
props: {
allLabels: {
@@ -5,7 +5,6 @@
</template>
<script>
import getUuid from 'widget/helpers/uuid';
import 'video.js/dist/video-js.css';
import 'videojs-record/dist/css/videojs.record.css';
@@ -29,25 +28,9 @@ import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { convertWavToMp3 } from './utils/mp3ConversionUtils';
WaveSurfer.microphone = MicrophonePlugin;
const RECORDER_CONFIG = {
[AUDIO_FORMATS.WAV]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.MP3]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.OGG]: {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
},
};
export default {
name: 'WootAudioRecorder',
mixins: [alertMixin],
@@ -56,10 +39,6 @@ export default {
type: String,
default: AUDIO_FORMATS.WAV,
},
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -109,7 +88,14 @@ export default {
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...RECORDER_CONFIG[this.audioRecordFormat],
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
}),
},
},
},
@@ -147,11 +133,7 @@ export default {
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (
[AUDIO_FORMATS.WAV, AUDIO_FORMATS.MP3].includes(
this.audioRecordFormat
)
) {
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
this.player.record().engine.audioType = 'audio/wav';
}
}
@@ -163,16 +145,12 @@ export default {
stopRecord() {
this.fireStateRecorderChanged('stopped');
},
async finishRecord() {
let recordedContent = this.player.recordedData;
let fileName = this.player.recordedData.name;
let type = this.player.recordedData.type;
if (this.audioRecordFormat === AUDIO_FORMATS.MP3) {
recordedContent = await convertWavToMp3(this.player.recordedData);
fileName = `${getUuid()}.mp3`;
type = AUDIO_FORMATS.MP3;
}
const file = new File([recordedContent], fileName, { type });
finishRecord() {
const file = new File(
[this.player.recordedData],
this.player.recordedData.name,
{ type: this.player.recordedData.type }
);
this.fireRecorderBlob(file);
},
progressRecord() {
@@ -253,13 +231,11 @@ export default {
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
}
}
// Added to override the default text and bg style to support dark and light mode.
.video-js .vjs-control-bar,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
}
// Added to fix div overlays the screen and takes over the button clicks
// https://github.com/collab-project/videojs-record/issues/688
// https://github.com/collab-project/videojs-record/pull/709
@@ -3,17 +3,17 @@
<tag-agents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="content => insertSpecialContent('mention', content)"
@click="insertMentionNode"
/>
<canned-response
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="content => insertSpecialContent('cannedResponse', content)"
@click="insertCannedResponse"
/>
<variable-list
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="content => insertSpecialContent('variable', content)"
@click="insertVariable"
/>
<input
ref="imageUpload"
@@ -47,9 +47,11 @@
<script>
import {
messageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
@@ -68,7 +70,6 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
createState,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -81,7 +82,10 @@ import {
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { createTypingIndicator } from '@chatwoot/utils';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -90,7 +94,27 @@ import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import useSpecialContent from 'dashboard/composables/editor/useSpecialContent';
const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
export default {
name: 'WootMessageEditor',
@@ -115,11 +139,6 @@ export default {
channelType: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
},
setup() {
const { getContentNode } = useSpecialContent();
return { getContentNode };
},
data() {
return {
typingIndicator: createTypingIndicator(
@@ -333,16 +352,10 @@ export default {
// Components using this
// 1. SearchPopover.vue
this.$emitter.on(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
beforeDestroy() {
this.$emitter.off(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
methods: {
reloadState(content = this.value) {
@@ -433,12 +446,22 @@ export default {
this.emitOnChange();
},
handleDOMEvents: {
keyup: this.onKeyup,
keydown: (_view, event) => this.onKeydown(event),
focus: this.onFocus,
click: this.isEditorMouseFocusedOnAnImage,
blur: this.onBlur,
paste: (_view, event) => {
keyup: () => {
this.onKeyup();
},
keydown: (view, event) => {
this.onKeydown(event);
},
focus: () => {
this.onFocus();
},
click: () => {
this.isEditorMouseFocusedOnAnImage();
},
blur: () => {
this.onBlur();
},
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
@@ -522,39 +545,57 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
/**
* Inserts special content (mention, canned response, or variable) into the editor.
*
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable'.
* @param {Object|string} content - The content to insert, depending on the type.
* - For 'mention' type: An object with 'id' and 'name' properties representing the user mention.
* - For 'canned_response' type: A string representing the canned response.
* - For 'variable' type: A string representing the variable name.
*/
insertSpecialContent(type, content) {
insertMentionNode(mentionItem) {
if (!this.editorView) {
return;
return null;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
}
let { node, from, to } = this.getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
if (!node) return;
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
this.insertNodeIntoEditor(node, from, to);
const event_map = {
mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
};
this.$track(event_map[type]);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -1,53 +0,0 @@
import lamejs from 'lamejs';
/**
* Encodes a mono channel audio stream to MP3 format.
* @param {number} channels - Number of audio channels.
* @param {number} sampleRate - Sample rate in Hz.
* @param {Int16Array} samples - Audio samples to be encoded.
* @returns {Blob} - The MP3 encoded audio as a Blob.
*/
export const encodeToMP3 = (channels, sampleRate, samples) => {
const outputBuffer = [];
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128);
const maxSamplesPerFrame = 1152;
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
const sampleSlice = samples.subarray(offset, sliceEnd);
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
if (mp3Buffer.length > 0) {
outputBuffer.push(new Int8Array(mp3Buffer));
}
}
const remainingData = encoder.flush();
if (remainingData.length > 0) {
outputBuffer.push(new Int8Array(remainingData));
}
return new Blob(outputBuffer, { type: 'audio/mp3' });
};
/**
* Converts a WAV audio Blob to an MP3 format Blob.
* @param {Blob} blob - The audio data in WAV format as a Blob.
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
*/
export const convertWavToMp3 = async blob => {
try {
const audioBuffer = await blob.arrayBuffer();
const wavHeader = lamejs.WavHeader.readHeader(new DataView(audioBuffer));
const samples = new Int16Array(
audioBuffer,
wavHeader.dataOffset,
wavHeader.dataLen / 2
);
return encodeToMP3(wavHeader.channels, wavHeader.sampleRate, samples);
} catch (error) {
// eslint-disable-next-line
console.log('Failed to convert WAV to MP3:', error);
throw new Error('Conversion from WAV to MP3 failed.');
}
};
@@ -45,6 +45,7 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -52,7 +53,7 @@ export default {
components: {
FilterItem,
},
mixins: [uiSettingsMixin],
mixins: [clickaway, uiSettingsMixin],
data() {
return {
showActionsDropdown: false,
@@ -103,7 +103,6 @@
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@@ -71,10 +71,6 @@
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -93,8 +89,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
export default {
components: {
@@ -103,7 +97,6 @@ export default {
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
@@ -128,9 +121,6 @@ export default {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
@@ -188,17 +178,6 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
methods: {
@@ -473,11 +473,11 @@ export default {
},
mounted() {
this.hasMediaLoadError = false;
this.$emitter.on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
beforeDestroy() {
this.$emitter.off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
bus.$off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout);
},
methods: {
@@ -531,7 +531,7 @@ export default {
const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
@@ -324,12 +324,12 @@ export default {
},
created() {
this.$emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
this.$emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
this.$emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
@@ -396,7 +396,7 @@ export default {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
this.$emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
@@ -514,7 +514,7 @@ export default {
} else {
this.hasUserScrolled = true;
}
this.$emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
@@ -37,6 +37,7 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
@@ -51,7 +52,7 @@ export default {
EmailTranscriptModal,
ResolveAction,
},
mixins: [alertMixin],
mixins: [alertMixin, clickaway],
data() {
return {
showEmailActionsModal: false,
@@ -61,14 +62,14 @@ export default {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
this.$emitter.on(CMD_MUTE_CONVERSATION, this.mute);
this.$emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
this.$emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
bus.$on(CMD_MUTE_CONVERSATION, this.mute);
bus.$on(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
destroyed() {
this.$emitter.off(CMD_MUTE_CONVERSATION, this.mute);
this.$emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
this.$emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
bus.$off(CMD_MUTE_CONVERSATION, this.mute);
bus.$off(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
@@ -153,6 +153,7 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -217,6 +218,7 @@ export default {
ArticleSearchPopover,
},
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
@@ -501,10 +503,11 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
if (
this.isAWhatsAppChannel ||
this.isAPIInbox ||
this.isATelegramChannel
) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -596,15 +599,12 @@ export default {
);
this.fetchAndSetReplyTo();
this.$emitter.on(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
// TODO need to find a better solution
this.$emitter.on(
bus.$on(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
@@ -612,13 +612,10 @@ export default {
destroyed() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
this.$emitter.off(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
bus.$off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
},
beforeDestroy() {
this.$emitter.off(
bus.$off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
@@ -631,7 +628,7 @@ export default {
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
this.$emitter.emit(
bus.$emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
@@ -873,8 +870,8 @@ export default {
'createPendingMessageAndSend',
messagePayload
);
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
this.$emitter.emit(BUS_EVENTS.MESSAGE_SENT);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private);
} catch (error) {
@@ -1200,7 +1197,7 @@ export default {
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
},
onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file
@@ -1258,7 +1255,6 @@ export default {
}
}
}
.send-button {
@apply mb-0;
}
@@ -1283,7 +1279,6 @@ export default {
.emoji-dialog--rtl {
@apply left-[unset] -right-80;
&::before {
transform: rotate(90deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
@@ -9,7 +9,7 @@
<woot-input
v-model.trim="$v.toEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
:class="{ error: $v.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
@@ -25,7 +25,7 @@
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
<woot-input
v-model.trim="$v.ccEmailsVal.$model"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
type="text"
:class="{ error: $v.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@@ -54,7 +54,7 @@
<woot-input
v-model.trim="$v.bccEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
:class="{ error: $v.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
@@ -7,10 +7,8 @@
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments,
}"
@click="scrollToMessage"
>
<message-preview
class="cursor-pointer"
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
@@ -21,7 +19,6 @@
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
name: 'ReplyTo',
@@ -45,12 +42,5 @@ export default {
data() {
return { MESSAGE_TYPE };
},
methods: {
scrollToMessage() {
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.message.id,
});
},
},
};
</script>
@@ -180,6 +180,7 @@
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
@@ -199,7 +200,7 @@ export default {
components: {
Thumbnail,
},
mixins: [keyboardEventListenerMixins, timeMixin],
mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
props: {
show: {
type: Boolean,
@@ -49,8 +49,9 @@
</template>
<script>
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
import { mixin as clickaway } from 'vue-clickaway';
const REFRESH_INTERVAL = 60000;
@@ -58,6 +59,7 @@ export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
chat: {
type: Object,
@@ -137,10 +139,7 @@ export default {
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
{{ $t('SLA.EVENTS.TITLE') }}
@@ -16,7 +16,7 @@
/>
</template>
<menu-item
v-if="showSnooze"
v-if="show(snoozeOption.key)"
:option="snoozeOption"
variant="icon"
@click="snoozeConversation()"
@@ -86,10 +86,6 @@ export default {
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
default: null,
},
status: {
type: String,
default: '',
@@ -209,10 +205,6 @@ export default {
...this.filteredAgentOnAvailability,
];
},
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -221,8 +213,7 @@ export default {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
snoozeConversation() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
@@ -101,12 +101,14 @@
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
@@ -95,40 +95,20 @@
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
:show.sync="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
},
props: {
conversations: {
@@ -163,68 +143,17 @@ export default {
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
mounted() {
this.$emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
this.$emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
destroyed() {
this.$emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
this.$emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('select-all-conversations', e.target.checked);
},
submit(agent) {
this.$emit('assign-agent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('update-conversations', status, snoozedUntil);
updateConversations(status) {
this.$emit('update-conversations', status);
},
assignLabels(labels) {
this.$emit('assign-labels', labels);
@@ -74,9 +74,11 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',
@@ -59,8 +59,10 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',
@@ -1,98 +1,12 @@
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
const { t } = useI18n();
const emits = defineEmits(['update', 'close']);
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const actions = ref([
{ icon: 'checkmark', key: 'resolved' },
{ icon: 'arrow-redo', key: 'open' },
{ icon: 'send-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emits('update', key);
}
};
const onClose = () => {
emits('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<div v-on-clickaway="onClose" class="actions-container">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50"
/>
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-slate-600 dark:text-slate-100">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<div class="header flex items-center justify-between">
<span>{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}</span>
<woot-button
size="tiny"
variant="clear"
@@ -101,8 +15,8 @@ const actionLabel = key => {
@click="onClose"
/>
</div>
<div class="px-2.5 pt-0 pb-2.5">
<woot-dropdown-menu class="m-0 list-none">
<div class="container">
<woot-dropdown-menu>
<template v-for="action in actions">
<woot-dropdown-item v-if="showAction(action.key)" :key="action.key">
<woot-button
@@ -120,3 +34,116 @@ const actionLabel = key => {
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
},
data() {
return {
query: '',
selectedAction: null,
actions: [
{
icon: 'checkmark',
key: 'resolved',
},
{
icon: 'arrow-redo',
key: 'open',
},
{
icon: 'send-clock',
key: 'snoozed',
},
],
};
},
methods: {
updateConversations(key) {
this.$emit('update', key);
},
goBack() {
this.selectedAgent = null;
},
onClose() {
this.$emit('close');
},
showAction(key) {
const actionsMap = {
resolved: this.showResolve,
open: this.showReopen,
snoozed: this.showSnooze,
};
return actionsMap[key] || false;
},
actionLabel(key) {
const labelsMap = {
resolved: this.$t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: this.$t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: this.$t('BULK_ACTION.UPDATE.SNOOZE_UNTIL_NEXT_REPLY'),
};
return labelsMap[key] || '';
},
},
};
</script>
<style scoped lang="scss">
.actions-container {
@apply absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply px-2.5 pt-0 pb-2.5;
}
.triangle {
right: var(--triangle-position);
@apply block z-10 absolute text-left -top-3;
svg path {
@apply fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50;
}
}
}
ul {
@apply m-0 list-none;
}
</style>
@@ -0,0 +1,117 @@
const calculateThreshold = (timeOffset, threshold) => {
// Calculate the time left for the SLA to breach or the time since the SLA has missed
if (threshold === null) return null;
const currentTime = Math.floor(Date.now() / 1000);
return timeOffset + threshold - currentTime;
};
const findMostUrgentSLAStatus = SLAStatuses => {
// Sort the SLAs based on the threshold and return the most urgent SLA
SLAStatuses.sort(
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
);
return SLAStatuses[0];
};
const formatSLATime = seconds => {
const units = {
y: 31536000, // 60 * 60 * 24 * 365
mo: 2592000, // 60 * 60 * 24 * 30
d: 86400, // 60 * 60 * 24
h: 3600, // 60 * 60
m: 60,
};
if (seconds < 60) {
return '1m';
}
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
const parts = [];
Object.keys(units).forEach(unit => {
const value = Math.floor(seconds / units[unit]);
if (seconds < 60 && parts.length > 0) return;
if (parts.length === 2) return;
if (value > 0) {
parts.push(value + unit);
seconds -= value * units[unit];
}
});
return parts.join(' ');
};
const createSLAObject = (
type,
{
sla_first_response_time_threshold: frtThreshold,
sla_next_response_time_threshold: nrtThreshold,
sla_resolution_time_threshold: rtThreshold,
created_at: createdAt,
} = {},
{
first_reply_created_at: firstReplyCreatedAt,
waiting_since: waitingSince,
status,
} = {}
) => {
// Mapping of breach types to their logic
const SLATypes = {
FRT: {
threshold: calculateThreshold(createdAt, frtThreshold),
// Check FRT only if threshold is not null and first reply hasn't been made
condition:
frtThreshold !== null &&
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
},
NRT: {
threshold: calculateThreshold(waitingSince, nrtThreshold),
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
condition:
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
},
RT: {
threshold: calculateThreshold(createdAt, rtThreshold),
// Check RT only if the conversation is open and threshold is not null
condition: status === 'open' && rtThreshold !== null,
},
};
const SLAStatus = SLATypes[type];
return SLAStatus ? { ...SLAStatus, type } : null;
};
const evaluateSLAConditions = (appliedSla, chat) => {
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
const SLATypes = ['FRT', 'NRT', 'RT'];
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
.map(SLAStatus => ({
...SLAStatus,
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: SLAStatus.threshold <= 0,
}));
};
export const evaluateSLAStatus = (appliedSla, chat) => {
if (!appliedSla || !chat)
return { type: '', threshold: '', icon: '', isSlaMissed: false };
// Filter out the SLA and create the object for each breach
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
// Return the most urgent SLA which is latest to breach or has missed
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
return mostUrgent
? {
type: mostUrgent.type,
threshold: formatSLATime(
mostUrgent.threshold <= 0
? -mostUrgent.threshold
: mostUrgent.threshold
),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
}
: { type: '', threshold: '', icon: '', isSlaMissed: false };
};
@@ -0,0 +1,150 @@
import { evaluateSLAStatus } from '../SLAHelper';
beforeEach(() => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('SLAHelper', () => {
describe('evaluateSLAStatus', () => {
it('returns an empty object when sla or chat is not present', () => {
expect(evaluateSLAStatus(null, null)).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
// Case when FRT SLA is missed
it('correctly identifies a missed FRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066540,
};
const chatMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when FRT SLA is not missed
it('correctly identifies an FRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066660,
};
const chatNotMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when NRT SLA is missed
it('correctly identifies a missed NRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704065940,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when NRT SLA is not missed
it('correctly identifies an NRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200 - 2000,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704066060,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when RT SLA is missed
it('correctly identifies a missed RT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065340,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when RT SLA is not missed
it('correctly identifies an RT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065460,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
});
});
@@ -1,257 +0,0 @@
<template>
<div @submit.prevent="onSubmit">
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<label :class="{ error: v$.teamId.$error }">
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
<select
v-model="formState.teamId"
:style="inputStyles"
@change="onChangeTeam"
>
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.teamId.$error" class="message">
{{ teamError }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
<select v-model="formState.assigneeId" :style="inputStyles">
<option v-for="item in assignees" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
<select v-model="formState.labelId" :style="inputStyles">
<option v-for="item in labels" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
<select v-model="formState.priority" :style="inputStyles">
<option v-for="item in priorities" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
<select v-model="formState.projectId" :style="inputStyles">
<option v-for="item in projects" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
<select v-model="formState.stateId" :style="inputStyles">
<option v-for="item in statuses" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChangeTeam = event => {
formState.teamId = event.target.value;
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
getTeamEntities();
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
@@ -1,92 +0,0 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<create-issue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<link-issue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const emits = defineEmits(['close']);
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emits('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
@@ -1,123 +0,0 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlink-issue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<issue-header
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-ash-900">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-ash-800 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-ash-900">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-ash-800">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-row justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-0.5">
<woot-button
variant="clear"
color-scheme="secondary"
class="h-[24px]"
:is-loading="isUnlinking"
@click="unlinkIssue"
>
<fluent-icon
v-if="!isUnlinking"
icon="unlink"
size="12"
type="outline"
icon-lib="lucide"
/>
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const isUnlinking = inject('isUnlinking');
const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => {
emit('unlink-issue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
@@ -1,143 +0,0 @@
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@click="onSelectIssue"
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking"
@click.prevent="linkIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emits = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emits('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>
@@ -1,141 +0,0 @@
<template>
<div class="relative" :class="{ group: linkedIssue }">
<woot-button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
variant="clear"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }}
</span>
</woot-button>
<issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
:show.sync="shouldShowPopup"
:on-close="closePopup"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<create-or-link-issue
:conversation="conversation"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
defineComponent({
name: 'Linear',
});
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(() =>
getters.getConversationById.value(props.conversationId)
);
const tooltipText = computed(() => {
return linkedIssue.value === null
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
}
};
const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
} finally {
isUnlinking.value = false;
}
};
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
loadLinkedIssue();
});
</script>
@@ -1,10 +0,0 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};
@@ -2,21 +2,12 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import Button from 'dashboard/components/buttons/Button';
import i18n from 'dashboard/i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import MoreActions from '../MoreActions';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
}));
import { emitter } from 'shared/helpers/mitt';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
@@ -25,12 +16,6 @@ localVue.use(VTooltip);
localVue.component('fluent-icon', FluentIcon);
localVue.component('woot-button', Button);
localVue.prototype.$emitter = {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('MoveActions', () => {
@@ -44,6 +29,12 @@ describe('MoveActions', () => {
let moreActions = null;
beforeEach(() => {
window.bus = {
$emit: jest.fn(),
$on: jest.fn(),
$off: jest.fn(),
};
state = {
authenticated: true,
currentChat,
@@ -85,7 +76,7 @@ describe('MoveActions', () => {
it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith(
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
'This contact is blocked successfully. You will not be notified of any future conversations.',
undefined
@@ -111,7 +102,7 @@ describe('MoveActions', () => {
it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith(
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
'This contact is unblocked successfully.',
undefined
@@ -15,7 +15,6 @@
<span v-if="error" class="message">
{{ error }}
</span>
<slot name="masked" />
</label>
</template>
@@ -1,16 +1,9 @@
<template>
<div class="phone-input--wrap relative">
<div
class="flex items-center dark:bg-slate-900 justify-start rounded-md border border-solid"
:class="
error
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
: 'mb-4 border-slate-200 dark:border-slate-600'
"
>
<div class="phone-input" :class="{ 'has-error': error }">
<div
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@click.prevent="toggleCountryDropdown"
@click="toggleCountryDropdown"
>
<h5 v-if="activeCountry" class="mb-0">
{{ activeCountry.emoji }}
@@ -20,15 +13,14 @@
</div>
<span
v-if="activeDialCode"
class="flex bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
class="flex bg-white dark:bg-slate-900 font-medium text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
>
{{ activeDialCode }}
</span>
<input
ref="phoneNumberInput"
:value="phoneNumber"
type="tel"
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
class="phone-input--field"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@@ -36,36 +28,24 @@
@blur="onBlur"
/>
</div>
<div
v-if="showDropdown"
ref="dropdown"
v-on-clickaway="onOutsideClick"
tabindex="0"
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
@keydown.prevent.up="moveUp"
@keydown.prevent.down="moveDown"
@keydown.prevent.enter="
onSelectCountry(filteredCountriesBySearch[selectedIndex])
"
>
<div class="top-0 sticky bg-white dark:bg-slate-900 p-1">
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-200 dark:!border-slate-600"
@input="onSearchCountry"
class="dropdown-search"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700"
class="country-dropdown--item"
:class="{
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
active: country.id === activeCountryCode,
focus: index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
@@ -94,8 +74,10 @@
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [keyboardEventListenerMixins],
props: {
value: {
type: [String, Number],
@@ -120,6 +102,15 @@ export default {
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
@@ -129,20 +120,6 @@ export default {
};
},
computed: {
countries() {
return [
{
name: this.dropdownFirstItemName,
dial_code: '',
emoji: '',
id: '',
},
...countries,
];
},
dropdownFirstItemName() {
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
},
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
@@ -177,8 +154,12 @@ export default {
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
@@ -196,14 +177,28 @@ export default {
onBlur(e) {
this.$emit('blur', e.target.value);
},
onSearchCountry() {
// Reset selected index to 0
this.selectedIndex = 0;
dropdownItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
if (!this.showDropdown) return -1;
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
moveUp() {
if (!this.showDropdown) return;
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
},
moveDown() {
if (!this.showDropdown) return;
@@ -211,27 +206,14 @@ export default {
this.selectedIndex + 1,
this.filteredCountriesBySearch.length - 1
);
this.scrollToSelected();
},
scrollToSelected() {
this.$nextTick(() => {
const dropdown = this.$refs.dropdown;
const selectedItem = this.$refs.dropdownItem[this.selectedIndex];
const dropdownSearchbarHeight = 40;
if (selectedItem) {
const selectedItemTop = selectedItem.offsetTop;
dropdown.scrollTop = selectedItemTop - dropdownSearchbarHeight;
}
});
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
},
onSelectCountry(country) {
if (!country || !this.showDropdown) return;
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
this.$refs.phoneNumberInput.focus();
},
setActiveCountry() {
const { phoneNumber } = this;
@@ -242,6 +224,33 @@ export default {
this.activeDialCode = number.countryCallingCode;
}
},
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
e.preventDefault();
this.moveUp();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.moveDown();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
e.preventDefault();
this.onSelectCountry(
this.filteredCountriesBySearch[this.selectedIndex]
);
},
allowOnFocusedInput: true,
},
};
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
@@ -258,3 +267,46 @@ export default {
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
.phone-input {
@apply flex items-center dark:bg-slate-900 justify-start mb-4 rounded-md border border-solid border-slate-200 dark:border-slate-600;
&.has-error {
@apply border border-solid border-red-400 dark:border-red-400;
}
}
.phone-input--field {
@apply mb-0 rounded-tl-none rounded-bl-none border-0 w-full dark:bg-slate-900 text-base px-1.5;
&::placeholder {
@apply font-normal;
}
}
.country-dropdown {
@apply z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900;
.dropdown-search--wrap {
@apply top-0 sticky bg-white dark:bg-slate-900 p-1;
.dropdown-search {
@apply h-8 mb-0 text-sm border border-solid border-slate-200 dark:border-slate-600;
}
}
.country-dropdown--item {
@apply flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700;
&.active {
@apply bg-slate-50 dark:bg-slate-700;
}
&.focus {
@apply bg-slate-25 dark:bg-slate-800;
}
}
}
}
</style>
@@ -87,6 +87,7 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { SHORTCUT_KEYS } from './constants';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
@@ -94,6 +95,7 @@ export default {
components: {
Hotkey,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,
@@ -1,101 +0,0 @@
import { replaceVariablesInMessage } from '@chatwoot/utils';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
/**
* Provides utility functions for creating special content nodes (mentions, canned responses, variables) in the editor state.
*
* @module useSpecialContent
* @returns {Object} - An object containing the `getContentNode` function.
*/
export default function useSpecialContent() {
/**
* Creates a mention node for the editor state
* @param {Object} editorView - The editor view instance
* @param {Object} content - The content object containing user id and name
* @param {number} from - The start position of the mention in the document
* @param {number} to - The end position of the mention in the document
* @returns {Object} - The created mention node and the updated from and to positions
*/
const getMentionNode = (editorView, content, from, to) => {
const node = editorView.state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
});
return { node, from, to };
};
/**
* Creates a canned response node for the editor state
* @param {Object} editorView - The editor view instance
* @param {string} content - The canned response content
* @param {number} from - The start position of the canned response in the document
* @param {number} to - The end position of the canned response in the document
* @param {Object} variables - The variables to replace in the canned response
* @returns {Object} - The created canned response node and the updated from and to positions
*/
const getCannedResponseNode = (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
from = node.textContent === updatedMessage ? from : from - 1;
return { node, from, to };
};
/**
* Creates a variable node for the editor state
* @param {Object} editorView - The editor view instance
* @param {string} content - The variable content
* @param {number} from - The start position of the variable in the document
* @param {number} to - The end position of the variable in the document
* @returns {Object} - The created variable node and the updated from and to positions
*/
const getVariableNode = (editorView, content, from, to) => {
const node = editorView.state.schema.text(`{{${content}}}`);
return { node, from, to };
};
/**
* Creates a content node based on the type (mention, canned response, or variable)
* @param {Object} editorView - The editor view instance
* @param {string} type - The type of content (mention, cannedResponse, or variable)
* @param {string|Object} content - The content to create the node from
* @param {Object} range - The range object containing the from and to positions
* @param {Object} variables - The variables to replace in the canned response (optional)
* @returns {Object} - The created content node and the updated from and to positions
*/
const getContentNode = (editorView, type, content, range, variables) => {
const methodMap = {
mention: getMentionNode,
cannedResponse: getCannedResponseNode,
variable: getVariableNode,
};
if (!methodMap[type]) {
return { node: null, from: range.from, to: range.to };
}
let { node, from, to } = methodMap[type](
editorView,
content,
range.from,
range.to,
variables
);
return { node, from, to };
};
return {
getContentNode,
};
}
@@ -1,13 +0,0 @@
import { getCurrentInstance } from 'vue';
import { emitter } from 'shared/helpers/mitt';
export const useTrack = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$track;
};
export function useAlert(message, action) {
emitter.emit('newToastMessage', message, action);
}

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