Compare commits

..
Author SHA1 Message Date
Shivam Mishra 2d49d499e4 test: remove case for 2024-03-25 20:40:34 +05:30
Shivam Mishra 29503af824 refactor: remove unnecessary function
* Devise library already does that for us
2024-03-25 20:39:05 +05:30
Shivam Kumar 75cc9356a4 Merge branch 'fix-Agent-Email' of https://github.com/shivamkb17/chatwoot into fix-Agent-Email 2024-03-19 23:09:13 +05:30
Shivam Kumar 0e47a14350 Fix agent double invite mail 2024-03-19 23:09:09 +05:30
Shivam Kumar 5c30cc2da3 Update agent_builder.rb 2024-03-19 23:06:58 +05:30
Sojan e93af2681e Merge branch 'release/3.7.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-03-18 17:27:13 +05:30
Sojan cb2b75ca17 Merge branch 'release/3.6.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-02-19 15:59:20 +05:30
Sojan 608592db0c Merge branch 'release/3.5.2'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-19 15:15:27 +04:00
Sojan ab1f803354 Merge branch 'release/3.5.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-17 13:48:22 +04:00
Sojan b81c722ac8 Merge branch 'release/3.5.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-16 11:15:11 +04:00
Sojan b40ad399f6 Merge branch 'release/3.4.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-12-18 21:22:55 -08:00
Sojan dcd18072b4 Merge branch 'release/3.3.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-18 10:24:25 -08:00
Sojan bda2d1d9a4 Merge branch 'release/3.3.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-17 20:37:25 -08:00
Sojan 83231b9678 Merge branch 'release/3.2.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-10-17 17:55:34 -07:00
229 changed files with 1973 additions and 6686 deletions
+5
View File
@@ -1,2 +1,7 @@
## All javascript files should be reviewed by pranav before merging
*.js @pranavrajs
*.vue @pranavrajs
## All enterprise related files should be reviewed by sojan before merging
/enterprise/* @sojan-official
+1 -1
View File
@@ -165,7 +165,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-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
## Gems for reponse bot
+12 -17
View File
@@ -237,8 +237,9 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday (2.7.4)
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
@@ -246,8 +247,7 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.1.0)
net-http
faraday-net_http (3.0.2)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
@@ -394,8 +394,7 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.8.1)
base64
jwt (2.7.0)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -471,8 +470,6 @@ GEM
multipart-post (2.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.9)
@@ -518,11 +515,11 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.2)
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
@@ -562,7 +559,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.3)
rack (2.2.9)
rack (2.2.8.1)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
@@ -571,8 +568,7 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
@@ -794,12 +790,11 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (4.0.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
version_gem (1.1.3)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -912,7 +907,7 @@ DEPENDENCIES
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-google-oauth2
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)
pg
+1 -4
View File
@@ -17,9 +17,6 @@ db_migrate:
db_seed:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
db_reset:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset
db:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
@@ -52,4 +49,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker
@@ -53,23 +53,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_or_build_for_multiple_conversations
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@@ -69,28 +69,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
@conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
) || build_conversation
end
def message_content
@@ -19,7 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
account: Current.account
)
@agent = builder.perform
builder.perform
end
def update
@@ -2,9 +2,13 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
before_action :authorize_request
def create
process_create
rescue StandardError => e
render_could_not_create_error(e.message)
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
rescue StandardError => e
render_could_not_create_error(e.message)
end
end
private
@@ -13,14 +17,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
authorize ::Inbox
end
def process_create
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
end
end
def authenticate_twilio
client = if permitted_params[:api_key_sid].present?
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
@@ -65,10 +65,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
def contactable_inboxes
@@ -44,10 +44,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
def mute
@@ -25,4 +25,3 @@ class ApplicationController < ActionController::Base
}
end
end
ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern')
@@ -1,5 +0,0 @@
module DomainHelper
def self.chatwoot_domain?(domain = request.host)
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
end
end
-15
View File
@@ -6,7 +6,6 @@ module SwitchLocale
def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= locale_from_params
locale ||= locale_from_custom_domain
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= locale_from_env_variable
set_locale(locale, &)
@@ -17,20 +16,6 @@ module SwitchLocale
set_locale(locale, &)
end
# If the request is coming from a custom domain, it should be for a helpcenter portal
# We will use the portal locale in such cases
def locale_from_custom_domain(&)
return if params[:locale]
domain = request.host
return if DomainHelper.chatwoot_domain?(domain)
@portal = Portal.find_by(custom_domain: domain)
return unless @portal
@portal.default_locale
end
def set_locale(locale, &)
# if locale is empty, use default_locale
locale ||= I18n.default_locale
-1
View File
@@ -18,7 +18,6 @@ class DashboardController < ActionController::Base
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'TERMS_URL',
'BRAND_URL', 'BRAND_NAME',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
@@ -4,10 +4,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
wrap_parameters format: []
before_action :process_sso_auth_token, only: [:create]
def new
redirect_to login_page_url(error: 'access-denied')
end
def create
# Authenticate user via the temporary sso auth token
if params[:sso_auth_token].present? && @resource.present?
@@ -25,12 +21,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
"#{frontend_url}/app/login?error=#{error}"
end
def authenticate_resource_with_sso_token
@token = @resource.create_token
@resource.save!
@@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
@locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
'en'
end
I18n.with_locale(@locale, &)
+2 -1
View File
@@ -8,7 +8,8 @@ class PublicController < ActionController::Base
def ensure_custom_domain_request
domain = request.host
return if DomainHelper.chatwoot_domain?(domain)
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
@portal = ::Portal.find_by(custom_domain: domain)
return if @portal.present?
+2 -7
View File
@@ -163,14 +163,10 @@ class ConversationFinder
params[:page] || 1
end
def conversations_base_query
@conversations.includes(
def conversations
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
end
def conversations
@conversations = conversations_base_query
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations = @conversations.send(sort_by, sort_order)
@@ -182,4 +178,3 @@ class ConversationFinder
end
end
end
ConversationFinder.prepend_mod_with('ConversationFinder')
-7
View File
@@ -2,11 +2,4 @@ module ApplicationHelper
def available_locales_with_name
LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) }
end
def feature_help_urls
features = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
features.each_with_object({}) do |feature, hash|
hash[feature['name']] = feature['help_url'] if feature['help_url']
end
end
end
-84
View File
@@ -1,84 +0,0 @@
module FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
# Throw InvalidOperator Error if the attribute is a standard attribute
# and the operator is not allowed in the config
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
raise CustomExceptions::CustomFilter::InvalidOperator.new(
attribute_name: query_hash['attribute_key'],
allowed_keys: current_filter['filter_operators']
)
end
# Every other filter expects a value to be present
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
end
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
# The query becomes empty only when it doesn't match to any supported
# standard attribute or custom attribute defined in the account.
if condition_query.empty?
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
allowed_keys: model_filters.keys)
end
condition_query
end
def build_condition_query_string(current_filter, query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
else
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
end
end
def handle_nil_filter(query_hash, current_index)
attribute_type = "#{filter_config[:entity].downcase}_attribute"
custom_attribute_query(query_hash, attribute_type, current_index)
end
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
end
end
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
case current_filter['data_type']
when 'date'
date_filter(current_filter, query_hash, filter_operator_value)
when 'labels'
tag_filter_query(query_hash, current_index)
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
default_filter(query_hash, filter_operator_value)
end
end
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
end
@@ -1,72 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class SLAReportsAPI extends ApiClient {
constructor() {
super('applied_slas', { accountScoped: true });
}
get({
from,
to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
page,
} = {}) {
return axios.get(this.url, {
params: {
since: from,
until: to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
page,
},
});
}
download({
from,
to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
} = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
},
});
}
getMetrics({
from,
to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
} = {}) {
return axios.get(`${this.url}/metrics`, {
params: {
since: from,
until: to,
assigned_agent_id,
inbox_id,
team_id,
sla_policy_id,
},
});
}
}
export default new SLAReportsAPI();
@@ -1,98 +0,0 @@
import SLAReportsAPI from '../slaReports';
import ApiClient from '../ApiClient';
describe('#SLAReports API', () => {
it('creates correct instance', () => {
expect(SLAReportsAPI).toBeInstanceOf(ApiClient);
expect(SLAReportsAPI.apiVersion).toBe('/api/v1');
expect(SLAReportsAPI).toHaveProperty('get');
expect(SLAReportsAPI).toHaveProperty('getMetrics');
});
describe('API calls', () => {
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('#get', () => {
SLAReportsAPI.get({
page: 1,
from: 1622485800,
to: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', {
params: {
page: 1,
since: 1622485800,
until: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
},
});
});
it('#getMetrics', () => {
SLAReportsAPI.getMetrics({
from: 1622485800,
to: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/applied_slas/metrics',
{
params: {
since: 1622485800,
until: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
},
}
);
});
it('#download', () => {
SLAReportsAPI.download({
from: 1622485800,
to: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/applied_slas/download',
{
params: {
since: 1622485800,
until: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
},
}
);
});
});
});
@@ -1,17 +1,17 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'shared/assets/fonts/inter';
// Inter,
// @import 'shared/assets/fonts/inter';
html,
body {
font-family:
'PlusJakarta',
Inter,
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif !important;
-moz-osx-font-smoothing: grayscale;
@@ -63,12 +63,12 @@ input:focus {
}
// Inputs
input[type='text']:not(.reset-base),
input[type='number']:not(.reset-base),
input[type='password']:not(.reset-base),
input[type='date']:not(.reset-base),
input[type='email']:not(.reset-base),
input[type='url']:not(.reset-base) {
input[type='text'],
input[type='number'],
input[type='password'],
input[type='date'],
input[type='email'],
input[type='url'] {
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
&[disabled] {
@@ -118,7 +118,7 @@ button {
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
&.secondary {
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
@apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
}
&.success {
@@ -1,14 +1,14 @@
<template>
<div
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
]"
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{
hide: !showConversationList,
'list--full-width': isOnExpandedLayout,
}"
>
<slot />
<div
class="flex items-center justify-between px-4 py-0"
class="flex items-center justify-between py-0 px-4"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
@@ -16,7 +16,7 @@
>
<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"
class="text-xl break-words overflow-hidden whitespace-nowrap font-medium text-ellipsis text-black-900 dark:text-slate-100 mb-0"
:title="pageTitle"
>
{{ pageTitle }}
@@ -107,7 +107,7 @@
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
class="overflow-auto p-4 flex justify-center items-center"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
@@ -127,7 +127,7 @@
/>
<div
ref="conversationList"
class="flex-1 conversations-list"
class="conversations-list flex-1"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<virtual-list
@@ -136,16 +136,16 @@
:data-sources="conversationList"
:data-component="itemComponent"
:extra-props="virtualListExtraProps"
class="w-full h-full overflow-auto"
class="w-full overflow-auto h-full"
footer-tag="div"
>
<template #footer>
<div v-if="chatListLoading" class="text-center">
<span class="mt-4 mb-4 spinner" />
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfListMessage"
class="p-4 text-center text-slate-400 dark:text-slate-300"
class="text-center text-slate-400 dark:text-slate-300 p-4"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
@@ -1034,10 +1034,24 @@ export default {
</style>
<style scoped lang="scss">
.conversations-list-wrap {
&.hide {
@apply hidden;
}
&.list--full-width {
@apply basis-full;
}
}
.conversations-list {
@apply overflow-hidden hover:overflow-y-auto;
}
.load-more--button {
@apply text-center rounded-none;
}
.tab--chat-type {
@apply py-0 px-4;
@@ -2,27 +2,23 @@
<div class="py-3 px-4">
<div class="flex items-center mb-1">
<h4 class="text-sm flex items-center m-0 w-full error">
<div v-if="isAttributeTypeCheckbox" class="flex items-center">
<div v-if="isAttributeTypeCheckbox" class="checkbox-wrap">
<input
v-model="editedValue"
class="!my-0 mr-2 ml-0"
class="checkbox"
type="checkbox"
@change="onUpdate"
/>
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full font-medium text-sm mb-0"
:class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
: 'text-slate-800 dark:text-slate-100'
"
class="attribute-name w-full text-slate-800 dark:text-slate-100 font-medium text-sm mb-0"
:class="{ error: $v.editedValue.$error }"
>
{{ label }}
</span>
<woot-button
v-if="showCopyAndDeleteButton"
v-if="showActions"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
@@ -35,7 +31,7 @@
</h4>
</div>
<div v-if="notAttributeTypeCheckboxAndList">
<div v-if="isEditing" v-on-clickaway="onClickAway">
<div v-show="isEditing">
<div class="mb-2 w-full flex items-center">
<input
ref="inputfield"
@@ -65,7 +61,7 @@
</div>
<div
v-show="!isEditing"
class="flex group"
class="value--view"
:class="{ 'is-editable': showActions }"
>
<a
@@ -73,35 +69,35 @@
:href="hrefURL"
target="_blank"
rel="noopener noreferrer"
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ urlValue }}
</a>
<p
v-else
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ displayValue || '---' }}
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showCopyAndDeleteButton"
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
color-scheme="secondary"
icon="clipboard"
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
class-names="edit-button"
@click="onCopy"
/>
<woot-button
v-if="showEditButton"
v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
color-scheme="secondary"
icon="edit"
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
class-names="edit-button"
@click="onEdit"
/>
</div>
@@ -130,7 +126,6 @@
</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';
@@ -143,7 +138,7 @@ export default {
components: {
MultiselectDropdown,
},
mixins: [customAttributeMixin, clickaway],
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
values: { type: Array, default: () => [] },
@@ -165,18 +160,11 @@ export default {
editedValue: null,
};
},
computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
? new Date(this.value || new Date()).toLocaleDateString()
: '';
return new Date(this.value || new Date()).toLocaleDateString();
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
@@ -242,10 +230,6 @@ export default {
this.isEditing = false;
this.editedValue = this.formattedValue;
},
contactId() {
// Fix to solve validation not resetting when contactId changes in contact page
this.$v.$reset();
},
},
validations() {
@@ -284,10 +268,6 @@ export default {
this.$refs.inputfield.focus();
}
},
onClickAway() {
this.$v.$reset();
this.isEditing = false;
},
onEdit() {
this.isEditing = true;
this.$nextTick(() => {
@@ -314,7 +294,6 @@ export default {
},
onDelete() {
this.isEditing = false;
this.$v.$reset();
this.$emit('delete', this.attributeKey);
},
onCopy() {
@@ -325,6 +304,35 @@ export default {
</script>
<style lang="scss" scoped>
.checkbox-wrap {
@apply flex items-center;
}
.checkbox {
@apply my-0 mr-2 ml-0;
}
.attribute-name {
&.error {
@apply text-red-400 dark:text-red-500;
}
}
.edit-button {
@apply hidden;
}
.value--view {
@apply flex;
&.is-editable:hover {
.value {
@apply bg-slate-50 dark:bg-slate-700 mb-0;
}
.edit-button {
@apply block;
}
}
}
::v-deep {
.selector-wrap {
@apply m-0 top-1;
+11 -8
View File
@@ -7,13 +7,7 @@
@mousedown="handleMouseDown"
>
<div
:class="{
'modal-container rtl:text-right shadow-md max-h-full overflow-auto relative bg-white dark:bg-slate-800 skip-context-menu': true,
'rounded-xl w-[37.5rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
}"
:class="modalContainerClassName"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
@@ -22,7 +16,7 @@
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
class="absolute ltr:right-2 rtl:left-2 top-2 z-10"
@click="close"
/>
<slot />
@@ -66,6 +60,15 @@ export default {
};
},
computed: {
modalContainerClassName() {
let className =
'modal-container rtl:text-right shadow-md rounded-sm max-h-full overflow-auto relative w-[37.5rem] bg-white dark:bg-slate-800 skip-context-menu';
if (this.fullWidth) {
return `${className} items-center rounded-none flex h-full justify-center w-full`;
}
return `${className} ${this.size}`;
},
modalClassName() {
const modalClassNameMap = {
centered: '',
@@ -1,21 +1,21 @@
<template>
<div class="flex flex-col items-start px-8 pt-8 pb-0">
<div class="flex flex-col items-start pt-8 px-8 pb-0">
<img v-if="headerImage" :src="headerImage" alt="No image" />
<h2
ref="modalHeaderTitle"
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
class="text-slate-800 text-lg font-semibold dark:text-slate-50"
>
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
ref="modalHeaderContent"
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
class="w-full break-words text-slate-600 mt-2 text-sm dark:text-slate-300"
>
{{ headerContent }}
<span
v-if="headerContentValue"
class="text-sm font-semibold text-slate-600 dark:text-slate-300"
class="font-semibold text-sm text-slate-600 dark:text-slate-300"
>
{{ headerContentValue }}
</span>
@@ -12,7 +12,6 @@ const reports = accountId => ({
'label_reports',
'inbox_reports',
'team_reports',
'sla_reports',
],
menuItems: [
{
@@ -72,14 +71,6 @@ const reports = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports',
},
{
icon: 'document-list-clock',
label: 'REPORTS_SLA',
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.SLA,
toState: frontendURL(`accounts/${accountId}/reports/sla`),
toStateName: 'sla_reports',
},
],
});
@@ -1,6 +1,6 @@
<template>
<div
class="inline-flex ltr:mr-1 rtl:ml-1 mb-1"
class="ltr:mr-1 rtl:ml-1 mb-1"
:class="labelClass"
:style="labelStyle"
:title="description"
@@ -111,7 +111,7 @@ export default {
<style scoped lang="scss">
.label {
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
@apply inline-flex items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
&.small {
@apply text-xs py-0.5 px-1 leading-tight h-5;
@@ -1,32 +1,42 @@
<template>
<div
class="flex flex-col min-w-[15rem] max-h-[21.25rem] w-[23rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
:class="{
'border border-solid border-woot-300 dark:border-woot-400': active,
'bg-woot-25 dark:bg-slate-700 border border-solid border-woot-300 dark:border-woot-400':
active,
}"
>
<div
class="flex justify-between items-center px-2 w-full h-10 rounded-t-[5px]"
class="flex justify-between items-center px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 rounded-t-[5px] border-b border-solid border-slate-50 dark:border-slate-600"
:class="{
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700':
active,
}"
>
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
<!-- <fluent-icon
<div class="items-center flex font-medium p-1 text-sm">{{ heading }}</div>
<fluent-icon
v-if="active"
icon="checkmark-circle"
type="solid"
size="24"
class="text-woot-500 dark:text-woot-500"
/> -->
<div>
<input type="radio" name="hotkey" value="enter" :checked="active" />
</div>
/>
</div>
<div
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pb-0 text-start"
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pt-3 pb-0 text-start"
>
{{ content }}
</div>
<div v-if="src" class="p-3">
<img :src="src" class="object-cover w-full" />
<img
:src="src"
class="border border-solid rounded-md"
:class="
active
? 'border-woot-75 dark:border-woot-700'
: 'border-slate-50 dark:border-slate-600'
"
/>
</div>
<slot v-else />
</div>
@@ -1,48 +1,171 @@
<template>
<footer
v-if="isFooterVisible"
class="h-12 flex items-center justify-between px-6"
class="bg-white dark:bg-slate-800 h-12 border-t border-solid border-slate-75 dark:border-slate-700/50 flex items-center justify-between px-6"
>
<table-footer-results
:first-index="firstIndex"
:last-index="lastIndex"
:total-count="totalCount"
/>
<table-footer-pagination
v-if="totalCount"
:current-page="currentPage"
:total-pages="totalPages"
:total-count="totalCount"
:page-size="pageSize"
@page-change="$emit('page-change', $event)"
/>
<div class="left-aligned-wrap">
<div class="text-xs text-slate-600 dark:text-slate-200">
<strong>{{ firstIndex }}</strong>
- <strong>{{ lastIndex }}</strong> of
<strong>{{ totalCount }}</strong> items
</div>
</div>
<div class="right-aligned-wrap">
<div
v-if="totalCount"
class="primary button-group pagination-button-group"
>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
class-names="goto-first"
:is-disabled="hasFirstPage"
@click="onFirstPage"
>
<fluent-icon icon="chevron-left" size="18" />
<fluent-icon
icon="chevron-left"
size="18"
:class="pageFooterIconClass"
/>
</woot-button>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasPrevPage"
@click="onPrevPage"
>
<fluent-icon icon="chevron-left" size="18" />
</woot-button>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
@click.prevent
>
{{ currentPage }}
</woot-button>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasNextPage"
@click="onNextPage"
>
<fluent-icon icon="chevron-right" size="18" />
</woot-button>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
class-names="goto-last"
:is-disabled="hasLastPage"
@click="onLastPage"
>
<fluent-icon icon="chevron-right" size="18" />
<fluent-icon
icon="chevron-right"
size="18"
:class="pageFooterIconClass"
/>
</woot-button>
</div>
</div>
</footer>
</template>
<script setup>
import { computed } from 'vue';
import TableFooterResults from './TableFooterResults.vue';
import TableFooterPagination from './TableFooterPagination.vue';
const props = defineProps({
currentPage: {
type: Number,
default: 1,
<script>
import rtlMixin from 'shared/mixins/rtlMixin';
export default {
components: {},
mixins: [rtlMixin],
props: {
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 25,
},
totalCount: {
type: Number,
default: 0,
},
},
pageSize: {
type: Number,
default: 25,
computed: {
pageFooterIconClass() {
return this.isRTLView ? '-mr-3' : '-ml-3';
},
isFooterVisible() {
return this.totalCount && !(this.firstIndex > this.totalCount);
},
firstIndex() {
return this.pageSize * (this.currentPage - 1) + 1;
},
lastIndex() {
return Math.min(this.totalCount, this.pageSize * this.currentPage);
},
searchButtonClass() {
return this.searchQuery !== '' ? 'show' : '';
},
hasLastPage() {
return !!Math.ceil(this.totalCount / this.pageSize);
},
hasFirstPage() {
return this.currentPage === 1;
},
hasNextPage() {
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
},
hasPrevPage() {
return this.currentPage === 1;
},
},
totalCount: {
type: Number,
default: 0,
methods: {
onNextPage() {
if (this.hasNextPage) {
return;
}
const newPage = this.currentPage + 1;
this.onPageChange(newPage);
},
onPrevPage() {
if (this.hasPrevPage) {
return;
}
const newPage = this.currentPage - 1;
this.onPageChange(newPage);
},
onFirstPage() {
if (this.hasFirstPage) {
return;
}
const newPage = 1;
this.onPageChange(newPage);
},
onLastPage() {
if (this.hasLastPage) {
return;
}
const newPage = Math.ceil(this.totalCount / this.pageSize);
this.onPageChange(newPage);
},
onPageChange(page) {
this.$emit('page-change', page);
},
},
});
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
const firstIndex = computed(() => props.pageSize * (props.currentPage - 1) + 1);
const lastIndex = computed(() =>
Math.min(props.totalCount, props.pageSize * props.currentPage)
);
const isFooterVisible = computed(
() => props.totalCount && !(firstIndex.value > props.totalCount)
);
};
</script>
<style lang="scss" scoped>
.goto-first,
.goto-last {
i:last-child {
@apply -ml-1;
}
}
</style>
@@ -1,143 +0,0 @@
<template>
<div class="flex items-center bg-slate-50 dark:bg-slate-800 h-8 rounded-lg">
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasFirstPage"
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
:class="buttonClass(hasFirstPage)"
@click="onFirstPage"
>
<fluent-icon
icon="chevrons-left"
size="20"
icon-lib="lucide"
:class="hasFirstPage && 'opacity-40'"
/>
</woot-button>
<div class="bg-slate-75 dark:bg-slate-700/50 w-px rounded-sm h-4" />
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasPrevPage"
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
:class="buttonClass(hasPrevPage)"
@click="onPrevPage"
>
<fluent-icon
icon="chevron-left-single"
size="20"
icon-lib="lucide"
:class="hasPrevPage && 'opacity-40'"
/>
</woot-button>
<div
class="flex px-3 items-center gap-3 tabular-nums bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
>
<span class="text-sm text-slate-800 dark:text-slate-75">
{{ currentPage }}
</span>
<span class="text-slate-600 dark:text-slate-500">/</span>
<span class="text-sm text-slate-600 dark:text-slate-500">
{{ totalPages }}
</span>
</div>
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
:is-disabled="hasNextPage"
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
:class="buttonClass(hasNextPage)"
@click="onNextPage"
>
<fluent-icon
icon="chevron-right-single"
size="20"
icon-lib="lucide"
:class="hasNextPage && 'opacity-40'"
/>
</woot-button>
<div class="bg-slate-75 dark:bg-slate-700/50 w-px rounded-sm h-4" />
<woot-button
size="small"
variant="smooth"
color-scheme="secondary"
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
:class="buttonClass(hasLastPage)"
:is-disabled="hasLastPage"
@click="onLastPage"
>
<fluent-icon
icon="chevrons-right"
size="20"
icon-lib="lucide"
:class="hasLastPage && 'opacity-40'"
/>
</woot-button>
</div>
</template>
<script setup>
import { computed, defineEmits } from 'vue';
// Props
const props = defineProps({
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 0,
},
totalPages: {
type: Number,
default: 0,
},
});
const hasLastPage = computed(
() => props.currentPage === props.totalPages || props.totalPages === 1
);
const hasFirstPage = computed(() => props.currentPage === 1);
const hasNextPage = computed(() => props.currentPage === props.totalPages);
const hasPrevPage = computed(() => props.currentPage === 1);
const emit = defineEmits(['page-change']);
function buttonClass(hasPage) {
if (hasPage) {
return 'hover:!bg-slate-50 dark:hover:!bg-slate-800';
}
return 'dark:hover:!bg-slate-700/50';
}
function onPageChange(newPage) {
emit('page-change', newPage);
}
const onNextPage = () => {
if (!onNextPage.value) {
onPageChange(props.currentPage + 1);
}
};
const onPrevPage = () => {
if (!hasPrevPage.value) {
onPageChange(props.currentPage - 1);
}
};
const onFirstPage = () => {
if (!hasFirstPage.value) {
onPageChange(1);
}
};
const onLastPage = () => {
if (!hasLastPage.value) {
onPageChange(props.totalPages);
}
};
</script>
@@ -1,28 +0,0 @@
<template>
<span class="text-sm text-slate-700 dark:text-slate-200 font-medium">
{{
$t('GENERAL.SHOWING_RESULTS', {
firstIndex,
lastIndex,
totalCount,
})
}}
</span>
</template>
<script setup>
defineProps({
firstIndex: {
type: Number,
default: 0,
},
lastIndex: {
type: Number,
default: 0,
},
totalCount: {
type: Number,
default: 0,
},
});
</script>
@@ -1,39 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
span: {
type: Number,
required: true,
},
label: {
type: String,
required: true,
default: '',
},
});
const spanClass = computed(() => {
if (props.span === 1) return 'col-span-1';
if (props.span === 2) return 'col-span-2';
if (props.span === 3) return 'col-span-3';
if (props.span === 4) return 'col-span-4';
if (props.span === 5) return 'col-span-5';
if (props.span === 6) return 'col-span-6';
if (props.span === 7) return 'col-span-7';
if (props.span === 8) return 'col-span-8';
if (props.span === 9) return 'col-span-9';
if (props.span === 10) return 'col-span-10';
return 'col-span-1';
});
</script>
<template>
<div
class="flex items-center px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
:class="spanClass"
>
<slot>
{{ label }}
</slot>
</div>
</template>
@@ -86,11 +86,7 @@
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<card-labels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<template v-if="hasSlaPolicyId" #before>
<SLA-card-label :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</card-labels>
<card-labels :conversation-id="chat.id" />
</div>
<woot-context-menu
v-if="showContextMenu"
@@ -129,7 +125,6 @@ import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
export default {
components: {
@@ -140,7 +135,6 @@ export default {
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
},
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
@@ -258,9 +252,6 @@ export default {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
onCardClick(e) {
@@ -1,12 +1,12 @@
<template>
<div
class="flex flex-col items-center justify-between px-4 py-2 bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 md:flex-row"
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
>
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
<back-button
v-if="showBackButton"
:back-url="backButtonUrl"
@@ -19,10 +19,10 @@
:status="currentContact.availability_status"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
>
<woot-button
variant="link"
@@ -31,7 +31,7 @@
@click.prevent="$emit('contact-panel-toggle')"
>
<span
class="text-base font-medium leading-tight text-slate-900 dark:text-slate-100"
class="text-base leading-tight font-medium text-slate-900 dark:text-slate-100"
>
{{ currentContact.name }}
</span>
@@ -46,7 +46,7 @@
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap"
>
<inbox-name v-if="hasMultipleInboxes" :inbox="inbox" />
<span
@@ -67,10 +67,9 @@
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -86,7 +85,6 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
@@ -97,7 +95,6 @@ export default {
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
},
mixins: [inboxMixin, agentMixin, eventListenerMixins],
props: {
@@ -176,9 +173,6 @@ export default {
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
@@ -1,153 +0,0 @@
<template>
<div
v-if="hasSlaThreshold"
class="relative flex items-center border cursor-pointer min-w-fit border-slate-100 dark:border-slate-700"
:class="showExtendedInfo ? 'h-[26px] rounded-lg' : 'rounded h-5'"
>
<div
v-on-clickaway="closeSlaPopover"
class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
>
<div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-100 dark:border-slate-700'
"
>
<fluent-icon
size="14"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
class="flex-shrink-0"
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
class="text-xs font-medium"
:class="slaTextStyles"
>
{{ slaStatusText }}
</span>
</div>
<span
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
</span>
</div>
<SLA-popover-card
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="right-0 top-7"
/>
</div>
</template>
<script>
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
import { mixin as clickaway } from 'vue-clickaway';
const REFRESH_INTERVAL = 60000;
export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed
? 'text-red-400 dark:text-red-300'
: 'text-yellow-600 dark:text-yellow-500';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
},
showSlaPopoverCard() {
return (
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
},
},
watch: {
chat() {
this.updateSlaStatus();
},
},
mounted() {
this.updateSlaStatus();
this.createTimer();
},
beforeDestroy() {
if (this.timer) {
clearTimeout(this.timer);
}
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.updateSlaStatus();
this.createTimer();
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
};
</script>
@@ -1,35 +0,0 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const formatDate = timestamp =>
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
</script>
<template>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
{{ label }}
</span>
<div class="flex flex-col w-full gap-2">
<span
v-for="item in items"
:key="item.id"
class="text-sm font-normal text-slate-900 dark:text-slate-25 text-right tabular-nums"
>
{{ formatDate(item.created_at) }}
</span>
<slot name="showMore" />
</div>
</div>
</template>
@@ -1,85 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue';
const { SLA_MISS_TYPES } = wootConstants;
const props = defineProps({
slaMissedEvents: {
type: Array,
required: true,
},
});
const shouldShowAllNrts = ref(false);
const frtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
)
);
const nrtMisses = computed(() => {
const missedEvents = props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
);
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
});
const rtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
)
);
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
const toggleShowAllNRT = () => {
shouldShowAllNrts.value = !shouldShowAllNrts.value;
};
</script>
<template>
<div
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') }}
</span>
<SLA-event-item
v-if="frtMisses.length"
:label="$t('SLA.EVENTS.FRT')"
:items="frtMisses"
/>
<SLA-event-item
v-if="nrtMisses.length"
:label="$t('SLA.EVENTS.NRT')"
:items="nrtMisses"
>
<template #showMore>
<div
v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full"
>
<woot-button
size="small"
:icon="!shouldShowAllNrts ? 'plus-sign' : ''"
variant="link"
color-scheme="secondary"
class="hover:!no-underline !gap-1 hover:!bg-transparent dark:hover:!bg-transparent"
@click="toggleShowAllNRT"
>
{{
shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
}}
</woot-button>
</div>
</template>
</SLA-event-item>
<SLA-event-item
v-if="rtMisses.length"
:label="$t('SLA.EVENTS.RT')"
:items="rtMisses"
/>
</div>
</template>
@@ -1,14 +1,13 @@
<template>
<div
v-if="activeLabels.length || $slots.before"
v-show="activeLabels.length"
ref="labelContainer"
v-resize="computeVisibleLabelPosition"
class="label-container mt-0.5 mx-2 mb-0"
>
<div
class="flex items-end flex-shrink min-w-0 gap-y-1"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
class="labels-wrap flex items-end min-w-0 flex-shrink gap-y-1 flex-wrap"
:class="{ expand: showAllLabels }"
>
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label.id"
@@ -27,7 +26,7 @@
? $t('CONVERSATION.CARD.HIDE_LABELS')
: $t('CONVERSATION.CARD.SHOW_LABELS')
"
class="sticky right-0 flex-shrink-0 mr-6 show-more--button rtl:rotate-180"
class="show-more--button sticky flex-shrink-0 right-0 mr-6 rtl:rotate-180"
color-scheme="secondary"
variant="hollow"
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
@@ -46,11 +45,6 @@ export default {
type: Number,
required: true,
},
conversationLabels: {
type: String,
required: false,
default: '',
},
},
data() {
return {
@@ -65,34 +59,26 @@ export default {
},
},
mounted() {
// the problem here is that there is a certain amount of delay between the conversation
// card being mounted and the resize event eventually being triggered
// This means we need to run the function immediately after the component is mounted
// Happens especially when used in a virtual list.
// We can make the first trigger, a standard part of the directive, in case
// we face this issue again
this.computeVisibleLabelPosition();
},
methods: {
onShowLabels(e) {
e.stopPropagation();
this.showAllLabels = !this.showAllLabels;
this.$nextTick(() => this.computeVisibleLabelPosition());
},
computeVisibleLabelPosition() {
const beforeSlot = this.$slots.before ? 100 : 0;
const labelContainer = this.$refs.labelContainer;
if (!labelContainer) return;
const labels = Array.from(labelContainer.querySelectorAll('.label'));
const labels = this.$refs.labelContainer.querySelectorAll('.label');
let labelOffset = 0;
this.showExpandLabelButton = false;
labels.forEach((label, index) => {
Array.from(labels).forEach((label, index) => {
labelOffset += label.offsetWidth + 8;
if (labelOffset < labelContainer.clientWidth - 16 - beforeSlot) {
if (labelOffset < labelContainer.clientWidth - 16) {
this.labelPosition = index;
} else {
this.showExpandLabelButton = labels.length > 1;
this.showExpandLabelButton = true;
}
});
},
@@ -109,6 +95,10 @@ export default {
}
.labels-wrap {
&.expand {
@apply h-auto overflow-visible flex-row flex-wrap;
}
.secondary {
@apply border border-solid border-slate-100 dark:border-slate-700;
}
@@ -1,117 +0,0 @@
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 };
};
@@ -1,150 +0,0 @@
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,
});
});
});
});
@@ -59,10 +59,5 @@ export default {
TYPE: 'type',
SORT_ORDER: 'sort_order',
},
SLA_MISS_TYPES: {
FRT: 'frt',
NRT: 'nrt',
RT: 'rt',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
-10
View File
@@ -20,14 +20,4 @@ export const FEATURE_FLAGS = {
INBOX_VIEW: 'inbox_view',
SLA: 'sla',
RESPONSE_BOT: 'response_bot',
CHANNEL_EMAIL: 'channel_email',
CHANNEL_FACEBOOK: 'channel_facebook',
CHANNEL_TWITTER: 'channel_twitter',
CHANNEL_WEBSITE: 'channel_website',
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
DISABLE_BRANDING: 'disable_branding',
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
INBOUND_EMAILS: 'inbound_emails',
IP_LOOKUP: 'ip_lookup',
};
@@ -1,41 +0,0 @@
import { debounce } from '@chatwoot/utils';
const RESIZE_OBSERVER_DEBOUNCE_TIME = 100;
function createResizeObserver(el, binding) {
const { value } = binding;
const observer = new ResizeObserver(
debounce(entries => {
const entry = entries[0];
if (entry && value && typeof value === 'function') {
value(entry);
}
}, RESIZE_OBSERVER_DEBOUNCE_TIME)
);
el.cwResizeObserver = observer;
observer.observe(el);
}
function destroyResizeObserver(el) {
if (el.cwResizeObserver) {
el.cwResizeObserver.unobserve(el);
el.cwResizeObserver.disconnect();
delete el.cwResizeObserver;
}
}
export default {
bind(el, binding) {
createResizeObserver(el, binding);
},
update(el, binding) {
if (binding.oldValue !== binding.value) {
destroyResizeObserver(el);
createResizeObserver(el, binding);
}
},
unbind(el) {
destroyResizeObserver(el);
},
};
@@ -1,4 +0,0 @@
export function getHelpUrlForFeature(featureName) {
const { helpUrls } = window.chatwootConfig;
return helpUrls[featureName];
}
@@ -1,78 +0,0 @@
import resize from '../../directives/resize';
class ResizeObserverMock {
// eslint-disable-next-line class-methods-use-this
observe() {}
// eslint-disable-next-line class-methods-use-this
unobserve() {}
// eslint-disable-next-line class-methods-use-this
disconnect() {}
}
describe('resize directive', () => {
let el;
let binding;
let observer;
beforeEach(() => {
el = document.createElement('div');
binding = {
value: jest.fn(),
};
observer = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
window.ResizeObserver = ResizeObserverMock;
jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create ResizeObserver on bind', () => {
resize.bind(el, binding);
expect(ResizeObserver).toHaveBeenCalled();
expect(observer.observe).toHaveBeenCalledWith(el);
});
it('should call callback on observer callback', () => {
el = document.createElement('div');
binding = {
value: jest.fn(),
};
resize.bind(el, binding);
const entries = [{ contentRect: { width: 100, height: 100 } }];
const callback = binding.value;
callback(entries[0]);
expect(binding.value).toHaveBeenCalledWith(entries[0]);
});
it('should destroy and recreate observer on update', () => {
resize.bind(el, binding);
resize.update(el, { ...binding, oldValue: 'old' });
expect(observer.unobserve).toHaveBeenCalledWith(el);
expect(observer.disconnect).toHaveBeenCalled();
expect(ResizeObserver).toHaveBeenCalledTimes(2);
expect(observer.observe).toHaveBeenCalledTimes(2);
});
it('should destroy observer on unbind', () => {
resize.bind(el, binding);
resize.unbind(el);
expect(observer.unobserve).toHaveBeenCalledWith(el);
expect(observer.disconnect).toHaveBeenCalled();
});
});
@@ -296,8 +296,6 @@
"BUTTON": "Add custom attribute",
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
"SHOW_MORE": "Show all attributes",
"SHOW_LESS": "Show less attributes",
"ACTIONS": {
"COPY": "Copy attribute",
"DELETE": "Delete attribute",
@@ -44,8 +44,7 @@
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link",
"BLOCKED": "Blocked"
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
@@ -64,14 +64,7 @@
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
"RT": "RT {status}",
"MISSED": "missed",
"DUE": "due"
}
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending",
@@ -1,5 +0,0 @@
{
"GENERAL": {
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
}
}
@@ -31,7 +31,6 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import sla from './sla.json';
import inbox from './inbox.json';
import general from './general.json';
export default {
...advancedFilters,
@@ -67,5 +66,4 @@ export default {
...teamsSettings,
...whatsappTemplates,
...inbox,
...general,
};
@@ -505,34 +505,5 @@
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
},
"SLA_REPORTS": {
"HEADER": "SLA Reports",
"NO_RECORDS": "SLA applied conversations are not available.",
"LOADING": "Loading SLA data...",
"DOWNLOAD_SLA_REPORTS": "Download SLA reports",
"DOWNLOAD_FAILED": "Failed to download SLA Reports",
"METRICS": {
"HIT_RATE": {
"LABEL": "Hit Rate",
"TOOLTIP": "Percentage of SLAs created were completed successfully"
},
"NO_OF_MISSES": {
"LABEL": "Number of Misses",
"TOOLTIP": "Total SLA misses in a certain period"
},
"NO_OF_CONVERSATIONS": {
"LABEL": "Number of Conversations",
"TOOLTIP": "Total number of conversations with SLA"
}
},
"TABLE": {
"HEADER": {
"POLICY": "Policy",
"CONVERSATION": "Conversation",
"AGENT": "Agent"
},
"VIEW_DETAILS": "View Details"
}
}
}
@@ -83,10 +83,7 @@
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created",
"CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation",
"SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation"
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
@@ -101,10 +98,7 @@
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation",
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
"REQUEST_PUSH": "Enable push notifications",
"SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA"
"REQUEST_PUSH": "Enable push notifications"
},
"PROFILE_IMAGE": {
"LABEL": "Profile Image"
@@ -240,7 +234,6 @@
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off",
"REPORTS_SLA": "SLA",
"REPORTS_BOT": "Bot",
"REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels",
@@ -1,31 +1,15 @@
{
"SLA": {
"HEADER": "SLA",
"ADD_ACTION": "Add SLA",
"ADD_ACTION_LONG": "Create a new SLA Policy",
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"HEADER_BTN_TXT": "Add SLA",
"LOADING": "Fetching SLAs",
"SEARCH_404": "There are no items matching this query",
"SIDEBAR_TXT": "<p><b>SLA</b> <p>Think of Service Level Agreements (SLAs) like friendly promises between a service provider and a customer.</p> <p> These promises set clear expectations for things like how quickly the team will respond to issues, making sure you always get a reliable and top-notch experience!</p>",
"LIST": {
"404": "There are no SLAs available in this account.",
"EMPTY": {
"TITLE_1": "Enterprise P0",
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
"TITLE_2": "Enterprise P1",
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
},
"BUSINESS_HOURS_ON": "Business hours on",
"BUSINESS_HOURS_OFF": "Business hours off",
"RESPONSE_TYPES": {
"FRT": "First response time threshold",
"NRT": "Next response time threshold",
"RT": "Resolution time threshold",
"SHORT_HAND": {
"FRT": "FRT",
"NRT": "NRT",
"RT": "RT"
}
}
"TITLE": "Manage SLA",
"DESC": "SLAs: Friendly promises for great service!",
"TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
},
"FORM": {
"NAME": {
@@ -65,7 +49,7 @@
},
"ADD": {
"TITLE": "Add SLA",
"DESC": "Friendly promises for great service!",
"DESC": "SLAs: Friendly promises for great service!",
"API": {
"SUCCESS_MESSAGE": "SLA added successfully",
"ERROR_MESSAGE": "There was an error, please try again"
@@ -83,14 +67,6 @@
"YES": "Yes, Delete ",
"NO": "No, Keep "
}
},
"EVENTS": {
"TITLE": "SLA Misses",
"FRT": "First response time",
"NRT": "Next response time",
"RT": "Resolution time",
"SHOW_MORE": "{count} more",
"HIDE": "Hide {count} rows"
}
}
}
@@ -29,6 +29,33 @@ export default {
conversationId() {
return this.currentChat.id;
},
filteredAttributes() {
return Object.keys(this.customAttributes).map(key => {
const item = this.attributes.find(
attribute => attribute.attribute_key === key
);
if (item) {
return {
...item,
value: this.customAttributes[key],
};
}
return {
...item,
value: this.customAttributes[key],
attribute_description: key,
attribute_display_name: key,
attribute_display_type: this.attributeDisplayType(
this.customAttributes[key]
),
attribute_key: key,
attribute_model: this.attributeType,
id: Math.random(),
};
});
},
},
methods: {
isAttributeNumber(attributeValue) {
@@ -4,15 +4,10 @@ export default {
computed: {
...mapGetters({ accountLabels: 'labels/getLabels' }),
savedLabels() {
// If conversationLabels is passed as prop, use it
if (this.conversationLabels)
return this.conversationLabels.split(',').map(item => item.trim());
// Otherwise, get labels from store
return this.$store.getters['conversationLabels/getConversationLabels'](
this.conversationId
);
},
// TODO - Get rid of this from the mixin
activeLabels() {
return this.accountLabels.filter(({ title }) =>
this.savedLabels.includes(title)
@@ -0,0 +1,26 @@
export default [
{
attribute_description: 'Product name',
attribute_display_name: 'Product name',
attribute_display_type: 'text',
attribute_key: 'product_name',
attribute_model: 'conversation_attribute',
created_at: '2021-09-03T10:45:09.587Z',
default_value: null,
id: 6,
updated_at: '2021-09-22T10:40:42.511Z',
},
{
attribute_description: 'Product identifier',
attribute_display_name: 'Product id',
attribute_display_type: 'number',
attribute_key: 'product_id',
attribute_model: 'conversation_attribute',
created_at: '2021-09-16T13:06:47.329Z',
default_value: null,
icon: 'fluent-calculator',
id: 10,
updated_at: '2021-09-22T10:42:25.873Z',
value: 2021,
},
];
@@ -1,6 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import attributeMixin from '../attributeMixin';
import Vuex from 'vuex';
import attributeFixtures from './attributeFixtures';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -40,6 +41,43 @@ describe('attributeMixin', () => {
expect(wrapper.vm.conversationId).toEqual(7165);
});
it('returns filtered attributes from conversation custom attributes', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
computed: {
attributes() {
return attributeFixtures;
},
contact() {
return {
id: 7165,
custom_attributes: {
product_id: 2021,
},
};
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.filteredAttributes).toEqual([
{
attribute_description: 'Product identifier',
attribute_display_name: 'Product id',
attribute_display_type: 'number',
attribute_key: 'product_id',
attribute_model: 'conversation_attribute',
created_at: '2021-09-16T13:06:47.329Z',
default_value: null,
icon: 'fluent-calculator',
id: 10,
updated_at: '2021-09-22T10:42:25.873Z',
value: 2021,
},
]);
});
it('return display type if attribute passed', () => {
const Component = {
render() {},
@@ -38,10 +38,13 @@
:contact-id="contact.id"
attribute-type="contact_attribute"
attribute-class="conversation--attribute"
attribute-from="contact_panel"
:custom-attributes="contact.custom_attributes"
class="even"
/>
<custom-attribute-selector
attribute-type="contact_attribute"
:contact-id="contact.id"
/>
</accordion-item>
</div>
<div v-if="element.name === 'contact_labels'">
@@ -82,6 +85,7 @@ import ContactConversations from 'dashboard/routes/dashboard/conversation/Contac
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue';
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributeSelector.vue';
import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -92,6 +96,7 @@ export default {
ContactInfo,
ContactLabel,
CustomAttributes,
CustomAttributeSelector,
draggable,
},
mixins: [uiSettingsMixin],
@@ -243,7 +243,7 @@ export default {
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox || type === 'blocked') {
if (isCustomAttributeCheckbox) {
return [
{
id: true,
@@ -25,7 +25,6 @@
@on-sort-change="onSortChange"
/>
<table-footer
class="border-t border-slate-75 dark:border-slate-700/50"
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
:page-size="15"
@@ -76,14 +76,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'blocked',
attributeI18nKey: 'BLOCKED',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
@@ -123,10 +115,6 @@ export const filterAttributeGroups = [
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
{
key: 'blocked',
i18nKey: 'BLOCKED',
},
],
},
];
@@ -87,7 +87,10 @@
attribute-type="contact_attribute"
attribute-class="conversation--attribute"
class="even"
attribute-from="conversation_contact_panel"
:contact-id="contact.id"
/>
<custom-attribute-selector
attribute-type="contact_attribute"
:contact-id="contact.id"
/>
</accordion-item>
@@ -139,6 +142,7 @@ import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue';
@@ -150,6 +154,7 @@ export default {
ContactInfo,
ConversationInfo,
CustomAttributes,
CustomAttributeSelector,
ConversationAction,
ConversationParticipant,
draggable,
@@ -1,109 +1,152 @@
<script setup>
import { computed } from 'vue';
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
const props = defineProps({
conversationAttributes: {
type: Object,
default: () => ({}),
},
contactAttributes: {
type: Object,
default: () => ({}),
},
});
const referer = computed(() => props.conversationAttributes.referer);
const initiatedAt = computed(
() => props.conversationAttributes.initiated_at?.timestamp
);
const browserInfo = props.conversationAttributes.browser;
const browserName = computed(() => {
if (!browserInfo) return '';
const { browser_name: name = '', browser_version: version = '' } =
browserInfo;
return `${name} ${version}`;
});
const browserLanguage = computed(() =>
getLanguageName(props.conversationAttributes.browser_language)
);
const platformName = computed(() => {
if (!browserInfo) return '';
const { platform_name: name = '', platform_version: version = '' } =
browserInfo;
return `${name} ${version}`;
});
const createdAtIp = computed(() => props.contactAttributes.created_at_ip);
const staticElements = computed(() =>
[
{
content: initiatedAt,
title: 'CONTACT_PANEL.INITIATED_AT',
},
{
content: browserLanguage,
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
},
{
content: referer,
title: 'CONTACT_PANEL.INITIATED_FROM',
type: 'link',
},
{
content: browserName,
title: 'CONTACT_PANEL.BROWSER',
},
{
content: platformName,
title: 'CONTACT_PANEL.OS',
},
{
content: createdAtIp,
title: 'CONTACT_PANEL.IP_ADDRESS',
},
].filter(attribute => !!attribute.content.value)
);
</script>
<template>
<div class="conversation--details">
<ContactDetailsItem
v-for="element in staticElements"
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
<contact-details-item
v-if="initiatedAt"
:title="$t('CONTACT_PANEL.INITIATED_AT')"
:value="initiatedAt.timestamp"
class="conversation--attribute"
/>
<contact-details-item
v-if="browserLanguage"
:title="$t('CONTACT_PANEL.BROWSER_LANGUAGE')"
:value="browserLanguage"
class="conversation--attribute"
/>
<contact-details-item
v-if="referer"
:title="$t('CONTACT_PANEL.INITIATED_FROM')"
:value="referer"
class="conversation--attribute"
>
<a
v-if="element.type === 'link'"
:href="referer"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-woot-400 dark:text-woot-600"
>
<a :href="referer" rel="noopener noreferrer nofollow" target="_blank">
{{ referer }}
</a>
</ContactDetailsItem>
<CustomAttributes
:class="staticElements.length % 2 === 0 ? 'even' : 'odd'"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
</contact-details-item>
<contact-details-item
v-if="browserName"
:title="$t('CONTACT_PANEL.BROWSER')"
:value="browserName"
class="conversation--attribute"
/>
<contact-details-item
v-if="platformName"
:title="$t('CONTACT_PANEL.OS')"
:value="platformName"
class="conversation--attribute"
/>
<contact-details-item
v-if="ipAddress"
:title="$t('CONTACT_PANEL.IP_ADDRESS')"
:value="ipAddress"
class="conversation--attribute"
/>
<custom-attributes
attribute-type="conversation_attribute"
attribute-class="conversation--attribute"
:class="customAttributeRowClass"
/>
<custom-attribute-selector attribute-type="conversation_attribute" />
</div>
</template>
<script>
import { getLanguageName } from '../../../components/widgets/conversation/advancedFilterItems/languages';
import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
export default {
components: {
ContactDetailsItem,
CustomAttributes,
CustomAttributeSelector,
},
props: {
conversationAttributes: {
type: Object,
default: () => ({}),
},
contactAttributes: {
type: Object,
default: () => ({}),
},
},
STATIC_ATTRIBUTES: [
{
name: 'initiated_at',
label: 'CONTACT_PANEL.INITIATED_AT',
},
{
name: 'referer',
label: 'CONTACT_PANEL.BROWSER',
},
{
name: 'browserName',
label: 'CONTACT_PANEL.BROWSER',
},
{
name: 'platformName',
label: 'CONTACT_PANEL.OS',
},
{
name: 'ipAddress',
label: 'CONTACT_PANEL.IP_ADDRESS',
},
],
computed: {
referer() {
return this.conversationAttributes.referer;
},
initiatedAt() {
return this.conversationAttributes.initiated_at;
},
browserName() {
if (!this.conversationAttributes.browser) {
return '';
}
const {
browser_name: browserName = '',
browser_version: browserVersion = '',
} = this.conversationAttributes.browser;
return `${browserName} ${browserVersion}`;
},
browserLanguage() {
return getLanguageName(this.conversationAttributes.browser_language);
},
platformName() {
if (!this.conversationAttributes.browser) {
return '';
}
const { platform_name: platformName, platform_version: platformVersion } =
this.conversationAttributes.browser;
return `${platformName || ''} ${platformVersion || ''}`;
},
ipAddress() {
const { created_at_ip: createdAtIp } = this.contactAttributes;
return createdAtIp;
},
customAttributeRowClass() {
const attributes = [
'initiatedAt',
'referer',
'browserName',
'platformName',
'ipAddress',
];
const availableAttributes = attributes.filter(
attribute => !!this[attribute]
);
return availableAttributes.length % 2 === 0 ? 'even' : 'odd';
},
},
};
</script>
<style scoped lang="scss">
.conversation--attribute {
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
@apply border-slate-50 dark:border-slate-700 border-b border-solid;
&:nth-child(2n) {
@apply bg-slate-25 dark:bg-slate-800/50;
@apply bg-slate-25 dark:bg-slate-800;
}
}
</style>
@@ -125,9 +125,8 @@
>
<span
class="flex items-center h-10 px-2 text-sm border-solid bg-slate-50 border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md dark:bg-slate-700 text-slate-800 dark:text-slate-100 border-slate-200 dark:border-slate-600"
>{{ socialProfile.prefixURL }}</span
>
{{ socialProfile.prefixURL }}
</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:rounded-l-none rtl:rounded-r-none !mb-0"
@@ -0,0 +1,114 @@
<template>
<div class="flex flex-col w-full max-h-[12.5rem]">
<h4
class="text-sm text-slate-800 dark:text-slate-100 mb-1 overflow-hidden whitespace-nowrap text-ellipsis flex-grow"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.TITLE') }}
</h4>
<div class="mb-2 flex-shrink-0 flex-grow-0 flex-auto max-h-8">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.PLACEHOLDER')"
/>
</div>
<div
class="flex justify-start items-start flex-grow flex-shrink flex-auto overflow-auto h-32"
>
<div class="w-full h-full">
<woot-dropdown-menu>
<custom-attribute-drop-down-item
v-for="attribute in filteredAttributes"
:key="attribute.attribute_display_name"
:title="attribute.attribute_display_name"
@click="onAddAttribute(attribute)"
/>
</woot-dropdown-menu>
<div
v-if="noResult"
class="w-full justify-center items-center flex mb-2 h-[70%] text-slate-500 dark:text-slate-300 py-2 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis text-sm"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.NO_RESULT') }}
</div>
<woot-button
class="float-right"
icon="add"
size="tiny"
@click="addNewAttribute"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.ADD.TITLE') }}
</woot-button>
</div>
</div>
</div>
</template>
<script>
import CustomAttributeDropDownItem from './CustomAttributeDropDownItem.vue';
import attributeMixin from 'dashboard/mixins/attributeMixin';
export default {
components: {
CustomAttributeDropDownItem,
},
mixins: [attributeMixin],
props: {
attributeType: {
type: String,
default: 'conversation_attribute',
},
contactId: { type: Number, default: null },
},
data() {
return {
search: '',
};
},
computed: {
filteredAttributes() {
return this.attributes
.filter(
item =>
!Object.keys(this.customAttributes).includes(item.attribute_key)
)
.filter(attribute => {
return attribute.attribute_display_name
.toLowerCase()
.includes(this.search.toLowerCase());
});
},
noResult() {
return this.filteredAttributes.length === 0;
},
},
mounted() {
this.focusInput();
},
methods: {
focusInput() {
this.$refs.searchbar.focus();
},
addNewAttribute() {
this.$router.push(
`/app/accounts/${this.accountId}/settings/custom-attributes/list`
);
},
async onAddAttribute(attribute) {
this.$emit('add-attribute', attribute);
},
},
};
</script>
<style lang="scss" scoped>
.search-input {
@apply m-0 w-full border border-solid border-transparent h-8 text-sm text-slate-700 dark:text-slate-100 rounded-md focus:border-woot-500 bg-slate-50 dark:bg-slate-900;
}
</style>
@@ -0,0 +1,79 @@
<template>
<woot-dropdown-item>
<woot-button variant="clear" @click="onClick">
<span class="label-text" :title="title">{{ title }}</span>
</woot-button>
</woot-dropdown-item>
</template>
<script>
export default {
name: 'AttributeDropDownItem',
props: {
title: {
type: String,
default: '',
},
},
methods: {
onClick() {
this.$emit('click', this.title);
},
},
};
</script>
<style lang="scss" scoped>
.item-wrap {
display: flex;
::v-deep .button__content {
width: 100%;
}
.button-wrap {
display: flex;
justify-content: space-between;
width: 100%;
&.active {
display: flex;
font-weight: var(--font-weight-bold);
color: var(--w-700);
}
.name-label-wrap {
display: flex;
min-width: 0;
width: 100%;
.label-color--display {
margin-right: var(--space-small);
}
.label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
padding-right: var(--space-small);
padding-left: var(--space-small);
}
.icon {
font-size: var(--font-size-small);
}
}
}
.label-color--display {
border-radius: var(--border-radius-normal);
height: var(--space-slab);
margin-right: var(--space-smaller);
margin-top: var(--space-micro);
min-width: var(--space-slab);
width: var(--space-slab);
}
}
</style>
@@ -0,0 +1,138 @@
<template>
<div class="custom-attribute--selector">
<div
v-on-clickaway="closeDropdown"
class="label-wrap"
@keyup.esc="closeDropdown"
>
<woot-button
size="small"
variant="link"
icon="add"
@click="toggleAttributeDropDown"
>
{{ $t('CUSTOM_ATTRIBUTES.ADD_BUTTON_TEXT') }}
</woot-button>
<div class="dropdown-wrap">
<div
:class="{ 'dropdown-pane--open': showAttributeDropDown }"
class="dropdown-pane"
>
<custom-attribute-drop-down
v-if="showAttributeDropDown"
:attribute-type="attributeType"
:contact-id="contactId"
@add-attribute="addAttribute"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import CustomAttributeDropDown from './CustomAttributeDropDown.vue';
import alertMixin from 'shared/mixins/alertMixin';
import attributeMixin from 'dashboard/mixins/attributeMixin';
import { mixin as clickaway } from 'vue-clickaway';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
components: {
CustomAttributeDropDown,
},
mixins: [clickaway, alertMixin, attributeMixin],
props: {
attributeType: {
type: String,
default: 'conversation_attribute',
},
contactId: { type: Number, default: null },
},
data() {
return {
showAttributeDropDown: false,
};
},
methods: {
async addAttribute(attribute) {
try {
const {
attribute_key: attributeKey,
attribute_display_type: attributeDisplayType,
default_value: attributeDefaultValue,
} = attribute;
const isCheckbox = attributeDisplayType === 'checkbox';
const defaultValue = isCheckbox ? false : attributeDefaultValue || null;
if (this.attributeType === 'conversation_attribute') {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: {
...this.customAttributes,
[attributeKey]: defaultValue,
},
});
} else {
await this.$store.dispatch('contacts/update', {
id: this.contactId,
custom_attributes: {
...this.customAttributes,
[attributeKey]: defaultValue,
},
});
}
bus.$emit(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, attributeKey);
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.ERROR');
this.showAlert(errorMessage);
} finally {
this.closeDropdown();
}
},
toggleAttributeDropDown() {
this.showAttributeDropDown = !this.showAttributeDropDown;
},
closeDropdown() {
this.showAttributeDropDown = false;
},
},
};
</script>
<style lang="scss" scoped>
.custom-attribute--selector {
width: 100%;
padding: var(--space-slab) var(--space-normal);
.label-wrap {
line-height: var(--space-medium);
position: relative;
.dropdown-wrap {
display: flex;
left: -1px;
margin-right: var(--space-medium);
position: absolute;
top: var(--space-medium);
width: 100%;
.dropdown-pane {
width: 100%;
box-sizing: border-box;
}
}
}
}
.error {
color: var(--r-500);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
}
</style>
@@ -1,35 +1,23 @@
<template>
<div class="custom-attributes--panel">
<custom-attribute
v-for="attribute in displayedAttributes"
v-for="attribute in filteredAttributes"
:key="attribute.id"
:attribute-key="attribute.attribute_key"
:attribute-type="attribute.attribute_display_type"
:values="attribute.attribute_values"
:label="attribute.attribute_display_name"
:icon="attribute.icon"
emoji=""
:value="attribute.value"
:show-actions="true"
:attribute-regex="attribute.regex_pattern"
:regex-cue="attribute.regex_cue"
:class="attributeClass"
:contact-id="contactId"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
/>
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
<div v-if="filteredAttributes.length > 5" class="flex px-2 py-2">
<woot-button
size="small"
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
variant="clear"
color-scheme="primary"
class="!px-2 hover:!bg-transparent dark:hover:!bg-transparent"
@click="onClickToggle"
>
{{ toggleButtonText }}
</woot-button>
</div>
</div>
</template>
@@ -37,14 +25,13 @@
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import alertMixin from 'shared/mixins/alertMixin';
import attributeMixin from 'dashboard/mixins/attributeMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
components: {
CustomAttribute,
},
mixins: [alertMixin, attributeMixin, uiSettingsMixin],
mixins: [alertMixin, attributeMixin],
props: {
attributeType: {
type: String,
@@ -55,67 +42,8 @@ export default {
default: '',
},
contactId: { type: Number, default: null },
attributeFrom: {
type: String,
required: true,
},
},
data() {
return {
showAllAttributes: false,
};
},
computed: {
toggleButtonText() {
return !this.showAllAttributes
? this.$t('CUSTOM_ATTRIBUTES.SHOW_MORE')
: this.$t('CUSTOM_ATTRIBUTES.SHOW_LESS');
},
filteredAttributes() {
return this.attributes.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
this.customAttributes,
attribute.attribute_key
);
const isCheckbox = attribute.attribute_display_type === 'checkbox';
const defaultValue = isCheckbox ? false : '';
return {
...attribute,
// Set value from customAttributes if it exists, otherwise use default value
value: hasValue
? this.customAttributes[attribute.attribute_key]
: defaultValue,
};
});
},
displayedAttributes() {
// Show only the first 5 attributes or all depending on showAllAttributes
if (this.showAllAttributes || this.filteredAttributes.length <= 5) {
return this.filteredAttributes;
}
return this.filteredAttributes.slice(0, 5);
},
showMoreUISettingsKey() {
return `show_all_attributes_${this.attributeFrom}`;
},
},
mounted() {
this.initializeSettings();
},
methods: {
initializeSettings() {
this.showAllAttributes =
this.uiSettings[this.showMoreUISettingsKey] || false;
},
onClickToggle() {
this.showAllAttributes = !this.showAllAttributes;
this.updateUISettings({
[this.showMoreUISettingsKey]: this.showAllAttributes,
});
},
async onUpdate(key, value) {
const updatedAttributes = { ...this.customAttributes, [key]: value };
try {
@@ -168,17 +96,16 @@ export default {
},
};
</script>
<style scoped lang="scss">
.custom-attributes--panel {
.conversation--attribute {
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
@apply border-slate-50 dark:border-slate-700 border-b border-solid;
}
&.odd {
.conversation--attribute {
&:nth-child(2n + 1) {
@apply bg-slate-25 dark:bg-slate-800/50;
@apply bg-slate-25 dark:bg-slate-800;
}
}
}
@@ -186,7 +113,7 @@ export default {
&.even {
.conversation--attribute {
&:nth-child(2n) {
@apply bg-slate-25 dark:bg-slate-800/50;
@apply bg-slate-25 dark:bg-slate-800;
}
}
}
@@ -59,7 +59,7 @@
:current-page="currentPage"
:total-count="totalCount"
:page-size="pageSize"
class="dark:bg-slate-900 bottom-0 border-t border-slate-75 dark:border-slate-700/50"
class="dark:bg-slate-900 sticky bottom-0"
@page-change="onPageChange"
/>
</div>
@@ -9,7 +9,6 @@
:on-mark-all-done-click="onMarkAllDoneClick"
/>
<table-footer
class="border-t border-slate-75 dark:border-slate-700/50"
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
:page-size="15"
@@ -1,23 +0,0 @@
<script setup>
defineProps({
isLoading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" />
<div>
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<slot v-else name="body" />
</div>
</div>
</template>
@@ -1,21 +0,0 @@
<script setup>
defineProps({
keepAlive: {
type: Boolean,
default: true,
},
});
</script>
<template>
<div
class="flex flex-col w-full h-full px-5 pt-8 pb-3 m-0 overflow-auto bg-white sm:px-16 sm:pt-16 dark:bg-slate-900"
>
<div class="flex items-start max-w-[900px] w-full">
<keep-alive v-if="keepAlive">
<router-view />
</keep-alive>
<router-view v-else />
</div>
</div>
</template>
@@ -57,7 +57,7 @@
:current-page="Number(meta.currentPage)"
:total-count="meta.totalEntries"
:page-size="meta.perPage"
class="!bg-slate-25 dark:!bg-slate-900 border-t border-slate-75 dark:border-slate-700/50"
class="dark:bg-slate-900"
@page-change="onPageChange"
/>
</div>
@@ -1,102 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
iconName: {
type: String,
required: true,
},
href: {
type: String,
default: '',
},
linkText: {
type: String,
default: '',
},
});
const openInNewTab = url => {
if (!url) return;
window.open(url, '_blank', 'noopener noreferrer');
};
</script>
<template>
<div class="flex flex-col items-start w-full gap-4">
<!-- Header section with icon, title and action button -->
<div class="flex items-center justify-between w-full gap-4">
<!-- Icon and title container -->
<div class="flex items-center gap-3">
<div
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
>
<div
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
>
<fluent-icon
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
/>
</div>
</div>
<h1
class="text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
>
{{ title }}
</h1>
</div>
<!-- Slot for additional actions on larger screens -->
<div class="hidden gap-2 sm:flex">
<slot name="actions" />
</div>
</div>
<!-- Description and optional link -->
<div
class="flex flex-col gap-2 text-slate-600 dark:text-slate-300 max-w-[721px] w-full"
>
<p
class="mb-0 text-sm font-normal tracking-[0.5%] line-clamp-5 sm:line-clamp-none"
>
<slot name="description">{{ description }}</slot>
</p>
<!-- Conditional link -->
<a
v-if="href && linkText"
:href="href"
target="_blank"
rel="noopener noreferrer"
class="sm:inline-flex hidden tracking-[-0.6%] gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium tracking=[-0.6%] hover:underline"
>
{{ linkText }}
<fluent-icon
size="16"
icon="chevron-right"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
/>
</a>
</div>
<!-- Mobile view for actions and link -->
<div class="flex items-start justify-start w-full gap-3 sm:hidden">
<slot name="actions" />
<woot-button
v-if="href && linkText"
color-scheme="secondary"
icon="arrow-outwards"
class="flex-row-reverse rounded-xl min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
@click="openInNewTab(href)"
>
{{ linkText }}
</woot-button>
</div>
</div>
</template>
@@ -1,53 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
>
<!-- left side section -->
<slot name="leftSection">
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
<div
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
>
<h3
class="justify-between text-sm tracking-[-0.6%] font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
>
<slot name="title">
{{ title }}
</slot>
</h3>
<slot name="label" />
</div>
<p
class="text-sm text-slate-600 tracking-[0.5%] dark:text-slate-300 max-w-[400px] w-full line-clamp-2"
>
<slot name="description">
{{ description }}
</slot>
</p>
</div>
</slot>
<!-- right side section -->
<slot name="rightSection" />
<!-- actions section -->
<div
v-if="$slots.actions"
class="absolute flex-col items-center hidden gap-1 border-none ltr:-right-3 rtl:-left-3 top-3 group-hover:flex"
>
<slot name="actions" />
</div>
</div>
</template>
@@ -589,9 +589,7 @@ export default {
return this.inbox.name;
},
canLocktoSingleConversation() {
return (
this.isASmsInbox || this.isAWhatsAppChannel || this.isAFacebookInbox
);
return this.isASmsInbox || this.isAWhatsAppChannel;
},
inboxNameLabel() {
if (this.isAWebWidgetInbox) {
@@ -1,55 +0,0 @@
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
}
</style>
@@ -1,410 +0,0 @@
<template>
<div class="flex items-center w-full overflow-y-auto">
<div
class="flex flex-col h-full p-5 pt-16 mx-auto my-0 bg-white dark:bg-slate-900 font-inter"
>
<div class="flex flex-col gap-8 sm:max-w-3xl">
<h2 class="text-2xl font-medium text-slate-900 dark:text-white">
Profile Settings
</h2>
<user-profile-picture username="John Doe" size="72px" />
<personal-wrapper
header="Personal message signature"
description="Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes."
:show-action-button="true"
button-text="Save message signature"
>
<template #settingsItem>
<message-signature />
</template>
</personal-wrapper>
<personal-wrapper
header="Hot key to send messages"
description="You can select a hotkey (either Enter or Cmd/Ctrl+Enter) based on your
preference of writing."
:show-action-button="false"
>
<template #settingsItem>
<div class="flex flex-row justify-between gap-4">
<button
v-for="keyOption in keyOptions"
:key="keyOption.key"
class="p-0 cursor-pointer"
>
<preview-card
:heading="keyOption.heading"
:content="keyOption.content"
:src="keyOption.src"
:active="keyOption.key === 'enter'"
/>
</button>
</div>
</template>
</personal-wrapper>
<div class="flex flex-col w-full gap-4">
<h4 class="text-lg font-medium text-slate-900 dark:text-slate-25">
Change password
</h4>
<div class="flex flex-row items-center justify-between gap-4">
<div class="flex-grow h-0.5 bg-slate-50 dark:bg-slate-700" />
</div>
<form-input
name="current_password"
class="flex-1"
:class="{ error: false }"
label="Current password"
placeholder="Please enter your current password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="new_password"
class="flex-1"
:class="{ error: false }"
label="New password"
placeholder="Please enter your new password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="confirm_password"
class="flex-1"
:class="{ error: false }"
label="Confirm password"
placeholder="Please confirm your new password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<woot-button
color-scheme="primary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
Update password
</woot-button>
</div>
<personal-wrapper
header="Audio notifications"
description="Enable audio notifications in dashboard for new messages and conversations."
:show-action-button="false"
>
<template #settingsItem>
<form-select
name="timezone"
spacing="compact"
label="Alert tone"
placeholder="Ding"
class="max-w-xl"
>
<option
v-for="file in audioFiles"
:key="file.label"
:value="file.value"
>
{{ file.label }}
</option>
</form-select>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-slate-900 dark:text-white"
>
Alert events for conversations
</label>
<div
class="flex flex-row justify-between h-10 max-w-xl p-2 border border-solid rounded-md border-slate-75 dark:border-slate-600"
>
<div
class="flex flex-row items-center justify-center gap-1 border-r border-slate-75 grow"
>
<input type="radio" name="hotkey" value="enter" checked />
<label>None</label>
</div>
<div
class="flex flex-row items-center justify-center gap-1 border-r border-slate-75 grow"
>
<input type="radio" name="hotkey" value="enter" />
<label>Assigned</label>
</div>
<div
class="flex flex-row items-center justify-center gap-1 grow"
>
<input type="radio" name="hotkey" value="enter" />
<label>All</label>
</div>
</div>
</div>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-slate-900 dark:text-white"
>
Alert conditions
</label>
<div class="flex flex-row items-center justify-start gap-2">
<input
type="checkbox"
name="send"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
<label>
Send audio alerts only if the browser window is not active
</label>
</div>
<div class="flex flex-row items-center justify-start gap-2">
<input
type="checkbox"
name="send"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
<label>
Send alerts every 30s until all the assigned conversations are
read
</label>
</div>
</div>
</template>
</personal-wrapper>
<personal-wrapper
header="Notification preferences"
:show-action-button="false"
>
<template #settingsItem>
<div>
<!-- Draw a line -->
<div class="flex flex-row items-center justify-between gap-4">
<div class="flex-grow h-0.5 bg-slate-50 dark:bg-slate-700" />
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<table-header-cell :span="6" label="Notification type" />
<table-header-cell :span="3" label="Email" />
<table-header-cell :span="3" label="Push notification" />
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
A new conversation is created
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
Conversation is assigned to you
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
Conversation is assigned to you
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
You are mentioned in a conversation
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
New message is created in an assigned conversation
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
</div>
</template>
</personal-wrapper>
<personal-wrapper
header="Access Token"
description="This token can be used if you are building an API based integration."
:show-action-button="false"
>
<template #settingsItem>
<div class="flex flex-row justify-between gap-4">
<form-input
name="access_token"
class="flex-1"
type="password"
value="1234567890"
:class="{ error: false }"
placeholder="Please enter your new password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<woot-button
icon="copy"
variant="hollow"
color-scheme="secondary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
Copy
</woot-button>
</div>
</template>
</personal-wrapper>
</div>
</div>
</div>
</template>
<script setup>
import TableHeaderCell from 'dashboard/components/widgets/TableHeaderCell.vue';
import MessageSignature from '../profile/MessageSignature.vue';
import UserProfilePicture from './UserProfilePicture.vue';
import PersonalWrapper from './PersonalWrapper.vue';
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
import FormInput from 'v3/components/Form/Input.vue';
import FormSelect from 'v3/components/Form/Select.vue';
const keyOptions = [
{
key: 'enter',
src: '/assets/images/dashboard/profile/hot-key-enter.svg',
heading: 'Enter (↵)',
content:
'Send messages by pressing Enter key instead of clicking send button.',
},
{
key: 'cmd_enter',
src: '/assets/images/dashboard/profile/hot-key-ctrl-enter.svg',
heading: 'CMD / Ctrl + Enter (⌘ + ↵)',
content:
'Send messages by pressing Enter key instead of clicking send button.',
},
];
const audioFiles = [
{ label: 'Ding', value: 'ding' },
{ label: 'Dong', value: 'dong' },
];
</script>
@@ -1,52 +0,0 @@
<template>
<div class="flex flex-col items-start w-full gap-6">
<div
class="flex flex-col w-full gap-4 divide-y divide-slate-200 dark:divide-slate-700"
>
<h4
v-if="header"
class="text-lg font-medium text-slate-900 dark:text-slate-25"
>
{{ header }}
</h4>
<p
v-if="description"
class="pt-4 mb-0 text-sm font-normal text-slate-600 dark:text-slate-400"
>
{{ description }}
</p>
</div>
<div v-if="$slots.settingsItem" class="flex flex-col w-full gap-6">
<slot name="settingsItem" />
<woot-button
v-if="showActionButton"
color-scheme="primary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</template>
<script setup>
defineProps({
header: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
showActionButton: {
type: Boolean,
default: true,
},
buttonText: {
type: String,
default: 'Save',
},
});
</script>
@@ -1,218 +0,0 @@
<template>
<div
class="relative rounded-xl"
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<img
class="rounded-xl"
src="https://picsum.photos/200"
draggable="false"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<div
class="absolute select-none w-6 h-6 z-10 p-1 -top-2 -right-2 border border-white dark:border-white rounded-full flex items-center justify-center bg-slate-200 dark:bg-slate-700"
>
<fluent-icon
icon="dismiss"
size="16"
class="text-slate-700 dark:text-slate-200"
/>
</div>
<div
:style="{ height: size, width: size }"
class="absolute top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
>
<fluent-icon
icon="avatar-upload"
size="32"
class="text-white dark:text-white"
/>
</div>
</div>
</template>
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - Username for avatar
*/
import Avatar from './Avatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
components: {
Avatar,
},
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '40px',
},
badge: {
type: String,
default: '',
},
username: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
hasBorder: {
type: Boolean,
default: false,
},
shouldShowStatusAlways: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.username);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
const borderRadius = `${size / 2}px`;
return { width: badgeSize, height: badgeSize, borderRadius };
},
statusStyle() {
const statusSize = `${this.avatarSize / 4}px`;
return { width: statusSize, height: statusSize };
},
thumbnailClass() {
const className = this.hasBorder
? 'border border-solid border-white dark:border-slate-700/50'
: '';
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${className} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
<style lang="scss" scoped>
.user-thumbnail-box {
flex: 0 0 auto;
max-width: 100%;
position: relative;
.user-thumbnail {
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
}
.source-badge {
border-radius: var(--border-radius-small);
bottom: var(--space-minus-micro);
box-shadow: var(--shadow-small);
height: var(--space-slab);
padding: var(--space-micro);
position: absolute;
right: 0;
width: var(--space-slab);
@apply bg-white dark:bg-slate-900;
}
.user-online-status {
border-radius: 50%;
bottom: var(--space-micro);
&:after {
content: ' ';
}
}
.user-online-status--online {
@apply bg-green-400 dark:bg-green-400;
}
.user-online-status--busy {
@apply bg-yellow-500 dark:bg-yellow-500;
}
.user-online-status--offline {
@apply bg-slate-500 dark:bg-slate-500;
}
}
</style>
@@ -1,130 +0,0 @@
<template>
<div>
<div class="flex flex-col gap-2 mt-2">
<span class="font-medium text-slate-900 dark:text-slate-25 text-normal">
Profile picture
</span>
<div
class="relative rounded-xl h-[72px] w-[72px]"
:title="title"
@mouse-over="showUpload"
>
<img
class="rounded-xl h-[72px] w-[72px]"
src="https://i.pravatar.cc/300"
draggable="false"
@load="onImgLoad"
@error="onImgError"
/>
<div
class="absolute z-10 flex items-center justify-center w-6 h-6 p-1 border border-white rounded-full select-none -top-2 -right-2 dark:border-white bg-slate-200 dark:bg-slate-700"
>
<fluent-icon
icon="dismiss"
size="16"
class="text-slate-800 dark:text-slate-200"
/>
</div>
<div
class="absolute h-[72px] w-[72px] top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
>
<fluent-icon
icon="avatar-upload"
size="32"
class="text-white dark:text-white"
/>
</div>
</div>
</div>
<div class="pt-6">
<form-input
name="full_name"
class="flex-1"
:class="{ error: false }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
placeholder="Enter your name"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="display_name"
class="flex-1"
:class="{ error: false }"
label="Display name"
placeholder="Enter your display name"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="email"
class="flex-1"
:class="{ error: false }"
label="Email"
placeholder="Enter your email"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<woot-button
color-scheme="primary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
Update profile
</woot-button>
</div>
</div>
</template>
<script>
import FormInput from 'v3/components/Form/Input.vue';
export default {
components: {
FormInput,
},
props: {
src: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
showUploadIcon: false,
};
},
computed: {
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
showUpload() {
this.showUploadIcon = true;
},
},
};
</script>
@@ -1,20 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
const Index = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/personal'),
name: 'personal_settings',
roles: ['administrator', 'agent'],
component: Index,
props: {
headerTitle: 'PROFILE_SETTINGS.TITLE',
icon: 'edit',
showNewButton: false,
showSidemenuIcon: false,
},
},
],
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 B

@@ -1,14 +1,40 @@
<template>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
<div
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
>
<div class="w-1/4 py-4 pr-6 ml-0">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE') }}</p>
</div>
<div class="p-4 w-[45%]">
<div>
<label for="message-signature-input">{{
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.LABEL')
}}</label>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
@click.prevent="updateSignature"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
</div>
</div>
</template>
<script>
@@ -73,7 +99,7 @@ export default {
<style lang="scss" scoped>
.message-editor {
@apply px-3;
@apply px-3 mb-4;
::v-deep {
.ProseMirror-menubar {
@@ -236,54 +236,6 @@
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_first_response"
@input="handleEmailInput"
/>
<label for="sla_missed_first_response">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_next_response"
@input="handleEmailInput"
/>
<label for="sla_missed_next_response">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_resolution"
@input="handleEmailInput"
/>
<label for="sla_missed_resolution">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
)
}}
</label>
</div>
</div>
</div>
<div
@@ -400,57 +352,6 @@
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_first_response"
@input="handlePushInput"
/>
<label for="sla_missed_first_response">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_next_response"
@input="handlePushInput"
/>
<label for="sla_missed_next_response">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_resolution"
@input="handlePushInput"
/>
<label for="sla_missed_resolution">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
)
}}
</label>
</div>
</div>
</div>
</div>
@@ -466,7 +367,6 @@ import {
requestPushPermissions,
verifyServiceWorkerExistence,
} from '../../../../helper/pushHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
mixins: [alertMixin, configMixin, uiSettingsMixin],
@@ -493,18 +393,13 @@ export default {
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
uiSettings: 'getUISettings',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasPushAPISupport() {
return !!('Notification' in window);
},
isSLAEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA);
},
},
watch: {
emailFlags(value) {
@@ -1,99 +0,0 @@
<template>
<div class="flex flex-col flex-1 px-4 pt-4 overflow-auto">
<SLAReportFilters @filter-change="onFilterChange" />
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="arrow-download"
@click="downloadReports"
>
{{ $t('SLA_REPORTS.DOWNLOAD_SLA_REPORTS') }}
</woot-button>
<div class="flex flex-col gap-6">
<SLAMetrics
:hit-rate="slaMetrics.hitRate"
:no-of-breaches="slaMetrics.numberOfSLAMisses"
:no-of-conversations="slaMetrics.numberOfConversations"
:is-loading="uiFlags.isFetchingMetrics"
/>
<SLATable
:sla-reports="slaReports"
:is-loading="uiFlags.isFetching"
:current-page="Number(slaMeta.currentPage)"
:total-count="Number(slaMeta.count)"
@page-change="onPageChange"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import SLAMetrics from './components/SLA/SLAMetrics.vue';
import SLATable from './components/SLA/SLATable.vue';
import alertMixin from 'shared/mixins/alertMixin';
import SLAReportFilters from './components/SLA/SLAReportFilters.vue';
import { generateFileName } from 'dashboard/helper/downloadHelper';
export default {
name: 'SLAReports',
components: {
SLAMetrics,
SLATable,
SLAReportFilters,
},
mixins: [alertMixin],
data() {
return {
pageNumber: 1,
from: 0,
to: 0,
};
},
computed: {
...mapGetters({
slaReports: 'slaReports/getAll',
slaMetrics: 'slaReports/getMetrics',
slaMeta: 'slaReports/getMeta',
uiFlags: 'slaReports/getUIFlags',
}),
},
mounted() {
this.fetchSLAMetrics();
this.fetchSLAReports();
},
methods: {
fetchSLAReports({ pageNumber } = {}) {
this.$store.dispatch('slaReports/get', {
page: pageNumber || this.pageNumber,
from: this.from,
to: this.to,
});
},
fetchSLAMetrics() {
this.$store.dispatch('slaReports/getMetrics', {
from: this.from,
to: this.to,
});
},
onPageChange(pageNumber) {
this.fetchSLAReports({ pageNumber });
},
onFilterChange({ from, to }) {
this.from = from;
this.to = to;
this.fetchSLAReports();
this.fetchSLAMetrics();
},
downloadReports() {
const type = 'sla';
try {
this.$store.dispatch('slaReports/download', {
fileName: generateFileName({ type, to: this.to }),
...this.requestPayload,
});
} catch (error) {
this.showAlert(this.$t('SLA_REPORTS.DOWNLOAD_FAILED'));
}
},
},
};
</script>
@@ -1,8 +1,6 @@
<template>
<div class="flex flex-col md:flex-row justify-between mb-4">
<div
class="md:grid flex-col gap-3 w-full grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] p-5"
>
<div class="md:grid flex flex-col filter-container gap-3 w-full">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
v-if="isDateRangeSelected"
@@ -1,26 +0,0 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 bg-white dark:bg-slate-900 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="$emit('click')"
>
<slot name="leftIcon" />
<span
v-if="buttonText"
class="text-sm font-medium text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<slot name="rightIcon" />
<div v-if="$slots.dropdown" class="absolute right-0 top-10" @click.stop>
<slot name="dropdown" />
</div>
</button>
</template>
@@ -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,45 +0,0 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
inputValue: {
type: String,
default: '',
},
inputPlaceholder: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
>
<div class="flex items-center gap-2">
<fluent-icon
icon="search"
size="18"
class="text-slate-400 dark:text-slate-400"
/>
<input
type="text"
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
:placeholder="inputPlaceholder"
:value="inputValue"
@input="$emit('input', $event.target.value)"
/>
</div>
<woot-button
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
@click="$emit('click')"
>
{{ buttonText }}
</woot-button>
</div>
</template>

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