diff --git a/.circleci/config.yml b/.circleci/config.yml index e287574e9..f758b5492 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -157,7 +157,7 @@ jobs: - run: name: Code Climate Test Coverage 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: root: coverage diff --git a/.env.example b/.env.example index 70bf5d588..f405ea03d 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## LogRocket # LOG_ROCKET_PROJECT_ID=xxxxx/some-project +# MICROSOFT CLARITY +# MS_CLARITY_TOKEN=xxxxxxxxx + ## Scout ## https://scoutapm.com/docs/ruby/configuration # SCOUT_KEY=YOURKEY diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml new file mode 100644 index 000000000..efe497157 --- /dev/null +++ b/.github/workflows/size-limit.yml @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml index 70c927242..914308551 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -162,41 +162,33 @@ Rails/RenderInline: Performance/CollectionLiteralInLoop: Exclude: - 'db/migrate/20210315101919_enable_email_channel.rb' -RSpec/NamedSubject: - Enabled: false -Style/RedundantConstantBase: - Enabled: false -Rails/RootPathnameMethods: - Enabled: false -RSpec/Rails/MinitestAssertions: - Enabled: false -RSpec/Rails/InferredSpecType: - Enabled: false +Rails/ThreeStateBooleanColumn: + Exclude: + - 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb' + - 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb' + - 'db/migrate/20200606132552_create_labels.rb' + - 'db/migrate/20201027135006_create_working_hours.rb' + - 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb' + - 'db/migrate/20210114202310_create_teams.rb' + - 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb' + - 'db/migrate/20210428135041_add_campaigns.rb' + - 'db/migrate/20210602182058_add_hmac_to_api_channel.rb' + - 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb' + - 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb' + - 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb' + - 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb' + - 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb' + - 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb' + - 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb' + - 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb' + - 'db/migrate/20220511072655_add_archive_column_to_portal.rb' + - 'db/migrate/20230503101201_create_sla_policies.rb' RSpec/IndexedLet: Enabled: false -RSpec/MatchArray: - Enabled: false -Rails/ResponseParsedBody: - Enabled: false -RSpec/FactoryBot/ConsistentParenthesesStyle: - Enabled: false -Rails/ThreeStateBooleanColumn: - Enabled: false -Rails/Pluck: - Enabled: false -Rails/TopLevelHashWithIndifferentAccess: - Enabled: false -Rails/ActionOrder: - Enabled: false -Style/ArrayIntersect: - Enabled: false -RSpec/NoExpectationExample: - Enabled: false -Style/RedundantReturn: - Enabled: false -Rails/I18nLocaleTexts: +RSpec/NamedSubject: Enabled: false + # we should bring this down RSpec/MultipleMemoizedHelpers: Max: 14 diff --git a/Gemfile b/Gemfile index d635d3809..d49157502 100644 --- a/Gemfile +++ b/Gemfile @@ -75,6 +75,7 @@ gem 'jwt' gem 'pundit' # super admin gem 'administrate' +gem 'administrate-field-active_storage' ##--- gems for pubsub service ---## # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ diff --git a/Gemfile.lock b/Gemfile.lock index 3723a738c..c4d6f7e56 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,70 +33,70 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.0.4.3) - actionpack (= 7.0.4.3) - activesupport (= 7.0.4.3) + actioncable (7.0.5) + actionpack (= 7.0.5) + activesupport (= 7.0.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.4.3) - actionpack (= 7.0.4.3) - activejob (= 7.0.4.3) - activerecord (= 7.0.4.3) - activestorage (= 7.0.4.3) - activesupport (= 7.0.4.3) + actionmailbox (7.0.5) + actionpack (= 7.0.5) + activejob (= 7.0.5) + activerecord (= 7.0.5) + activestorage (= 7.0.5) + activesupport (= 7.0.5) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.4.3) - actionpack (= 7.0.4.3) - actionview (= 7.0.4.3) - activejob (= 7.0.4.3) - activesupport (= 7.0.4.3) + actionmailer (7.0.5) + actionpack (= 7.0.5) + actionview (= 7.0.5) + activejob (= 7.0.5) + activesupport (= 7.0.5) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.4.3) - actionview (= 7.0.4.3) - activesupport (= 7.0.4.3) - rack (~> 2.0, >= 2.2.0) + actionpack (7.0.5) + actionview (= 7.0.5) + activesupport (= 7.0.5) + rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.4.3) - actionpack (= 7.0.4.3) - activerecord (= 7.0.4.3) - activestorage (= 7.0.4.3) - activesupport (= 7.0.4.3) + actiontext (7.0.5) + actionpack (= 7.0.5) + activerecord (= 7.0.5) + activestorage (= 7.0.5) + activesupport (= 7.0.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.4.3) - activesupport (= 7.0.4.3) + actionview (7.0.5) + activesupport (= 7.0.5) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (7.0.4.3) - activesupport (= 7.0.4.3) + activejob (7.0.5) + activesupport (= 7.0.5) globalid (>= 0.3.6) - activemodel (7.0.4.3) - activesupport (= 7.0.4.3) - activerecord (7.0.4.3) - activemodel (= 7.0.4.3) - activesupport (= 7.0.4.3) + activemodel (7.0.5) + activesupport (= 7.0.5) + activerecord (7.0.5) + activemodel (= 7.0.5) + activesupport (= 7.0.5) activerecord-import (1.4.1) activerecord (>= 4.2) - activestorage (7.0.4.3) - actionpack (= 7.0.4.3) - activejob (= 7.0.4.3) - activerecord (= 7.0.4.3) - activesupport (= 7.0.4.3) + activestorage (7.0.5) + actionpack (= 7.0.5) + activejob (= 7.0.5) + activerecord (= 7.0.5) + activesupport (= 7.0.5) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.4.3) + activesupport (7.0.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -113,6 +113,9 @@ GEM kaminari (>= 1.0) sassc-rails (~> 2.1) selectize-rails (~> 0.6) + administrate-field-active_storage (0.4.2) + administrate (>= 0.2.2) + rails (>= 7.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -353,7 +356,7 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.13.0) + i18n (1.14.1) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -420,9 +423,9 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.20.0) + loofah (2.21.3) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.8.1) mini_mime (>= 0.1.1) net-imap @@ -462,14 +465,14 @@ GEM sidekiq newrelic_rpm (8.16.0) nio4r (2.5.9) - nokogiri (1.14.3) - mini_portile2 (~> 2.8.0) + nokogiri (1.15.2) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.14.3-arm64-darwin) + nokogiri (1.15.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.14.3-x86_64-darwin) + nokogiri (1.15.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.14.3-x86_64-linux) + nokogiri (1.15.2-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -522,7 +525,7 @@ GEM pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.6.2) + racc (1.7.0) rack (2.2.7) rack-attack (6.6.1) rack (>= 1.0, < 3) @@ -537,28 +540,29 @@ GEM rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rails (7.0.4.3) - actioncable (= 7.0.4.3) - actionmailbox (= 7.0.4.3) - actionmailer (= 7.0.4.3) - actionpack (= 7.0.4.3) - actiontext (= 7.0.4.3) - actionview (= 7.0.4.3) - activejob (= 7.0.4.3) - activemodel (= 7.0.4.3) - activerecord (= 7.0.4.3) - activestorage (= 7.0.4.3) - activesupport (= 7.0.4.3) + rails (7.0.5) + actioncable (= 7.0.5) + actionmailbox (= 7.0.5) + actionmailer (= 7.0.5) + actionpack (= 7.0.5) + actiontext (= 7.0.5) + actionview (= 7.0.5) + activejob (= 7.0.5) + activemodel (= 7.0.5) + activerecord (= 7.0.5) + activestorage (= 7.0.5) + activesupport (= 7.0.5) bundler (>= 1.15.0) - railties (= 7.0.4.3) + railties (= 7.0.5) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - railties (7.0.4.3) - actionpack (= 7.0.4.3) - activesupport (= 7.0.4.3) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.0.5) + actionpack (= 7.0.5) + activesupport (= 7.0.5) method_source rake (>= 12.2) thor (~> 1.0) @@ -722,8 +726,8 @@ GEM stripe (8.5.0) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.2.1) - tilt (2.1.0) + thor (1.2.2) + tilt (2.2.0) time_diff (0.3.0) activesupport i18n @@ -785,6 +789,7 @@ GEM PLATFORMS arm64-darwin-20 arm64-darwin-21 + arm64-darwin-22 ruby x86_64-darwin-18 x86_64-darwin-20 @@ -797,6 +802,7 @@ DEPENDENCIES activerecord-import acts-as-taggable-on administrate + administrate-field-active_storage annotate attr_extras audited (~> 5.3) @@ -915,4 +921,4 @@ RUBY VERSION ruby 3.2.2p185 BUNDLED WITH - 2.4.10 + 2.4.6 diff --git a/VERSION_CW b/VERSION_CW index ccbccc3dc..d76bd2ba3 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -2.2.0 +2.17.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 7ec1d6db4..276cbf9e2 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -2.1.0 +2.3.0 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 38da59e09..2cc9549bc 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,5 +1,6 @@ //= link_tree ../images //= link administrate/application.css //= link administrate/application.js +//= link administrate-field-active_storage/application.css //= link dashboardChart.js //= link secretField.js diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index eb34d4052..e2c594376 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -63,9 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder end def conversation - @conversation ||= Conversation.where( + @conversation ||= Conversation.where(conversation_params).find_by( "additional_attributes ->> 'type' = 'instagram_direct_message'" - ).find_by(conversation_params) || build_conversation + ) || build_conversation end def message_content @@ -96,6 +96,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder def build_conversation @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) + Conversation.create!(conversation_params.merge( contact_inbox_id: @contact_inbox.id, additional_attributes: { type: 'instagram_direct_message' } diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index b786ba25c..b5fb8045b 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -96,10 +96,9 @@ class V2::ReportBuilder def conversations @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 = { 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 diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index cc0976375..a1e348723 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -16,6 +16,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end end + def show; end + def edit; end + def create @article = @portal.articles.create!(article_params) @article.associate_root_article(article_params[:associated_article_id]) @@ -23,10 +26,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? end - def edit; end - - def show; end - def update @article.update!(article_params) if params[:article].present? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 0bb78d3ea..3431af9c3 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rules = Current.account.automation_rules end + def show; end + def create @automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule.actions = params[:actions] @@ -28,8 +30,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont render json: { blob_key: file_blob.key, blob_id: file_blob.id } end - def show; end - def update ActiveRecord::Base.transaction do automation_rule_update diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index 6d2fb7729..b1a132246 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -6,21 +6,21 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController @campaigns = Current.account.campaigns end + def show; end + def create @campaign = Current.account.campaigns.create!(campaign_params) end + def update + @campaign.update!(campaign_params) + end + def destroy @campaign.destroy! head :ok end - def show; end - - def update - @campaign.update!(campaign_params) - end - private def campaign diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index e28e601d5..73fc5d885 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -9,6 +9,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle @categories = @portal.categories.search(params) end + def show; end + def create @category = @portal.categories.create!(category_params) @category.related_categories << related_categories_records @@ -17,8 +19,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle @category.save! end - def show; end - def update @category.update!(category_params) @category.related_categories = related_categories_records if related_categories_records.any? diff --git a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb index b4287ae08..d985c8a73 100644 --- a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb @@ -1,11 +1,13 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController + include HmacConcern before_action :ensure_inbox, only: [:create] def create @contact_inbox = ContactInboxBuilder.new( contact: @contact, inbox: @inbox, - source_id: params[:source_id] + source_id: params[:source_id], + hmac_verified: hmac_verified? ).perform end diff --git a/app/controllers/api/v1/accounts/contacts/notes_controller.rb b/app/controllers/api/v1/accounts/contacts/notes_controller.rb index 7bc9dd121..58ba935db 100644 --- a/app/controllers/api/v1/accounts/contacts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/notes_controller.rb @@ -5,21 +5,21 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts @notes = @contact.notes.latest.includes(:user) end + def show; end + def create @note = @contact.notes.create!(note_params) end + def update + @note.update(note_params) + end + def destroy @note.destroy! head :ok end - def show; end - - def update - @note.update(note_params) - end - private def note diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 8afd5b655..ba78cb805 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController head :ok 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 def active contacts = Current.account.contacts.where(id: ::OnlineStatusTracker diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index ebd673d6f..cd8547213 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController include Events::Types include DateRangeHelper + include HmacConcern before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :inbox, :contact, :contact_inbox, only: [:create] @@ -26,6 +27,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @attachments = @conversation.attachments end + def show; end + def create ActiveRecord::Base.transaction do @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 - def show; end - def filter result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @@ -104,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end 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.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] end @@ -152,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro ContactInboxBuilder.new( contact: @contact, inbox: @inbox, - source_id: params[:source_id] + source_id: params[:source_id], + hmac_verified: hmac_verified? ).perform end diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index 9495bbfa8..f5bed6c34 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -34,9 +34,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base end def set_csat_survey_responses - @csat_survey_responses = filtrate( - Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact]) - ).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids]) + base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact]) + @csat_survey_responses = filtrate(base_query).filter_by_created_at(range) + .filter_by_assigned_agent_id(params[:user_ids]) + .filter_by_inbox_id(params[:inbox_id]) + .filter_by_team_id(params[:team_id]) + .filter_by_rating(params[:rating]) end def set_current_page_surveys diff --git a/app/controllers/api/v1/accounts/custom_filters_controller.rb b/app/controllers/api/v1/accounts/custom_filters_controller.rb index 188f0e623..6cd70b07d 100644 --- a/app/controllers/api/v1/accounts/custom_filters_controller.rb +++ b/app/controllers/api/v1/accounts/custom_filters_controller.rb @@ -11,6 +11,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro @custom_filter = current_user.custom_filters.create!( permitted_payload.merge(account_id: Current.account.id) ) + render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid? end def update diff --git a/app/controllers/api/v1/accounts/inbox_members_controller.rb b/app/controllers/api/v1/accounts/inbox_members_controller.rb index 22726a855..0cb1a0c85 100644 --- a/app/controllers/api/v1/accounts/inbox_members_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_members_controller.rb @@ -2,6 +2,11 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl before_action :fetch_inbox before_action :current_agents_ids, only: [:create, :update] + def show + authorize @inbox, :show? + fetch_updated_agents + end + def create authorize @inbox, :create? ActiveRecord::Base.transaction do @@ -10,11 +15,6 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl fetch_updated_agents end - def show - authorize @inbox, :show? - fetch_updated_agents - end - def update authorize @inbox, :update? update_agents_list diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 9b5cafc4c..70c3f2a23 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def update @inbox.update!(permitted_params.except(:channel)) update_inbox_working_hours - channel_attributes = get_channel_attributes(@inbox.channel_type) - - # Inbox update doesn't necessarily need channel attributes - return if permitted_params(channel_attributes)[:channel].blank? - - if @inbox.inbox_type == 'Email' - begin - validate_email_channel(channel_attributes) - rescue StandardError => e - render json: { message: e }, status: :unprocessable_entity and return - end - @inbox.channel.reauthorized! - end - - @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) - update_channel_feature_flags - end - - def update_inbox_working_hours - @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] + update_channel if channel_update_required? end def agent_bot @@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) end + def update_inbox_working_hours + @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] + end + + def update_channel + channel_attributes = get_channel_attributes(@inbox.channel_type) + return if permitted_params(channel_attributes)[:channel].blank? + + validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email' + + reauthorize_and_update_channel(channel_attributes) + update_channel_feature_flags + end + + def channel_update_required? + permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present? + end + + def validate_and_update_email_channel(channel_attributes) + validate_email_channel(channel_attributes) + rescue StandardError => e + render json: { message: e }, status: :unprocessable_entity and return + end + + def reauthorize_and_update_channel(channel_attributes) + @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) + @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) + end + def update_channel_feature_flags return unless @inbox.web_widget? return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index a13d74995..604e053f1 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -6,6 +6,10 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController @macros = Macro.with_visibility(current_user, params) end + def show + head :not_found if @macro.nil? + end + def create @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) @macro.set_visibility(current_user, permitted_params) @@ -18,8 +22,16 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController @macro end - def show - head :not_found if @macro.nil? + def update + ActiveRecord::Base.transaction do + @macro.update!(macros_with_user) + @macro.set_visibility(current_user, permitted_params) + process_attachments + @macro.save! + rescue StandardError => e + Rails.logger.error e + render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity + end end def destroy @@ -37,18 +49,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController render json: { blob_key: file_blob.key, blob_id: file_blob.id } end - def update - ActiveRecord::Base.transaction do - @macro.update!(macros_with_user) - @macro.set_visibility(current_user, permitted_params) - process_attachments - @macro.save! - rescue StandardError => e - Rails.logger.error e - render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity - end - end - def execute ::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index af48ccdf3..ef0e0c777 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -14,6 +14,11 @@ class Api::V1::AccountsController < Api::BaseController CustomExceptions::Account::UserErrors, with: :render_error_response + def show + @latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION) + render 'api/v1/accounts/show', format: :json + end + def create @user, @account = AccountBuilder.new( account_name: account_params[:account_name], @@ -32,14 +37,10 @@ class Api::V1::AccountsController < Api::BaseController end def cache_keys + expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes render json: { cache_keys: get_cache_keys }, status: :ok end - def show - @latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION) - render 'api/v1/accounts/show', format: :json - end - def update @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) end diff --git a/app/controllers/concerns/hmac_concern.rb b/app/controllers/concerns/hmac_concern.rb new file mode 100644 index 000000000..abc55a394 --- /dev/null +++ b/app/controllers/concerns/hmac_concern.rb @@ -0,0 +1,5 @@ +module HmacConcern + def hmac_verified? + ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present? + end +end diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 06092c5ab..26a9d4555 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -4,6 +4,16 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController skip_before_action :require_no_authentication, raise: false skip_before_action :authenticate_user!, raise: false + def create + @user = User.find_by(email: params[:email]) + if @user + @user.send_reset_password_instructions + build_response(I18n.t('messages.reset_password_success'), 200) + else + build_response(I18n.t('messages.reset_password_failure'), 404) + end + end + def update # params: reset_password_token, password, password_confirmation original_token = params[:reset_password_token] @@ -17,16 +27,6 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController end end - def create - @user = User.find_by(email: params[:email]) - if @user - @user.send_reset_password_instructions - build_response(I18n.t('messages.reset_password_success'), 200) - else - build_response(I18n.t('messages.reset_password_failure'), 404) - end - end - private def reset_password_and_confirmation(recoverable) diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 831c41ddc..2659aeebf 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -1,4 +1,4 @@ -class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController +class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] @@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) end end + +DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/controllers/devise_overrides/token_validations_controller.rb b/app/controllers/devise_overrides/token_validations_controller.rb index 64b7949ac..432aa7752 100644 --- a/app/controllers/devise_overrides/token_validations_controller.rb +++ b/app/controllers/devise_overrides/token_validations_controller.rb @@ -1,4 +1,4 @@ -class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController +class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController def validate_token # @resource will have been set by set_user_by_token concern if @resource diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index 1ea7d4954..9e6d53fef 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -1,12 +1,12 @@ class Platform::Api::V1::AccountsController < PlatformController + def show; end + def create @resource = Account.create!(account_params) update_resource_features @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) end - def show; end - def update @resource.assign_attributes(account_params) update_resource_features diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb index 0c79e118d..138052b77 100644 --- a/app/controllers/platform/api/v1/agent_bots_controller.rb +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -6,14 +6,14 @@ class Platform::Api::V1::AgentBotsController < PlatformController @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all end + def show; end + def create @resource = AgentBot.new(agent_bot_params) @resource.save! @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) end - def show; end - def update @resource.update!(agent_bot_params) end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index e0de74da1..03ed55754 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -5,6 +5,8 @@ class Platform::Api::V1::UsersController < PlatformController before_action(only: [:login]) { set_resource } before_action(only: [:login]) { validate_platform_app_permissible } + def show; end + def create @resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) @resource.skip_confirmation! @@ -16,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController render json: { url: @resource.generate_sso_link } end - def show; end - def update @resource.assign_attributes(user_update_params) diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index b3ebd4ade..835c2596b 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -2,6 +2,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon before_action :contact_inbox, except: [:create] before_action :process_hmac + def show; end + def create source_id = params[:source_id] || SecureRandom.uuid @contact_inbox = ::ContactInboxWithContactBuilder.new( @@ -11,8 +13,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon ).perform end - def show; end - def update contact_identify_action = ContactIdentifyAction.new( contact: @contact_inbox.contact, diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index b4739e0d1..1f8be3ad7 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -43,8 +43,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B end def render_article_content(content) - # rubocop:disable Rails/OutputSafety - CommonMarker.render_html(content).html_safe - # rubocop:enable Rails/OutputSafety + ChatwootMarkdownRenderer.new(content).render_article end end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 21b605396..c5297e6e4 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -6,6 +6,8 @@ class Public::Api::V1::Portals::BaseController < PublicController def set_locale(&) switch_locale_with_portal(&) if params[:locale].present? switch_locale_with_article(&) if params[:article_slug].present? + + yield end def switch_locale_with_portal(&) diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 112357363..5de25f677 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -45,13 +45,24 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController def seed Internal::SeedAccountJob.perform_later(requested_resource) + # rubocop:disable Rails/I18nLocaleTexts redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered') + # rubocop:enable Rails/I18nLocaleTexts + end + + def 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 def destroy account = Account.find(params[:id]) DeleteObjectJob.perform_later(account) if account.present? + # rubocop:disable Rails/I18nLocaleTexts redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.') + # rubocop:enable Rails/I18nLocaleTexts end end diff --git a/app/controllers/super_admin/agent_bots_controller.rb b/app/controllers/super_admin/agent_bots_controller.rb index 8e094e752..29a3021ee 100644 --- a/app/controllers/super_admin/agent_bots_controller.rb +++ b/app/controllers/super_admin/agent_bots_controller.rb @@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # 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 diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index e7b037099..7f43e63ca 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -5,6 +5,17 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController sha postgres_status redis_metrics + chatwoot_edition + end + + def chatwoot_edition + @metrics['Chatwoot edition'] = if ChatwootApp.enterprise? + 'Enterprise' + elsif ChatwootApp.custom? + 'Custom' + else + 'Community' + end end def chatwoot_version diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index fec4fb790..ff242030a 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -45,6 +45,17 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController # empty values into nil values. It uses other APIs such as `resource_class` # 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 permitted_params = super permitted_params.delete(:password) if permitted_params[:password].blank? diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index 697f04ed0..af5a4b039 100644 --- a/app/controllers/swagger_controller.rb +++ b/app/controllers/swagger_controller.rb @@ -1,7 +1,7 @@ class SwaggerController < ApplicationController def respond if Rails.env.development? || Rails.env.test? - render inline: File.read(Rails.root.join('swagger', derived_path)) + render inline: Rails.root.join('swagger', derived_path).read else head :not_found end diff --git a/app/dashboards/agent_bot_dashboard.rb b/app/dashboards/agent_bot_dashboard.rb index e97a64de8..baeb6e814 100644 --- a/app/dashboards/agent_bot_dashboard.rb +++ b/app/dashboards/agent_bot_dashboard.rb @@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard ATTRIBUTE_TYPES = { access_token: Field::HasOne, 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, name: Field::String, 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. SHOW_PAGE_ATTRIBUTES = %i[ id + avatar_url account name description @@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard # on the model's form (`new` and `edit`) pages. FORM_ATTRIBUTES = %i[ name + avatar account description outgoing_url diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 1d59525f2..9573d8447 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -11,6 +11,11 @@ class UserDashboard < Administrate::BaseDashboard account_users: Field::HasMany, id: Field::Number, 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, uid: Field::String, password: Field::Password, @@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard # on the model's form (`new` and `edit`) pages. FORM_ATTRIBUTES = %i[ name + avatar display_name email password diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 7ec0a6d30..a072b68b3 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -102,7 +102,7 @@ class ConversationFinder when 'participating' @conversations = current_user.participating_conversations.where(account_id: current_account.id) when 'unattended' - @conversations = @conversations.where(first_reply_created_at: nil) + @conversations = @conversations.unattended end @conversations end diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index 1e89c56c1..2c50fd609 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -7,8 +7,6 @@ module MessageFormatHelper end def render_message_content(message_content) - # rubocop:disable Rails/OutputSafety - CommonMarker.render_html(message_content).html_safe - # rubocop:enable Rails/OutputSafety + ChatwootMarkdownRenderer.new(message_content).render_message end end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 5fdb34170..8ee747eb9 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -29,7 +29,9 @@ module ReportHelper end 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 def avg_first_response_time diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index ccae9e52c..203ccec30 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -115,11 +115,6 @@ export default { diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 3599961e1..4def5ee2e 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -75,6 +75,10 @@ class ContactAPI extends ApiClient { destroyAvatar(contactId) { return axios.delete(`${this.url}/${contactId}/avatar`); } + + exportContacts() { + return axios.get(`${this.url}/export`); + } } export default new ContactAPI(); diff --git a/app/javascript/dashboard/api/csatReports.js b/app/javascript/dashboard/api/csatReports.js index 4decc8de3..2d3ce12e5 100644 --- a/app/javascript/dashboard/api/csatReports.js +++ b/app/javascript/dashboard/api/csatReports.js @@ -6,7 +6,7 @@ class CSATReportsAPI extends ApiClient { super('csat_survey_responses', { accountScoped: true }); } - get({ page, from, to, user_ids } = {}) { + get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) { return axios.get(this.url, { params: { page, @@ -14,24 +14,31 @@ class CSATReportsAPI extends ApiClient { until: to, sort: '-created_at', user_ids, + inbox_id, + team_id, + rating, }, }); } - download({ from, to, user_ids } = {}) { + download({ from, to, user_ids, inbox_id, team_id, rating } = {}) { return axios.get(`${this.url}/download`, { params: { since: from, until: to, sort: '-created_at', user_ids, + inbox_id, + team_id, + rating, }, }); } - getMetrics({ from, to, user_ids } = {}) { + getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) { + // no ratings for metrics return axios.get(`${this.url}/metrics`, { - params: { since: from, until: to, user_ids }, + params: { since: from, until: to, user_ids, inbox_id, team_id, rating }, }); } } diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index bee3cd23b..94cc81354 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -127,6 +127,10 @@ class ConversationApi extends ApiClient { user_ids: userIds, }); } + + getAllAttachments(conversationId) { + return axios.get(`${this.url}/${conversationId}/attachments`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index d276b8053..08343b69c 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -210,5 +210,12 @@ describe('#ConversationAPI', () => { { params: { page: payload.page } } ); }); + + it('#getAllAttachments', () => { + conversationAPI.getAllAttachments(1); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/1/attachments' + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/scss/_date-picker.scss b/app/javascript/dashboard/assets/scss/_date-picker.scss index ece12c999..4377480c9 100644 --- a/app/javascript/dashboard/assets/scss/_date-picker.scss +++ b/app/javascript/dashboard/assets/scss/_date-picker.scss @@ -5,12 +5,20 @@ } .date-picker { - .mx-datepicker { - width: 100%; + &.no-margin { + .mx-input { + margin-bottom: 0; + } } - .mx-datepicker-range { - width: 320px; + &:not(.auto-width) { + .mx-datepicker-range { + width: 320px; + } + } + + .mx-datepicker { + width: 100%; } .mx-input { diff --git a/app/javascript/dashboard/assets/scss/_rtl.scss b/app/javascript/dashboard/assets/scss/_rtl.scss index 0ed2fe2f4..4f7efaab9 100644 --- a/app/javascript/dashboard/assets/scss/_rtl.scss +++ b/app/javascript/dashboard/assets/scss/_rtl.scss @@ -261,6 +261,12 @@ } } + // Basic filter dropdown + .basic-filter { + left: 0; + right: unset; + } + // Card label .label-container { .label { diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index 3167b2085..f111a414f 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -13,7 +13,9 @@ } .multiselect { - margin-bottom: var(--space-normal); + &:not(.no-margin) { + margin-bottom: var(--space-normal); + } &.multiselect--disabled { opacity: 0.8; diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index 6c37e5677..f95ee8a65 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -29,13 +29,13 @@ } .modal-image { - max-height: 90%; - max-width: 90%; + max-height: 80vh; + max-width: 80vw; } .modal-video { - max-height: 75vh; - max-width: 100%; + max-height: 80vh; + max-width: 80vw; } &::before { @@ -53,16 +53,6 @@ width: 100%; } } - - .video { - .modal-container { - width: auto; - - .modal--close { - z-index: var(--z-index-low); - } - } - } } .conversations-list-wrap { @@ -400,4 +390,3 @@ margin-bottom: 0; } } - diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 7995c91fd..f4ba84ead 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -47,6 +47,14 @@ />
+
@@ -193,6 +204,9 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import alertMixin from 'shared/mixins/alertMixin'; 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 { hasPressedAltAndJKey, @@ -289,6 +303,11 @@ export default { appliedFilters: 'getAppliedConversationFilters', folders: 'customViews/getCustomViews', inboxes: 'inboxes/getInboxes', + agentList: 'agents/getAgents', + teamsList: 'teams/getTeams', + inboxesList: 'inboxes/getInboxes', + campaigns: 'campaigns/getAllCampaigns', + labels: 'labels/getLabels', }), hasAppliedFilters() { return this.appliedFilters.length !== 0; @@ -451,6 +470,9 @@ export default { } return undefined; }, + activeFolderName() { + return this.activeFolder?.name; + }, activeTeam() { if (this.teamId) { return this.$store.getters['teams/getTeam'](this.teamId); @@ -483,9 +505,7 @@ export default { this.resetAndFetchData(); }, activeFolder() { - if (!this.hasAppliedFilters) { - this.resetAndFetchData(); - } + this.resetAndFetchData(); }, chatLists() { this.chatsOnView = this.conversationList; @@ -496,6 +516,10 @@ export default { this.$store.dispatch('setChatSortFilter', this.activeSortBy); this.resetAndFetchData(); + if (this.hasActiveFolders) { + this.$store.dispatch('campaigns/get'); + } + bus.$on('fetch_conversation_stats', () => { this.$store.dispatch('conversationStats/get', this.conversationFilters); }); @@ -508,6 +532,15 @@ export default { this.$store.dispatch('emptyAllConversations'); this.fetchFilteredConversations(payload); }, + onUpdateSavedFilter(payload, folderName) { + const payloadData = { + ...this.activeFolder, + name: folderName, + query: filterQueryGenerator(payload), + }; + this.$store.dispatch('customViews/update', payloadData); + this.closeAdvanceFiltersModal(); + }, onClickOpenAddFoldersModal() { this.showAddFoldersModal = true; }, @@ -521,15 +554,70 @@ export default { this.showDeleteFoldersModal = false; }, onToggleAdvanceFiltersModal() { - if (!this.hasAppliedFilters) { + if (!this.hasAppliedFilters && !this.hasActiveFolders) { this.initializeExistingFilterToModal(); } + if (this.hasActiveFolders) { + this.initializeFolderToFilterModal(this.activeFolder); + } this.showAdvancedFilters = true; }, closeAdvanceFiltersModal() { this.showAdvancedFilters = false; 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() { const allConversations = this.$refs.activeConversation.querySelectorAll( 'div.conversations-list div.conversation' @@ -575,6 +663,7 @@ export default { } }, resetAndFetchData() { + this.appliedFilter = []; this.resetBulkActions(); this.$store.dispatch('conversationPage/reset'); this.$store.dispatch('emptyAllConversations'); @@ -587,7 +676,6 @@ export default { return; } this.fetchConversations(); - this.appliedFilter = []; }, fetchConversations() { this.$store diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 80d5da911..3ce2787ce 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -115,7 +115,7 @@ diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue new file mode 100644 index 000000000..0f04c1d07 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue @@ -0,0 +1,201 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue index ca7117cfc..643b2be03 100644 --- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue @@ -79,6 +79,7 @@ import MenuItem from './menuItem.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import wootConstants from 'dashboard/constants/globals'; import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin'; +import agentMixin from 'dashboard/mixins/agentMixin'; import { mapGetters } from 'vuex'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; export default { @@ -87,7 +88,7 @@ export default { MenuItemWithSubmenu, AgentLoadingPlaceholder, }, - mixins: [snoozeTimesMixin], + mixins: [snoozeTimesMixin, agentMixin], props: { status: { type: String, @@ -202,6 +203,16 @@ export default { teams: 'teams/getTeams', assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags', }), + filteredAgentOnAvailability() { + const agents = this.$store.getters[ + 'inboxAssignableAgents/getAssignableAgents' + ](this.inboxId); + const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents); + const filteredAgents = this.sortedAgentsByAvailability( + agentsByUpdatedPresence + ); + return filteredAgents; + }, assignableAgents() { return [ { @@ -212,9 +223,7 @@ export default { account_id: 0, email: 'None', }, - ...this.$store.getters['inboxAssignableAgents/getAssignableAgents']( - this.inboxId - ), + ...this.filteredAgentOnAvailability, ]; }, }, @@ -246,6 +255,7 @@ export default { ...(type === 'icon' && { icon: option.icon }), ...(type === 'label' && { color: option.color }), ...(type === 'agent' && { thumbnail: option.thumbnail }), + ...(type === 'agent' && { status: option.availability_status }), ...(type === 'text' && { label: option.label }), ...(type === 'label' && { label: option.title }), ...(type === 'agent' && { label: option.name }), diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue index a2fc5eb7c..670d5a61a 100644 --- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue @@ -15,6 +15,7 @@ v-if="variant === 'agent'" :username="option.label" :src="option.thumbnail" + :status="option.status" size="20px" class="agent-thumbnail" /> diff --git a/app/javascript/dashboard/helper/customViewsHelper.js b/app/javascript/dashboard/helper/customViewsHelper.js new file mode 100644 index 000000000..5e8b550ab --- /dev/null +++ b/app/javascript/dashboard/helper/customViewsHelper.js @@ -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(); +}; diff --git a/app/javascript/dashboard/helper/filterQueryGenerator.js b/app/javascript/dashboard/helper/filterQueryGenerator.js index d519806bf..d70a30a6b 100644 --- a/app/javascript/dashboard/helper/filterQueryGenerator.js +++ b/app/javascript/dashboard/helper/filterQueryGenerator.js @@ -1,9 +1,12 @@ +const setArrayValues = item => { + return item.values[0]?.id ? item.values.map(val => val.id) : item.values; +}; const generatePayload = data => { // Make a copy of data to avoid vue data reactivity issues const filters = JSON.parse(JSON.stringify(data)); let payload = filters.map(item => { if (Array.isArray(item.values)) { - item.values = item.values.map(val => val.id); + item.values = setArrayValues(item); } else if (typeof item.values === 'object') { item.values = [item.values.id]; } else if (!item.values) { diff --git a/app/javascript/dashboard/helper/scriptHelpers.js b/app/javascript/dashboard/helper/scriptHelpers.js index 7807f2b45..1169f2ee8 100644 --- a/app/javascript/dashboard/helper/scriptHelpers.js +++ b/app/javascript/dashboard/helper/scriptHelpers.js @@ -18,18 +18,18 @@ export const initializeAnalyticsEvents = () => { }); } }); - window.bus.$on(ANALYTICS_RESET, () => {}); }; const initializeAudioAlerts = user => { - // InitializeAudioNotifications const { ui_settings: uiSettings } = user || {}; const { always_play_audio_alert: alwaysPlayAudioAlert, enable_audio_alerts: audioAlertType, alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist, 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({ currentUserId: user.id, diff --git a/app/javascript/dashboard/helper/specs/customViewsHelper.spec.js b/app/javascript/dashboard/helper/specs/customViewsHelper.spec.js new file mode 100644 index 000000000..473f0bf01 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/customViewsHelper.spec.js @@ -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'); + }); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json index 361c0c8ae..eae814131 100644 --- a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json @@ -2,12 +2,17 @@ "FILTER": { "TITLE": "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", "FILTER_DELETE_ERROR": "You should have atleast one filter to save", "SUBMIT_BUTTON_LABEL": "Apply filters", + "UPDATE_BUTTON_LABEL": "Update folder", "CANCEL_BUTTON_LABEL": "Cancel", "CLEAR_BUTTON_LABEL": "Clear Filters", "EMPTY_VALUE_ERROR": "Value is required", + "FOLDER_LABEL": "Folder Name", + "FOLDER_QUERY_LABEL": "Folder Query", "TOOLTIP_LABEL": "Filter conversations", "QUERY_DROPDOWN_LABELS": { "AND": "AND", @@ -71,6 +76,9 @@ "ERROR_MESSAGE": "Error while creating segment" } }, + "EDIT": { + "EDIT_BUTTON": "Edit folder" + }, "DELETE": { "DELETE_BUTTON": "Delete filter", "MODAL": { diff --git a/app/javascript/dashboard/i18n/locale/en/auditLogs.json b/app/javascript/dashboard/i18n/locale/en/auditLogs.json index e288e2959..22b0f1bf1 100644 --- a/app/javascript/dashboard/i18n/locale/en/auditLogs.json +++ b/app/javascript/dashboard/i18n/locale/en/auditLogs.json @@ -10,15 +10,39 @@ "TITLE": "Manage Audit Logs", "DESC": "Audit Logs are trails for events and actions in a Chatwoot System.", "TABLE_HEADER": [ - "User", - "Action", - "IP Address", - "Time" + "Activity", + "Time", + "IP Address" ] }, "API": { "SUCCESS_MESSAGE": "AuditLogs retrieved successfully", "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})" + } } } diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 4d1736ed0..feb6f0595 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -73,6 +73,13 @@ "SUCCESS_MESSAGE": "Contacts saved successfully", "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": { "CONFIRM": { "TITLE": "Confirm Deletion", @@ -211,6 +218,7 @@ "FILTER_CONTACTS": "Filter", "FILTER_CONTACTS_SAVE": "Save filter", "FILTER_CONTACTS_DELETE": "Delete filter", + "FILTER_CONTACTS_EDIT": "Edit segment", "LIST": { "LOADING_MESSAGE": "Loading contacts...", "404": "No contacts matches your search 🔍", diff --git a/app/javascript/dashboard/i18n/locale/en/contactFilters.json b/app/javascript/dashboard/i18n/locale/en/contactFilters.json index 3f84a8476..09a543984 100644 --- a/app/javascript/dashboard/i18n/locale/en/contactFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/contactFilters.json @@ -1,50 +1,55 @@ { - "CONTACTS_FILTER": { - "TITLE": "Filter Contacts", - "SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.", - "ADD_NEW_FILTER": "Add Filter", - "CLEAR_ALL_FILTERS": "Clear All Filters", - "FILTER_DELETE_ERROR": "You should have atleast one filter to save", - "SUBMIT_BUTTON_LABEL": "Submit", - "CANCEL_BUTTON_LABEL": "Cancel", - "CLEAR_BUTTON_LABEL": "Clear Filters", - "EMPTY_VALUE_ERROR": "Value is required", - "TOOLTIP_LABEL": "Filter contacts", - "QUERY_DROPDOWN_LABELS": { - "AND": "AND", - "OR": "OR" - }, - "OPERATOR_LABELS": { - "equal_to": "Equal to", - "not_equal_to": "Not equal to", - "contains": "Contains", - "does_not_contain": "Does not contain", - "is_present": "Is present", - "is_not_present": "Is not present", - "is_greater_than": "Is greater than", - "is_lesser_than": "Is lesser than", - "days_before": "Is x days before" - }, - "ATTRIBUTES": { - "NAME": "Name", - "EMAIL": "Email", - "PHONE_NUMBER": "Phone number", - "IDENTIFIER": "Identifier", - "CITY": "City", - "COUNTRY": "Country", - "CUSTOM_ATTRIBUTE_LIST": "List", - "CUSTOM_ATTRIBUTE_TEXT": "Text", - "CUSTOM_ATTRIBUTE_NUMBER": "Number", - "CUSTOM_ATTRIBUTE_LINK": "Link", - "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", - "CREATED_AT": "Created At", - "LAST_ACTIVITY": "Last Activity", - "REFERER_LINK": "Referrer link" - }, - "GROUPS": { - "STANDARD_FILTERS": "Standard Filters", - "ADDITIONAL_FILTERS": "Additional Filters", - "CUSTOM_ATTRIBUTES": "Custom Attributes" - } + "CONTACTS_FILTER": { + "TITLE": "Filter Contacts", + "SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.", + "EDIT_CUSTOM_SEGMENT": "Edit Segment", + "CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your segment.", + "ADD_NEW_FILTER": "Add Filter", + "CLEAR_ALL_FILTERS": "Clear All Filters", + "FILTER_DELETE_ERROR": "You should have atleast one filter to save", + "SUBMIT_BUTTON_LABEL": "Submit", + "UPDATE_BUTTON_LABEL": "Update Segment", + "CANCEL_BUTTON_LABEL": "Cancel", + "CLEAR_BUTTON_LABEL": "Clear Filters", + "EMPTY_VALUE_ERROR": "Value is required", + "SEGMENT_LABEL": "Segment Name", + "SEGMENT_QUERY_LABEL": "Segment Query", + "TOOLTIP_LABEL": "Filter contacts", + "QUERY_DROPDOWN_LABELS": { + "AND": "AND", + "OR": "OR" + }, + "OPERATOR_LABELS": { + "equal_to": "Equal to", + "not_equal_to": "Not equal to", + "contains": "Contains", + "does_not_contain": "Does not contain", + "is_present": "Is present", + "is_not_present": "Is not present", + "is_greater_than": "Is greater than", + "is_lesser_than": "Is lesser than", + "days_before": "Is x days before" + }, + "ATTRIBUTES": { + "NAME": "Name", + "EMAIL": "Email", + "PHONE_NUMBER": "Phone number", + "IDENTIFIER": "Identifier", + "CITY": "City", + "COUNTRY": "Country", + "CUSTOM_ATTRIBUTE_LIST": "List", + "CUSTOM_ATTRIBUTE_TEXT": "Text", + "CUSTOM_ATTRIBUTE_NUMBER": "Number", + "CUSTOM_ATTRIBUTE_LINK": "Link", + "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", + "CREATED_AT": "Created At", + "LAST_ACTIVITY": "Last Activity", + "REFERER_LINK": "Referrer link" + }, + "GROUPS": { + "STANDARD_FILTERS": "Standard Filters", + "ADDITIONAL_FILTERS": "Additional Filters", + "CUSTOM_ATTRIBUTES": "Custom Attributes" } + } } diff --git a/app/javascript/dashboard/i18n/locale/en/csatMgmt.json b/app/javascript/dashboard/i18n/locale/en/csatMgmt.json index d7d2efc2a..9e16dc2b3 100644 --- a/app/javascript/dashboard/i18n/locale/en/csatMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/csatMgmt.json @@ -1,6 +1,13 @@ { "CSAT": { "TITLE": "Rate your conversation", - "PLACEHOLDER": "Tell us more..." + "PLACEHOLDER": "Tell us more...", + "RATINGS": { + "POOR": "😞 Poor", + "FAIR": "😑 Fair", + "AVERAGE": "😐 Average", + "GOOD": "😀 Good", + "EXCELLENT": "😍 Excellent" + } } } diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 782945f34..55c406fa9 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -74,6 +74,14 @@ "DELETE": "Delete article" } }, + "ARTICLE_SEARCH_RESULT": { + "UNCATEGORIZED": "Uncategorized", + "INSERT_ARTICLE": "Insert", + "NO_RESULT": "No articles found", + "COPY_LINK": "Copy article link to clipboard", + "OPEN_LINK": "Open article in new tab", + "PREVIEW_LINK": "Preview article" + }, "PORTAL": { "HEADER": "Portals", "DEFAULT": "Default", diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 06af3794e..5280c7e1a 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -83,7 +83,7 @@ }, "CHANNEL_GREETING_TOGGLE": { "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", "DISABLED": "Disabled" }, @@ -535,7 +535,6 @@ "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", "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.", "DAY": { "ENABLE": "Enable availability for this day", diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 6ff093260..5858605a0 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -4,6 +4,8 @@ "LOADING_CHART": "Loading chart data...", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_AGENT_REPORTS": "Download agent reports", + "DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.", + "SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.", "METRICS": { "CONVERSATIONS": { "NAME": "Conversations", @@ -34,6 +36,14 @@ "DESC": "( Total )" } }, + "DATE_RANGE_OPTIONS": { + "LAST_7_DAYS": "Last 7 days", + "LAST_30_DAYS": "Last 30 days", + "LAST_3_MONTHS": "Last 3 months", + "LAST_6_MONTHS": "Last 6 months", + "LAST_YEAR": "Last year", + "CUSTOM_DATE_RANGE": "Custom date range" + }, "DATE_RANGE": [ { "id": 0, @@ -66,6 +76,12 @@ }, "GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By", "DURATION_FILTER_LABEL": "Duration", + "GROUPING_OPTIONS": { + "DAY": "Day", + "WEEK": "Week", + "MONTH": "Month", + "YEAR": "Year" + }, "GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }], "GROUP_BY_WEEK_OPTIONS": [ { "id": 1, "groupBy": "Day" }, @@ -356,6 +372,7 @@ "HEADER": "CSAT Reports", "NO_RECORDS": "There are no CSAT survey responses available.", "DOWNLOAD": "Download CSAT Reports", + "DOWNLOAD_FAILED": "Failed to download CSAT Reports", "FILTERS": { "AGENTS": { "PLACEHOLDER": "Choose Agents" diff --git a/app/javascript/dashboard/mixins/agentMixin.js b/app/javascript/dashboard/mixins/agentMixin.js index 14c5bb043..1b3545a62 100644 --- a/app/javascript/dashboard/mixins/agentMixin.js +++ b/app/javascript/dashboard/mixins/agentMixin.js @@ -2,39 +2,68 @@ import { mapGetters } from 'vuex'; export default { computed: { + ...mapGetters({ + currentUser: 'getCurrentUser', + currentAccountId: 'getCurrentAccountId', + }), assignableAgents() { return this.$store.getters['inboxAssignableAgents/getAssignableAgents']( this.inboxId ); }, - ...mapGetters({ currentUser: 'getCurrentUser' }), isAgentSelected() { return this.currentChat?.meta?.assignee; }, + createNoneAgent() { + return { + confirmed: true, + name: 'None', + id: 0, + role: 'agent', + account_id: 0, + email: 'None', + }; + }, agentsList() { const agents = this.assignableAgents || []; - return [ - ...(this.isAgentSelected - ? [ - { - confirmed: true, - name: 'None', - id: 0, - role: 'agent', - account_id: 0, - email: 'None', - }, - ] - : []), - ...agents, - ].map(item => + const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents); + const none = this.createNoneAgent; + const filteredAgentsByAvailability = this.sortedAgentsByAvailability( + agentsByUpdatedPresence + ); + const filteredAgents = [ + ...(this.isAgentSelected ? [none] : []), + ...filteredAgentsByAvailability, + ]; + return filteredAgents; + }, + }, + methods: { + getAgentsByAvailability(agents, availability) { + return agents + .filter(agent => agent.availability_status === availability) + .sort((a, b) => a.name.localeCompare(b.name)); + }, + sortedAgentsByAvailability(agents) { + const onlineAgents = this.getAgentsByAvailability(agents, 'online'); + const busyAgents = this.getAgentsByAvailability(agents, 'busy'); + const offlineAgents = this.getAgentsByAvailability(agents, 'offline'); + const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents]; + return filteredAgents; + }, + getAgentsByUpdatedPresence(agents) { + // Here we are updating the availability status of the current user dynamically (live) based on the current account availability status + const agentsWithDynamicPresenceUpdate = agents.map(item => item.id === this.currentUser.id ? { ...item, - availability_status: this.currentUser.availability_status, + availability_status: this.currentUser.accounts.find( + account => account.id === this.currentAccountId + ).availability_status, } : item ); + return agentsWithDynamicPresenceUpdate; }, }, }; diff --git a/app/javascript/dashboard/mixins/specs/agentFixtures.js b/app/javascript/dashboard/mixins/specs/agentFixtures.js index 1c45b6034..58c360ed1 100644 --- a/app/javascript/dashboard/mixins/specs/agentFixtures.js +++ b/app/javascript/dashboard/mixins/specs/agentFixtures.js @@ -20,6 +20,36 @@ export default { name: 'Samuel Keta', role: 'agent', }, + { + account_id: 1, + availability_status: 'offline', + available_name: 'James K', + confirmed: true, + email: 'james@chatwoot.com', + id: 3, + name: 'James Koti', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'busy', + available_name: 'Honey', + confirmed: true, + email: 'bee@chatwoot.com', + id: 4, + name: 'Honey Bee', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'online', + available_name: 'Abraham', + confirmed: true, + email: 'abraham@chatwoot.com', + id: 5, + name: 'Abraham Keta', + role: 'agent', + }, ], formattedAgents: [ { @@ -32,7 +62,17 @@ export default { }, { account_id: 1, - availability_status: 'busy', + availability_status: 'online', + available_name: 'Abraham', + confirmed: true, + email: 'abraham@chatwoot.com', + id: 5, + name: 'Abraham Keta', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'online', available_name: 'John K', confirmed: true, email: 'john@chatwoot.com', @@ -40,6 +80,70 @@ export default { name: 'John Kennady', role: 'administrator', }, + { + account_id: 1, + availability_status: 'busy', + available_name: 'Honey', + confirmed: true, + email: 'bee@chatwoot.com', + id: 4, + name: 'Honey Bee', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'busy', + available_name: 'Samuel K', + confirmed: true, + email: 'samuel@chatwoot.com', + id: 2, + name: 'Samuel Keta', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'offline', + available_name: 'James K', + confirmed: true, + email: 'james@chatwoot.com', + id: 3, + name: 'James Koti', + role: 'agent', + }, + ], + onlineAgents: [ + { + account_id: 1, + availability_status: 'online', + available_name: 'Abraham', + confirmed: true, + email: 'abraham@chatwoot.com', + id: 5, + name: 'Abraham Keta', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'online', + available_name: 'John K', + confirmed: true, + email: 'john@chatwoot.com', + id: 1, + name: 'John Kennady', + role: 'administrator', + }, + ], + busyAgents: [ + { + account_id: 1, + availability_status: 'busy', + available_name: 'Honey', + confirmed: true, + email: 'bee@chatwoot.com', + id: 4, + name: 'Honey Bee', + role: 'agent', + }, { account_id: 1, availability_status: 'busy', @@ -51,4 +155,92 @@ export default { role: 'agent', }, ], + offlineAgents: [ + { + account_id: 1, + availability_status: 'offline', + available_name: 'James K', + confirmed: true, + email: 'james@chatwoot.com', + id: 3, + name: 'James Koti', + role: 'agent', + }, + ], + sortedByAvailability: [ + { + account_id: 1, + availability_status: 'online', + available_name: 'Abraham', + confirmed: true, + email: 'abraham@chatwoot.com', + id: 5, + name: 'Abraham Keta', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'online', + available_name: 'John K', + confirmed: true, + email: 'john@chatwoot.com', + id: 1, + name: 'John Kennady', + role: 'administrator', + }, + { + account_id: 1, + availability_status: 'busy', + available_name: 'Honey', + confirmed: true, + email: 'bee@chatwoot.com', + id: 4, + name: 'Honey Bee', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'busy', + available_name: 'Samuel K', + confirmed: true, + email: 'samuel@chatwoot.com', + id: 2, + name: 'Samuel Keta', + role: 'agent', + }, + { + account_id: 1, + availability_status: 'offline', + available_name: 'James K', + confirmed: true, + email: 'james@chatwoot.com', + id: 3, + name: 'James Koti', + role: 'agent', + }, + ], + formattedAgentsByPresenceOnline: [ + { + account_id: 1, + availability_status: 'online', + available_name: 'Abraham', + confirmed: true, + email: 'abr@chatwoot.com', + id: 1, + name: 'Abraham Keta', + role: 'agent', + }, + ], + formattedAgentsByPresenceOffline: [ + { + account_id: 1, + availability_status: 'offline', + available_name: 'Abraham', + confirmed: true, + email: 'abr@chatwoot.com', + id: 1, + name: 'Abraham Keta', + role: 'agent', + }, + ], }; diff --git a/app/javascript/dashboard/mixins/specs/agentMixin.spec.js b/app/javascript/dashboard/mixins/specs/agentMixin.spec.js index 7cd2ad0db..f92f48555 100644 --- a/app/javascript/dashboard/mixins/specs/agentMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/agentMixin.spec.js @@ -12,12 +12,71 @@ describe('agentMixin', () => { getters = { getCurrentUser: () => ({ id: 1, - availability_status: 'busy', + accounts: [ + { + id: 1, + availability_status: 'online', + auto_offline: false, + }, + ], }), + getCurrentAccountId: () => 1, }; store = new Vuex.Store({ getters }); }); + it('return agents by availability', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [agentMixin], + data() { + return { + inboxId: 1, + currentChat: { meta: { assignee: { name: 'John' } } }, + }; + }, + computed: { + assignableAgents() { + return agentFixtures.allAgents; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect( + wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'online') + ).toEqual(agentFixtures.onlineAgents); + expect( + wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'busy') + ).toEqual(agentFixtures.busyAgents); + expect( + wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'offline') + ).toEqual(agentFixtures.offlineAgents); + }); + + it('return sorted agents by availability', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [agentMixin], + data() { + return { + inboxId: 1, + currentChat: { meta: { assignee: { name: 'John' } } }, + }; + }, + computed: { + assignableAgents() { + return agentFixtures.allAgents; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect( + wrapper.vm.sortedAgentsByAvailability(agentFixtures.allAgents) + ).toEqual(agentFixtures.sortedByAvailability); + }); + it('return formatted agents', () => { const Component = { render() {}, @@ -38,4 +97,44 @@ describe('agentMixin', () => { const wrapper = shallowMount(Component, { store, localVue }); expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents); }); + + it('return formatted agents by presence', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [agentMixin], + data() { + return { + inboxId: 1, + currentChat: { meta: { assignee: { name: 'John' } } }, + }; + }, + computed: { + currentUser() { + return { + id: 1, + accounts: [ + { + id: 1, + availability_status: 'offline', + auto_offline: false, + }, + ], + }; + }, + currentAccountId() { + return 1; + }, + assignableAgents() { + return agentFixtures.allAgents; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect( + wrapper.vm.getAgentsByUpdatedPresence( + agentFixtures.formattedAgentsByPresenceOnline + ) + ).toEqual(agentFixtures.formattedAgentsByPresenceOffline); + }); }); diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue index 3e988e0a4..d214a7b01 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue @@ -1,9 +1,25 @@