Merge branch 'release/2.18.0'

This commit is contained in:
Sojan
2023-06-15 18:44:51 +05:30
478 changed files with 13886 additions and 1677 deletions

View File

@@ -157,7 +157,7 @@ jobs:
- run: - run:
name: Code Climate Test Coverage name: Code Climate Test Coverage
command: | command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
- persist_to_workspace: - persist_to_workspace:
root: coverage root: coverage

View File

@@ -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 ## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project # LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
## Scout ## Scout
## https://scoutapm.com/docs/ruby/configuration ## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY # SCOUT_KEY=YOURKEY

38
.github/workflows/size-limit.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Run Size Limit Check
on:
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v3
with:
node-version: 16
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Run asset compile
run: bundle exec rake assets:precompile
- name: Size Check
run: yarn run size

View File

@@ -162,41 +162,33 @@ Rails/RenderInline:
Performance/CollectionLiteralInLoop: Performance/CollectionLiteralInLoop:
Exclude: Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb' - 'db/migrate/20210315101919_enable_email_channel.rb'
RSpec/NamedSubject: Rails/ThreeStateBooleanColumn:
Enabled: false Exclude:
Style/RedundantConstantBase: - 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
Enabled: false - 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
Rails/RootPathnameMethods: - 'db/migrate/20200606132552_create_labels.rb'
Enabled: false - 'db/migrate/20201027135006_create_working_hours.rb'
RSpec/Rails/MinitestAssertions: - 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
Enabled: false - 'db/migrate/20210114202310_create_teams.rb'
RSpec/Rails/InferredSpecType: - 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
Enabled: false - '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: RSpec/IndexedLet:
Enabled: false Enabled: false
RSpec/MatchArray: RSpec/NamedSubject:
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:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 14 Max: 14

View File

@@ -75,6 +75,7 @@ gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate' gem 'administrate'
gem 'administrate-field-active_storage'
##--- gems for pubsub service ---## ##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/

View File

@@ -33,70 +33,70 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.4.3) actioncable (7.0.5)
actionpack (= 7.0.4.3) actionpack (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.4.3) actionmailbox (7.0.5)
actionpack (= 7.0.4.3) actionpack (= 7.0.5)
activejob (= 7.0.4.3) activejob (= 7.0.5)
activerecord (= 7.0.4.3) activerecord (= 7.0.5)
activestorage (= 7.0.4.3) activestorage (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.4.3) actionmailer (7.0.5)
actionpack (= 7.0.4.3) actionpack (= 7.0.5)
actionview (= 7.0.4.3) actionview (= 7.0.5)
activejob (= 7.0.4.3) activejob (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.4.3) actionpack (7.0.5)
actionview (= 7.0.4.3) actionview (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
rack (~> 2.0, >= 2.2.0) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.4.3) actiontext (7.0.5)
actionpack (= 7.0.4.3) actionpack (= 7.0.5)
activerecord (= 7.0.4.3) activerecord (= 7.0.5)
activestorage (= 7.0.4.3) activestorage (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.4.3) actionview (7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.0.4.3) activejob (7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.4.3) activemodel (7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
activerecord (7.0.4.3) activerecord (7.0.5)
activemodel (= 7.0.4.3) activemodel (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
activerecord-import (1.4.1) activerecord-import (1.4.1)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.0.4.3) activestorage (7.0.5)
actionpack (= 7.0.4.3) actionpack (= 7.0.5)
activejob (= 7.0.4.3) activejob (= 7.0.5)
activerecord (= 7.0.4.3) activerecord (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.4.3) activesupport (7.0.5)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@@ -113,6 +113,9 @@ GEM
kaminari (>= 1.0) kaminari (>= 1.0)
sassc-rails (~> 2.1) sassc-rails (~> 2.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate (>= 0.2.2)
rails (>= 7.0)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@@ -353,7 +356,7 @@ GEM
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.13.0) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@@ -420,9 +423,9 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.20.0) loofah (2.21.3)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
@@ -462,14 +465,14 @@ GEM
sidekiq sidekiq
newrelic_rpm (8.16.0) newrelic_rpm (8.16.0)
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.14.3) nokogiri (1.15.2)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.3-arm64-darwin) nokogiri (1.15.2-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.3-x86_64-darwin) nokogiri (1.15.2-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.3-x86_64-linux) nokogiri (1.15.2-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -522,7 +525,7 @@ GEM
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.6.2) racc (1.7.0)
rack (2.2.7) rack (2.2.7)
rack-attack (6.6.1) rack-attack (6.6.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
@@ -537,28 +540,29 @@ GEM
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rails (7.0.4.3) rails (7.0.5)
actioncable (= 7.0.4.3) actioncable (= 7.0.5)
actionmailbox (= 7.0.4.3) actionmailbox (= 7.0.5)
actionmailer (= 7.0.4.3) actionmailer (= 7.0.5)
actionpack (= 7.0.4.3) actionpack (= 7.0.5)
actiontext (= 7.0.4.3) actiontext (= 7.0.5)
actionview (= 7.0.4.3) actionview (= 7.0.5)
activejob (= 7.0.4.3) activejob (= 7.0.5)
activemodel (= 7.0.4.3) activemodel (= 7.0.5)
activerecord (= 7.0.4.3) activerecord (= 7.0.5)
activestorage (= 7.0.4.3) activestorage (= 7.0.5)
activesupport (= 7.0.4.3) activesupport (= 7.0.5)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.4.3) railties (= 7.0.5)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.19, >= 2.19.1) loofah (~> 2.21)
railties (7.0.4.3) nokogiri (~> 1.14)
actionpack (= 7.0.4.3) railties (7.0.5)
activesupport (= 7.0.4.3) actionpack (= 7.0.5)
activesupport (= 7.0.5)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@@ -722,8 +726,8 @@ GEM
stripe (8.5.0) stripe (8.5.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.2.1) thor (1.2.2)
tilt (2.1.0) tilt (2.2.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
@@ -785,6 +789,7 @@ GEM
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
arm64-darwin-21 arm64-darwin-21
arm64-darwin-22
ruby ruby
x86_64-darwin-18 x86_64-darwin-18
x86_64-darwin-20 x86_64-darwin-20
@@ -797,6 +802,7 @@ DEPENDENCIES
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate administrate
administrate-field-active_storage
annotate annotate
attr_extras attr_extras
audited (~> 5.3) audited (~> 5.3)
@@ -915,4 +921,4 @@ RUBY VERSION
ruby 3.2.2p185 ruby 3.2.2p185
BUNDLED WITH BUNDLED WITH
2.4.10 2.4.6

View File

@@ -1 +1 @@
2.2.0 2.17.0

View File

@@ -1 +1 @@
2.1.0 2.3.0

View File

@@ -1,5 +1,6 @@
//= link_tree ../images //= link_tree ../images
//= link administrate/application.css //= link administrate/application.css
//= link administrate/application.js //= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.js //= link dashboardChart.js
//= link secretField.js //= link secretField.js

View File

@@ -63,9 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def conversation def conversation
@conversation ||= Conversation.where( @conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'" "additional_attributes ->> 'type' = 'instagram_direct_message'"
).find_by(conversation_params) || build_conversation ) || build_conversation
end end
def message_content def message_content
@@ -96,6 +96,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_conversation def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge( Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' } additional_attributes: { type: 'instagram_direct_message' }

View File

@@ -96,10 +96,9 @@ class V2::ReportBuilder
def conversations def conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open @open_conversations = scope.conversations.where(account_id: @account.id).open
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = { metric = {
open: @open_conversations.count, open: @open_conversations.count,
unattended: @open_conversations.count - first_response_count unattended: @open_conversations.unattended.count
} }
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric metric

View File

@@ -16,6 +16,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end end
end end
def show; end
def edit; end
def create def create
@article = @portal.articles.create!(article_params) @article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id]) @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? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end
def edit; end
def show; end
def update def update
@article.update!(article_params) if params[:article].present? @article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?

View File

@@ -6,6 +6,8 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rules = Current.account.automation_rules @automation_rules = Current.account.automation_rules
end end
def show; end
def create def create
@automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions] @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 } render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end end
def show; end
def update def update
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
automation_rule_update automation_rule_update

View File

@@ -6,21 +6,21 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
@campaigns = Current.account.campaigns @campaigns = Current.account.campaigns
end end
def show; end
def create def create
@campaign = Current.account.campaigns.create!(campaign_params) @campaign = Current.account.campaigns.create!(campaign_params)
end end
def update
@campaign.update!(campaign_params)
end
def destroy def destroy
@campaign.destroy! @campaign.destroy!
head :ok head :ok
end end
def show; end
def update
@campaign.update!(campaign_params)
end
private private
def campaign def campaign

View File

@@ -9,6 +9,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@categories = @portal.categories.search(params) @categories = @portal.categories.search(params)
end end
def show; end
def create def create
@category = @portal.categories.create!(category_params) @category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records @category.related_categories << related_categories_records
@@ -17,8 +19,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@category.save! @category.save!
end end
def show; end
def update def update
@category.update!(category_params) @category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any? @category.related_categories = related_categories_records if related_categories_records.any?

View File

@@ -1,11 +1,13 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
include HmacConcern
before_action :ensure_inbox, only: [:create] before_action :ensure_inbox, only: [:create]
def create def create
@contact_inbox = ContactInboxBuilder.new( @contact_inbox = ContactInboxBuilder.new(
contact: @contact, contact: @contact,
inbox: @inbox, inbox: @inbox,
source_id: params[:source_id] source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform ).perform
end end

View File

@@ -5,21 +5,21 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
@notes = @contact.notes.latest.includes(:user) @notes = @contact.notes.latest.includes(:user)
end end
def show; end
def create def create
@note = @contact.notes.create!(note_params) @note = @contact.notes.create!(note_params)
end end
def update
@note.update(note_params)
end
def destroy def destroy
@note.destroy! @note.destroy!
head :ok head :ok
end end
def show; end
def update
@note.update(note_params)
end
private private
def note def note

View File

@@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
head :ok, message: I18n.t('errors.contacts.export.success')
end
# returns online contacts # returns online contacts
def active def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker

View File

@@ -1,6 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types include Events::Types
include DateRangeHelper include DateRangeHelper
include HmacConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create] before_action :inbox, :contact, :contact_inbox, only: [:create]
@@ -26,6 +27,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@attachments = @conversation.attachments @attachments = @conversation.attachments
end end
def show; end
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform @conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
@@ -33,8 +36,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
end end
def show; end
def filter def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations] @conversations = result[:conversations]
@@ -104,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def set_conversation_status def set_conversation_status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status] @conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end end
@@ -152,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
ContactInboxBuilder.new( ContactInboxBuilder.new(
contact: @contact, contact: @contact,
inbox: @inbox, inbox: @inbox,
source_id: params[:source_id] source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform ).perform
end end

View File

@@ -34,9 +34,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
end end
def set_csat_survey_responses def set_csat_survey_responses
@csat_survey_responses = filtrate( base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact]) @csat_survey_responses = filtrate(base_query).filter_by_created_at(range)
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids]) .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 end
def set_current_page_surveys def set_current_page_surveys

View File

@@ -11,6 +11,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
@custom_filter = current_user.custom_filters.create!( @custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(account_id: Current.account.id) 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 end
def update def update

View File

@@ -2,6 +2,11 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
before_action :fetch_inbox before_action :fetch_inbox
before_action :current_agents_ids, only: [:create, :update] before_action :current_agents_ids, only: [:create, :update]
def show
authorize @inbox, :show?
fetch_updated_agents
end
def create def create
authorize @inbox, :create? authorize @inbox, :create?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@@ -10,11 +15,6 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
fetch_updated_agents fetch_updated_agents
end end
def show
authorize @inbox, :show?
fetch_updated_agents
end
def update def update
authorize @inbox, :update? authorize @inbox, :update?
update_agents_list update_agents_list

View File

@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update def update
@inbox.update!(permitted_params.except(:channel)) @inbox.update!(permitted_params.except(:channel))
update_inbox_working_hours update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type) update_channel if channel_update_required?
# 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]
end end
def agent_bot 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)) account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end 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 def update_channel_feature_flags
return unless @inbox.web_widget? return unless @inbox.web_widget?
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags

View File

@@ -6,6 +6,10 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macros = Macro.with_visibility(current_user, params) @macros = Macro.with_visibility(current_user, params)
end end
def show
head :not_found if @macro.nil?
end
def create def create
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params) @macro.set_visibility(current_user, permitted_params)
@@ -18,8 +22,16 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macro @macro
end end
def show def update
head :not_found if @macro.nil? 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 end
def destroy 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 } render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end 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 def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user) ::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)

View File

@@ -14,6 +14,11 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserErrors, CustomExceptions::Account::UserErrors,
with: :render_error_response 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 def create
@user, @account = AccountBuilder.new( @user, @account = AccountBuilder.new(
account_name: account_params[:account_name], account_name: account_params[:account_name],
@@ -32,14 +37,10 @@ class Api::V1::AccountsController < Api::BaseController
end end
def cache_keys def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: get_cache_keys }, status: :ok render json: { cache_keys: get_cache_keys }, status: :ok
end end
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def update def update
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
end end

View File

@@ -0,0 +1,5 @@
module HmacConcern
def hmac_verified?
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
end
end

View File

@@ -4,6 +4,16 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
skip_before_action :require_no_authentication, raise: false skip_before_action :require_no_authentication, raise: false
skip_before_action :authenticate_user!, 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 def update
# params: reset_password_token, password, password_confirmation # params: reset_password_token, password, password_confirmation
original_token = params[:reset_password_token] original_token = params[:reset_password_token]
@@ -17,16 +27,6 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
end end
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 private
def reset_password_and_confirmation(recoverable) def reset_password_and_confirmation(recoverable)

View File

@@ -1,4 +1,4 @@
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
# Prevent session parameter from being passed # Prevent session parameter from being passed
# Unpermitted parameter: session # Unpermitted parameter: session
wrap_parameters format: [] wrap_parameters format: []
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end end
end end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')

View File

@@ -1,4 +1,4 @@
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
def validate_token def validate_token
# @resource will have been set by set_user_by_token concern # @resource will have been set by set_user_by_token concern
if @resource if @resource

View File

@@ -1,12 +1,12 @@
class Platform::Api::V1::AccountsController < PlatformController class Platform::Api::V1::AccountsController < PlatformController
def show; end
def create def create
@resource = Account.create!(account_params) @resource = Account.create!(account_params)
update_resource_features update_resource_features
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end end
def show; end
def update def update
@resource.assign_attributes(account_params) @resource.assign_attributes(account_params)
update_resource_features update_resource_features

View File

@@ -6,14 +6,14 @@ class Platform::Api::V1::AgentBotsController < PlatformController
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end end
def show; end
def create def create
@resource = AgentBot.new(agent_bot_params) @resource = AgentBot.new(agent_bot_params)
@resource.save! @resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end end
def show; end
def update def update
@resource.update!(agent_bot_params) @resource.update!(agent_bot_params)
end end

View File

@@ -5,6 +5,8 @@ class Platform::Api::V1::UsersController < PlatformController
before_action(only: [:login]) { set_resource } before_action(only: [:login]) { set_resource }
before_action(only: [:login]) { validate_platform_app_permissible } before_action(only: [:login]) { validate_platform_app_permissible }
def show; end
def create def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) @resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation! @resource.skip_confirmation!
@@ -16,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link } render json: { url: @resource.generate_sso_link }
end end
def show; end
def update def update
@resource.assign_attributes(user_update_params) @resource.assign_attributes(user_update_params)

View File

@@ -2,6 +2,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
before_action :contact_inbox, except: [:create] before_action :contact_inbox, except: [:create]
before_action :process_hmac before_action :process_hmac
def show; end
def create def create
source_id = params[:source_id] || SecureRandom.uuid source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactInboxWithContactBuilder.new( @contact_inbox = ::ContactInboxWithContactBuilder.new(
@@ -11,8 +13,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
).perform ).perform
end end
def show; end
def update def update
contact_identify_action = ContactIdentifyAction.new( contact_identify_action = ContactIdentifyAction.new(
contact: @contact_inbox.contact, contact: @contact_inbox.contact,

View File

@@ -43,8 +43,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end end
def render_article_content(content) def render_article_content(content)
# rubocop:disable Rails/OutputSafety ChatwootMarkdownRenderer.new(content).render_article
CommonMarker.render_html(content).html_safe
# rubocop:enable Rails/OutputSafety
end end
end end

View File

@@ -6,6 +6,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def set_locale(&) def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present? switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present? switch_locale_with_article(&) if params[:article_slug].present?
yield
end end
def switch_locale_with_portal(&) def switch_locale_with_portal(&)

View File

@@ -45,13 +45,24 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
def seed def seed
Internal::SeedAccountJob.perform_later(requested_resource) Internal::SeedAccountJob.perform_later(requested_resource)
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered') redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
# rubocop:enable Rails/I18nLocaleTexts
end
def reset_cache
requested_resource.reset_cache_keys
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
# rubocop:enable Rails/I18nLocaleTexts
end end
def destroy def destroy
account = Account.find(params[:id]) account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present? DeleteObjectJob.perform_later(account) if account.present?
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.') redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
# rubocop:enable Rails/I18nLocaleTexts
end end
end end

View File

@@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions # See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information # for more information
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_agent_bots_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
end end

View File

@@ -5,6 +5,17 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
sha sha
postgres_status postgres_status
redis_metrics redis_metrics
chatwoot_edition
end
def chatwoot_edition
@metrics['Chatwoot edition'] = if ChatwootApp.enterprise?
'Enterprise'
elsif ChatwootApp.custom?
'Custom'
else
'Community'
end
end end
def chatwoot_version def chatwoot_version

View File

@@ -45,6 +45,17 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# empty values into nil values. It uses other APIs such as `resource_class` # empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`: # and `dashboard`:
# #
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_users_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
def resource_params def resource_params
permitted_params = super permitted_params = super
permitted_params.delete(:password) if permitted_params[:password].blank? permitted_params.delete(:password) if permitted_params[:password].blank?

View File

@@ -1,7 +1,7 @@
class SwaggerController < ApplicationController class SwaggerController < ApplicationController
def respond def respond
if Rails.env.development? || Rails.env.test? 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 else
head :not_found head :not_found
end end

View File

@@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = { ATTRIBUTE_TYPES = {
access_token: Field::HasOne, access_token: Field::HasOne,
avatar_url: AvatarField, avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_agent_bot, { attachment_id: attachment.id }]
end
),
id: Field::Number, id: Field::Number,
name: Field::String, name: Field::String,
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'), account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
@@ -36,6 +41,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# an array of attributes that will be displayed on the model's show page. # an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[ SHOW_PAGE_ATTRIBUTES = %i[
id id
avatar_url
account account
name name
description description
@@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages. # on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[ FORM_ATTRIBUTES = %i[
name name
avatar
account account
description description
outgoing_url outgoing_url

View File

@@ -11,6 +11,11 @@ class UserDashboard < Administrate::BaseDashboard
account_users: Field::HasMany, account_users: Field::HasMany,
id: Field::Number, id: Field::Number,
avatar_url: AvatarField, avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_user, { attachment_id: attachment.id }]
end
),
provider: Field::String, provider: Field::String,
uid: Field::String, uid: Field::String,
password: Field::Password, password: Field::Password,
@@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages. # on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[ FORM_ATTRIBUTES = %i[
name name
avatar
display_name display_name
email email
password password

View File

@@ -102,7 +102,7 @@ class ConversationFinder
when 'participating' when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id) @conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended' when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil) @conversations = @conversations.unattended
end end
@conversations @conversations
end end

View File

@@ -7,8 +7,6 @@ module MessageFormatHelper
end end
def render_message_content(message_content) def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety ChatwootMarkdownRenderer.new(message_content).render_message
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
end end
end end

View File

@@ -29,7 +29,9 @@ module ReportHelper
end end
def resolutions_count def resolutions_count
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
conversations: { status: :resolved }).distinct
(get_grouped_values object_scope).count
end end
def avg_first_response_time def avg_first_response_time

View File

@@ -115,11 +115,6 @@ export default {
<style lang="scss"> <style lang="scss">
@import './assets/scss/app'; @import './assets/scss/app';
.update-banner {
height: var(--space-larger);
align-items: center;
font-size: var(--font-size-small) !important;
}
</style> </style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style> <style src="vue-multiselect/dist/vue-multiselect.min.css"></style>

View File

@@ -75,6 +75,10 @@ class ContactAPI extends ApiClient {
destroyAvatar(contactId) { destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`); return axios.delete(`${this.url}/${contactId}/avatar`);
} }
exportContacts() {
return axios.get(`${this.url}/export`);
}
} }
export default new ContactAPI(); export default new ContactAPI();

View File

@@ -6,7 +6,7 @@ class CSATReportsAPI extends ApiClient {
super('csat_survey_responses', { accountScoped: true }); 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, { return axios.get(this.url, {
params: { params: {
page, page,
@@ -14,24 +14,31 @@ class CSATReportsAPI extends ApiClient {
until: to, until: to,
sort: '-created_at', sort: '-created_at',
user_ids, 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`, { return axios.get(`${this.url}/download`, {
params: { params: {
since: from, since: from,
until: to, until: to,
sort: '-created_at', sort: '-created_at',
user_ids, 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`, { 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 },
}); });
} }
} }

View File

@@ -127,6 +127,10 @@ class ConversationApi extends ApiClient {
user_ids: userIds, user_ids: userIds,
}); });
} }
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
} }
export default new ConversationApi(); export default new ConversationApi();

View File

@@ -210,5 +210,12 @@ describe('#ConversationAPI', () => {
{ params: { page: payload.page } } { params: { page: payload.page } }
); );
}); });
it('#getAllAttachments', () => {
conversationAPI.getAllAttachments(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/attachments'
);
});
}); });
}); });

View File

@@ -5,12 +5,20 @@
} }
.date-picker { .date-picker {
.mx-datepicker { &.no-margin {
width: 100%; .mx-input {
margin-bottom: 0;
}
} }
.mx-datepicker-range { &:not(.auto-width) {
width: 320px; .mx-datepicker-range {
width: 320px;
}
}
.mx-datepicker {
width: 100%;
} }
.mx-input { .mx-input {

View File

@@ -261,6 +261,12 @@
} }
} }
// Basic filter dropdown
.basic-filter {
left: 0;
right: unset;
}
// Card label // Card label
.label-container { .label-container {
.label { .label {

View File

@@ -13,7 +13,9 @@
} }
.multiselect { .multiselect {
margin-bottom: var(--space-normal); &:not(.no-margin) {
margin-bottom: var(--space-normal);
}
&.multiselect--disabled { &.multiselect--disabled {
opacity: 0.8; opacity: 0.8;

View File

@@ -29,13 +29,13 @@
} }
.modal-image { .modal-image {
max-height: 90%; max-height: 80vh;
max-width: 90%; max-width: 80vw;
} }
.modal-video { .modal-video {
max-height: 75vh; max-height: 80vh;
max-width: 100%; max-width: 80vw;
} }
&::before { &::before {
@@ -53,16 +53,6 @@
width: 100%; width: 100%;
} }
} }
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
} }
.conversations-list-wrap { .conversations-list-wrap {
@@ -400,4 +390,3 @@
margin-bottom: 0; margin-bottom: 0;
} }
} }

View File

@@ -47,6 +47,14 @@
/> />
</div> </div>
<div v-if="hasActiveFolders"> <div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button <woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')" v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny" size="tiny"
@@ -168,8 +176,11 @@
v-if="showAdvancedFilters" v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes" :initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter" :initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal" :on-close="closeAdvanceFiltersModal"
:is-folder-view="hasActiveFolders"
@applyFilter="onApplyFilter" @applyFilter="onApplyFilter"
@updateFolder="onUpdateSavedFilter"
/> />
</woot-modal> </woot-modal>
</div> </div>
@@ -193,6 +204,9 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin'; import filterMixin from 'shared/mixins/filterMixin';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import { import {
hasPressedAltAndJKey, hasPressedAltAndJKey,
@@ -289,6 +303,11 @@ export default {
appliedFilters: 'getAppliedConversationFilters', appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews', folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes', inboxes: 'inboxes/getInboxes',
agentList: 'agents/getAgents',
teamsList: 'teams/getTeams',
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
}), }),
hasAppliedFilters() { hasAppliedFilters() {
return this.appliedFilters.length !== 0; return this.appliedFilters.length !== 0;
@@ -451,6 +470,9 @@ export default {
} }
return undefined; return undefined;
}, },
activeFolderName() {
return this.activeFolder?.name;
},
activeTeam() { activeTeam() {
if (this.teamId) { if (this.teamId) {
return this.$store.getters['teams/getTeam'](this.teamId); return this.$store.getters['teams/getTeam'](this.teamId);
@@ -483,9 +505,7 @@ export default {
this.resetAndFetchData(); this.resetAndFetchData();
}, },
activeFolder() { activeFolder() {
if (!this.hasAppliedFilters) { this.resetAndFetchData();
this.resetAndFetchData();
}
}, },
chatLists() { chatLists() {
this.chatsOnView = this.conversationList; this.chatsOnView = this.conversationList;
@@ -496,6 +516,10 @@ export default {
this.$store.dispatch('setChatSortFilter', this.activeSortBy); this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.resetAndFetchData(); this.resetAndFetchData();
if (this.hasActiveFolders) {
this.$store.dispatch('campaigns/get');
}
bus.$on('fetch_conversation_stats', () => { bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters); this.$store.dispatch('conversationStats/get', this.conversationFilters);
}); });
@@ -508,6 +532,15 @@ export default {
this.$store.dispatch('emptyAllConversations'); this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload); this.fetchFilteredConversations(payload);
}, },
onUpdateSavedFilter(payload, folderName) {
const payloadData = {
...this.activeFolder,
name: folderName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
onClickOpenAddFoldersModal() { onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true; this.showAddFoldersModal = true;
}, },
@@ -521,15 +554,70 @@ export default {
this.showDeleteFoldersModal = false; this.showDeleteFoldersModal = false;
}, },
onToggleAdvanceFiltersModal() { onToggleAdvanceFiltersModal() {
if (!this.hasAppliedFilters) { if (!this.hasAppliedFilters && !this.hasActiveFolders) {
this.initializeExistingFilterToModal(); this.initializeExistingFilterToModal();
} }
if (this.hasActiveFolders) {
this.initializeFolderToFilterModal(this.activeFolder);
}
this.showAdvancedFilters = true; this.showAdvancedFilters = true;
}, },
closeAdvanceFiltersModal() { closeAdvanceFiltersModal() {
this.showAdvancedFilters = false; this.showAdvancedFilters = false;
this.appliedFilter = []; this.appliedFilter = [];
}, },
setParamsForEditFolderModal() {
// Here we are setting the params for edit folder modal to show the existing values.
// For agent, team, inboxes,and campaigns we get only the id's from the query.
// So we are mapping the id's to the actual values.
// For labels we get the name of the label from the query.
// If we delete the label from the label list then we will not be able to show the label name.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
const params = {
agents: this.agentList,
teams: this.teamsList,
inboxes: this.inboxesList,
labels: this.labels,
campaigns: this.campaigns,
languages: languages,
countries: countries,
filterTypes: advancedFilterTypes,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('conversation_attribute'),
};
return params;
},
initializeFolderToFilterModal(activeFolder) {
// Here we are setting the params for edit folder modal.
// To show the existing values. when we click on edit folder button.
// Here we get the query from the active folder.
// And we are mapping the query to the actual values.
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
const query = activeFolder?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditFolderModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
getKeyboardListenerParams() { getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll( const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation' 'div.conversations-list div.conversation'
@@ -575,6 +663,7 @@ export default {
} }
}, },
resetAndFetchData() { resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions(); this.resetBulkActions();
this.$store.dispatch('conversationPage/reset'); this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations'); this.$store.dispatch('emptyAllConversations');
@@ -587,7 +676,6 @@ export default {
return; return;
} }
this.fetchConversations(); this.fetchConversations();
this.appliedFilter = [];
}, },
fetchConversations() { fetchConversations() {
this.$store this.$store

View File

@@ -115,7 +115,7 @@
</template> </template>
<script> <script>
import format from 'date-fns/format'; import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators'; import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue'; import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
@@ -143,15 +143,20 @@ export default {
}, },
computed: { computed: {
formattedValue() { displayValue() {
if (this.isAttributeTypeDate) { if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT); return new Date(this.value || new Date()).toLocaleDateString();
} }
if (this.isAttributeTypeCheckbox) { if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value; return this.value === 'false' ? false : this.value;
} }
return this.value; return this.value;
}, },
formattedValue() {
return this.isAttributeTypeDate
? format(this.value ? new Date(this.value) : new Date(), DATE_FORMAT)
: this.value;
},
listOptions() { listOptions() {
return this.values.map((value, index) => ({ return this.values.map((value, index) => ({
id: index + 1, id: index + 1,
@@ -192,17 +197,11 @@ export default {
} }
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}, },
displayValue() {
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
}
return this.editedValue;
},
}, },
watch: { watch: {
value() { value() {
this.isEditing = false; this.isEditing = false;
this.editedValue = this.value; this.editedValue = this.formattedValue;
}, },
}, },
@@ -249,9 +248,8 @@ export default {
onUpdate() { onUpdate() {
const updatedValue = const updatedValue =
this.attributeType === 'date' this.attributeType === 'date'
? format(new Date(this.editedValue), DATE_FORMAT) ? parseISO(this.editedValue)
: this.editedValue; : this.editedValue;
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) { if (this.$v.$invalid) {
return; return;

View File

@@ -1,7 +1,6 @@
<template> <template>
<banner <banner
v-if="shouldShowBanner" v-if="shouldShowBanner"
class="update-banner"
color-scheme="primary" color-scheme="primary"
:banner-message="bannerMessage" :banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases" href-link="https://github.com/chatwoot/chatwoot/releases"

View File

@@ -11,26 +11,29 @@
{{ hrefLinkText }} {{ hrefLinkText }}
</a> </a>
</span> </span>
<woot-button <div class="actions">
v-if="hasActionButton" <woot-button
size="small" v-if="hasActionButton"
variant="link" size="tiny"
icon="arrow-right" icon="arrow-right"
color-scheme="primary" :variant="actionButtonVariant"
class-names="banner-action__button" color-scheme="primary"
@click="onClick" class-names="banner-action__button"
> @click="onClick"
{{ actionButtonLabel }} >
</woot-button> {{ actionButtonLabel }}
<woot-button </woot-button>
v-if="hasCloseButton" <woot-button
size="small" v-if="hasCloseButton"
variant="link" size="tiny"
color-scheme="secondary" :color-scheme="colorScheme"
icon="dismiss-circle" icon="dismiss-circle"
class-names="banner-action__button" class-names="banner-action__button"
@click="onClickClose" @click="onClickClose"
/> >
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
</div>
</div> </div>
</template> </template>
@@ -53,6 +56,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
actionButtonVariant: {
type: String,
default: '',
},
actionButtonLabel: { actionButtonLabel: {
type: String, type: String,
default: '', default: '',
@@ -68,7 +75,12 @@ export default {
}, },
computed: { computed: {
bannerClasses() { bannerClasses() {
return [this.colorScheme]; const classList = [this.colorScheme, `banner-align-${this.align}`];
if (this.hasActionButton || this.hasCloseButton) {
classList.push('has-button');
}
return classList;
}, },
}, },
methods: { methods: {
@@ -84,17 +96,26 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.banner { .banner {
--x-padding: var(--space-normal);
--y-padding: var(--space-slab);
display: flex; display: flex;
gap: var(--x-padding);
color: var(--white); color: var(--white);
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal); padding: var(--y-padding) var(--x-padding);
justify-content: center; justify-content: center;
position: sticky;
&.primary { &.primary {
background: var(--w-500); background: var(--w-500);
.banner-action__button { .banner-action__button {
background: var(--w-600);
border: none;
color: var(--white); color: var(--white);
&:hover {
background: var(--w-800);
}
} }
} }
@@ -107,7 +128,16 @@ export default {
} }
&.alert { &.alert {
background: var(--r-400); background: var(--r-500);
.banner-action__button {
background: var(--r-700);
border: none;
color: var(--white);
&:hover {
background: var(--r-800);
}
}
} }
&.warning { &.warning {
@@ -133,8 +163,6 @@ export default {
} }
.banner-action__button { .banner-action__button {
margin: 0 var(--space-smaller);
::v-deep .button__content { ::v-deep .button__content {
white-space: nowrap; white-space: nowrap;
} }
@@ -144,5 +172,11 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.actions {
display: flex;
gap: var(--space-smaller);
right: var(--y-padding);
}
} }
</style> </style>

View File

@@ -45,6 +45,14 @@ export default {
default: () => {}, default: () => {},
}, },
}, },
watch: {
collection() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
},
mounted() { mounted() {
this.renderChart(this.collection, { this.renderChart(this.collection, {
...chartOptions, ...chartOptions,

View File

@@ -1,9 +1,21 @@
<template> <template>
<div class="column"> <div class="column">
<woot-modal-header :header-title="$t('FILTER.TITLE')"> <woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ $t('FILTER.SUBTITLE') }}</p> <p>{{ filterModalSubTitle }}</p>
</woot-modal-header> </woot-modal-header>
<div class="row modal-content"> <div class="column modal-content">
<div v-if="isFolderView" class="columns">
<label class="input-label" :class="{ error: !activeFolderNewName }">
{{ $t('FILTER.FOLDER_LABEL') }}
<input v-model="activeFolderNewName" type="text" class="name-input" />
<span v-if="!activeFolderNewName" class="message">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap"> <div class="medium-12 columns filters-wrap">
<filter-input-box <filter-input-box
v-for="(filter, i) in appliedFilters" v-for="(filter, i) in appliedFilters"
@@ -42,7 +54,14 @@
<woot-button class="button clear" @click.prevent="onClose"> <woot-button class="button clear" @click.prevent="onClose">
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }} {{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button> </woot-button>
<woot-button @click="submitFilterQuery"> <woot-button
v-if="isFolderView"
:disabled="!activeFolderNewName"
@click="updateSavedCustomViews"
>
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }} {{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button> </woot-button>
</div> </div>
@@ -81,6 +100,14 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
activeFolderName: {
type: String,
default: '',
},
isFolderView: {
type: Boolean,
default: false,
},
}, },
validations: { validations: {
appliedFilters: { appliedFilters: {
@@ -107,6 +134,7 @@ export default {
return { return {
show: true, show: true,
appliedFilters: this.initialAppliedFilters, appliedFilters: this.initialAppliedFilters,
activeFolderNewName: this.activeFolderName,
filterTypes: this.initialFilterTypes, filterTypes: this.initialFilterTypes,
filterAttributeGroups, filterAttributeGroups,
filterGroups: [], filterGroups: [],
@@ -119,6 +147,16 @@ export default {
...mapGetters({ ...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters', getAppliedConversationFilters: 'getAppliedConversationFilters',
}), }),
filterModalHeaderTitle() {
return !this.isFolderView
? this.$t('FILTER.TITLE')
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
},
filterModalSubTitle() {
return !this.isFolderView
? this.$t('FILTER.SUBTITLE')
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
},
}, },
mounted() { mounted() {
this.setFilterAttributes(); this.setFilterAttributes();
@@ -126,7 +164,7 @@ export default {
if (this.getAppliedConversationFilters.length) { if (this.getAppliedConversationFilters.length) {
this.appliedFilters = []; this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters]; this.appliedFilters = [...this.getAppliedConversationFilters];
} else { } else if (!this.isFolderView) {
this.appliedFilters.push({ this.appliedFilters.push({
attribute_key: 'status', attribute_key: 'status',
filter_operator: 'equal_to', filter_operator: 'equal_to',
@@ -177,11 +215,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at') if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text'; if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key); const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType; return type?.inputType;
}, },
getOperators(key) { getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key); const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators; return type?.filterOperators;
}, },
getDropdownValues(type) { getDropdownValues(type) {
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'); const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
@@ -267,11 +305,30 @@ export default {
} }
}, },
appendNewFilter() { appendNewFilter() {
this.appliedFilters.push({ if (this.isFolderView) {
attribute_key: 'status', this.setQueryOperatorOnLastQuery();
filter_operator: 'equal_to', } else {
values: '', this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and', query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}); });
}, },
removeFilter(index) { removeFilter(index) {
@@ -296,6 +353,9 @@ export default {
})), })),
}); });
}, },
updateSavedCustomViews() {
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
},
resetFilter(index, currentFilter) { resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find( this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key filter => filter.attributeKey === currentFilter.attribute_key
@@ -322,4 +382,12 @@ export default {
.filter-actions { .filter-actions {
margin-top: var(--space-normal); margin-top: var(--space-normal);
} }
.input-label {
margin-bottom: var(--space-smaller);
.name-input {
width: 50%;
}
}
</style> </style>

View File

@@ -9,7 +9,7 @@
}" }"
@mouseenter="onCardHover" @mouseenter="onCardHover"
@mouseleave="onCardLeave" @mouseleave="onCardLeave"
@click="cardClick(chat)" @click="onCardClick"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
> >
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop> <label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
@@ -313,21 +313,33 @@ export default {
}, },
}, },
methods: { methods: {
cardClick(chat) { onCardClick(e) {
const { activeInbox } = this; const { activeInbox, chat } = this;
const path = conversationUrl({ const path = frontendURL(
accountId: this.accountId, conversationUrl({
activeInbox, accountId: this.accountId,
id: chat.id, activeInbox,
label: this.activeLabel, id: chat.id,
teamId: this.teamId, label: this.activeLabel,
foldersId: this.foldersId, teamId: this.teamId,
conversationType: this.conversationType, 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) { if (this.isActiveChat) {
return; return;
} }
router.push({ path: frontendURL(path) });
router.push({ path });
}, },
onCardHover() { onCardHover() {
this.hovered = !this.hideThumbnail; this.hovered = !this.hideThumbnail;

View File

@@ -25,10 +25,14 @@
<blockquote v-if="storyReply" class="story-reply-quote"> <blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span> <span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image <bubble-image
v-if="!hasStoryError" v-if="!hasImgStoryError && storyUrl"
:url="storyUrl" :url="storyUrl"
@error="onStoryLoadError" @error="onStoryLoadError"
/> />
<bubble-video
v-else-if="hasImgStoryError && storyUrl"
:url="storyUrl"
/>
</blockquote> </blockquote>
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
@@ -49,22 +53,11 @@
</span> </span>
<div v-if="!isPending && hasAttachments"> <div v-if="!isPending && hasAttachments">
<div v-for="attachment in data.attachments" :key="attachment.id"> <div v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image <bubble-image-audio-video
v-if="attachment.file_type === 'image' && !hasImageError" v-if="isAttachmentImageVideoAudio(attachment.file_type)"
:url="attachment.data_url" :attachment="attachment"
@error="onImageLoadError" @error="onImageLoadError"
/> />
<audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
/>
<bubble-location <bubble-location
v-else-if="attachment.file_type === 'location'" v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat" :latitude="attachment.coordinates_lat"
@@ -140,11 +133,12 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions'; import BubbleActions from './bubble/Actions';
import BubbleFile from './bubble/File'; import BubbleFile from './bubble/File';
import BubbleImage from './bubble/Image'; import BubbleImage from './bubble/Image';
import BubbleVideo from './bubble/Video';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
import BubbleIntegration from './bubble/Integration.vue'; import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location'; import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead'; import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text'; import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact'; import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner'; import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@@ -161,11 +155,12 @@ export default {
BubbleActions, BubbleActions,
BubbleFile, BubbleFile,
BubbleImage, BubbleImage,
BubbleVideo,
BubbleImageAudioVideo,
BubbleIntegration, BubbleIntegration,
BubbleLocation, BubbleLocation,
BubbleMailHead, BubbleMailHead,
BubbleText, BubbleText,
BubbleVideo,
BubbleContact, BubbleContact,
ContextMenu, ContextMenu,
Spinner, Spinner,
@@ -200,7 +195,7 @@ export default {
hasImageError: false, hasImageError: false,
contextMenuPosition: {}, contextMenuPosition: {},
showBackgroundHighlight: false, showBackgroundHighlight: false,
hasStoryError: false, hasImgStoryError: false,
}; };
}, },
computed: { computed: {
@@ -429,12 +424,12 @@ export default {
watch: { watch: {
data() { data() {
this.hasImageError = false; this.hasImageError = false;
this.hasStoryError = false; this.hasImgStoryError = false;
}, },
}, },
mounted() { mounted() {
this.hasImageError = false; this.hasImageError = false;
this.hasStoryError = false; this.hasImgStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu); bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer(); this.setupHighlightTimer();
}, },
@@ -443,6 +438,9 @@ export default {
clearTimeout(this.higlightTimeout); clearTimeout(this.higlightTimeout);
}, },
methods: { methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video'].includes(fileType);
},
hasMediaAttachment(type) { hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) { if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data; const { attachments = [{}] } = this.data;
@@ -464,7 +462,7 @@ export default {
this.hasImageError = true; this.hasImageError = true;
}, },
onStoryLoadError() { onStoryLoadError() {
this.hasStoryError = true; this.hasImgStoryError = true;
}, },
openContextMenu(e) { openContextMenu(e) {
const shouldSkipContextMenu = const shouldSkipContextMenu =

View File

@@ -36,6 +36,7 @@
v-for="message in getReadMessages" v-for="message in getReadMessages"
:key="message.id" :key="message.id"
class="message--read ph-no-capture" class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message" :data="message"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -56,6 +57,7 @@
v-for="message in getUnReadMessages" v-for="message in getUnReadMessages"
:key="message.id" :key="message.id"
class="message--unread ph-no-capture" class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message" :data="message"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -277,6 +279,7 @@ export default {
if (newChat.id === oldChat.id) { if (newChat.id === oldChat.id) {
return; return;
} }
this.fetchAllAttachmentsFromCurrentChat();
this.selectedTweetId = null; this.selectedTweetId = null;
}, },
}, },
@@ -288,6 +291,7 @@ export default {
mounted() { mounted() {
this.addScrollListener(); this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
}, },
beforeDestroy() { beforeDestroy() {
@@ -296,6 +300,9 @@ export default {
}, },
methods: { methods: {
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() { removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet); bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);

View File

@@ -2,6 +2,7 @@
<div class="reply-box" :class="replyBoxClass"> <div class="reply-box" :class="replyBoxClass">
<banner <banner
v-if="showSelfAssignBanner" v-if="showSelfAssignBanner"
action-button-variant="link"
color-scheme="secondary" color-scheme="secondary"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')" :banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
:has-action-button="true" :has-action-button="true"
@@ -501,7 +502,7 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`; return `draft-${this.conversationIdByRoute}-${this.replyType}`;
}, },
audioRecordFormat() { audioRecordFormat() {
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel || this.isAPIInbox) {
return AUDIO_FORMATS.OGG; return AUDIO_FORMATS.OGG;
} }
return AUDIO_FORMATS.WAV; return AUDIO_FORMATS.WAV;

View File

@@ -0,0 +1,106 @@
<template>
<div class="message-text__wrap" :class="attachmentTypeClasses">
<img
v-if="isImage && !isImageError"
:src="attachment.data_url"
@click="onClick"
@error="onImgError()"
/>
<video
v-if="isVideo"
:src="attachment.data_url"
muted
playsInline
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<gallery-view
v-if="show"
:show.sync="show"
:attachment="attachment"
:all-attachments="filteredCurrentChatAttachments"
@error="onImgError()"
@close="onClose"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
GalleryView,
},
props: {
attachment: {
type: Object,
required: true,
},
},
data() {
return {
show: false,
isImageError: false,
};
},
computed: {
...mapGetters({
currentChatAttachments: 'getSelectedChatAttachments',
}),
isImage() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
attachmentTypeClasses() {
return {
image: this.isImage,
video: this.isVideo,
};
},
filteredCurrentChatAttachments() {
const attachments = this.currentChatAttachments.filter(attachment =>
['image', 'video', 'audio'].includes(attachment.file_type)
);
return attachments;
},
},
watch: {
attachment() {
this.isImageError = false;
},
},
methods: {
onClose() {
this.show = false;
},
onClick(e) {
if (hasPressedCommand(e)) {
window.open(this.attachment.data_url, '_blank');
return;
}
this.show = true;
},
onImgError() {
this.isImageError = true;
this.$emit('error');
},
},
};
</script>

View File

@@ -0,0 +1,201 @@
<template>
<woot-modal full-width :show.sync="show" :on-close="onClose">
<div v-on-clickaway="onClose" class="gallery-modal--wrap" @click="onClose">
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="secondary"
icon="chevron-left"
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="attachments-viewer">
<div class="attachment-view">
<img
v-if="isImage"
:key="attachmentSrc"
:src="attachmentSrc"
class="modal-image skip-context-menu"
@click.stop
/>
<video
v-if="isVideo"
:key="attachmentSrc"
:src="attachmentSrc"
controls
playsInline
class="modal-video skip-context-menu"
@click.stop
/>
<audio
v-if="isAudio"
:key="attachmentSrc"
controls
class="skip-context-menu"
@click.stop
>
<source :src="`${attachmentSrc}?t=${Date.now()}`" />
</audio>
</div>
</div>
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="secondary"
:disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</div>
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
mixins: [eventListenerMixins, clickaway],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
},
data() {
return {
attachmentSrc: '',
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.id === this.attachment.id
) || 0,
};
},
computed: {
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.activeFileType === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.attachmentSrc = attachment.data_url;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
},
},
};
</script>
<style lang="scss" scoped>
.gallery-modal--wrap {
display: flex;
flex-direction: row;
align-items: center;
width: inherit;
height: inherit;
.attachments-viewer {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
.attachment-view {
display: flex;
align-items: center;
justify-content: center;
img {
margin: 0 auto;
}
video {
margin: 0 auto;
}
}
}
.attachment-toggle--button {
width: var(--space-mega);
min-width: var(--space-mega);
display: flex;
justify-content: center;
}
}
</style>

View File

@@ -79,6 +79,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin'; import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default { export default {
@@ -87,7 +88,7 @@ export default {
MenuItemWithSubmenu, MenuItemWithSubmenu,
AgentLoadingPlaceholder, AgentLoadingPlaceholder,
}, },
mixins: [snoozeTimesMixin], mixins: [snoozeTimesMixin, agentMixin],
props: { props: {
status: { status: {
type: String, type: String,
@@ -202,6 +203,16 @@ export default {
teams: 'teams/getTeams', teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags', 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() { assignableAgents() {
return [ return [
{ {
@@ -212,9 +223,7 @@ export default {
account_id: 0, account_id: 0,
email: 'None', email: 'None',
}, },
...this.$store.getters['inboxAssignableAgents/getAssignableAgents']( ...this.filteredAgentOnAvailability,
this.inboxId
),
]; ];
}, },
}, },
@@ -246,6 +255,7 @@ export default {
...(type === 'icon' && { icon: option.icon }), ...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }), ...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }), ...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'agent' && { status: option.availability_status }),
...(type === 'text' && { label: option.label }), ...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }), ...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }), ...(type === 'agent' && { label: option.name }),

View File

@@ -15,6 +15,7 @@
v-if="variant === 'agent'" v-if="variant === 'agent'"
:username="option.label" :username="option.label"
:src="option.thumbnail" :src="option.thumbnail"
:status="option.status"
size="20px" size="20px"
class="agent-thumbnail" class="agent-thumbnail"
/> />

View File

@@ -0,0 +1,119 @@
export const getInputType = (key, operator, filterTypes) => {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
};
export const generateCustomAttributesInputType = type => {
const filterInputTypes = {
text: 'string',
number: 'string',
date: 'string',
checkbox: 'multi_select',
list: 'multi_select',
link: 'string',
};
return filterInputTypes[type];
};
export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key
);
const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type
);
return filterInputTypes;
};
export const getValuesName = (values, list, idKey, nameKey) => {
const item = list?.find(v => v[idKey] === values[0]);
return {
id: values[0],
name: item ? item[nameKey] : values[0],
};
};
const getValuesForLabels = (values, labels) => {
const selectedLabels = labels.filter(label => values.includes(label.title));
return selectedLabels.map(({ title }) => ({
id: title,
name: title,
}));
};
const getValuesForLanguages = (values, languages) => {
const selectedLanguages = languages.filter(language =>
values.includes(language.id)
);
return selectedLanguages.map(({ id, name }) => ({
id: id.toLowerCase(),
name: name,
}));
};
const getValuesForCountries = (values, countries) => {
const selectedCountries = countries.filter(country =>
values.includes(country.id)
);
return selectedCountries.map(({ id, name }) => ({
id: id,
name: name,
}));
};
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const {
languages,
countries,
agents,
inboxes,
teams,
campaigns,
labels,
} = params;
switch (attribute_key) {
case 'assignee_id':
return getValuesName(values, agents, 'id', 'name');
case 'inbox_id':
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels': {
return getValuesForLabels(values, labels);
}
case 'browser_language': {
return getValuesForLanguages(values, languages);
}
case 'country_code': {
return getValuesForCountries(values, countries);
}
default:
return { id: values[0], name: values[0] };
}
};
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
if (inboxType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
);
return filterInputTypes === 'string'
? values[0].toString()
: { id: values[0], name: values[0] };
}
return inboxType === 'multi_select' || inboxType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};

View File

@@ -1,9 +1,12 @@
const setArrayValues = item => {
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
};
const generatePayload = data => { const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues // Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data)); const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => { let payload = filters.map(item => {
if (Array.isArray(item.values)) { if (Array.isArray(item.values)) {
item.values = item.values.map(val => val.id); item.values = setArrayValues(item);
} else if (typeof item.values === 'object') { } else if (typeof item.values === 'object') {
item.values = [item.values.id]; item.values = [item.values.id];
} else if (!item.values) { } else if (!item.values) {

View File

@@ -18,18 +18,18 @@ export const initializeAnalyticsEvents = () => {
}); });
} }
}); });
window.bus.$on(ANALYTICS_RESET, () => {});
}; };
const initializeAudioAlerts = user => { const initializeAudioAlerts = user => {
// InitializeAudioNotifications
const { ui_settings: uiSettings } = user || {}; const { ui_settings: uiSettings } = user || {};
const { const {
always_play_audio_alert: alwaysPlayAudioAlert, always_play_audio_alert: alwaysPlayAudioAlert,
enable_audio_alerts: audioAlertType, enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist, alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone, notification_tone: audioAlertTone,
} = uiSettings; // UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({ DashboardAudioNotificationHelper.setInstanceValues({
currentUserId: user.id, currentUserId: user.id,

View File

@@ -0,0 +1,278 @@
import {
getAttributeInputType,
getInputType,
getValuesName,
getValuesForFilter,
generateValuesForEditCustomViews,
generateCustomAttributesInputType,
} from '../customViewsHelper';
import advancedFilterTypes from 'dashboard/components/widgets/conversation/advancedFilterItems/index';
describe('customViewsHelper', () => {
describe('#getInputType', () => {
it('should return plain_text if key is created_at or last_activity_at and operator is days_before', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
];
expect(getInputType('created_at', 'days_before', filterTypes)).toEqual(
'plain_text'
);
expect(
getInputType('last_activity_at', 'days_before', filterTypes)
).toEqual('plain_text');
});
it('should return inputType if key is not created_at or last_activity_at', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
it('should return undefined if key is not created_at or last_activity_at and inputType is not present', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
});
describe('#getAttributeInputType', () => {
it('should return multi_select if attribute_display_type is checkbox or list', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'multi_select'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'multi_select'
);
});
it('should return string if attribute_display_type is text, number, date or link', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'text' },
{ attribute_key: 'test2', attribute_display_type: 'number' },
{ attribute_key: 'test3', attribute_display_type: 'date' },
{ attribute_key: 'test4', attribute_display_type: 'link' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test3', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test4', allCustomAttributes)).toEqual(
'string'
);
});
});
describe('#getValuesName', () => {
it('should return id and name if item is present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [1];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and value if item is not present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [2];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 2,
name: 2,
});
});
});
describe('#getValuesForFilter', () => {
it('should return id and name if attribute_key is assignee_id', () => {
const filter = { attribute_key: 'assignee_id', values: [1] };
const params = { agents: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is inbox_id', () => {
const filter = { attribute_key: 'inbox_id', values: [1] };
const params = { inboxes: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is team_id', () => {
const filter = { attribute_key: 'team_id', values: [1] };
const params = { teams: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is campaign_id', () => {
const filter = { attribute_key: 'campaign_id', values: [1] };
const params = { campaigns: [{ id: 1, title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is labels', () => {
const filter = { attribute_key: 'labels', values: ['test'] };
const params = { labels: [{ title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'test', name: 'test' },
]);
});
it('should return id and name if attribute_key is browser_language', () => {
const filter = { attribute_key: 'browser_language', values: ['en'] };
const params = { languages: [{ id: 'en', name: 'English' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'en', name: 'English' },
]);
});
it('should return id and name if attribute_key is country_code', () => {
const filter = { attribute_key: 'country_code', values: ['IN'] };
const params = { countries: [{ id: 'IN', name: 'India' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'IN', name: 'India' },
]);
});
it('should return id and name if attribute_key is not present', () => {
const filter = { attribute_key: 'test', values: [1] };
const params = {};
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 1,
});
});
});
describe('#generateValuesForEditCustomViews', () => {
it('should return id and name if inboxType is multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is not multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is undefined', () => {
const filter = {
attribute_key: 'test2',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 1,
});
});
it('should return value as string if filterInputTypes is string', () => {
const filter = {
attribute_key: 'test',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'date' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
});
describe('#generateCustomAttributesInputType', () => {
it('should return string if type is text', () => {
expect(generateCustomAttributesInputType('text')).toEqual('string');
});
it('should return string if type is number', () => {
expect(generateCustomAttributesInputType('number')).toEqual('string');
});
it('should return string if type is date', () => {
expect(generateCustomAttributesInputType('date')).toEqual('string');
});
it('should return multi_select if type is checkbox', () => {
expect(generateCustomAttributesInputType('checkbox')).toEqual(
'multi_select'
);
});
it('should return multi_select if type is list', () => {
expect(generateCustomAttributesInputType('list')).toEqual('multi_select');
});
it('should return string if type is link', () => {
expect(generateCustomAttributesInputType('link')).toEqual('string');
});
});
});

View File

@@ -2,12 +2,17 @@
"FILTER": { "FILTER": {
"TITLE": "Filter Conversations", "TITLE": "Filter Conversations",
"SUBTITLE": "Add filters below and hit 'Apply filters' to filter conversations.", "SUBTITLE": "Add filters below and hit 'Apply filters' to filter conversations.",
"EDIT_CUSTOM_FILTER": "Edit Folder",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your folder.",
"ADD_NEW_FILTER": "Add Filter", "ADD_NEW_FILTER": "Add Filter",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save", "FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Apply filters", "SUBMIT_BUTTON_LABEL": "Apply filters",
"UPDATE_BUTTON_LABEL": "Update folder",
"CANCEL_BUTTON_LABEL": "Cancel", "CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters", "CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required", "EMPTY_VALUE_ERROR": "Value is required",
"FOLDER_LABEL": "Folder Name",
"FOLDER_QUERY_LABEL": "Folder Query",
"TOOLTIP_LABEL": "Filter conversations", "TOOLTIP_LABEL": "Filter conversations",
"QUERY_DROPDOWN_LABELS": { "QUERY_DROPDOWN_LABELS": {
"AND": "AND", "AND": "AND",
@@ -71,6 +76,9 @@
"ERROR_MESSAGE": "Error while creating segment" "ERROR_MESSAGE": "Error while creating segment"
} }
}, },
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"DELETE": { "DELETE": {
"DELETE_BUTTON": "Delete filter", "DELETE_BUTTON": "Delete filter",
"MODAL": { "MODAL": {

View File

@@ -10,15 +10,39 @@
"TITLE": "Manage Audit Logs", "TITLE": "Manage Audit Logs",
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.", "DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
"TABLE_HEADER": [ "TABLE_HEADER": [
"User", "Activity",
"Action", "Time",
"IP Address", "IP Address"
"Time"
] ]
}, },
"API": { "API": {
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully", "SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later" "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
} },
"DEFAULT_USER": "System",
"AUTOMATION_RULE": {
"ADD": "%{agentName} created a new automation rule (#%{id})",
"EDIT": "%{agentName} updated an automation rule (#%{id})",
"DELETE": "%{agentName} deleted an automation rule (#%{id})"
},
"INBOX": {
"ADD": "%{agentName} created a new inbox (#%{id})",
"EDIT": "%{agentName} updated an inbox (#%{id})",
"DELETE": "%{agentName} deleted an inbox (#%{id})"
},
"WEBHOOK": {
"ADD": "%{agentName} created a new webhook (#%{id})",
"EDIT": "%{agentName} updated a webhook (#%{id})",
"DELETE": "%{agentName} deleted a webhook (#%{id})"
},
"USER_ACTION": {
"SIGN_IN": "%{agentName} signed in",
"SIGN_OUT": "%{agentName} signed out"
},
"TEAM": {
"ADD": "%{agentName} created a new team (#%{id})",
"EDIT": "%{agentName} updated a team (#%{id})",
"DELETE": "%{agentName} deleted a team (#%{id})"
}
} }
} }

View File

@@ -73,6 +73,13 @@
"SUCCESS_MESSAGE": "Contacts saved successfully", "SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again" "ERROR_MESSAGE": "There was an error, please try again"
}, },
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"DELETE_NOTE": { "DELETE_NOTE": {
"CONFIRM": { "CONFIRM": {
"TITLE": "Confirm Deletion", "TITLE": "Confirm Deletion",
@@ -211,6 +218,7 @@
"FILTER_CONTACTS": "Filter", "FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter", "FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter", "FILTER_CONTACTS_DELETE": "Delete filter",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": { "LIST": {
"LOADING_MESSAGE": "Loading contacts...", "LOADING_MESSAGE": "Loading contacts...",
"404": "No contacts matches your search 🔍", "404": "No contacts matches your search 🔍",

View File

@@ -1,50 +1,55 @@
{ {
"CONTACTS_FILTER": { "CONTACTS_FILTER": {
"TITLE": "Filter Contacts", "TITLE": "Filter Contacts",
"SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.", "SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.",
"ADD_NEW_FILTER": "Add Filter", "EDIT_CUSTOM_SEGMENT": "Edit Segment",
"CLEAR_ALL_FILTERS": "Clear All Filters", "CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your segment.",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save", "ADD_NEW_FILTER": "Add Filter",
"SUBMIT_BUTTON_LABEL": "Submit", "CLEAR_ALL_FILTERS": "Clear All Filters",
"CANCEL_BUTTON_LABEL": "Cancel", "FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"CLEAR_BUTTON_LABEL": "Clear Filters", "SUBMIT_BUTTON_LABEL": "Submit",
"EMPTY_VALUE_ERROR": "Value is required", "UPDATE_BUTTON_LABEL": "Update Segment",
"TOOLTIP_LABEL": "Filter contacts", "CANCEL_BUTTON_LABEL": "Cancel",
"QUERY_DROPDOWN_LABELS": { "CLEAR_BUTTON_LABEL": "Clear Filters",
"AND": "AND", "EMPTY_VALUE_ERROR": "Value is required",
"OR": "OR" "SEGMENT_LABEL": "Segment Name",
}, "SEGMENT_QUERY_LABEL": "Segment Query",
"OPERATOR_LABELS": { "TOOLTIP_LABEL": "Filter contacts",
"equal_to": "Equal to", "QUERY_DROPDOWN_LABELS": {
"not_equal_to": "Not equal to", "AND": "AND",
"contains": "Contains", "OR": "OR"
"does_not_contain": "Does not contain", },
"is_present": "Is present", "OPERATOR_LABELS": {
"is_not_present": "Is not present", "equal_to": "Equal to",
"is_greater_than": "Is greater than", "not_equal_to": "Not equal to",
"is_lesser_than": "Is lesser than", "contains": "Contains",
"days_before": "Is x days before" "does_not_contain": "Does not contain",
}, "is_present": "Is present",
"ATTRIBUTES": { "is_not_present": "Is not present",
"NAME": "Name", "is_greater_than": "Is greater than",
"EMAIL": "Email", "is_lesser_than": "Is lesser than",
"PHONE_NUMBER": "Phone number", "days_before": "Is x days before"
"IDENTIFIER": "Identifier", },
"CITY": "City", "ATTRIBUTES": {
"COUNTRY": "Country", "NAME": "Name",
"CUSTOM_ATTRIBUTE_LIST": "List", "EMAIL": "Email",
"CUSTOM_ATTRIBUTE_TEXT": "Text", "PHONE_NUMBER": "Phone number",
"CUSTOM_ATTRIBUTE_NUMBER": "Number", "IDENTIFIER": "Identifier",
"CUSTOM_ATTRIBUTE_LINK": "Link", "CITY": "City",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", "COUNTRY": "Country",
"CREATED_AT": "Created At", "CUSTOM_ATTRIBUTE_LIST": "List",
"LAST_ACTIVITY": "Last Activity", "CUSTOM_ATTRIBUTE_TEXT": "Text",
"REFERER_LINK": "Referrer link" "CUSTOM_ATTRIBUTE_NUMBER": "Number",
}, "CUSTOM_ATTRIBUTE_LINK": "Link",
"GROUPS": { "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"STANDARD_FILTERS": "Standard Filters", "CREATED_AT": "Created At",
"ADDITIONAL_FILTERS": "Additional Filters", "LAST_ACTIVITY": "Last Activity",
"CUSTOM_ATTRIBUTES": "Custom Attributes" "REFERER_LINK": "Referrer link"
} },
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Custom Attributes"
} }
}
} }

View File

@@ -1,6 +1,13 @@
{ {
"CSAT": { "CSAT": {
"TITLE": "Rate your conversation", "TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..." "PLACEHOLDER": "Tell us more...",
"RATINGS": {
"POOR": "😞 Poor",
"FAIR": "😑 Fair",
"AVERAGE": "😐 Average",
"GOOD": "😀 Good",
"EXCELLENT": "😍 Excellent"
}
} }
} }

View File

@@ -74,6 +74,14 @@
"DELETE": "Delete article" "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": { "PORTAL": {
"HEADER": "Portals", "HEADER": "Portals",
"DEFAULT": "Default", "DEFAULT": "Default",

View File

@@ -83,7 +83,7 @@
}, },
"CHANNEL_GREETING_TOGGLE": { "CHANNEL_GREETING_TOGGLE": {
"LABEL": "Enable channel greeting", "LABEL": "Enable channel greeting",
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.", "HELP_TEXT": "Auto-send greeting messages when customers start a conversation and send their first message.",
"ENABLED": "Enabled", "ENABLED": "Enabled",
"DISABLED": "Disabled" "DISABLED": "Disabled"
}, },
@@ -535,7 +535,6 @@
"UPDATE": "Update business hours settings", "UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": { "DAY": {
"ENABLE": "Enable availability for this day", "ENABLE": "Enable availability for this day",

View File

@@ -4,6 +4,8 @@
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports", "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": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",
@@ -34,6 +36,14 @@
"DESC": "( Total )" "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": [ "DATE_RANGE": [
{ {
"id": 0, "id": 0,
@@ -66,6 +76,12 @@
}, },
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By", "GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
"DURATION_FILTER_LABEL": "Duration", "DURATION_FILTER_LABEL": "Duration",
"GROUPING_OPTIONS": {
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Year"
},
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }], "GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
"GROUP_BY_WEEK_OPTIONS": [ "GROUP_BY_WEEK_OPTIONS": [
{ "id": 1, "groupBy": "Day" }, { "id": 1, "groupBy": "Day" },
@@ -356,6 +372,7 @@
"HEADER": "CSAT Reports", "HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.", "NO_RECORDS": "There are no CSAT survey responses available.",
"DOWNLOAD": "Download CSAT Reports", "DOWNLOAD": "Download CSAT Reports",
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
"FILTERS": { "FILTERS": {
"AGENTS": { "AGENTS": {
"PLACEHOLDER": "Choose Agents" "PLACEHOLDER": "Choose Agents"

View File

@@ -2,39 +2,68 @@ import { mapGetters } from 'vuex';
export default { export default {
computed: { computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
assignableAgents() { assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents']( return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId this.inboxId
); );
}, },
...mapGetters({ currentUser: 'getCurrentUser' }),
isAgentSelected() { isAgentSelected() {
return this.currentChat?.meta?.assignee; return this.currentChat?.meta?.assignee;
}, },
createNoneAgent() {
return {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
},
agentsList() { agentsList() {
const agents = this.assignableAgents || []; const agents = this.assignableAgents || [];
return [ const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
...(this.isAgentSelected const none = this.createNoneAgent;
? [ const filteredAgentsByAvailability = this.sortedAgentsByAvailability(
{ agentsByUpdatedPresence
confirmed: true, );
name: 'None', const filteredAgents = [
id: 0, ...(this.isAgentSelected ? [none] : []),
role: 'agent', ...filteredAgentsByAvailability,
account_id: 0, ];
email: 'None', return filteredAgents;
}, },
] },
: []), methods: {
...agents, getAgentsByAvailability(agents, availability) {
].map(item => 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.id === this.currentUser.id
? { ? {
...item, ...item,
availability_status: this.currentUser.availability_status, availability_status: this.currentUser.accounts.find(
account => account.id === this.currentAccountId
).availability_status,
} }
: item : item
); );
return agentsWithDynamicPresenceUpdate;
}, },
}, },
}; };

View File

@@ -20,6 +20,36 @@ export default {
name: 'Samuel Keta', name: 'Samuel Keta',
role: 'agent', 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: [ formattedAgents: [
{ {
@@ -32,7 +62,17 @@ export default {
}, },
{ {
account_id: 1, 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', available_name: 'John K',
confirmed: true, confirmed: true,
email: 'john@chatwoot.com', email: 'john@chatwoot.com',
@@ -40,6 +80,70 @@ export default {
name: 'John Kennady', name: 'John Kennady',
role: 'administrator', 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, account_id: 1,
availability_status: 'busy', availability_status: 'busy',
@@ -51,4 +155,92 @@ export default {
role: 'agent', 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',
},
],
}; };

View File

@@ -12,12 +12,71 @@ describe('agentMixin', () => {
getters = { getters = {
getCurrentUser: () => ({ getCurrentUser: () => ({
id: 1, id: 1,
availability_status: 'busy', accounts: [
{
id: 1,
availability_status: 'online',
auto_offline: false,
},
],
}), }),
getCurrentAccountId: () => 1,
}; };
store = new Vuex.Store({ getters }); 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', () => { it('return formatted agents', () => {
const Component = { const Component = {
render() {}, render() {},
@@ -38,4 +97,44 @@ describe('agentMixin', () => {
const wrapper = shallowMount(Component, { store, localVue }); const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents); 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);
});
}); });

View File

@@ -1,9 +1,25 @@
<template> <template>
<div class="column"> <div class="column">
<woot-modal-header :header-title="$t('CONTACTS_FILTER.TITLE')"> <woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ $t('CONTACTS_FILTER.SUBTITLE') }}</p> <p>{{ filterModalSubTitle }}</p>
</woot-modal-header> </woot-modal-header>
<div class="row modal-content"> <div class="column modal-content">
<div v-if="isSegmentsView" class="columns">
<label class="input-label" :class="{ error: !activeSegmentNewName }">
{{ $t('CONTACTS_FILTER.SEGMENT_LABEL') }}
<input
v-model="activeSegmentNewName"
type="text"
class="name-input"
/>
<span v-if="!activeSegmentNewName" class="message">
{{ $t('CONTACTS_FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('CONTACTS_FILTER.SEGMENT_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap"> <div class="medium-12 columns filters-wrap">
<filter-input-box <filter-input-box
v-for="(filter, i) in appliedFilters" v-for="(filter, i) in appliedFilters"
@@ -36,7 +52,7 @@
{{ $t('CONTACTS_FILTER.ADD_NEW_FILTER') }} {{ $t('CONTACTS_FILTER.ADD_NEW_FILTER') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-if="hasAppliedFilters" v-if="hasAppliedFilters && !isSegmentsView"
icon="subtract" icon="subtract"
color-scheme="alert" color-scheme="alert"
variant="smooth" variant="smooth"
@@ -52,7 +68,14 @@
<woot-button class="button clear" @click.prevent="onClose"> <woot-button class="button clear" @click.prevent="onClose">
{{ $t('CONTACTS_FILTER.CANCEL_BUTTON_LABEL') }} {{ $t('CONTACTS_FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button> </woot-button>
<woot-button @click="submitFilterQuery"> <woot-button
v-if="isSegmentsView"
:disabled="!activeSegmentNewName"
@click="updateSegment"
>
{{ $t('CONTACTS_FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('CONTACTS_FILTER.SUBMIT_BUTTON_LABEL') }} {{ $t('CONTACTS_FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button> </woot-button>
</div> </div>
@@ -85,6 +108,18 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
initialAppliedFilters: {
type: Array,
default: () => [],
},
isSegmentsView: {
type: Boolean,
default: false,
},
activeSegmentName: {
type: String,
default: '',
},
}, },
validations: { validations: {
appliedFilters: { appliedFilters: {
@@ -105,7 +140,8 @@ export default {
data() { data() {
return { return {
show: true, show: true,
appliedFilters: [], appliedFilters: this.initialAppliedFilters,
activeSegmentNewName: this.activeSegmentName,
filterTypes: this.initialFilterTypes, filterTypes: this.initialFilterTypes,
filterGroups: [], filterGroups: [],
allCustomAttributes: [], allCustomAttributes: [],
@@ -121,12 +157,22 @@ export default {
hasAppliedFilters() { hasAppliedFilters() {
return this.getAppliedContactFilters.length; return this.getAppliedContactFilters.length;
}, },
filterModalHeaderTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.TITLE')
: this.$t('CONTACTS_FILTER.EDIT_CUSTOM_SEGMENT');
},
filterModalSubTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.SUBTITLE')
: this.$t('CONTACTS_FILTER.CUSTOM_VIEWS_SUBTITLE');
},
}, },
mounted() { mounted() {
this.setFilterAttributes(); this.setFilterAttributes();
if (this.getAppliedContactFilters.length) { if (this.getAppliedContactFilters.length) {
this.appliedFilters = [...this.getAppliedContactFilters]; this.appliedFilters = [...this.getAppliedContactFilters];
} else { } else if (!this.isSegmentsView) {
this.appliedFilters.push({ this.appliedFilters.push({
attribute_key: 'name', attribute_key: 'name',
filter_operator: 'equal_to', filter_operator: 'equal_to',
@@ -177,11 +223,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at') if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text'; if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key); const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType; return type?.inputType;
}, },
getOperators(key) { getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key); const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators; return type?.filterOperators;
}, },
getDropdownValues(type) { getDropdownValues(type) {
const allCustomAttributes = this.$store.getters[ const allCustomAttributes = this.$store.getters[
@@ -230,11 +276,30 @@ export default {
} }
}, },
appendNewFilter() { appendNewFilter() {
this.appliedFilters.push({ if (this.isSegmentsView) {
attribute_key: 'name', this.setQueryOperatorOnLastQuery();
filter_operator: 'equal_to', } else {
values: '', this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and', query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}); });
}, },
removeFilter(index) { removeFilter(index) {
@@ -259,6 +324,13 @@ export default {
})), })),
}); });
}, },
updateSegment() {
this.$emit(
'updateSegment',
this.appliedFilters,
this.activeSegmentNewName
);
},
resetFilter(index, currentFilter) { resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find( this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key filter => filter.attributeKey === currentFilter.attribute_key

View File

@@ -1,10 +1,11 @@
<template> <template>
<div class="contacts-page row"> <div class="contacts-page row">
<div class="left-wrap" :class="wrapClas"> <div class="left-wrap" :class="wrapClass">
<contacts-header <contacts-header
:search-query="searchQuery" :search-query="searchQuery"
:segments-id="segmentsId" :segments-id="segmentsId"
:on-search-submit="onSearchSubmit" :on-search-submit="onSearchSubmit"
:on-export-submit="onExportSubmit"
this-selected-contact-id="" this-selected-contact-id=""
:on-input-search="onInputSearch" :on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate" :on-toggle-create="onToggleCreate"
@@ -13,6 +14,7 @@
:header-title="pageTitle" :header-title="pageTitle"
@on-toggle-save-filter="onToggleSaveFilters" @on-toggle-save-filter="onToggleSaveFilters"
@on-toggle-delete-filter="onToggleDeleteFilters" @on-toggle-delete-filter="onToggleDeleteFilters"
@on-toggle-edit-filter="onToggleFilters"
/> />
<contacts-table <contacts-table
:contacts="records" :contacts="records"
@@ -58,14 +60,18 @@
</woot-modal> </woot-modal>
<woot-modal <woot-modal
:show.sync="showFiltersModal" :show.sync="showFiltersModal"
:on-close="onToggleFilters" :on-close="closeAdvanceFiltersModal"
size="medium" size="medium"
> >
<contacts-advanced-filters <contacts-advanced-filters
v-if="showFiltersModal" v-if="showFiltersModal"
:on-close="onToggleFilters" :on-close="closeAdvanceFiltersModal"
:initial-filter-types="contactFilterItems" :initial-filter-types="contactFilterItems"
:initial-applied-filters="appliedFilter"
:active-segment-name="activeSegmentName"
:is-segments-view="hasActiveSegments"
@applyFilter="onApplyFilter" @applyFilter="onApplyFilter"
@updateSegment="onUpdateSegment"
@clearFilters="clearFilters" @clearFilters="clearFilters"
/> />
</woot-modal> </woot-modal>
@@ -87,6 +93,9 @@ import filterQueryGenerator from '../../../../helper/filterQueryGenerator';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews'; import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import alertMixin from 'shared/mixins/alertMixin';
import countries from 'shared/constants/countries.js';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
const DEFAULT_PAGE = 1; const DEFAULT_PAGE = 1;
const FILTER_TYPE_CONTACT = 1; const FILTER_TYPE_CONTACT = 1;
@@ -103,6 +112,7 @@ export default {
AddCustomViews, AddCustomViews,
DeleteCustomViews, DeleteCustomViews,
}, },
mixins: [alertMixin],
props: { props: {
label: { type: String, default: '' }, label: { type: String, default: '' },
segmentsId: { segmentsId: {
@@ -128,6 +138,7 @@ export default {
filterType: FILTER_TYPE_CONTACT, filterType: FILTER_TYPE_CONTACT,
showAddSegmentsModal: false, showAddSegmentsModal: false,
showDeleteSegmentsModal: false, showDeleteSegmentsModal: false,
appliedFilter: [],
}; };
}, },
computed: { computed: {
@@ -175,7 +186,7 @@ export default {
showContactViewPane() { showContactViewPane() {
return this.selectedContactId !== ''; return this.selectedContactId !== '';
}, },
wrapClas() { wrapClass() {
return this.showContactViewPane ? 'medium-9' : 'medium-12'; return this.showContactViewPane ? 'medium-9' : 'medium-12';
}, },
pageParameter() { pageParameter() {
@@ -194,6 +205,9 @@ export default {
} }
return undefined; return undefined;
}, },
activeSegmentName() {
return this.activeSegment?.name;
},
}, },
watch: { watch: {
label() { label() {
@@ -345,7 +359,14 @@ export default {
}); });
}, },
onToggleFilters() { onToggleFilters() {
this.showFiltersModal = !this.showFiltersModal; if (this.hasActiveSegments) {
this.initializeSegmentToFilterModal(this.activeSegment);
}
this.showFiltersModal = true;
},
closeAdvanceFiltersModal() {
this.showFiltersModal = false;
this.appliedFilter = [];
}, },
onApplyFilter(payload) { onApplyFilter(payload) {
this.closeContactInfoPanel(); this.closeContactInfoPanel();
@@ -355,10 +376,69 @@ export default {
}); });
this.showFiltersModal = false; this.showFiltersModal = false;
}, },
onUpdateSegment(payload, segmentName) {
const payloadData = {
...this.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
clearFilters() { clearFilters() {
this.$store.dispatch('contacts/clearContactFilters'); this.$store.dispatch('contacts/clearContactFilters');
this.fetchContacts(this.pageParameter); this.fetchContacts(this.pageParameter);
}, },
onExportSubmit() {
try {
this.$store.dispatch('contacts/export');
this.showAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
error.message || this.$t('EXPORT_CONTACTS.ERROR_MESSAGE')
);
}
},
setParamsForEditSegmentModal() {
// Here we are setting the params for edit segment modal to show the existing values.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit segment modal.
const params = {
countries: countries,
filterTypes: contactFilterItems,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('contact_attribute'),
};
return params;
},
initializeSegmentToFilterModal(activeSegment) {
// Here we are setting the params for edit segment modal.
// To show the existing values. when we click on edit segment button.
// Here we get the query from the active segment.
// And we are mapping the query to the actual values.
// To show in the edit segment modal by the help of generateValuesForEditCustomViews helper.
const query = activeSegment?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditSegmentModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
openSavedItemInSegment() { openSavedItemInSegment() {
const lastItemInSegments = this.segments[this.segments.length - 1]; const lastItemInSegments = this.segments[this.segments.length - 1];
const lastItemId = lastItemInSegments.id; const lastItemId = lastItemInSegments.id;

View File

@@ -27,15 +27,24 @@
{{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }} {{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
</woot-button> </woot-button>
</div> </div>
<woot-button <div v-if="hasActiveSegments">
v-if="hasActiveSegments" <woot-button
class="margin-right-1 clear" class="margin-right-1 clear"
color-scheme="alert" color-scheme="secondary"
icon="delete" icon="edit"
@click="onToggleDeleteSegmentsModal" @click="onToggleEditSegmentsModal"
> >
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }} {{ $t('CONTACTS_PAGE.FILTER_CONTACTS_EDIT') }}
</woot-button> </woot-button>
<woot-button
class="margin-right-1 clear"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
</div>
<div v-if="!hasActiveSegments" class="filters__button-wrap"> <div v-if="!hasActiveSegments" class="filters__button-wrap">
<div v-if="hasAppliedFilters" class="filters__applied-indicator" /> <div v-if="hasAppliedFilters" class="filters__applied-indicator" />
<woot-button <woot-button
@@ -78,6 +87,16 @@
> >
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button> </woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="upload"
class="clear"
@click="onExportSubmit"
>
{{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div> </div>
</div> </div>
</header> </header>
@@ -118,6 +137,10 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
onExportSubmit: {
type: Function,
default: () => {},
},
onToggleFilter: { onToggleFilter: {
type: Function, type: Function,
default: () => {}, default: () => {},
@@ -147,6 +170,9 @@ export default {
onToggleSegmentsModal() { onToggleSegmentsModal() {
this.$emit('on-toggle-save-filter'); this.$emit('on-toggle-save-filter');
}, },
onToggleEditSegmentsModal() {
this.$emit('on-toggle-edit-filter');
},
onToggleDeleteSegmentsModal() { onToggleDeleteSegmentsModal() {
this.$emit('on-toggle-delete-filter'); this.$emit('on-toggle-delete-filter');
}, },

View File

@@ -98,7 +98,7 @@ export default {
const errorMessage = error?.message; const errorMessage = error?.message;
this.alertMessage = this.alertMessage =
errorMessage || this.filterType === 0 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'); : this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
} finally { } finally {
this.showAlert(this.alertMessage); this.showAlert(this.alertMessage);

View File

@@ -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>

View File

@@ -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, weve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
};

View File

@@ -37,7 +37,7 @@
</div> </div>
<div> <div>
<thumbnail <thumbnail
v-if="notificationItem.primary_actor.meta.assignee" v-if="hasAssignee(notificationItem)"
:src="notificationItem.primary_actor.meta.assignee.thumbnail" :src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="16px" size="16px"
:username="notificationItem.primary_actor.meta.assignee.name" :username="notificationItem.primary_actor.meta.assignee.name"
@@ -127,6 +127,9 @@ export default {
}); });
} }
}, },
hasAssignee(notification) {
return notification.primary_actor.meta?.assignee;
},
}, },
}; };
</script> </script>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="column content-box"> <div class="column content-box audit-log--settings">
<!-- List Audit Logs --> <!-- List Audit Logs -->
<div class="row"> <div>
<div class="small-8 columns with-right-space "> <div>
<p <p
v-if="!uiFlags.fetchingList && !records.length" v-if="!uiFlags.fetchingList && !records.length"
class="no-items-error-message" class="no-items-error-message"
@@ -16,8 +16,13 @@
<table <table
v-if="!uiFlags.fetchingList && records.length" v-if="!uiFlags.fetchingList && records.length"
class="woot-table" class="woot-table width-100"
> >
<colgroup>
<col class="column-activity" />
<col />
<col />
</colgroup>
<thead> <thead>
<!-- Header --> <!-- Header -->
<th <th
@@ -29,16 +34,20 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="auditLogItem in records" :key="auditLogItem.id"> <tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="wrap-break-words">{{ auditLogItem.username }}</td>
<td class="wrap-break-words"> <td class="wrap-break-words">
{{ auditLogItem.auditable_type }}.{{ auditLogItem.action }} {{ generateLogText(auditLogItem) }}
</td>
<td class="wrap-break-words">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td> </td>
<td class="remote-address"> <td class="remote-address">
{{ auditLogItem.remote_address }} {{ auditLogItem.remote_address }}
</td> </td>
<td class="wrap-break-words">
{{ dynamicTime(auditLogItem.created_at) }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -76,13 +85,55 @@ export default {
records: 'auditlogs/getAuditLogs', records: 'auditlogs/getAuditLogs',
uiFlags: 'auditlogs/getUIFlags', uiFlags: 'auditlogs/getUIFlags',
meta: 'auditlogs/getMeta', meta: 'auditlogs/getMeta',
agentList: 'agents/getAgents',
}), }),
}, },
mounted() { mounted() {
// Fetch API Call // Fetch API Call
this.$store.dispatch('auditlogs/fetch', { page: 1 }); this.$store.dispatch('auditlogs/fetch', { page: 1 });
this.$store.dispatch('agents/get');
}, },
methods: { methods: {
getAgentName(email) {
if (email === null) {
return this.$t('AUDIT_LOGS.DEFAULT_USER');
}
const agentName = this.agentList.find(agent => agent.email === email)
?.name;
// If agent does not exist(removed/deleted), return email from audit log
return agentName || email;
},
generateLogText(auditLogItem) {
const agentName = this.getAgentName(auditLogItem.username);
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
const auditId = auditLogItem.auditable_id;
const logActionKey = `${auditableType}:${action}`;
const translationPayload = {
agentName,
id: auditId,
};
const translationKeys = {
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
'team:create': `AUDIT_LOGS.TEAM.ADD`,
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
};
return this.$t(translationKeys[logActionKey] || '', translationPayload);
},
onPageChange(page) { onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`); window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
try { try {
@@ -96,12 +147,24 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.remote-address { <style lang="scss" scoped>
width: 14rem; .audit-log--settings {
} display: flex;
.wrap-break-words { justify-content: space-between;
word-break: break-all; flex-direction: column;
white-space: normal;
.remote-address {
width: 14rem;
}
.wrap-break-words {
word-break: break-all;
white-space: normal;
}
.column-activity {
width: 60%;
}
} }
</style> </style>

View File

@@ -102,9 +102,7 @@ export default {
data() { data() {
return { return {
isBusinessHoursEnabled: false, isBusinessHoursEnabled: false,
unavailableMessage: this.$t( unavailableMessage: '',
'INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT'
),
timeZone: DEFAULT_TIMEZONE, timeZone: DEFAULT_TIMEZONE,
dayNames: { dayNames: {
0: 'Sunday', 0: 'Sunday',
@@ -157,9 +155,7 @@ export default {
? timeSlotParse(timeSlots) ? timeSlotParse(timeSlots)
: defaultTimeSlot; : defaultTimeSlot;
this.isBusinessHoursEnabled = isEnabled; this.isBusinessHoursEnabled = isEnabled;
this.unavailableMessage = this.unavailableMessage = unavailableMessage || '';
unavailableMessage ||
this.$t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT');
this.timeSlots = slots; this.timeSlots = slots;
this.timeZone = this.timeZone =
this.timeZones.find(item => timeZone === item.value) || this.timeZones.find(item => timeZone === item.value) ||

View File

@@ -1,11 +1,12 @@
<template> <template>
<div class="column content-box"> <div class="column content-box">
<report-filter-selector <report-filter-selector
agents-filter :show-agents-filter="true"
:agents-filter-items-list="agentList" :show-inbox-filter="true"
:show-rating-filter="true"
:show-team-filter="isTeamsEnabled"
:show-business-hours-switch="false" :show-business-hours-switch="false"
@date-range-change="onDateRangeChange" @filter-change="onFilterChange"
@agents-filter-change="onAgentsFilterChange"
/> />
<woot-button <woot-button
color-scheme="success" color-scheme="success"
@@ -15,7 +16,7 @@
> >
{{ $t('CSAT_REPORTS.DOWNLOAD') }} {{ $t('CSAT_REPORTS.DOWNLOAD') }}
</woot-button> </woot-button>
<csat-metrics /> <csat-metrics :filters="requestPayload" />
<csat-table :page-index="pageIndex" @page-change="onPageNumberChange" /> <csat-table :page-index="pageIndex" @page-change="onPageNumberChange" />
</div> </div>
</template> </template>
@@ -23,9 +24,11 @@
import CsatMetrics from './components/CsatMetrics'; import CsatMetrics from './components/CsatMetrics';
import CsatTable from './components/CsatTable'; import CsatTable from './components/CsatTable';
import ReportFilterSelector from './components/FilterSelector'; import ReportFilterSelector from './components/FilterSelector';
import { mapGetters } from 'vuex';
import { generateFileName } from '../../../../helper/downloadHelper'; import { generateFileName } from '../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import alertMixin from '../../../../../shared/mixins/alertMixin';
export default { export default {
name: 'CsatResponses', name: 'CsatResponses',
@@ -34,39 +37,78 @@ export default {
CsatTable, CsatTable,
ReportFilterSelector, ReportFilterSelector,
}, },
mixins: [alertMixin],
data() { data() {
return { pageIndex: 1, from: 0, to: 0, userIds: [] }; return {
pageIndex: 1,
from: 0,
to: 0,
userIds: [],
inbox: null,
team: null,
rating: null,
};
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
agentList: 'agents/getAgents', accountId: 'getCurrentAccountId',
isFeatureEnabledOnAccount: 'accounts/isFeatureEnabledonAccount',
}), }),
}, requestPayload() {
mounted() { return {
this.$store.dispatch('agents/get');
},
methods: {
getAllData() {
this.$store.dispatch('csat/getMetrics', {
from: this.from, from: this.from,
to: this.to, to: this.to,
user_ids: this.userIds, user_ids: this.userIds,
}); inbox_id: this.inbox,
this.getResponses(); 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() { getResponses() {
this.$store.dispatch('csat/get', { this.$store.dispatch('csat/get', {
page: this.pageIndex, page: this.pageIndex,
from: this.from, ...this.requestPayload,
to: this.to,
user_ids: this.userIds,
}); });
}, },
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) { onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex; this.pageIndex = pageIndex;
this.getResponses(); this.getResponses();
}, },
onDateRangeChange({ from, to }) { onFilterChange({
from,
to,
selectedAgents,
selectedInbox,
selectedTeam,
selectedRating,
}) {
// do not track filter change on inital load // do not track filter change on inital load
if (this.from !== 0 && this.to !== 0) { if (this.from !== 0 && this.to !== 0) {
this.$track(REPORTS_EVENTS.FILTER_REPORT, { this.$track(REPORTS_EVENTS.FILTER_REPORT, {
@@ -74,27 +116,16 @@ export default {
reportType: 'csat', reportType: 'csat',
}); });
} }
this.from = from; this.from = from;
this.to = to; this.to = to;
this.userIds = selectedAgents.map(el => el.id);
this.inbox = selectedInbox?.id;
this.team = selectedTeam?.id;
this.rating = selectedRating?.value;
this.getAllData(); 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> </script>

View File

@@ -9,12 +9,9 @@
{{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }} {{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }}
</woot-button> </woot-button>
<report-filter-selector <report-filter-selector
group-by-filter :show-agents-filter="false"
:selected-group-by-filter="selectedGroupByFilter" :show-group-by-filter="true"
:filter-items-list="filterItemsList"
@date-range-change="onDateRangeChange"
@filter-change="onFilterChange" @filter-change="onFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/> />
<div class="row"> <div class="row">
<woot-report-stats-card <woot-report-stats-card
@@ -55,7 +52,8 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format'; import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector'; import ReportFilterSelector from './components/FilterSelector';
import { GROUP_BY_FILTER, METRIC_CHART } from './constants'; 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 { formatTime } from '@chatwoot/utils';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -73,15 +71,13 @@ export default {
components: { components: {
ReportFilterSelector, ReportFilterSelector,
}, },
mixins: [reportMixin], mixins: [reportMixin, alertMixin],
data() { data() {
return { return {
from: 0, from: 0,
to: 0, to: 0,
currentSelection: 0, currentSelection: 0,
groupBy: GROUP_BY_FILTER[1], groupBy: GROUP_BY_FILTER[1],
filterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
selectedGroupByFilter: {},
businessHours: false, businessHours: false,
}; };
}, },
@@ -96,7 +92,7 @@ export default {
} }
if (!this.accountReport.data.length) return {}; if (!this.accountReport.data.length) return {};
const labels = this.accountReport.data.map(element => { 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)); let week_date = new Date(fromUnixTime(element.timestamp));
const first_day = week_date.getDate() - week_date.getDay(); const first_day = week_date.getDate() - week_date.getDay();
const last_day = first_day + 6; const last_day = first_day + 6;
@@ -109,10 +105,10 @@ export default {
'dd/MM/yy' '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'); 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), 'yyyy');
} }
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy'); return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
@@ -191,24 +187,35 @@ export default {
}, },
methods: { methods: {
fetchAllData() { fetchAllData() {
const { from, to, groupBy, businessHours } = this; this.fetchAccountSummary();
this.$store.dispatch('fetchAccountSummary', {
from,
to,
groupBy: groupBy.period,
businessHours,
});
this.fetchChartData(); this.fetchChartData();
}, },
fetchAccountSummary() {
try {
this.$store.dispatch('fetchAccountSummary', this.getRequestPayload());
} catch {
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
}
},
fetchChartData() { 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; const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY, return {
from, from,
to, to,
groupBy: groupBy.period, groupBy: groupBy?.period,
businessHours, businessHours,
}); };
}, },
downloadAgentReports() { downloadAgentReports() {
const { from, to } = this; const { from, to } = this;
@@ -222,57 +229,15 @@ export default {
this.currentSelection = index; this.currentSelection = index;
this.fetchChartData(); this.fetchChartData();
}, },
onDateRangeChange({ from, to, groupBy }) { onFilterChange({ from, to, groupBy, businessHours }) {
// 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',
});
}
this.from = from; this.from = from;
this.to = to; this.to = to;
this.filterItemsList = this.fetchFilterItems(groupBy); this.groupBy = groupBy;
const filterItems = this.filterItemsList.filter( this.businessHours = businessHours;
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.fetchAllData(); this.fetchAllData();
this.$track(REPORTS_EVENTS.FILTER_REPORT, { this.$track(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'groupBy', filterValue: { from, to, groupBy, businessHours },
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,
reportType: 'conversations', reportType: 'conversations',
}); });
}, },

View File

@@ -1,5 +1,10 @@
<template> <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"> <h3 class="heading">
<span>{{ label }}</span> <span>{{ label }}</span>
<fluent-icon <fluent-icon
@@ -29,6 +34,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
disabled: {
type: Boolean,
default: false,
},
}, },
}; };
</script> </script>
@@ -37,6 +46,13 @@ export default {
margin: 0; margin: 0;
padding: var(--space-normal); padding: var(--space-normal);
&.disabled {
// grayscale everything
filter: grayscale(100%);
opacity: 0.3;
pointer-events: none;
}
.heading { .heading {
align-items: center; align-items: center;
color: var(--color-heading); color: var(--color-heading);

View File

@@ -6,16 +6,20 @@
:value="responseCount" :value="responseCount"
/> />
<csat-metric-card <csat-metric-card
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')" :label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')" :info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="formatToPercent(satisfactionScore)" :value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
/> />
<csat-metric-card <csat-metric-card
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')" :label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')" :info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)" :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"> <h3 class="heading">
<div class="emoji--distribution"> <div class="emoji--distribution">
<div <div
@@ -24,7 +28,7 @@
class="emoji--distribution-item" class="emoji--distribution-item"
> >
<span class="emoji--distribution-key">{{ <span class="emoji--distribution-key">{{
csatRatings[key - 1].emoji ratingToEmoji(key)
}}</span> }}</span>
<span>{{ formatToPercent(rating) }}</span> <span>{{ formatToPercent(rating) }}</span>
</div> </div>
@@ -45,6 +49,12 @@ export default {
components: { components: {
CsatMetricCard, CsatMetricCard,
}, },
props: {
filters: {
type: Object,
required: true,
},
},
data() { data() {
return { return {
csatRatings: CSAT_RATINGS, csatRatings: CSAT_RATINGS,
@@ -57,12 +67,15 @@ export default {
satisfactionScore: 'csat/getSatisfactionScore', satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate', responseRate: 'csat/getResponseRate',
}), }),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() { chartData() {
return { return {
labels: ['Rating'], labels: ['Rating'],
datasets: CSAT_RATINGS.map((rating, index) => ({ datasets: CSAT_RATINGS.map(rating => ({
label: rating.emoji, label: rating.emoji,
data: [this.ratingPercentage[index + 1]], data: [this.ratingPercentage[rating.value]],
backgroundColor: rating.color, backgroundColor: rating.color,
})), })),
}; };
@@ -77,6 +90,9 @@ export default {
formatToPercent(value) { formatToPercent(value) {
return value ? `${value}%` : '--'; return value ? `${value}%` : '--';
}, },
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
}, },
}; };
</script> </script>

View File

@@ -1,72 +1,43 @@
<template> <template>
<div class="flex-container flex-dir-column medium-flex-dir-row"> <div class="filter-container">
<div class="small-12 medium-3 pull-right multiselect-wrap--small"> <reports-filters-date-range @on-range-change="onDateRangeChange" />
<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>
<woot-date-range-picker <woot-date-range-picker
v-if="isDateRangeSelected" v-if="isDateRangeSelected"
class="margin-left-1"
show-range show-range
class="no-margin auto-width"
:value="customDateRange" :value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')" :confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')" :placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onChange" @change="onCustomDateRangeChange"
/> />
<div <reports-filters-date-group-by
v-if="notLast7Days && groupByFilter" v-if="showGroupByFilter && isGroupByPossible"
class="small-12 medium-3 pull-right margin-left-1 margin-right-1 multiselect-wrap--small" :valid-group-options="validGroupOptions"
> :selected-option="selectedGroupByFilter"
<p aria-hidden="true" class="hide"> @on-grouping-change="onGroupingChange"
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }} />
</p> <reports-filters-agents
<multiselect v-if="showAgentsFilter"
v-model="currentSelectedFilter" @agents-filter-selection="handleAgentsFilterSelection"
track-by="id" />
label="groupBy" <reports-filters-labels
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')" v-if="showLabelsFilter"
:options="filterItemsList" @labels-filter-selection="handleLabelsFilterSelection"
:allow-empty="false" />
:show-labels="false" <reports-filters-teams
@input="changeFilterSelection" v-if="showTeamFilter"
/> @team-filter-selection="handleTeamFilterSelection"
</div> />
<div <reports-filters-inboxes
v-if="agentsFilter" v-if="showInboxFilter"
class="small-12 medium-3 pull-right margin-left-1 margin-right-1 multiselect-wrap--small" @inbox-filter-selection="handleInboxFilterSelection"
> />
<multiselect <reports-filters-ratings
v-model="selectedAgents" v-if="showRatingFilter"
:options="agentsFilterItemsList" @rating-filter-selection="handleRatingFilterSelection"
track-by="id" />
label="name" <div v-if="showBusinessHoursSwitch" class="business-hours">
:multiple="true" <span class="business-hours-text ">
: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">
{{ $t('REPORT.BUSINESS_HOURS') }} {{ $t('REPORT.BUSINESS_HOURS') }}
</span> </span>
<span> <span>
@@ -77,36 +48,54 @@
</template> </template>
<script> <script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue'; 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 subDays from 'date-fns/subDays';
import startOfDay from 'date-fns/startOfDay'; import { DATE_RANGE_OPTIONS } from '../constants';
import getUnixTime from 'date-fns/getUnixTime'; import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import { GROUP_BY_FILTER } from '../constants';
import endOfDay from 'date-fns/endOfDay';
const CUSTOM_DATE_RANGE_ID = 5;
export default { export default {
components: { components: {
WootDateRangePicker, WootDateRangePicker,
ReportsFiltersDateRange,
ReportsFiltersDateGroupBy,
ReportsFiltersAgents,
ReportsFiltersLabels,
ReportsFiltersInboxes,
ReportsFiltersTeams,
ReportsFiltersRatings,
}, },
props: { props: {
filterItemsList: { filterItemsList: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
agentsFilterItemsList: { showGroupByFilter: {
type: Array,
default: () => [],
},
selectedGroupByFilter: {
type: Object,
default: () => {},
},
groupByFilter: {
type: Boolean, type: Boolean,
default: false, 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, type: Boolean,
default: false, default: false,
}, },
@@ -117,95 +106,134 @@ export default {
}, },
data() { data() {
return { return {
currentDateRangeSelection: this.$t('REPORT.DATE_RANGE')[0], // default value, need not be translated
dateRange: this.$t('REPORT.DATE_RANGE'), selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
customDateRange: [new Date(), new Date()], selectedGroupByFilter: null,
currentSelectedFilter: null, selectedLabel: null,
selectedInbox: null,
selectedTeam: null,
selectedRating: null,
selectedAgents: [], selectedAgents: [],
customDateRange: [new Date(), new Date()],
businessHoursSelected: false, businessHoursSelected: false,
}; };
}, },
computed: { computed: {
isDateRangeSelected() { 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() { to() {
if (this.isDateRangeSelected) { if (this.isDateRangeSelected) {
return this.toCustomDate(this.customDateRange[1]); return getUnixEndOfDay(this.customDateRange[1]);
} }
return this.toCustomDate(new Date()); return getUnixEndOfDay(new Date());
}, },
from() { from() {
if (this.isDateRangeSelected) { if (this.isDateRangeSelected) {
return this.fromCustomDate(this.customDateRange[0]); return getUnixStartOfDay(this.customDateRange[0]);
} }
const dateRange = {
0: 6, const { offset } = this.selectedDateRange;
1: 29, const fromDate = subDays(new Date(), offset);
2: 89, return getUnixStartOfDay(fromDate);
3: 179,
4: 364,
};
const diff = dateRange[this.currentDateRangeSelection.id];
const fromDate = subDays(new Date(), diff);
return this.fromCustomDate(fromDate);
}, },
groupBy() { validGroupOptions() {
if (this.isDateRangeSelected) { return this.selectedDateRange.groupByOptions;
return GROUP_BY_FILTER[4].period; },
validGroupBy() {
if (!this.selectedGroupByFilter) {
return this.validGroupOptions[0];
} }
const groupRange = {
0: GROUP_BY_FILTER[1].period, const validIds = this.validGroupOptions.map(opt => opt.id);
1: GROUP_BY_FILTER[2].period, if (validIds.includes(this.selectedGroupByFilter.id)) {
2: GROUP_BY_FILTER[3].period, return this.selectedGroupByFilter;
3: GROUP_BY_FILTER[3].period, }
4: GROUP_BY_FILTER[3].period, return this.validGroupOptions[0];
};
return groupRange[this.currentDateRangeSelection.id];
},
notLast7Days() {
return this.groupBy !== GROUP_BY_FILTER[1].period;
}, },
}, },
watch: { watch: {
filterItemsList() {
this.currentSelectedFilter = this.selectedGroupByFilter;
},
businessHoursSelected() { businessHoursSelected() {
this.$emit('business-hours-toggle', this.businessHoursSelected); this.emitChange();
}, },
}, },
mounted() { mounted() {
this.onDateRangeChange(); this.emitChange();
}, },
methods: { methods: {
onDateRangeChange() { emitChange() {
this.$emit('date-range-change', { const {
from: this.from, from,
to: this.to, to,
groupBy: this.groupBy, 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) { onDateRangeChange(selectedRange) {
return getUnixTime(startOfDay(date)); this.selectedDateRange = selectedRange;
this.selectedGroupByFilter = this.validGroupBy;
this.emitChange();
}, },
toCustomDate(date) { onCustomDateRangeChange(value) {
return getUnixTime(endOfDay(date));
},
changeDateSelection(selectedRange) {
this.currentDateRangeSelection = selectedRange;
this.onDateRangeChange();
},
onChange(value) {
this.customDateRange = value; this.customDateRange = value;
this.onDateRangeChange(); this.selectedGroupByFilter = this.validGroupBy;
this.emitChange();
}, },
changeFilterSelection() { onGroupingChange(payload) {
this.$emit('filter-change', this.currentSelectedFilter); this.selectedGroupByFilter = payload;
this.emitChange();
}, },
handleAgentsFilterSelection() { handleAgentsFilterSelection(selectedAgents) {
this.$emit('agents-filter-change', this.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> </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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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