Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e66c706ff0 | ||
|
|
c3e90d4cbc | ||
|
|
afc97faa8b | ||
|
|
1d0930ef63 | ||
|
|
776ea6d39a | ||
|
|
2c85098698 | ||
|
|
6bd0e074dc | ||
|
|
123fc73394 | ||
|
|
88cef88d80 | ||
|
|
8e79cf72e1 | ||
|
|
bfaca851f1 | ||
|
|
5618b5ebd3 | ||
|
|
4807052c37 | ||
|
|
44b1047b90 | ||
|
|
9c6c19c3e5 | ||
|
|
2764338453 | ||
|
|
4f0fcfcbcd | ||
|
|
fdbaf98c3b | ||
|
|
03bbd048a7 | ||
|
|
d481b9fbcf | ||
|
|
68dedc37ba | ||
|
|
958c0d7946 | ||
|
|
9553329092 | ||
|
|
d01f2063d0 | ||
|
|
590e4e9c1c | ||
|
|
0e903d2365 | ||
|
|
d6ce1ceeeb | ||
|
|
e8f56d0e56 | ||
|
|
b8dae07c0f | ||
|
|
e3f4be97c0 | ||
|
|
1f4d096804 | ||
|
|
1bcc438b21 | ||
|
|
8b3d13a920 | ||
|
|
dcdd58bae5 | ||
|
|
7b9a9831c1 | ||
|
|
7ab7bac6bf | ||
|
|
b988a01df3 | ||
|
|
105f9a27d2 | ||
|
|
132cf802d5 | ||
|
|
f2f33038f2 | ||
|
|
590ce788b9 | ||
|
|
a25179c342 | ||
|
|
aea9470b6a | ||
|
|
6382707146 | ||
|
|
49ef4e54ca | ||
|
|
db0d32f01d | ||
|
|
de1fb6ba4e | ||
|
|
7b8bcaba11 | ||
|
|
3f39b7ad1f | ||
|
|
a3547c5a1f | ||
|
|
0f776a173c | ||
|
|
d320605556 | ||
|
|
ca85d4e1c6 | ||
|
|
b994706265 | ||
|
|
d9f2cd0a6e | ||
|
|
f2a34ad8aa | ||
|
|
65e91f6a6f | ||
|
|
7dd1562b40 | ||
|
|
c70367bbd7 | ||
|
|
5a8eff35b5 | ||
|
|
1936369b12 | ||
|
|
354010a6e1 | ||
|
|
385eab6b96 | ||
|
|
198cd9b28d | ||
|
|
abdf00d2cf | ||
|
|
2c3160cfee | ||
|
|
708bddf4db | ||
|
|
020dcc4dc7 | ||
|
|
271263bcc2 | ||
|
|
c97d6021e0 | ||
|
|
d99997d17d | ||
|
|
9c5d062efc | ||
|
|
87f758ee1f | ||
|
|
d0a1ad746a | ||
|
|
86384bf876 | ||
|
|
55e5fa2205 | ||
|
|
65bad90b69 | ||
|
|
07aaa046c1 | ||
|
|
520bdabefe | ||
|
|
662967b5d3 | ||
|
|
6137a45214 | ||
|
|
18c5c47836 |
@@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## LogRocket
|
||||
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
|
||||
|
||||
# MICROSOFT CLARITY
|
||||
# MS_CLARITY_TOKEN=xxxxxxxxx
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
|
||||
+23
-31
@@ -162,41 +162,33 @@ Rails/RenderInline:
|
||||
Performance/CollectionLiteralInLoop:
|
||||
Exclude:
|
||||
- 'db/migrate/20210315101919_enable_email_channel.rb'
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
Style/RedundantConstantBase:
|
||||
Enabled: false
|
||||
Rails/RootPathnameMethods:
|
||||
Enabled: false
|
||||
RSpec/Rails/MinitestAssertions:
|
||||
Enabled: false
|
||||
RSpec/Rails/InferredSpecType:
|
||||
Enabled: false
|
||||
Rails/ThreeStateBooleanColumn:
|
||||
Exclude:
|
||||
- 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
|
||||
- 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
|
||||
- 'db/migrate/20200606132552_create_labels.rb'
|
||||
- 'db/migrate/20201027135006_create_working_hours.rb'
|
||||
- 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
|
||||
- 'db/migrate/20210114202310_create_teams.rb'
|
||||
- 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
|
||||
- 'db/migrate/20210428135041_add_campaigns.rb'
|
||||
- 'db/migrate/20210602182058_add_hmac_to_api_channel.rb'
|
||||
- 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb'
|
||||
- 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb'
|
||||
- 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb'
|
||||
- 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb'
|
||||
- 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb'
|
||||
- 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb'
|
||||
- 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb'
|
||||
- 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb'
|
||||
- 'db/migrate/20220511072655_add_archive_column_to_portal.rb'
|
||||
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
||||
RSpec/IndexedLet:
|
||||
Enabled: false
|
||||
RSpec/MatchArray:
|
||||
Enabled: false
|
||||
Rails/ResponseParsedBody:
|
||||
Enabled: false
|
||||
RSpec/FactoryBot/ConsistentParenthesesStyle:
|
||||
Enabled: false
|
||||
Rails/ThreeStateBooleanColumn:
|
||||
Enabled: false
|
||||
Rails/Pluck:
|
||||
Enabled: false
|
||||
Rails/TopLevelHashWithIndifferentAccess:
|
||||
Enabled: false
|
||||
Rails/ActionOrder:
|
||||
Enabled: false
|
||||
Style/ArrayIntersect:
|
||||
Enabled: false
|
||||
RSpec/NoExpectationExample:
|
||||
Enabled: false
|
||||
Style/RedundantReturn:
|
||||
Enabled: false
|
||||
Rails/I18nLocaleTexts:
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
|
||||
|
||||
# we should bring this down
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 14
|
||||
|
||||
@@ -785,6 +785,7 @@ GEM
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
arm64-darwin-21
|
||||
arm64-darwin-22
|
||||
ruby
|
||||
x86_64-darwin-18
|
||||
x86_64-darwin-20
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.2.0
|
||||
2.17.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.1.0
|
||||
2.3.0
|
||||
|
||||
@@ -63,7 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
@conversation ||= Conversation.where(conversation_params).find_by(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@@ -94,8 +96,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
))
|
||||
end
|
||||
|
||||
@@ -103,10 +107,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id,
|
||||
additional_attributes: {
|
||||
type: 'instagram_direct_message'
|
||||
}
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
def show; end
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@article = @portal.articles.create!(article_params)
|
||||
@article.associate_root_article(article_params[:associated_article_id])
|
||||
@@ -23,10 +26,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@article.update!(article_params) if params[:article].present?
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
|
||||
@@ -6,6 +6,8 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
@automation_rules = Current.account.automation_rules
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions]
|
||||
@@ -28,8 +30,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
automation_rule_update
|
||||
|
||||
@@ -6,21 +6,21 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
||||
@campaigns = Current.account.campaigns
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@campaign = Current.account.campaigns.create!(campaign_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@campaign.update!(campaign_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@campaign.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@campaign.update!(campaign_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def campaign
|
||||
|
||||
@@ -9,6 +9,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
@categories = @portal.categories.search(params)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@category = @portal.categories.create!(category_params)
|
||||
@category.related_categories << related_categories_records
|
||||
@@ -17,8 +19,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
@category.save!
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@category.update!(category_params)
|
||||
@category.related_categories = related_categories_records if related_categories_records.any?
|
||||
|
||||
@@ -5,21 +5,21 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
|
||||
@notes = @contact.notes.latest.includes(:user)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@note = @contact.notes.create!(note_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@note.update(note_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@note.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@note.update(note_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def note
|
||||
|
||||
@@ -22,6 +22,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
def attachments
|
||||
@attachments = @conversation.attachments
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
||||
@@ -29,8 +35,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def filter
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
@conversations = result[:conversations]
|
||||
|
||||
@@ -34,9 +34,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def set_csat_survey_responses
|
||||
@csat_survey_responses = filtrate(
|
||||
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
|
||||
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
|
||||
base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
|
||||
@csat_survey_responses = filtrate(base_query).filter_by_created_at(range)
|
||||
.filter_by_assigned_agent_id(params[:user_ids])
|
||||
.filter_by_inbox_id(params[:inbox_id])
|
||||
.filter_by_team_id(params[:team_id])
|
||||
.filter_by_rating(params[:rating])
|
||||
end
|
||||
|
||||
def set_current_page_surveys
|
||||
|
||||
@@ -11,6 +11,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
|
||||
@custom_filter = current_user.custom_filters.create!(
|
||||
permitted_payload.merge(account_id: Current.account.id)
|
||||
)
|
||||
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -2,6 +2,11 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
|
||||
before_action :fetch_inbox
|
||||
before_action :current_agents_ids, only: [:create, :update]
|
||||
|
||||
def show
|
||||
authorize @inbox, :show?
|
||||
fetch_updated_agents
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @inbox, :create?
|
||||
ActiveRecord::Base.transaction do
|
||||
@@ -10,11 +15,6 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
|
||||
fetch_updated_agents
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @inbox, :show?
|
||||
fetch_updated_agents
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @inbox, :update?
|
||||
update_agents_list
|
||||
|
||||
@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def update
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
update_inbox_working_hours
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
if @inbox.inbox_type == 'Email'
|
||||
begin
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
@inbox.channel.reauthorized!
|
||||
end
|
||||
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
end
|
||||
|
||||
def update_channel
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
||||
|
||||
reauthorize_and_update_channel(channel_attributes)
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def channel_update_required?
|
||||
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
|
||||
end
|
||||
|
||||
def validate_and_update_email_channel(channel_attributes)
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
def reauthorize_and_update_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
return unless @inbox.web_widget?
|
||||
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
||||
|
||||
@@ -6,6 +6,10 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
@macros = Macro.with_visibility(current_user, params)
|
||||
end
|
||||
|
||||
def show
|
||||
head :not_found if @macro.nil?
|
||||
end
|
||||
|
||||
def create
|
||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@@ -18,8 +22,16 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
@macro
|
||||
end
|
||||
|
||||
def show
|
||||
head :not_found if @macro.nil?
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
process_attachments
|
||||
@macro.save!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -37,18 +49,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
process_attachments
|
||||
@macro.save!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def execute
|
||||
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
CustomExceptions::Account::UserErrors,
|
||||
with: :render_error_response
|
||||
|
||||
def show
|
||||
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
|
||||
render 'api/v1/accounts/show', format: :json
|
||||
end
|
||||
|
||||
def create
|
||||
@user, @account = AccountBuilder.new(
|
||||
account_name: account_params[:account_name],
|
||||
@@ -35,11 +40,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
render json: { cache_keys: get_cache_keys }, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
|
||||
render 'api/v1/accounts/show', format: :json
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
|
||||
end
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
def message_finder_params
|
||||
{
|
||||
filter_internal_messages: true,
|
||||
before: permitted_params[:before]
|
||||
before: permitted_params[:before],
|
||||
after: permitted_params[:after]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -62,7 +63,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -4,6 +4,16 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
skip_before_action :require_no_authentication, raise: false
|
||||
skip_before_action :authenticate_user!, raise: false
|
||||
|
||||
def create
|
||||
@user = User.find_by(email: params[:email])
|
||||
if @user
|
||||
@user.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
||||
else
|
||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
# params: reset_password_token, password, password_confirmation
|
||||
original_token = params[:reset_password_token]
|
||||
@@ -17,16 +27,6 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.find_by(email: params[:email])
|
||||
if @user
|
||||
@user.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
||||
else
|
||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reset_password_and_confirmation(recoverable)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController
|
||||
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
# Prevent session parameter from being passed
|
||||
# Unpermitted parameter: session
|
||||
wrap_parameters format: []
|
||||
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController
|
||||
class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
|
||||
def validate_token
|
||||
# @resource will have been set by set_user_by_token concern
|
||||
if @resource
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
class Platform::Api::V1::AccountsController < PlatformController
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@resource = Account.create!(account_params)
|
||||
update_resource_features
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@resource.assign_attributes(account_params)
|
||||
update_resource_features
|
||||
|
||||
@@ -6,14 +6,14 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@resource = AgentBot.new(agent_bot_params)
|
||||
@resource.save!
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@resource.update!(agent_bot_params)
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
before_action(only: [:login]) { set_resource }
|
||||
before_action(only: [:login]) { validate_platform_app_permissible }
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
|
||||
@resource.skip_confirmation!
|
||||
@@ -16,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
render json: { url: @resource.generate_sso_link }
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@resource.assign_attributes(user_update_params)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
before_action :contact_inbox, except: [:create]
|
||||
before_action :process_hmac
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
source_id = params[:source_id] || SecureRandom.uuid
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
@@ -11,8 +13,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
).perform
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
contact_identify_action = ContactIdentifyAction.new(
|
||||
contact: @contact_inbox.contact,
|
||||
|
||||
@@ -45,13 +45,17 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||
|
||||
def seed
|
||||
Internal::SeedAccountJob.perform_later(requested_resource)
|
||||
# rubocop:disable Rails/I18nLocaleTexts
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
|
||||
def destroy
|
||||
account = Account.find(params[:id])
|
||||
|
||||
DeleteObjectJob.perform_later(account) if account.present?
|
||||
# rubocop:disable Rails/I18nLocaleTexts
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class SwaggerController < ApplicationController
|
||||
def respond
|
||||
if Rails.env.development? || Rails.env.test?
|
||||
render inline: File.read(Rails.root.join('swagger', derived_path))
|
||||
render inline: Rails.root.join('swagger', derived_path).read
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
|
||||
@@ -5,7 +5,8 @@ class ConversationFinder
|
||||
SORT_OPTIONS = {
|
||||
latest: 'latest',
|
||||
sort_on_created_at: 'sort_on_created_at',
|
||||
last_user_message_at: 'last_user_message_at'
|
||||
last_user_message_at: 'last_user_message_at',
|
||||
sort_on_priority: 'sort_on_priority'
|
||||
}.with_indifferent_access
|
||||
|
||||
# assumptions
|
||||
@@ -53,9 +54,10 @@ class ConversationFinder
|
||||
|
||||
find_all_conversations
|
||||
filter_by_status unless params[:q]
|
||||
filter_by_team if @team
|
||||
filter_by_labels if params[:labels]
|
||||
filter_by_query if params[:q]
|
||||
filter_by_team
|
||||
filter_by_labels
|
||||
filter_by_query
|
||||
filter_by_source_id
|
||||
end
|
||||
|
||||
def set_inboxes
|
||||
@@ -106,6 +108,8 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def filter_by_query
|
||||
return unless params[:q]
|
||||
|
||||
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
|
||||
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
|
||||
.where(messages: { message_type: allowed_message_types }).includes(:messages)
|
||||
@@ -120,13 +124,24 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def filter_by_team
|
||||
return unless @team
|
||||
|
||||
@conversations = @conversations.where(team: @team)
|
||||
end
|
||||
|
||||
def filter_by_labels
|
||||
return unless params[:labels]
|
||||
|
||||
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||
end
|
||||
|
||||
def filter_by_source_id
|
||||
return unless params[:source_id]
|
||||
|
||||
@conversations = @conversations.joins(:contact_inbox)
|
||||
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
|
||||
@@ -28,6 +28,7 @@ import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
import * as chrono from 'chrono-node';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -81,6 +82,25 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
console.log(
|
||||
'An appointment on Sep 12-13unted --->',
|
||||
chrono.parseDate('An appointment on Sep 12-13')
|
||||
);
|
||||
console.log('tomorrow --->', chrono.parseDate('tomorrow'));
|
||||
console.log(
|
||||
'tomorrow at 9.00AM --->',
|
||||
chrono.parseDate('tomorrow at 9.00AM')
|
||||
);
|
||||
console.log('may 31 --->', chrono.parseDate('may 31'));
|
||||
console.log('12.30 --->', chrono.parseDate('12.30'));
|
||||
console.log('9.00AM --->', chrono.parseDate('9.00AM'));
|
||||
console.log('tuesday --->', chrono.parseDate('tuesday'));
|
||||
console.log('after 5 days --->', chrono.parseDate('after 5 days'));
|
||||
console.log('tom 13.00 --->', chrono.parseDate('tom 13.00'));
|
||||
console.log(
|
||||
'next month 10.00AM --->',
|
||||
chrono.parseDate('next month 10.00AM')
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
setLocale(locale) {
|
||||
|
||||
@@ -6,7 +6,7 @@ class CSATReportsAPI extends ApiClient {
|
||||
super('csat_survey_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page, from, to, user_ids } = {}) {
|
||||
get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
@@ -14,24 +14,31 @@ class CSATReportsAPI extends ApiClient {
|
||||
until: to,
|
||||
sort: '-created_at',
|
||||
user_ids,
|
||||
inbox_id,
|
||||
team_id,
|
||||
rating,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
download({ from, to, user_ids } = {}) {
|
||||
download({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
|
||||
return axios.get(`${this.url}/download`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
sort: '-created_at',
|
||||
user_ids,
|
||||
inbox_id,
|
||||
team_id,
|
||||
rating,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ from, to, user_ids } = {}) {
|
||||
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
|
||||
// no ratings for metrics
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: { since: from, until: to, user_ids },
|
||||
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,22 @@ class OpenAIAPI extends ApiClient {
|
||||
super('integrations', { accountScoped: true });
|
||||
}
|
||||
|
||||
processEvent({ name = 'rephrase', content, tone, hookId }) {
|
||||
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
||||
let data = {
|
||||
tone,
|
||||
content,
|
||||
};
|
||||
|
||||
if (type === 'reply_suggestion' || type === 'summarize') {
|
||||
data = {
|
||||
conversation_display_id: conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
||||
event: {
|
||||
name: name,
|
||||
data: {
|
||||
tone,
|
||||
content,
|
||||
},
|
||||
name: type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,12 +5,20 @@
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
.mx-datepicker {
|
||||
width: 100%;
|
||||
&.no-margin {
|
||||
.mx-input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-datepicker-range {
|
||||
width: 320px;
|
||||
&:not(.auto-width) {
|
||||
.mx-datepicker-range {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-datepicker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx-input {
|
||||
|
||||
@@ -254,7 +254,7 @@ $button-sizes: (tiny: var(--font-size-micro),
|
||||
default: var(--font-size-small),
|
||||
large: var(--font-size-medium));
|
||||
$button-palette: $foundation-palette;
|
||||
$button-opacity-disabled: 0.25;
|
||||
$button-opacity-disabled: 0.4;
|
||||
$button-background-hover-lightness: -20%;
|
||||
$button-hollow-hover-lightness: -50%;
|
||||
$button-transition: background-color 0.25s ease-out,
|
||||
|
||||
@@ -261,6 +261,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Basic filter dropdown
|
||||
.basic-filter {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
// Card label
|
||||
.label-container {
|
||||
.label {
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
margin-bottom: var(--space-normal);
|
||||
&:not(.no-margin) {
|
||||
margin-bottom: var(--space-normal);
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
opacity: 0.8;
|
||||
|
||||
@@ -115,12 +115,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import format from 'date-fns/format';
|
||||
import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
import { isValidURL } from '../helper/URLHelper';
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -144,9 +142,6 @@ export default {
|
||||
|
||||
computed: {
|
||||
formattedValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return format(new Date(this.value || new Date()), DATE_FORMAT);
|
||||
}
|
||||
if (this.isAttributeTypeCheckbox) {
|
||||
return this.value === 'false' ? false : this.value;
|
||||
}
|
||||
@@ -193,8 +188,11 @@ export default {
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
displayValue() {
|
||||
if (this.attributeType === 'date') {
|
||||
return format(new Date(this.editedValue), 'dd-MM-yyyy');
|
||||
if (this.attributeType === 'date' && this.editedValue) {
|
||||
return this.editedValue
|
||||
.split('-')
|
||||
.reverse()
|
||||
.join('-');
|
||||
}
|
||||
return this.editedValue;
|
||||
},
|
||||
@@ -247,17 +245,12 @@ export default {
|
||||
}
|
||||
},
|
||||
onUpdate() {
|
||||
const updatedValue =
|
||||
this.attributeType === 'date'
|
||||
? format(new Date(this.editedValue), DATE_FORMAT)
|
||||
: this.editedValue;
|
||||
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.isEditing = false;
|
||||
this.$emit('update', this.attributeKey, updatedValue);
|
||||
this.$emit('update', this.attributeKey, this.editedValue);
|
||||
},
|
||||
onDelete() {
|
||||
this.isEditing = false;
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
:disabled="isDisabled || isLoading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<spinner v-if="isLoading" size="small" />
|
||||
<spinner
|
||||
v-if="isLoading"
|
||||
size="small"
|
||||
:color-scheme="showDarkSpinner ? 'dark' : ''"
|
||||
/>
|
||||
<emoji-or-icon
|
||||
v-else-if="icon || emoji"
|
||||
class="icon"
|
||||
@@ -108,6 +112,14 @@ export default {
|
||||
return 16;
|
||||
}
|
||||
},
|
||||
showDarkSpinner() {
|
||||
return (
|
||||
this.colorScheme === 'secondary' ||
|
||||
this.variant === 'clear' ||
|
||||
this.variant === 'link' ||
|
||||
this.variant === 'hollow'
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick(evt) {
|
||||
|
||||
@@ -1,45 +1,70 @@
|
||||
<template>
|
||||
<div v-if="isAIIntegrationEnabled" class="position-relative">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
|
||||
icon="wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open ai-modal"
|
||||
>
|
||||
<h4 class="sub-block-title margin-top-1">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
|
||||
</label>
|
||||
<div class="tone__item">
|
||||
<select v-model="activeTone" class="status--filter small">
|
||||
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
|
||||
{{ tone.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer flex-container align-right">
|
||||
<woot-button variant="clear" size="small" @click="closeDropdown">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-loading="isGenerating"
|
||||
size="small"
|
||||
@click="processText"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</woot-button>
|
||||
<div v-if="!message">
|
||||
<woot-button
|
||||
v-if="isPrivateNote"
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
|
||||
icon="book-pulse"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:is-loading="uiFlags.summarize"
|
||||
@click="processEvent('summarize')"
|
||||
/>
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
|
||||
icon="wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:is-loading="uiFlags.reply_suggestion"
|
||||
@click="processEvent('reply_suggestion')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
|
||||
icon="text-grammar-wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open ai-modal"
|
||||
>
|
||||
<h4 class="sub-block-title margin-top-1">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
|
||||
</label>
|
||||
<div class="tone__item">
|
||||
<select v-model="activeTone" class="status--filter small">
|
||||
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
|
||||
{{ tone.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer flex-container align-right">
|
||||
<woot-button variant="clear" size="small" @click="closeDropdown">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-loading="uiFlags.rephrase"
|
||||
size="small"
|
||||
@click="processEvent('rephrase')"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +74,7 @@ import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin, clickaway],
|
||||
@@ -61,10 +87,18 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isGenerating: false,
|
||||
uiFlags: {
|
||||
rephrase: false,
|
||||
reply_suggestion: false,
|
||||
summarize: false,
|
||||
},
|
||||
showDropdown: false,
|
||||
activeTone: 'professional',
|
||||
tones: [
|
||||
@@ -94,7 +128,7 @@ export default {
|
||||
).hooks[0].id;
|
||||
},
|
||||
buttonText() {
|
||||
return this.isGenerating
|
||||
return this.uiFlags.isRephrasing
|
||||
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
|
||||
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
|
||||
},
|
||||
@@ -111,24 +145,35 @@ export default {
|
||||
closeDropdown() {
|
||||
this.showDropdown = false;
|
||||
},
|
||||
async processText() {
|
||||
this.isGenerating = true;
|
||||
async recordAnalytics({ type, tone }) {
|
||||
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||
if (event) {
|
||||
this.$track(event, {
|
||||
type,
|
||||
tone,
|
||||
});
|
||||
}
|
||||
},
|
||||
async processEvent(type = 'rephrase') {
|
||||
this.uiFlags[type] = true;
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
hookId: this.hookId,
|
||||
type: 'rephrase',
|
||||
type,
|
||||
content: this.message,
|
||||
tone: this.activeTone,
|
||||
conversationId: this.conversationId,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedMessage },
|
||||
} = result;
|
||||
this.$emit('replace-text', generatedMessage || this.message);
|
||||
this.closeDropdown();
|
||||
this.recordAnalytics({ type, tone: this.activeTone });
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
this.uiFlags[type] = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,15 +13,19 @@ import videojs from 'video.js';
|
||||
import alertMixin from '../../../../shared/mixins/alertMixin';
|
||||
|
||||
import Recorder from 'opus-recorder';
|
||||
|
||||
// Workers to record Audio .ogg and .wav
|
||||
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
|
||||
import waveWorker from 'opus-recorder/dist/waveWorker.min';
|
||||
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
||||
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
||||
|
||||
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
||||
import 'videojs-record/dist/videojs.record.js';
|
||||
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
||||
|
||||
import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
||||
|
||||
import { format, addSeconds } from 'date-fns';
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
|
||||
@@ -33,7 +37,7 @@ export default {
|
||||
props: {
|
||||
audioRecordFormat: {
|
||||
type: String,
|
||||
default: AUDIO_FORMATS.WEBM,
|
||||
default: AUDIO_FORMATS.WAV,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
@@ -79,24 +83,18 @@ export default {
|
||||
maxLength: 900,
|
||||
timeSlice: 1000,
|
||||
maxFileSize: 15 * 1024 * 1024,
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && {
|
||||
monitorGain: 0,
|
||||
recordingGain: 1,
|
||||
numberOfChannels: 1,
|
||||
encoderSampleRate: 16000,
|
||||
originalSampleRateOverride: 16000,
|
||||
streamPages: true,
|
||||
maxFramesPerPage: 1,
|
||||
encoderFrameSize: 1,
|
||||
encoderPath: waveWorker,
|
||||
displayMilliseconds: false,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
audioEngine: 'opus-recorder',
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
|
||||
audioMimeType: 'audio/wav',
|
||||
audioWorkerURL: waveWorker,
|
||||
}),
|
||||
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
|
||||
displayMilliseconds: false,
|
||||
audioEngine: 'opus-recorder',
|
||||
audioMimeType: 'audio/ogg',
|
||||
audioWorkerURL: encoderWorker,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -134,6 +132,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
deviceReady() {
|
||||
if (this.player.record().engine instanceof OpusRecorderEngine) {
|
||||
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
|
||||
this.player.record().engine.audioType = 'audio/wav';
|
||||
}
|
||||
}
|
||||
this.player.record().start();
|
||||
},
|
||||
startRecord() {
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
</file-upload>
|
||||
<woot-button
|
||||
v-if="showAudioRecorderButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
|
||||
emoji="🎤"
|
||||
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<woot-button
|
||||
@@ -92,8 +92,8 @@
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<AIAssistanceButton
|
||||
v-if="message"
|
||||
:conversation-id="conversationId"
|
||||
:is-private-note="isOnPrivateNote"
|
||||
:message="message"
|
||||
@replace-text="replaceText"
|
||||
/>
|
||||
|
||||
@@ -45,6 +45,14 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
collection() {
|
||||
this.renderChart(this.collection, {
|
||||
...chartOptions,
|
||||
...this.chartOptions,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.renderChart(this.collection, {
|
||||
...chartOptions,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}"
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="cardClick(chat)"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
@@ -313,21 +313,33 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
cardClick(chat) {
|
||||
const { activeInbox } = this;
|
||||
const path = conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
});
|
||||
onCardClick(e) {
|
||||
const { activeInbox, chat } = this;
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
})
|
||||
);
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
window.open(
|
||||
window.chatwootConfig.hostURL + path,
|
||||
'_blank',
|
||||
'noopener noreferrer nofollow'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isActiveChat) {
|
||||
return;
|
||||
}
|
||||
router.push({ path: frontendURL(path) });
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
onCardHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
|
||||
@@ -25,10 +25,14 @@
|
||||
<blockquote v-if="storyReply" class="story-reply-quote">
|
||||
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
|
||||
<bubble-image
|
||||
v-if="!hasStoryError"
|
||||
v-if="!hasImgStoryError && storyUrl"
|
||||
:url="storyUrl"
|
||||
@error="onStoryLoadError"
|
||||
/>
|
||||
<bubble-video
|
||||
v-else-if="hasImgStoryError && storyUrl"
|
||||
:url="storyUrl"
|
||||
/>
|
||||
</blockquote>
|
||||
<bubble-text
|
||||
v-if="data.content"
|
||||
@@ -59,7 +63,7 @@
|
||||
controls
|
||||
class="skip-context-menu"
|
||||
>
|
||||
<source :src="attachment.data_url" />
|
||||
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
<bubble-video
|
||||
v-else-if="attachment.file_type === 'video'"
|
||||
@@ -200,7 +204,7 @@ export default {
|
||||
hasImageError: false,
|
||||
contextMenuPosition: {},
|
||||
showBackgroundHighlight: false,
|
||||
hasStoryError: false,
|
||||
hasImgStoryError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -429,12 +433,12 @@ export default {
|
||||
watch: {
|
||||
data() {
|
||||
this.hasImageError = false;
|
||||
this.hasStoryError = false;
|
||||
this.hasImgStoryError = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.hasImageError = false;
|
||||
this.hasStoryError = false;
|
||||
this.hasImgStoryError = false;
|
||||
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
||||
this.setupHighlightTimer();
|
||||
},
|
||||
@@ -464,7 +468,7 @@ export default {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
onStoryLoadError() {
|
||||
this.hasStoryError = true;
|
||||
this.hasImgStoryError = true;
|
||||
},
|
||||
openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
v-for="message in getReadMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
@@ -56,6 +57,7 @@
|
||||
v-for="message in getUnReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
|
||||
@@ -501,10 +501,10 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWebWidgetInbox) {
|
||||
return AUDIO_FORMATS.WEBM;
|
||||
if (this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
return AUDIO_FORMATS.OGG;
|
||||
return AUDIO_FORMATS.WAV;
|
||||
},
|
||||
messageVariables() {
|
||||
const variables = getMessageVariables({
|
||||
|
||||
@@ -79,6 +79,7 @@ import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
|
||||
import agentMixin from 'dashboard/mixins/agentMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
export default {
|
||||
@@ -87,7 +88,7 @@ export default {
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
},
|
||||
mixins: [snoozeTimesMixin],
|
||||
mixins: [snoozeTimesMixin, agentMixin],
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
@@ -202,6 +203,16 @@ export default {
|
||||
teams: 'teams/getTeams',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgentOnAvailability() {
|
||||
const agents = this.$store.getters[
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
](this.inboxId);
|
||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||
const filteredAgents = this.sortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
return filteredAgents;
|
||||
},
|
||||
assignableAgents() {
|
||||
return [
|
||||
{
|
||||
@@ -212,9 +223,7 @@ export default {
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.inboxId
|
||||
),
|
||||
...this.filteredAgentOnAvailability,
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -246,6 +255,7 @@ export default {
|
||||
...(type === 'icon' && { icon: option.icon }),
|
||||
...(type === 'label' && { color: option.color }),
|
||||
...(type === 'agent' && { thumbnail: option.thumbnail }),
|
||||
...(type === 'agent' && { status: option.availability_status }),
|
||||
...(type === 'text' && { label: option.label }),
|
||||
...(type === 'label' && { label: option.title }),
|
||||
...(type === 'agent' && { label: option.name }),
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:src="option.thumbnail"
|
||||
:status="option.status"
|
||||
size="20px"
|
||||
class="agent-thumbnail"
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ export default {
|
||||
SORT_BY_TYPE: {
|
||||
LATEST: 'latest',
|
||||
CREATED_AT: 'sort_on_created_at',
|
||||
PRIORITY: 'sort_on_priority',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -76,3 +76,9 @@ export const PORTALS_EVENTS = Object.freeze({
|
||||
DELETE_ARTICLE: 'Deleted an article',
|
||||
PREVIEW_ARTICLE: 'Previewed article',
|
||||
});
|
||||
|
||||
export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import es from './locale/es';
|
||||
import fa from './locale/fa';
|
||||
import fi from './locale/fi';
|
||||
import fr from './locale/fr';
|
||||
import he from './locale/he';
|
||||
import hi from './locale/hi';
|
||||
import hu from './locale/hu';
|
||||
import id from './locale/id';
|
||||
@@ -47,6 +48,7 @@ export default {
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
he,
|
||||
hi,
|
||||
hu,
|
||||
id,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Canned Response added successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not create canned response, Please try again later"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -56,14 +56,14 @@
|
||||
"BUTTON_TEXT": "Edit",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Canned Response updated successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not update canned response, Please try again later"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Canned response deleted successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not delete canned response, Please try again later"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
},
|
||||
"sort_on_created_at": {
|
||||
"TEXT": "Created at"
|
||||
},
|
||||
"sort_on_priority": {
|
||||
"TEXT": "Priority"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
"PLACEHOLDER": "Tell us more...",
|
||||
"RATINGS": {
|
||||
"POOR": "😞 Poor",
|
||||
"FAIR": "😑 Fair",
|
||||
"AVERAGE": "😐 Average",
|
||||
"GOOD": "😀 Good",
|
||||
"EXCELLENT": "😍 Excellent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
"DELETE": "Delete article"
|
||||
}
|
||||
},
|
||||
"ARTICLE_SEARCH_RESULT": {
|
||||
"UNCATEGORIZED": "Uncategorized",
|
||||
"INSERT_ARTICLE": "Insert",
|
||||
"NO_RESULT": "No articles found",
|
||||
"COPY_LINK": "Copy article link to clipboard",
|
||||
"OPEN_LINK": "Open article in new tab",
|
||||
"PREVIEW_LINK": "Preview article"
|
||||
},
|
||||
"PORTAL": {
|
||||
"HEADER": "Portals",
|
||||
"DEFAULT": "Default",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"CHANNEL_GREETING_TOGGLE": {
|
||||
"LABEL": "Enable channel greeting",
|
||||
"HELP_TEXT": "Automatically send a greeting message when a new conversation is created.",
|
||||
"HELP_TEXT": "Auto-send greeting messages when customers start a conversation and send their first message.",
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
},
|
||||
"OPEN_AI": {
|
||||
"TITLE": "Improve With AI",
|
||||
"SUMMARY_TITLE": "Summary with AI",
|
||||
"REPLY_TITLE": "Reply suggestion with AI",
|
||||
"SUBTITLE": "An improved reply will be generated using AI, based on your current draft.",
|
||||
"TONE": {
|
||||
"TITLE": "Tone",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
||||
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -34,6 +36,14 @@
|
||||
"DESC": "( Total )"
|
||||
}
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"CUSTOM_DATE_RANGE": "Custom date range"
|
||||
},
|
||||
"DATE_RANGE": [
|
||||
{
|
||||
"id": 0,
|
||||
@@ -66,6 +76,12 @@
|
||||
},
|
||||
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
|
||||
"DURATION_FILTER_LABEL": "Duration",
|
||||
"GROUPING_OPTIONS": {
|
||||
"DAY": "Day",
|
||||
"WEEK": "Week",
|
||||
"MONTH": "Month",
|
||||
"YEAR": "Year"
|
||||
},
|
||||
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
|
||||
"GROUP_BY_WEEK_OPTIONS": [
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
@@ -356,6 +372,7 @@
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
"DOWNLOAD": "Download CSAT Reports",
|
||||
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
|
||||
"FILTERS": {
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "Choose Agents"
|
||||
|
||||
@@ -2,39 +2,68 @@ import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.inboxId
|
||||
);
|
||||
},
|
||||
...mapGetters({ currentUser: 'getCurrentUser' }),
|
||||
isAgentSelected() {
|
||||
return this.currentChat?.meta?.assignee;
|
||||
},
|
||||
createNoneAgent() {
|
||||
return {
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
};
|
||||
},
|
||||
agentsList() {
|
||||
const agents = this.assignableAgents || [];
|
||||
return [
|
||||
...(this.isAgentSelected
|
||||
? [
|
||||
{
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...agents,
|
||||
].map(item =>
|
||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||
const none = this.createNoneAgent;
|
||||
const filteredAgentsByAvailability = this.sortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
const filteredAgents = [
|
||||
...(this.isAgentSelected ? [none] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
];
|
||||
return filteredAgents;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getAgentsByAvailability(agents, availability) {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
sortedAgentsByAvailability(agents) {
|
||||
const onlineAgents = this.getAgentsByAvailability(agents, 'online');
|
||||
const busyAgents = this.getAgentsByAvailability(agents, 'busy');
|
||||
const offlineAgents = this.getAgentsByAvailability(agents, 'offline');
|
||||
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
|
||||
return filteredAgents;
|
||||
},
|
||||
getAgentsByUpdatedPresence(agents) {
|
||||
// Here we are updating the availability status of the current user dynamically (live) based on the current account availability status
|
||||
const agentsWithDynamicPresenceUpdate = agents.map(item =>
|
||||
item.id === this.currentUser.id
|
||||
? {
|
||||
...item,
|
||||
availability_status: this.currentUser.availability_status,
|
||||
availability_status: this.currentUser.accounts.find(
|
||||
account => account.id === this.currentAccountId
|
||||
).availability_status,
|
||||
}
|
||||
: item
|
||||
);
|
||||
return agentsWithDynamicPresenceUpdate;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,36 @@ export default {
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
formattedAgents: [
|
||||
{
|
||||
@@ -32,7 +62,17 @@ export default {
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
@@ -40,6 +80,70 @@ export default {
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
onlineAgents: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
],
|
||||
busyAgents: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
@@ -51,4 +155,92 @@ export default {
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
offlineAgents: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
sortedByAvailability: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
formattedAgentsByPresenceOnline: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abr@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
formattedAgentsByPresenceOffline: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abr@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,12 +12,71 @@ describe('agentMixin', () => {
|
||||
getters = {
|
||||
getCurrentUser: () => ({
|
||||
id: 1,
|
||||
availability_status: 'busy',
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
availability_status: 'online',
|
||||
auto_offline: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
getCurrentAccountId: () => 1,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
||||
it('return agents by availability', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [agentMixin],
|
||||
data() {
|
||||
return {
|
||||
inboxId: 1,
|
||||
currentChat: { meta: { assignee: { name: 'John' } } },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
assignableAgents() {
|
||||
return agentFixtures.allAgents;
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(
|
||||
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'online')
|
||||
).toEqual(agentFixtures.onlineAgents);
|
||||
expect(
|
||||
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'busy')
|
||||
).toEqual(agentFixtures.busyAgents);
|
||||
expect(
|
||||
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'offline')
|
||||
).toEqual(agentFixtures.offlineAgents);
|
||||
});
|
||||
|
||||
it('return sorted agents by availability', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [agentMixin],
|
||||
data() {
|
||||
return {
|
||||
inboxId: 1,
|
||||
currentChat: { meta: { assignee: { name: 'John' } } },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
assignableAgents() {
|
||||
return agentFixtures.allAgents;
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(
|
||||
wrapper.vm.sortedAgentsByAvailability(agentFixtures.allAgents)
|
||||
).toEqual(agentFixtures.sortedByAvailability);
|
||||
});
|
||||
|
||||
it('return formatted agents', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
@@ -38,4 +97,44 @@ describe('agentMixin', () => {
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents);
|
||||
});
|
||||
|
||||
it('return formatted agents by presence', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [agentMixin],
|
||||
data() {
|
||||
return {
|
||||
inboxId: 1,
|
||||
currentChat: { meta: { assignee: { name: 'John' } } },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentUser() {
|
||||
return {
|
||||
id: 1,
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
availability_status: 'offline',
|
||||
auto_offline: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
currentAccountId() {
|
||||
return 1;
|
||||
},
|
||||
assignableAgents() {
|
||||
return agentFixtures.allAgents;
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(
|
||||
wrapper.vm.getAgentsByUpdatedPresence(
|
||||
agentFixtures.formattedAgentsByPresenceOnline
|
||||
)
|
||||
).toEqual(agentFixtures.formattedAgentsByPresenceOffline);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('#messageTimestamp', () => {
|
||||
|
||||
describe('#dynamicTime', () => {
|
||||
it('returns correct value', () => {
|
||||
Date.now = jest.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
|
||||
expect(TimeMixin.methods.dynamicTime(1612971343)).toEqual(
|
||||
'about 2 years ago'
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ export default {
|
||||
const errorMessage = error?.message;
|
||||
this.alertMessage =
|
||||
errorMessage || this.filterType === 0
|
||||
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.ERROR_MESSAGE')
|
||||
? errorMessage
|
||||
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
|
||||
} finally {
|
||||
this.showAlert(this.alertMessage);
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="article-item">
|
||||
<h4 class="text-block-title margin-bottom-0">{{ title }}</h4>
|
||||
<p class="margin-bottom-0 text-truncate">{{ body }}</p>
|
||||
<div class="footer">
|
||||
<p class="text-small meta">
|
||||
{{ locale }}
|
||||
{{ ` / ` }}
|
||||
{{
|
||||
category ||
|
||||
$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.ARTICLE_SEARCH_RESULT')
|
||||
}}
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<woot-button
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.COPY_LINK')"
|
||||
variant="hollow"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
icon="copy"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
|
||||
<a
|
||||
:href="url"
|
||||
class="button hollow button--only-icon tiny secondary"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.OPEN_LINK')"
|
||||
>
|
||||
<fluent-icon size="12" icon="arrow-up-right" />
|
||||
<span class="show-for-sr">{{ url }}</span>
|
||||
</a>
|
||||
<woot-button
|
||||
variant="hollow"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
icon="preview-link"
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.PREVIEW_LINK')"
|
||||
@click="handlePreview"
|
||||
/>
|
||||
<woot-button
|
||||
class="insert-button"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
export default {
|
||||
name: 'ArticleSearchResultItem',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Untitled',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
default: 'en-US',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick() {
|
||||
this.$emit('click');
|
||||
},
|
||||
handlePreview() {
|
||||
this.$emit('preview');
|
||||
},
|
||||
async handleCopy() {
|
||||
await copyTextToClipboard(this.url);
|
||||
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.article-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-micro);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-micro);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--s-600);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-micro);
|
||||
}
|
||||
.action-buttons .button:not(.insert-button) {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.article-item:hover .action-buttons .button:not(.insert-button) {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import ArticleSearchResultItem from '../ArticleSearchResultItem.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center/ArticleSearchResultItem',
|
||||
component: ArticleSearchResultItem,
|
||||
argTypes: {
|
||||
title: {
|
||||
defaultValue: 'Setup your account',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
body: {
|
||||
defaultValue:
|
||||
'You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
category: {
|
||||
defaultValue: 'Getting started',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
defaultValue: 'en-US',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
url: {
|
||||
defaultValue: '/app/accounts/1/conversations/23842',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { ArticleSearchResultItem },
|
||||
template:
|
||||
'<ArticleSearchResultItem v-bind="$props" ></ArticleSearchResultItem>',
|
||||
});
|
||||
|
||||
export const ArticleSearchResultItemStory = Template.bind({});
|
||||
ArticleSearchResultItemStory.args = {
|
||||
title: 'Setup your account',
|
||||
body: `You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.
|
||||
|
||||
You can manage your conversations and collaborate with your team on the go with Chatwoot mobile apps (available for Android and iOS).
|
||||
|
||||
In this user guide, we’ve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
|
||||
};
|
||||
+4
-1
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<thumbnail
|
||||
v-if="notificationItem.primary_actor.meta.assignee"
|
||||
v-if="hasAssignee(notificationItem)"
|
||||
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
|
||||
size="16px"
|
||||
:username="notificationItem.primary_actor.meta.assignee.name"
|
||||
@@ -127,6 +127,9 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
hasAssignee(notification) {
|
||||
return notification.primary_actor.meta?.assignee;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
{{ auditLogItem.remote_address }}
|
||||
</td>
|
||||
<td class="wrap-break-words">
|
||||
{{ dynamicTime(auditLogItem.created_at) }}
|
||||
{{
|
||||
messageTimestamp(
|
||||
auditLogItem.created_at,
|
||||
'MMM dd, yyyy hh:mm a'
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
|
||||
:header-content="$t('CANNED_MGMT.ADD.DESC')"
|
||||
/>
|
||||
<form class="row" @submit.prevent="addAgent()">
|
||||
<form class="row" @submit.prevent="addCannedResponse()">
|
||||
<div class="medium-12 columns">
|
||||
<label :class="{ error: $v.shortCode.$error }">
|
||||
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
|
||||
@@ -107,7 +107,7 @@ export default {
|
||||
this.$v.shortCode.$reset();
|
||||
this.$v.content.$reset();
|
||||
},
|
||||
addAgent() {
|
||||
addCannedResponse() {
|
||||
// Show loading on button
|
||||
this.addCanned.showLoading = true;
|
||||
// Make API Calls
|
||||
@@ -123,9 +123,11 @@ export default {
|
||||
this.resetForm();
|
||||
this.onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
this.addCanned.showLoading = false;
|
||||
this.showAlert(this.$t('CANNED_MGMT.ADD.API.ERROR_MESSAGE'));
|
||||
const errorMessage =
|
||||
error?.message || this.$t('CANNED_MGMT.ADD.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import Modal from '../../../../components/Modal';
|
||||
|
||||
export default {
|
||||
@@ -65,6 +66,7 @@ export default {
|
||||
Modal,
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
id: { type: Number, default: null },
|
||||
edcontent: { type: String, default: '' },
|
||||
@@ -76,7 +78,6 @@ export default {
|
||||
editCanned: {
|
||||
showAlert: false,
|
||||
showLoading: false,
|
||||
message: '',
|
||||
},
|
||||
shortCode: this.edshortCode,
|
||||
content: this.edcontent,
|
||||
@@ -102,9 +103,6 @@ export default {
|
||||
this.$v.content.$touch();
|
||||
this.content = name;
|
||||
},
|
||||
showAlert() {
|
||||
bus.$emit('newToastMessage', this.editCanned.message);
|
||||
},
|
||||
resetForm() {
|
||||
this.shortCode = '';
|
||||
this.content = '';
|
||||
@@ -124,21 +122,17 @@ export default {
|
||||
.then(() => {
|
||||
// Reset Form, Show success message
|
||||
this.editCanned.showLoading = false;
|
||||
this.editCanned.message = this.$t(
|
||||
'CANNED_MGMT.EDIT.API.SUCCESS_MESSAGE'
|
||||
);
|
||||
this.showAlert();
|
||||
this.showAlert(this.$t('CANNED_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
this.resetForm();
|
||||
setTimeout(() => {
|
||||
this.onClose();
|
||||
}, 10);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
this.editCanned.showLoading = false;
|
||||
this.editCanned.message = this.$t(
|
||||
'CANNED_MGMT.EDIT.API.ERROR_MESSAGE'
|
||||
);
|
||||
this.showAlert();
|
||||
const errorMessage =
|
||||
error?.message || this.$t('CANNED_MGMT.EDIT.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -198,8 +198,10 @@ export default {
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(() => {
|
||||
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE'));
|
||||
.catch(error => {
|
||||
const errorMessage =
|
||||
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="column content-box">
|
||||
<report-filter-selector
|
||||
agents-filter
|
||||
:agents-filter-items-list="agentList"
|
||||
:show-agents-filter="true"
|
||||
:show-inbox-filter="true"
|
||||
:show-rating-filter="true"
|
||||
:show-team-filter="isTeamsEnabled"
|
||||
:show-business-hours-switch="false"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@agents-filter-change="onAgentsFilterChange"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
@@ -15,7 +16,7 @@
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
|
||||
</woot-button>
|
||||
<csat-metrics />
|
||||
<csat-metrics :filters="requestPayload" />
|
||||
<csat-table :page-index="pageIndex" @page-change="onPageNumberChange" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,9 +24,11 @@
|
||||
import CsatMetrics from './components/CsatMetrics';
|
||||
import CsatTable from './components/CsatTable';
|
||||
import ReportFilterSelector from './components/FilterSelector';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { generateFileName } from '../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import alertMixin from '../../../../../shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
name: 'CsatResponses',
|
||||
@@ -34,39 +37,78 @@ export default {
|
||||
CsatTable,
|
||||
ReportFilterSelector,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
data() {
|
||||
return { pageIndex: 1, from: 0, to: 0, userIds: [] };
|
||||
return {
|
||||
pageIndex: 1,
|
||||
from: 0,
|
||||
to: 0,
|
||||
userIds: [],
|
||||
inbox: null,
|
||||
team: null,
|
||||
rating: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledOnAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
this.$store.dispatch('csat/getMetrics', {
|
||||
requestPayload() {
|
||||
return {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
user_ids: this.userIds,
|
||||
});
|
||||
this.getResponses();
|
||||
inbox_id: this.inbox,
|
||||
team_id: this.team,
|
||||
rating: this.rating,
|
||||
};
|
||||
},
|
||||
isTeamsEnabled() {
|
||||
return this.isFeatureEnabledOnAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.TEAM_MANAGEMENT
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
try {
|
||||
this.$store.dispatch('csat/getMetrics', this.requestPayload);
|
||||
this.getResponses();
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
|
||||
}
|
||||
},
|
||||
getResponses() {
|
||||
this.$store.dispatch('csat/get', {
|
||||
page: this.pageIndex,
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
user_ids: this.userIds,
|
||||
...this.requestPayload,
|
||||
});
|
||||
},
|
||||
downloadReports() {
|
||||
const type = 'csat';
|
||||
try {
|
||||
this.$store.dispatch('csat/downloadCSATReports', {
|
||||
fileName: generateFileName({ type, to: this.to }),
|
||||
...this.requestPayload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('REPORT.CSAT_REPORTS.DOWNLOAD_FAILED'));
|
||||
}
|
||||
},
|
||||
onPageNumberChange(pageIndex) {
|
||||
this.pageIndex = pageIndex;
|
||||
this.getResponses();
|
||||
},
|
||||
onDateRangeChange({ from, to }) {
|
||||
onFilterChange({
|
||||
from,
|
||||
to,
|
||||
selectedAgents,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
}) {
|
||||
// do not track filter change on inital load
|
||||
if (this.from !== 0 && this.to !== 0) {
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
@@ -74,27 +116,16 @@ export default {
|
||||
reportType: 'csat',
|
||||
});
|
||||
}
|
||||
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.userIds = selectedAgents.map(el => el.id);
|
||||
this.inbox = selectedInbox?.id;
|
||||
this.team = selectedTeam?.id;
|
||||
this.rating = selectedRating?.value;
|
||||
|
||||
this.getAllData();
|
||||
},
|
||||
onAgentsFilterChange(agents) {
|
||||
this.userIds = agents.map(el => el.id);
|
||||
this.getAllData();
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'agent',
|
||||
reportType: 'csat',
|
||||
});
|
||||
},
|
||||
downloadReports() {
|
||||
const type = 'csat';
|
||||
this.$store.dispatch('csat/downloadCSATReports', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
user_ids: this.userIds,
|
||||
fileName: generateFileName({ type, to: this.to }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,9 @@
|
||||
{{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }}
|
||||
</woot-button>
|
||||
<report-filter-selector
|
||||
group-by-filter
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
:filter-items-list="filterItemsList"
|
||||
@date-range-change="onDateRangeChange"
|
||||
:show-agents-filter="false"
|
||||
:show-group-by-filter="true"
|
||||
@filter-change="onFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<div class="row">
|
||||
<woot-report-stats-card
|
||||
@@ -55,7 +52,8 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import ReportFilterSelector from './components/FilterSelector';
|
||||
import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
|
||||
import reportMixin from '../../../../mixins/reportMixin';
|
||||
import reportMixin from 'dashboard/mixins/reportMixin';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
@@ -73,15 +71,13 @@ export default {
|
||||
components: {
|
||||
ReportFilterSelector,
|
||||
},
|
||||
mixins: [reportMixin],
|
||||
mixins: [reportMixin, alertMixin],
|
||||
data() {
|
||||
return {
|
||||
from: 0,
|
||||
to: 0,
|
||||
currentSelection: 0,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
filterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
|
||||
selectedGroupByFilter: {},
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
@@ -96,7 +92,7 @@ export default {
|
||||
}
|
||||
if (!this.accountReport.data.length) return {};
|
||||
const labels = this.accountReport.data.map(element => {
|
||||
if (this.groupBy.period === GROUP_BY_FILTER[2].period) {
|
||||
if (this.groupBy?.period === GROUP_BY_FILTER[2].period) {
|
||||
let week_date = new Date(fromUnixTime(element.timestamp));
|
||||
const first_day = week_date.getDate() - week_date.getDay();
|
||||
const last_day = first_day + 6;
|
||||
@@ -109,10 +105,10 @@ export default {
|
||||
'dd/MM/yy'
|
||||
)}`;
|
||||
}
|
||||
if (this.groupBy.period === GROUP_BY_FILTER[3].period) {
|
||||
if (this.groupBy?.period === GROUP_BY_FILTER[3].period) {
|
||||
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
|
||||
}
|
||||
if (this.groupBy.period === GROUP_BY_FILTER[4].period) {
|
||||
if (this.groupBy?.period === GROUP_BY_FILTER[4].period) {
|
||||
return format(fromUnixTime(element.timestamp), 'yyyy');
|
||||
}
|
||||
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
|
||||
@@ -191,24 +187,35 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
fetchAllData() {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountSummary', {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy.period,
|
||||
businessHours,
|
||||
});
|
||||
this.fetchAccountSummary();
|
||||
this.fetchChartData();
|
||||
},
|
||||
fetchAccountSummary() {
|
||||
try {
|
||||
this.$store.dispatch('fetchAccountSummary', this.getRequestPayload());
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
try {
|
||||
this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.metrics[this.currentSelection].KEY,
|
||||
...this.getRequestPayload(),
|
||||
});
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
|
||||
}
|
||||
},
|
||||
getRequestPayload() {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.metrics[this.currentSelection].KEY,
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy.period,
|
||||
groupBy: groupBy?.period,
|
||||
businessHours,
|
||||
});
|
||||
};
|
||||
},
|
||||
downloadAgentReports() {
|
||||
const { from, to } = this;
|
||||
@@ -222,57 +229,15 @@ export default {
|
||||
this.currentSelection = index;
|
||||
this.fetchChartData();
|
||||
},
|
||||
onDateRangeChange({ from, to, groupBy }) {
|
||||
// do not track filter change on inital load
|
||||
if (this.from !== 0 && this.to !== 0) {
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'date',
|
||||
reportType: 'conversations',
|
||||
});
|
||||
}
|
||||
onFilterChange({ from, to, groupBy, businessHours }) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.filterItemsList = this.fetchFilterItems(groupBy);
|
||||
const filterItems = this.filterItemsList.filter(
|
||||
item => item.id === this.groupBy.id
|
||||
);
|
||||
if (filterItems.length > 0) {
|
||||
this.selectedGroupByFilter = filterItems[0];
|
||||
} else {
|
||||
this.selectedGroupByFilter = this.filterItemsList[0];
|
||||
this.groupBy = GROUP_BY_FILTER[this.selectedGroupByFilter.id];
|
||||
}
|
||||
this.fetchAllData();
|
||||
},
|
||||
onFilterChange(payload) {
|
||||
this.groupBy = GROUP_BY_FILTER[payload.id];
|
||||
this.groupBy = groupBy;
|
||||
this.businessHours = businessHours;
|
||||
this.fetchAllData();
|
||||
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'groupBy',
|
||||
filterValue: this.groupBy?.period,
|
||||
reportType: 'conversations',
|
||||
});
|
||||
},
|
||||
fetchFilterItems(groupBy) {
|
||||
switch (groupBy) {
|
||||
case GROUP_BY_FILTER[2].period:
|
||||
return this.$t('REPORT.GROUP_BY_WEEK_OPTIONS');
|
||||
case GROUP_BY_FILTER[3].period:
|
||||
return this.$t('REPORT.GROUP_BY_MONTH_OPTIONS');
|
||||
case GROUP_BY_FILTER[4].period:
|
||||
return this.$t('REPORT.GROUP_BY_YEAR_OPTIONS');
|
||||
default:
|
||||
return this.$t('REPORT.GROUP_BY_DAY_OPTIONS');
|
||||
}
|
||||
},
|
||||
onBusinessHoursToggle(value) {
|
||||
this.businessHours = value;
|
||||
this.fetchAllData();
|
||||
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'businessHours',
|
||||
filterValue: value,
|
||||
filterValue: { from, to, groupBy, businessHours },
|
||||
reportType: 'conversations',
|
||||
});
|
||||
},
|
||||
|
||||
+17
-1
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="medium-2 small-6 csat--metric-card">
|
||||
<div
|
||||
class="medium-2 small-6 csat--metric-card"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
}"
|
||||
>
|
||||
<h3 class="heading">
|
||||
<span>{{ label }}</span>
|
||||
<fluent-icon
|
||||
@@ -29,6 +34,10 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -37,6 +46,13 @@ export default {
|
||||
margin: 0;
|
||||
padding: var(--space-normal);
|
||||
|
||||
&.disabled {
|
||||
// grayscale everything
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
align-items: center;
|
||||
color: var(--color-heading);
|
||||
|
||||
+21
-5
@@ -6,16 +6,20 @@
|
||||
:value="responseCount"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:disabled="ratingFilterEnabled"
|
||||
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
|
||||
:value="formatToPercent(satisfactionScore)"
|
||||
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(responseRate)"
|
||||
/>
|
||||
<div v-if="metrics.totalResponseCount" class="medium-6 report-card">
|
||||
<div
|
||||
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
||||
class="medium-6 report-card"
|
||||
>
|
||||
<h3 class="heading">
|
||||
<div class="emoji--distribution">
|
||||
<div
|
||||
@@ -24,7 +28,7 @@
|
||||
class="emoji--distribution-item"
|
||||
>
|
||||
<span class="emoji--distribution-key">{{
|
||||
csatRatings[key - 1].emoji
|
||||
ratingToEmoji(key)
|
||||
}}</span>
|
||||
<span>{{ formatToPercent(rating) }}</span>
|
||||
</div>
|
||||
@@ -45,6 +49,12 @@ export default {
|
||||
components: {
|
||||
CsatMetricCard,
|
||||
},
|
||||
props: {
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
csatRatings: CSAT_RATINGS,
|
||||
@@ -57,12 +67,15 @@ export default {
|
||||
satisfactionScore: 'csat/getSatisfactionScore',
|
||||
responseRate: 'csat/getResponseRate',
|
||||
}),
|
||||
ratingFilterEnabled() {
|
||||
return Boolean(this.filters.rating);
|
||||
},
|
||||
chartData() {
|
||||
return {
|
||||
labels: ['Rating'],
|
||||
datasets: CSAT_RATINGS.map((rating, index) => ({
|
||||
datasets: CSAT_RATINGS.map(rating => ({
|
||||
label: rating.emoji,
|
||||
data: [this.ratingPercentage[index + 1]],
|
||||
data: [this.ratingPercentage[rating.value]],
|
||||
backgroundColor: rating.color,
|
||||
})),
|
||||
};
|
||||
@@ -77,6 +90,9 @@ export default {
|
||||
formatToPercent(value) {
|
||||
return value ? `${value}%` : '--';
|
||||
},
|
||||
ratingToEmoji(value) {
|
||||
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
+162
-134
@@ -1,72 +1,43 @@
|
||||
<template>
|
||||
<div class="flex-container flex-dir-column medium-flex-dir-row">
|
||||
<div class="small-12 medium-3 pull-right multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="currentDateRangeSelection"
|
||||
track-by="name"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="dateRange"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="changeDateSelection"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-container">
|
||||
<reports-filters-date-range @on-range-change="onDateRangeChange" />
|
||||
<woot-date-range-picker
|
||||
v-if="isDateRangeSelected"
|
||||
class="margin-left-1"
|
||||
show-range
|
||||
class="no-margin auto-width"
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
@change="onChange"
|
||||
@change="onCustomDateRangeChange"
|
||||
/>
|
||||
<div
|
||||
v-if="notLast7Days && groupByFilter"
|
||||
class="small-12 medium-3 pull-right margin-left-1 margin-right-1 multiselect-wrap--small"
|
||||
>
|
||||
<p aria-hidden="true" class="hide">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="filterItemsList"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@input="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="agentsFilter"
|
||||
class="small-12 medium-3 pull-right margin-left-1 margin-right-1 multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="selectedAgents"
|
||||
:options="agentsFilterItemsList"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
:hide-selected="true"
|
||||
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@input="handleAgentsFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBusinessHoursSwitch"
|
||||
class="small-12 medium-3 business-hours"
|
||||
>
|
||||
<span class="business-hours-text margin-right-1">
|
||||
<reports-filters-date-group-by
|
||||
v-if="showGroupByFilter && isGroupByPossible"
|
||||
:valid-group-options="validGroupOptions"
|
||||
:selected-option="selectedGroupByFilter"
|
||||
@on-grouping-change="onGroupingChange"
|
||||
/>
|
||||
<reports-filters-agents
|
||||
v-if="showAgentsFilter"
|
||||
@agents-filter-selection="handleAgentsFilterSelection"
|
||||
/>
|
||||
<reports-filters-labels
|
||||
v-if="showLabelsFilter"
|
||||
@labels-filter-selection="handleLabelsFilterSelection"
|
||||
/>
|
||||
<reports-filters-teams
|
||||
v-if="showTeamFilter"
|
||||
@team-filter-selection="handleTeamFilterSelection"
|
||||
/>
|
||||
<reports-filters-inboxes
|
||||
v-if="showInboxFilter"
|
||||
@inbox-filter-selection="handleInboxFilterSelection"
|
||||
/>
|
||||
<reports-filters-ratings
|
||||
v-if="showRatingFilter"
|
||||
@rating-filter-selection="handleRatingFilterSelection"
|
||||
/>
|
||||
<div v-if="showBusinessHoursSwitch" class="business-hours">
|
||||
<span class="business-hours-text ">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
@@ -77,36 +48,54 @@
|
||||
</template>
|
||||
<script>
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import ReportsFiltersDateRange from './Filters/DateRange.vue';
|
||||
import ReportsFiltersDateGroupBy from './Filters/DateGroupBy.vue';
|
||||
import ReportsFiltersAgents from './Filters/Agents.vue';
|
||||
import ReportsFiltersLabels from './Filters/Labels.vue';
|
||||
import ReportsFiltersInboxes from './Filters/Inboxes.vue';
|
||||
import ReportsFiltersTeams from './Filters/Teams.vue';
|
||||
import ReportsFiltersRatings from './Filters/Ratings.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
|
||||
const CUSTOM_DATE_RANGE_ID = 5;
|
||||
import { DATE_RANGE_OPTIONS } from '../constants';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
ReportsFiltersDateRange,
|
||||
ReportsFiltersDateGroupBy,
|
||||
ReportsFiltersAgents,
|
||||
ReportsFiltersLabels,
|
||||
ReportsFiltersInboxes,
|
||||
ReportsFiltersTeams,
|
||||
ReportsFiltersRatings,
|
||||
},
|
||||
props: {
|
||||
filterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
agentsFilterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedGroupByFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
groupByFilter: {
|
||||
showGroupByFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
agentsFilter: {
|
||||
showAgentsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showLabelsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showInboxFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRatingFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTeamFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -117,95 +106,134 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentDateRangeSelection: this.$t('REPORT.DATE_RANGE')[0],
|
||||
dateRange: this.$t('REPORT.DATE_RANGE'),
|
||||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedFilter: null,
|
||||
// default value, need not be translated
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
selectedLabel: null,
|
||||
selectedInbox: null,
|
||||
selectedTeam: null,
|
||||
selectedRating: null,
|
||||
selectedAgents: [],
|
||||
customDateRange: [new Date(), new Date()],
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isDateRangeSelected() {
|
||||
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
|
||||
return (
|
||||
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
|
||||
);
|
||||
},
|
||||
isGroupByPossible() {
|
||||
return this.selectedDateRange.id !== DATE_RANGE_OPTIONS.LAST_7_DAYS.id;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.toCustomDate(this.customDateRange[1]);
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
}
|
||||
return this.toCustomDate(new Date());
|
||||
return getUnixEndOfDay(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.fromCustomDate(this.customDateRange[0]);
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
}
|
||||
const dateRange = {
|
||||
0: 6,
|
||||
1: 29,
|
||||
2: 89,
|
||||
3: 179,
|
||||
4: 364,
|
||||
};
|
||||
const diff = dateRange[this.currentDateRangeSelection.id];
|
||||
const fromDate = subDays(new Date(), diff);
|
||||
return this.fromCustomDate(fromDate);
|
||||
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
return getUnixStartOfDay(fromDate);
|
||||
},
|
||||
groupBy() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return GROUP_BY_FILTER[4].period;
|
||||
validGroupOptions() {
|
||||
return this.selectedDateRange.groupByOptions;
|
||||
},
|
||||
validGroupBy() {
|
||||
if (!this.selectedGroupByFilter) {
|
||||
return this.validGroupOptions[0];
|
||||
}
|
||||
const groupRange = {
|
||||
0: GROUP_BY_FILTER[1].period,
|
||||
1: GROUP_BY_FILTER[2].period,
|
||||
2: GROUP_BY_FILTER[3].period,
|
||||
3: GROUP_BY_FILTER[3].period,
|
||||
4: GROUP_BY_FILTER[3].period,
|
||||
};
|
||||
return groupRange[this.currentDateRangeSelection.id];
|
||||
},
|
||||
notLast7Days() {
|
||||
return this.groupBy !== GROUP_BY_FILTER[1].period;
|
||||
|
||||
const validIds = this.validGroupOptions.map(opt => opt.id);
|
||||
if (validIds.includes(this.selectedGroupByFilter.id)) {
|
||||
return this.selectedGroupByFilter;
|
||||
}
|
||||
return this.validGroupOptions[0];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filterItemsList() {
|
||||
this.currentSelectedFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
businessHoursSelected() {
|
||||
this.$emit('business-hours-toggle', this.businessHoursSelected);
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
this.emitChange();
|
||||
},
|
||||
methods: {
|
||||
onDateRangeChange() {
|
||||
this.$emit('date-range-change', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
groupBy: this.groupBy,
|
||||
emitChange() {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
selectedGroupByFilter: groupBy,
|
||||
businessHoursSelected: businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
} = this;
|
||||
this.$emit('filter-change', {
|
||||
from,
|
||||
to,
|
||||
groupBy,
|
||||
businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
});
|
||||
},
|
||||
fromCustomDate(date) {
|
||||
return getUnixTime(startOfDay(date));
|
||||
onDateRangeChange(selectedRange) {
|
||||
this.selectedDateRange = selectedRange;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
toCustomDate(date) {
|
||||
return getUnixTime(endOfDay(date));
|
||||
},
|
||||
changeDateSelection(selectedRange) {
|
||||
this.currentDateRangeSelection = selectedRange;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
onChange(value) {
|
||||
onCustomDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.onDateRangeChange();
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
changeFilterSelection() {
|
||||
this.$emit('filter-change', this.currentSelectedFilter);
|
||||
onGroupingChange(payload) {
|
||||
this.selectedGroupByFilter = payload;
|
||||
this.emitChange();
|
||||
},
|
||||
handleAgentsFilterSelection() {
|
||||
this.$emit('agents-filter-change', this.selectedAgents);
|
||||
handleAgentsFilterSelection(selectedAgents) {
|
||||
this.selectedAgents = selectedAgents;
|
||||
this.emitChange();
|
||||
},
|
||||
handleLabelsFilterSelection(selectedLabel) {
|
||||
this.selectedLabel = selectedLabel;
|
||||
this.emitChange();
|
||||
},
|
||||
handleInboxFilterSelection(selectedInbox) {
|
||||
this.selectedInbox = selectedInbox;
|
||||
this.emitChange();
|
||||
},
|
||||
handleTeamFilterSelection(selectedTeam) {
|
||||
this.selectedTeam = selectedTeam;
|
||||
this.emitChange();
|
||||
},
|
||||
handleRatingFilterSelection(selectedRating) {
|
||||
this.selectedRating = selectedRating;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-gap: var(--space-slab);
|
||||
|
||||
margin-bottom: var(--space-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOptions"
|
||||
class="no-margin"
|
||||
:options="options"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
:hide-selected="true"
|
||||
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersAgents',
|
||||
data() {
|
||||
return {
|
||||
selectedOptions: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('agents-filter-selection', this.selectedOptions);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<p aria-hidden="true" class="hide">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="translatedOptions"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@select="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { GROUP_BY_OPTIONS } from '../../constants';
|
||||
|
||||
const EVENT_NAME = 'on-grouping-change';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersDateGroupBy',
|
||||
props: {
|
||||
validGroupOptions: {
|
||||
type: Array,
|
||||
default: () => [GROUP_BY_OPTIONS.DAY],
|
||||
},
|
||||
selectedOption: {
|
||||
type: Object,
|
||||
default: () => GROUP_BY_OPTIONS.DAY,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
translatedOptions() {
|
||||
return this.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: this.$t(option.translationKey),
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedOption: {
|
||||
handler() {
|
||||
this.currentSelectedFilter = {
|
||||
...this.selectedOption,
|
||||
groupBy: this.$t(this.selectedOption.translationKey),
|
||||
};
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changeFilterSelection(selectedFilter) {
|
||||
this.groupByOptions = this.$emit(EVENT_NAME, selectedFilter);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
track-by="name"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="options"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="updateRange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
|
||||
const EVENT_NAME = 'on-range-change';
|
||||
|
||||
export default {
|
||||
name: 'ReportFiltersDateRange',
|
||||
data() {
|
||||
const translatedOptions = Object.values(DATE_RANGE_OPTIONS).map(option => ({
|
||||
...option,
|
||||
name: this.$t(option.translationKey),
|
||||
}));
|
||||
|
||||
return {
|
||||
// relies on translations, need to move it to constants
|
||||
selectedOption: translatedOptions[0],
|
||||
options: translatedOptions,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateRange(selectedRange) {
|
||||
this.selectedOption = selectedRange;
|
||||
this.$emit(EVENT_NAME, selectedRange);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersInboxes',
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'inboxes/getInboxes',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('inbox-filter-selection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template slot="singleLabel" slot-scope="props">
|
||||
<div class="reports-option__wrap">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="reports-option__rounded--item"
|
||||
/>
|
||||
<span class="reports-option__desc">
|
||||
<span class="reports-option__title">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="option" slot-scope="props">
|
||||
<div class="reports-option__wrap">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="
|
||||
reports-option__rounded--item
|
||||
reports-option__item
|
||||
reports-option__label--swatch
|
||||
"
|
||||
/>
|
||||
<span class="reports-option__desc">
|
||||
<span class="reports-option__title">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersLabels',
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'labels/getLabels',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('labels/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('labels-filter-selection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:option-height="24"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
:options="options"
|
||||
:show-labels="false"
|
||||
track-by="value"
|
||||
label="label"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'ReportFiltersRatings',
|
||||
data() {
|
||||
const translatedOptions = CSAT_RATINGS.reverse().map(option => ({
|
||||
...option,
|
||||
label: this.$t(option.translationKey),
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedOption: null,
|
||||
options: translatedOptions,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleInput(selectedRating) {
|
||||
this.$emit('rating-filter-selection', selectedRating);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersTeams',
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'teams/getTeams',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('teams/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('team-filter-selection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import CsatMetrics from '../CsatMetrics.vue';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['csat-metric-card', 'woot-horizontal-bar'],
|
||||
};
|
||||
|
||||
describe('CsatMetrics.vue', () => {
|
||||
let getters;
|
||||
let store;
|
||||
let wrapper;
|
||||
const filters = { rating: 3 };
|
||||
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
'csat/getMetrics': () => ({ totalResponseCount: 100 }),
|
||||
'csat/getRatingPercentage': () => ({ 1: 10, 2: 20, 3: 30, 4: 30, 5: 10 }),
|
||||
'csat/getSatisfactionScore': () => 85,
|
||||
'csat/getResponseRate': () => 90,
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
getters,
|
||||
});
|
||||
|
||||
wrapper = shallowMount(CsatMetrics, {
|
||||
store,
|
||||
localVue,
|
||||
propsData: { filters },
|
||||
...mountParams,
|
||||
});
|
||||
});
|
||||
|
||||
it('computes response count correctly', () => {
|
||||
expect(wrapper.vm.responseCount).toBe('100');
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('formats values to percent correctly', () => {
|
||||
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
|
||||
expect(wrapper.vm.formatToPercent(null)).toBe('--');
|
||||
});
|
||||
|
||||
it('maps rating value to emoji correctly', () => {
|
||||
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
|
||||
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
|
||||
});
|
||||
|
||||
it('hides report card if rating filter is enabled', () => {
|
||||
expect(wrapper.find('.report-card').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows report card if rating filter is not enabled', async () => {
|
||||
await wrapper.setProps({ filters: {} });
|
||||
expect(wrapper.find('.report-card').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import CsatMetricCard from '../CsatMetricCard.vue';
|
||||
|
||||
import VTooltip from 'v-tooltip';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VTooltip);
|
||||
|
||||
describe('CsatMetricCard.vue', () => {
|
||||
it('renders props correctly', () => {
|
||||
const label = 'Total Responses';
|
||||
const value = '100';
|
||||
const infoText = 'Total number of responses';
|
||||
const wrapper = shallowMount(CsatMetricCard, {
|
||||
propsData: { label, value, infoText },
|
||||
localVue,
|
||||
stubs: ['fluent-icon'],
|
||||
});
|
||||
|
||||
expect(wrapper.find('.heading span').text()).toMatch(label);
|
||||
expect(wrapper.find('.metric').text()).toMatch(value);
|
||||
expect(wrapper.find('.csat--icon').classes()).toContain('has-tooltip');
|
||||
});
|
||||
|
||||
it('adds disabled class when disabled prop is true', () => {
|
||||
const wrapper = shallowMount(CsatMetricCard, {
|
||||
propsData: { label: '', value: '', infoText: '', disabled: true },
|
||||
localVue,
|
||||
stubs: ['fluent-icon'],
|
||||
});
|
||||
|
||||
expect(wrapper.find('.csat--metric-card').classes()).toContain('disabled');
|
||||
});
|
||||
|
||||
it('does not add disabled class when disabled prop is false', () => {
|
||||
const wrapper = shallowMount(CsatMetricCard, {
|
||||
propsData: { label: '', value: '', infoText: '', disabled: false },
|
||||
localVue,
|
||||
stubs: ['fluent-icon'],
|
||||
});
|
||||
|
||||
expect(wrapper.find('.csat--metric-card').classes()).not.toContain(
|
||||
'disabled'
|
||||
);
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import ReportsFiltersAgents from '../../Filters/Agents';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const mockStore = new Vuex.Store({
|
||||
modules: {
|
||||
agents: {
|
||||
namespaced: true,
|
||||
state: {
|
||||
agents: [],
|
||||
},
|
||||
getters: {
|
||||
getAgents: state => state.agents,
|
||||
},
|
||||
actions: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mountParams = {
|
||||
localVue,
|
||||
store: mockStore,
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
describe('ReportsFiltersAgents.vue', () => {
|
||||
it('emits "agents-filter-selection" event when handleInput is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
const selectedAgents = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
wrapper.setData({ selectedOptions: selectedAgents });
|
||||
|
||||
wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('agents-filter-selection')).toBeTruthy();
|
||||
expect(wrapper.emitted('agents-filter-selection')[0]).toEqual([
|
||||
selectedAgents,
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches the "agents/get" action when the component is mounted', () => {
|
||||
const dispatchSpy = jest.spyOn(mockStore, 'dispatch');
|
||||
|
||||
shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy';
|
||||
import { GROUP_BY_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
describe('ReportsFiltersDateGroupBy.vue', () => {
|
||||
it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const selectedFilter = GROUP_BY_OPTIONS.DAY;
|
||||
wrapper.vm.changeFilterSelection(selectedFilter);
|
||||
|
||||
expect(wrapper.emitted('on-grouping-change')).toBeTruthy();
|
||||
expect(wrapper.emitted('on-grouping-change')[0]).toEqual([selectedFilter]);
|
||||
});
|
||||
|
||||
it('updates currentSelectedFilter when selectedOption is changed', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
|
||||
await wrapper.setProps({ selectedOption: newSelectedOption });
|
||||
|
||||
expect(wrapper.vm.currentSelectedFilter).toEqual({
|
||||
...newSelectedOption,
|
||||
groupBy: newSelectedOption.translationKey,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes translatedOptions correctly', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersDateRange from '../../Filters/DateRange';
|
||||
import { DATE_RANGE_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
describe('ReportFiltersDateRange.vue', () => {
|
||||
it('emits "on-range-change" event when updateRange is called', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
|
||||
wrapper.vm.updateRange(selectedRange);
|
||||
|
||||
expect(wrapper.emitted('on-range-change')).toBeTruthy();
|
||||
expect(wrapper.emitted('on-range-change')[0]).toEqual([selectedRange]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const expectedOptions = Object.values(DATE_RANGE_OPTIONS).map(option => ({
|
||||
...option,
|
||||
name: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.options).toEqual(expectedOptions);
|
||||
});
|
||||
|
||||
it('initializes selectedOption correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
const expectedSelectedOption = Object.values(DATE_RANGE_OPTIONS)[0];
|
||||
expect(wrapper.vm.selectedOption).toEqual({
|
||||
...expectedSelectedOption,
|
||||
name: expectedSelectedOption.translationKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import ReportsFiltersInboxes from '../../Filters/Inboxes';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
describe('ReportsFiltersInboxes.vue', () => {
|
||||
let store;
|
||||
let inboxesModule;
|
||||
|
||||
beforeEach(() => {
|
||||
inboxesModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getInboxes: () => () => [
|
||||
{ id: 1, name: 'Inbox 1' },
|
||||
{ id: 2, name: 'Inbox 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
modules: {
|
||||
inboxes: inboxesModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "inboxes/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersInboxes, {
|
||||
store,
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
expect(inboxesModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "inbox-filter-selection" event when handleInput is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersInboxes, {
|
||||
store,
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const selectedInbox = { id: 1, name: 'Inbox 1' };
|
||||
wrapper.setData({ selectedOption: selectedInbox });
|
||||
|
||||
wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('inbox-filter-selection')).toBeTruthy();
|
||||
expect(wrapper.emitted('inbox-filter-selection')[0]).toEqual([
|
||||
selectedInbox,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import ReportsFiltersLabels from '../../Filters/Labels';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
describe('ReportsFiltersLabels.vue', () => {
|
||||
let store;
|
||||
let labelsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
labelsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getLabels: () => () => [
|
||||
{ id: 1, title: 'Label 1', color: 'red' },
|
||||
{ id: 2, title: 'Label 2', color: 'blue' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
modules: {
|
||||
labels: labelsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "labels/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersLabels, {
|
||||
store,
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
expect(labelsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "labels-filter-selection" event when handleInput is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersLabels, {
|
||||
store,
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
|
||||
wrapper.setData({ selectedOption: selectedLabel });
|
||||
|
||||
wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('labels-filter-selection')).toBeTruthy();
|
||||
expect(wrapper.emitted('labels-filter-selection')[0]).toEqual([
|
||||
selectedLabel,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import ReportFiltersRatings from '../../Filters/Ratings';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
describe('ReportFiltersRatings.vue', () => {
|
||||
it('emits "rating-filter-selection" event when handleInput is called', () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const selectedRating = { value: 1, label: 'Rating 1' };
|
||||
wrapper.setData({ selectedOption: selectedRating });
|
||||
|
||||
wrapper.vm.handleInput(selectedRating);
|
||||
|
||||
expect(wrapper.emitted('rating-filter-selection')).toBeTruthy();
|
||||
expect(wrapper.emitted('rating-filter-selection')[0]).toEqual([
|
||||
selectedRating,
|
||||
]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const expectedOptions = CSAT_RATINGS.map(option => ({
|
||||
...option,
|
||||
label: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.options).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import ReportsFiltersTeams from '../../Filters/Teams.vue';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
};
|
||||
|
||||
describe('ReportsFiltersTeams.vue', () => {
|
||||
let store;
|
||||
let teamsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
teamsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getTeams: () => () => [
|
||||
{ id: 1, name: 'Team 1' },
|
||||
{ id: 2, name: 'Team 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
modules: {
|
||||
teams: teamsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "teams/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersTeams, {
|
||||
store,
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
expect(teamsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "team-filter-selection" event when handleInput is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersTeams, {
|
||||
store,
|
||||
localVue,
|
||||
...mountParams,
|
||||
});
|
||||
wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
|
||||
wrapper.vm.handleInput();
|
||||
expect(wrapper.emitted('team-filter-selection')).toBeTruthy();
|
||||
expect(wrapper.emitted('team-filter-selection')[0]).toEqual([
|
||||
{ id: 1, name: 'Team 1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CsatMetrics.vue computes response count correctly 1`] = `
|
||||
<div class="row csat--metrics-container">
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP"></csat-metric-card-stub>
|
||||
<!---->
|
||||
</div>
|
||||
`;
|
||||
@@ -7,6 +7,85 @@ export const GROUP_BY_FILTER = {
|
||||
4: { id: 4, period: 'year' },
|
||||
};
|
||||
|
||||
export const GROUP_BY_OPTIONS = {
|
||||
DAY: {
|
||||
id: 'DAY',
|
||||
period: 'day',
|
||||
translationKey: 'REPORT.GROUPING_OPTIONS.DAY',
|
||||
},
|
||||
WEEK: {
|
||||
id: 'WEEK',
|
||||
period: 'week',
|
||||
translationKey: 'REPORT.GROUPING_OPTIONS.WEEK',
|
||||
},
|
||||
MONTH: {
|
||||
id: 'MONTH',
|
||||
period: 'month',
|
||||
translationKey: 'REPORT.GROUPING_OPTIONS.MONTH',
|
||||
},
|
||||
YEAR: {
|
||||
id: 'YEAR',
|
||||
period: 'year',
|
||||
translationKey: 'REPORT.GROUPING_OPTIONS.YEAR',
|
||||
},
|
||||
};
|
||||
|
||||
export const DATE_RANGE_OPTIONS = {
|
||||
LAST_7_DAYS: {
|
||||
id: 'LAST_7_DAYS',
|
||||
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS',
|
||||
offset: 6,
|
||||
groupByOptions: [GROUP_BY_OPTIONS.DAY],
|
||||
},
|
||||
LAST_30_DAYS: {
|
||||
id: 'LAST_30_DAYS',
|
||||
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS',
|
||||
offset: 29,
|
||||
groupByOptions: [GROUP_BY_OPTIONS.DAY, GROUP_BY_OPTIONS.WEEK],
|
||||
},
|
||||
LAST_3_MONTHS: {
|
||||
id: 'LAST_3_MONTHS',
|
||||
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
|
||||
offset: 89,
|
||||
groupByOptions: [
|
||||
GROUP_BY_OPTIONS.DAY,
|
||||
GROUP_BY_OPTIONS.WEEK,
|
||||
GROUP_BY_OPTIONS.MONTH,
|
||||
],
|
||||
},
|
||||
LAST_6_MONTHS: {
|
||||
id: 'LAST_6_MONTHS',
|
||||
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
|
||||
offset: 179,
|
||||
groupByOptions: [
|
||||
GROUP_BY_OPTIONS.DAY,
|
||||
GROUP_BY_OPTIONS.WEEK,
|
||||
GROUP_BY_OPTIONS.MONTH,
|
||||
],
|
||||
},
|
||||
LAST_YEAR: {
|
||||
id: 'LAST_YEAR',
|
||||
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_YEAR',
|
||||
offset: 364,
|
||||
groupByOptions: [
|
||||
GROUP_BY_OPTIONS.DAY,
|
||||
GROUP_BY_OPTIONS.WEEK,
|
||||
GROUP_BY_OPTIONS.MONTH,
|
||||
],
|
||||
},
|
||||
CUSTOM_DATE_RANGE: {
|
||||
id: 'CUSTOM_DATE_RANGE',
|
||||
translationKey: 'REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE',
|
||||
offset: null,
|
||||
groupByOptions: [
|
||||
GROUP_BY_OPTIONS.DAY,
|
||||
GROUP_BY_OPTIONS.WEEK,
|
||||
GROUP_BY_OPTIONS.MONTH,
|
||||
GROUP_BY_OPTIONS.YEAR,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CHART_FONT_FAMILY =
|
||||
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import CannedResponseAPI from '../../api/cannedResponse';
|
||||
@@ -46,8 +47,10 @@ const actions = {
|
||||
const response = await CannedResponseAPI.create(cannedObj);
|
||||
commit(types.default.ADD_CANNED, response.data);
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,8 +63,10 @@ const actions = {
|
||||
const response = await CannedResponseAPI.update(id, updateObj);
|
||||
commit(types.default.EDIT_CANNED, response.data);
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,8 +76,10 @@ const actions = {
|
||||
await CannedResponseAPI.delete(id);
|
||||
commit(types.default.DELETE_CANNED, id);
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
|
||||
return id;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import {
|
||||
MESSAGE_TYPE,
|
||||
CONVERSATION_PRIORITY_ORDER,
|
||||
} from 'shared/constants/messages';
|
||||
import { applyPageFilters } from './helpers';
|
||||
|
||||
export const getSelectedChatConversation = ({
|
||||
@@ -13,6 +16,12 @@ const getters = {
|
||||
const comparator = {
|
||||
latest: (a, b) => b.last_activity_at - a.last_activity_at,
|
||||
sort_on_created_at: (a, b) => a.created_at - b.created_at,
|
||||
sort_on_priority: (a, b) => {
|
||||
return (
|
||||
CONVERSATION_PRIORITY_ORDER[a.priority] -
|
||||
CONVERSATION_PRIORITY_ORDER[b.priority]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return allConversations.sort(comparator[chatSortFilter]);
|
||||
|
||||
@@ -85,13 +85,10 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async function getResponses(
|
||||
{ commit },
|
||||
{ page = 1, from, to, user_ids } = {}
|
||||
) {
|
||||
get: async function getResponses({ commit }, params) {
|
||||
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const response = await CSATReports.get({ page, from, to, user_ids });
|
||||
const response = await CSATReports.get(params);
|
||||
commit(types.SET_CSAT_RESPONSE, response.data);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
@@ -99,10 +96,10 @@ export const actions = {
|
||||
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
getMetrics: async function getMetrics({ commit }, { from, to, user_ids }) {
|
||||
getMetrics: async function getMetrics({ commit }, params) {
|
||||
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: true });
|
||||
try {
|
||||
const response = await CSATReports.getMetrics({ from, to, user_ids });
|
||||
const response = await CSATReports.getMetrics(params);
|
||||
commit(types.SET_CSAT_RESPONSE_METRICS, response.data);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
|
||||
@@ -404,6 +404,33 @@ describe('#actions', () => {
|
||||
expect(commit.mock.calls).toEqual([[types.CLEAR_CONVERSATION_FILTERS]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateConversationLastActivity', () => {
|
||||
it('sends correct action', async () => {
|
||||
await actions.updateConversationLastActivity(
|
||||
{ commit },
|
||||
{ conversationId: 1, lastActivityAt: 12121212 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
{ conversationId: 1, lastActivityAt: 12121212 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setChatSortFilter', () => {
|
||||
it('sends correct action', async () => {
|
||||
await actions.setChatSortFilter(
|
||||
{ commit },
|
||||
{ data: 'sort_on_created_at' }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['CHANGE_CHAT_SORT_FILTER', { data: 'sort_on_created_at' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteMessage', () => {
|
||||
|
||||
@@ -6,43 +6,187 @@ commonHelpers();
|
||||
|
||||
describe('#getters', () => {
|
||||
describe('#getAllConversations', () => {
|
||||
it('order conversations based on last message date', () => {
|
||||
it('order conversations based on last activity', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
created_at: 1466424480,
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [
|
||||
{
|
||||
created_at: 2466424490,
|
||||
},
|
||||
],
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('order conversations based on created at', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 1683645801, // Tuesday, 9 May 2023
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1652109801, // Monday, 9 May 2022
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
],
|
||||
chatSortFilter: 'sort_on_created_at',
|
||||
};
|
||||
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
messages: [
|
||||
{
|
||||
created_at: 2466424490,
|
||||
},
|
||||
],
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
created_at: 1466424480,
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('order conversations based on default order', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
created_at: 2466424490,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
created_at: 1466424480,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('order conversations based on priority', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
priority: 'low',
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'urgent',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'medium',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466421280,
|
||||
},
|
||||
],
|
||||
chatSortFilter: 'sort_on_priority',
|
||||
};
|
||||
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'urgent',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'medium',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466421280,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
priority: 'low',
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,19 @@ describe('#mutations', () => {
|
||||
|
||||
describe('#ASSIGN_TEAM', () => {
|
||||
it('clears current chat window', () => {
|
||||
const state = { allConversations: [{ id: 1, meta: {} }] };
|
||||
mutations[types.UPDATE_CONVERSATION_LAST_ACTIVITY](state, {
|
||||
lastActivityAt: 1602256198,
|
||||
conversationId: 1,
|
||||
});
|
||||
|
||||
expect(state.allConversations).toEqual([
|
||||
{ id: 1, meta: {}, last_activity_at: 1602256198 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('#UPDATE_CONVERSATION_LAST_ACTIVITY', () => {
|
||||
it('update conversation last activity', () => {
|
||||
const state = { allConversations: [{ id: 1, meta: {} }] };
|
||||
mutations[types.ASSIGN_TEAM](state, {
|
||||
team: { id: 1, name: 'Team 1' },
|
||||
@@ -50,6 +63,16 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CHANGE_CHAT_SORT_FILTER', () => {
|
||||
it('update conversation sort filter', () => {
|
||||
const state = { chatSortFilter: 'latest' };
|
||||
mutations[types.CHANGE_CHAT_SORT_FILTER](state, {
|
||||
data: 'sort_on_created_at',
|
||||
});
|
||||
expect(state.chatSortFilter).toEqual({ data: 'sort_on_created_at' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CURRENT_CHAT_WINDOW', () => {
|
||||
it('set current chat window', () => {
|
||||
const state = { selectedChatId: 1 };
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so that it will be compiled.
|
||||
|
||||
import Vue from 'vue';
|
||||
import Rails from '@rails/ujs';
|
||||
import Turbolinks from 'turbolinks';
|
||||
import PublicArticleSearch from '../portal/components/PublicArticleSearch.vue';
|
||||
|
||||
import { navigateToLocalePage } from '../portal/portalHelpers';
|
||||
|
||||
import '../portal/application.scss';
|
||||
import { InitializationHelpers } from '../portal/portalHelpers';
|
||||
|
||||
Rails.start();
|
||||
Turbolinks.start();
|
||||
|
||||
const initPageSetUp = () => {
|
||||
navigateToLocalePage();
|
||||
const isSearchContainerAvailable = document.querySelector('#search-wrap');
|
||||
if (isSearchContainerAvailable) {
|
||||
new Vue({
|
||||
components: { PublicArticleSearch },
|
||||
template: '<PublicArticleSearch />',
|
||||
}).$mount('#search-wrap');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPageSetUp();
|
||||
});
|
||||
|
||||
document.addEventListener('turbolinks:load', () => {
|
||||
initPageSetUp();
|
||||
});
|
||||
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user