Compare commits

..
446 changed files with 4117 additions and 7143 deletions
+1
View File
@@ -254,6 +254,7 @@ AZURE_APP_SECRET=
# Sentiment analysis model file path # Sentiment analysis model file path
SENTIMENT_FILE_PATH= SENTIMENT_FILE_PATH=
# Housekeeping/Performance related configurations # Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed # contact_inboxes with no conversation older than 90 days will be removed
-7
View File
@@ -1,7 +0,0 @@
## All javascript files should be reviewed by pranav before merging
*.js @pranavrajs
*.vue @pranavrajs
## All enterprise related files should be reviewed by sojan before merging
/enterprise/* @sojan-official
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Check for log lines and calculate percentage - name: Check for log lines and calculate percentage
run: | run: |
+3 -3
View File
@@ -4,7 +4,7 @@
# # This action will try to install and setup # # This action will try to install and setup
# # chatwoot on an Ubuntu 20.04 machine using # # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script. # # the linux installer script.
# # # #
# # This is set to run daily at midnight. # # This is set to run daily at midnight.
# # # #
@@ -35,7 +35,7 @@ jobs:
run: | run: |
sudo ./install.sh --install < input sudo ./install.sh --install < input
# disabling http verify for now as http # disabling http verify for now as http
# access to port 3000 fails in gh action env # access to port 3000 fails in gh action env
# - name: Verify # - name: Verify
# if: always() # if: always()
@@ -45,7 +45,7 @@ jobs:
# curl http://localhost:3000/api # curl http://localhost:3000/api
- name: Upload chatwoot setup log file as an artifact - name: Upload chatwoot setup log file as an artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: chatwoot-setup-log-file name: chatwoot-setup-log-file
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v1
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
+4 -4
View File
@@ -41,7 +41,7 @@ jobs:
options: --entrypoint redis-server options: --entrypoint redis-server
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -50,7 +50,7 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 20
cache: yarn cache: yarn
@@ -76,11 +76,11 @@ jobs:
- name: Run backend tests - name: Run backend tests
run: | run: |
bundle exec rspec --profile=10 --format documentation bundle exec rspec --profile=10 --format documentation
env: env:
NODE_OPTIONS: --openssl-legacy-provider NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: rails-log-folder name: rails-log-folder
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
options: --entrypoint redis-server options: --entrypoint redis-server
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -49,7 +49,7 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 20
cache: yarn cache: yarn
@@ -77,7 +77,7 @@ jobs:
--format documentation --format documentation
- name: Upload rails log folder - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: rails-log-folder name: rails-log-folder
+3 -3
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -19,7 +19,7 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 20
cache: 'yarn' cache: 'yarn'
@@ -31,7 +31,7 @@ jobs:
run: | run: |
rm -rf enterprise rm -rf enterprise
rm -rf spec/enterprise rm -rf spec/enterprise
- name: Run asset compile - name: Run asset compile
run: bundle exec rake assets:precompile run: bundle exec rake assets:precompile
env: env:
+70 -11
View File
@@ -2,6 +2,7 @@ require:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
inherit_from: .rubocop_todo.yml
Layout/LineLength: Layout/LineLength:
Max: 150 Max: 150
@@ -11,8 +12,7 @@ Metrics/ClassLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
Metrics/MethodLength:
Max: 19
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@@ -50,7 +50,6 @@ Lint/OrAssignmentToConstant:
Exclude: Exclude:
- 'lib/redis/config.rb' - 'lib/redis/config.rb'
Metrics/BlockLength: Metrics/BlockLength:
Max: 30
Exclude: Exclude:
- spec/**/* - spec/**/*
- '**/routes.rb' - '**/routes.rb'
@@ -103,31 +102,84 @@ RSpec/FactoryBot/SyntaxMethods:
Enabled: false Enabled: false
Naming/VariableNumber: Naming/VariableNumber:
Enabled: false Enabled: false
Naming/MemoizedInstanceVariableName: Metrics/MethodLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Rails/CreateTableWithTimestamps:
Exclude:
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
Style/GuardClause: Style/GuardClause:
Exclude: Exclude:
- 'app/builders/account_builder.rb' - 'app/builders/account_builder.rb'
- 'app/models/attachment.rb' - 'app/models/attachment.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/AbcSize: Metrics/AbcSize:
Max: 26
Exclude: Exclude:
- 'app/controllers/concerns/auth_helper.rb' - 'app/controllers/concerns/auth_helper.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'app/controllers/api/v1/accounts/inboxes_controller.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Metrics/CyclomaticComplexity:
Max: 7
Exclude:
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Rails/ReversibleMigration:
Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb'
- 'db/migrate/20161025070645_remchannel.rb'
- 'db/migrate/20161025070645_remchannel.rb'
- 'db/migrate/20161110102609_removeinboxid.rb'
- 'db/migrate/20170519091539_add_avatar_to_fb.rb'
- 'db/migrate/20191020085608_rename_old_tables.rb'
- 'db/migrate/20191126185833_update_user_invite_foreign_key.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210513083044_remove_not_null_from_webhook_url_channel_api.rb'
Rails/BulkChangeTable:
Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb'
- 'db/migrate/20200121190901_create_account_users.rb'
- 'db/migrate/20170211092540_notnullableusers.rb'
- 'db/migrate/20170403095203_contactadder.rb'
- 'db/migrate/20170406104018_add_default_status_conv.rb'
- 'db/migrate/20170511134418_latlong.rb'
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb'
Rails/UniqueValidationWithoutIndex: Rails/UniqueValidationWithoutIndex:
Exclude: Exclude:
- 'app/models/channel/twitter_profile.rb' - 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb' - 'app/models/webhook.rb'
- 'app/models/contact.rb' - 'app/models/contact.rb'
- 'app/models/integrations/hook.rb' - 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
Rails/ThreeStateBooleanColumn: Rails/ThreeStateBooleanColumn:
Exclude: 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' - 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet: RSpec/IndexedLet:
Enabled: false Enabled: false
@@ -135,8 +187,6 @@ RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleExpectations:
Max: 7
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 14 Max: 14
@@ -153,4 +203,13 @@ AllCops:
- 'config/environments/**/*' - 'config/environments/**/*'
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20200225162150_init_schema.rb'
- 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb'
- 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb'
- db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb
- db/migrate/20200331095710_add_identifier_to_contact.rb
- db/migrate/20200429082655_add_medium_to_twilio_sms.rb
- db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb
+287
View File
@@ -0,0 +1,287 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2019-10-23 16:47:02 +0530 using RuboCop version 0.73.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 1
Lint/DuplicateMethods:
Exclude:
- 'app/controllers/api/v1/reports_controller.rb'
# Offense count: 1
Lint/RescueException:
Exclude:
- 'app/builders/messages/message_builder.rb'
# Offense count: 4
Lint/ShadowingOuterLocalVariable:
Exclude:
- 'app/controllers/api/v1/reports_controller.rb'
# Offense count: 3
# Configuration parameters: AllowKeywordBlockArguments.
Lint/UnderscorePrefixedVariableName:
Exclude:
- 'app/models/account.rb'
- 'deploy/before_symlink.rb'
# Offense count: 18
Lint/UselessAssignment:
Exclude:
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/facebook_indicators_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/listeners/reporting_listener.rb'
- 'app/models/channel/facebook_page.rb'
- 'app/models/facebook_page.rb'
# Offense count: 14
Metrics/AbcSize:
Max: 26
# Offense count: 1
# Configuration parameters: CountComments, ExcludedMethods.
# ExcludedMethods: refine
Metrics/BlockLength:
Max: 30
# Offense count: 2
Metrics/CyclomaticComplexity:
Max: 7
# Offense count: 10
# Configuration parameters: CountComments, ExcludedMethods.
Metrics/MethodLength:
Max: 19
# Offense count: 1
Metrics/PerceivedComplexity:
Max: 8
# Offense count: 6
Naming/AccessorMethodName:
Exclude:
- 'app/builders/report_builder.rb'
- 'app/controllers/api/v1/accounts_controller.rb'
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/conversations_controller.rb'
# Offense count: 9
# Configuration parameters: EnforcedStyleForLeadingUnderscores.
# SupportedStylesForLeadingUnderscores: disallowed, required, optional
Naming/MemoizedInstanceVariableName:
Exclude:
- 'app/controllers/api/base_controller.rb'
- 'app/controllers/api/v1/conversations_controller.rb'
- 'app/controllers/api/v1/webhooks_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/models/message.rb'
- 'lib/integrations/widget/outgoing_message_builder.rb'
# Offense count: 4
# Cop supports --auto-correct.
# Configuration parameters: MaxKeyValuePairs.
Performance/RedundantMerge:
Exclude:
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/models/message.rb'
# Offense count: 1
# Cop supports --auto-correct.
Performance/StringReplacement:
Exclude:
- 'lib/events/base.rb'
# Offense count: 4
# Configuration parameters: Prefixes.
# Prefixes: when, with, without
RSpec/ContextWording:
Exclude:
- 'spec/models/contact_spec.rb'
- 'spec/models/user_spec.rb'
# Offense count: 1
RSpec/DescribeClass:
Exclude:
- 'spec/mailers/confirmation_instructions_spec.rb'
# Offense count: 1
RSpec/DescribeSymbol:
Exclude:
- 'spec/mailers/confirmation_instructions_spec.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: AllowConsecutiveOneLiners.
RSpec/EmptyLineAfterExample:
Exclude:
- 'spec/models/user_spec.rb'
# Offense count: 1
# Configuration parameters: Max.
RSpec/ExampleLength:
Exclude:
- 'spec/models/conversation_spec.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: single_line_only, single_statement_only, disallow
RSpec/ImplicitSubject:
Exclude:
- 'spec/models/user_spec.rb'
# Offense count: 7
# Configuration parameters: AggregateFailuresByDefault.
RSpec/MultipleExpectations:
Max: 7
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: not_to, to_not
RSpec/NotToNot:
Exclude:
- 'spec/mailers/confirmation_instructions_spec.rb'
# Offense count: 1
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
RSpec/VerifiedDoubles:
Exclude:
- 'spec/models/conversation_spec.rb'
# Offense count: 4
# Cop supports --auto-correct.
Rails/ActiveRecordAliases:
Exclude:
- 'app/controllers/api/v1/agents_controller.rb'
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/canned_responses_controller.rb'
- 'app/controllers/api/v1/contacts_controller.rb'
# Offense count: 2
# Cop supports --auto-correct.
Rails/BelongsTo:
Exclude:
- 'app/models/message.rb'
- 'app/models/user.rb'
# Offense count: 6
# Cop supports --auto-correct.
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/EnumHash:
Exclude:
- 'app/models/attachment.rb'
- 'app/models/conversation.rb'
- 'app/models/message.rb'
- 'app/models/user.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/HasManyOrHasOneDependent:
Exclude:
- 'app/models/user.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/InverseOf:
Exclude:
- 'app/models/user.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: app/controllers/**/*.rb
Rails/LexicallyScopedActionFilter:
Exclude:
- 'app/controllers/home_controller.rb'
# Offense count: 2
# Configuration parameters: Include.
# Include: app/**/*.rb, config/**/*.rb, db/**/*.rb, lib/**/*.rb
Rails/Output:
Exclude:
- 'app/bot/bot.rb'
- 'app/builders/account_builder.rb'
# Offense count: 7
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: strict, flexible
Rails/TimeZone:
Exclude:
- 'app/builders/report_builder.rb'
- 'lib/reports/update_account_identity.rb'
- 'lib/reports/update_agent_identity.rb'
- 'lib/reports/update_identity.rb'
- 'spec/models/conversation_spec.rb'
# Offense count: 8
# Cop supports --auto-correct.
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/Validation:
Exclude:
- 'app/models/canned_response.rb'
- 'app/models/channel/facebook_page.rb'
- 'app/models/facebook_page.rb'
- 'app/models/telegram_bot.rb'
- 'app/models/user.rb'
# Offense count: 15
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle.
# SupportedStyles: nested, compact
Style/ClassAndModuleChildren:
Exclude:
- 'app/builders/messages/message_builder.rb'
- 'app/controllers/api/v1/inbox_members_controller.rb'
- 'app/models/channel/facebook_page.rb'
- 'app/models/channel/web_widget.rb'
- 'app/presenters/conversations/event_data_presenter.rb'
- 'app/services/facebook/send_reply_service.rb'
- 'lib/integrations/facebook/delivery_status.rb'
- 'lib/integrations/facebook/message_creator.rb'
- 'lib/integrations/facebook/message_parser.rb'
- 'lib/integrations/widget/incoming_message_builder.rb'
# Offense count: 4
Style/CommentedKeyword:
Exclude:
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/conversations/assignments_controller.rb'
- 'app/controllers/api/v1/conversations/labels_controller.rb'
- 'app/controllers/api/v1/labels_controller.rb'
# Offense count: 1
# Configuration parameters: AllowIfModifier.
Style/IfInsideElse:
Exclude:
- 'app/finders/conversation_finder.rb'
# Offense count: 1
Style/MixinUsage:
Exclude:
- 'app/bot/bot.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods.
# SupportedStyles: predicate, comparison
Style/NumericPredicate:
Exclude:
- 'spec/**/*'
- 'app/controllers/api/v1/callbacks_controller.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: implicit, explicit
Style/RescueStandardError:
Exclude:
- 'app/models/channel/facebook_page.rb'
+15 -15
View File
@@ -277,7 +277,7 @@ GEM
gli (2.21.1) gli (2.21.1)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
gmail_xoauth (0.4.3) gmail_xoauth (0.4.2)
oauth (>= 0.3.6) oauth (>= 0.3.6)
google-apis-core (0.11.0) google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
@@ -316,16 +316,16 @@ GEM
google-cloud-translate-v3 (0.6.0) google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a) gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.2) google-protobuf (3.22.3)
google-protobuf (3.25.2-arm64-darwin) google-protobuf (3.22.3-arm64-darwin)
google-protobuf (3.25.2-x86_64-darwin) google-protobuf (3.22.3-x86_64-darwin)
google-protobuf (3.25.2-x86_64-linux) google-protobuf (3.22.3-x86_64-linux)
googleapis-common-protos (1.4.0) googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2) googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27) grpc (~> 1.27)
googleapis-common-protos-types (1.11.0) googleapis-common-protos-types (1.6.0)
google-protobuf (~> 3.18) google-protobuf (~> 3.14)
googleauth (1.5.2) googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
@@ -335,13 +335,13 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
groupdate (6.2.1) groupdate (6.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.54.3) grpc (1.54.0)
google-protobuf (~> 3.21) google-protobuf (~> 3.21)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-darwin) grpc (1.54.0-x86_64-darwin)
google-protobuf (~> 3.21) google-protobuf (~> 3.21)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-linux) grpc (1.54.0-x86_64-linux)
google-protobuf (~> 3.21) google-protobuf (~> 3.21)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
@@ -488,14 +488,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.0) nio4r (2.7.0)
nokogiri (1.16.2) nokogiri (1.16.0)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.2-arm64-darwin) nokogiri (1.16.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.2-x86_64-darwin) nokogiri (1.16.0-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.2-x86_64-linux) nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
numo-narray (0.9.2.1) numo-narray (0.9.2.1)
oauth (1.1.0) oauth (1.1.0)
@@ -794,7 +794,7 @@ GEM
valid_email2 (4.0.6) valid_email2 (4.0.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.3) version_gem (1.1.2)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.2.1)
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2024 Chatwoot Inc. Copyright (c) 2017-2021 Chatwoot Inc.
Portions of this software are licensed as follows: Portions of this software are licensed as follows:
@@ -9,7 +9,7 @@
padding: 4px 12px; padding: 4px 12px;
.icon-container { .icon-container {
margin-right: 2px; margin-right: 4px;
} }
+4 -14
View File
@@ -2,7 +2,7 @@
class AccountBuilder class AccountBuilder
include CustomExceptions::Account include CustomExceptions::Account
pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale] pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
def perform def perform
if @user.nil? if @user.nil?
@@ -15,22 +15,12 @@ class AccountBuilder
end end
[@user, @account] [@user, @account]
rescue StandardError => e rescue StandardError => e
Rails.logger.debug e.inspect puts e.inspect
raise e raise e
end end
private private
def user_full_name
# the empty string ensures that not-null constraint is not violated
@user_full_name || ''
end
def account_name
# the empty string ensures that not-null constraint is not violated
@account_name || ''
end
def validate_email def validate_email
address = ValidEmail2::Address.new(@email) address = ValidEmail2::Address.new(@email)
if address.valid? # && !address.disposable? if address.valid? # && !address.disposable?
@@ -49,7 +39,7 @@ class AccountBuilder
end end
def create_account def create_account
@account = Account.create!(name: account_name, locale: I18n.locale) @account = Account.create!(name: @account_name, locale: I18n.locale)
Current.account = @account Current.account = @account
end end
@@ -74,7 +64,7 @@ class AccountBuilder
@user = User.new(email: @email, @user = User.new(email: @email,
password: user_password, password: user_password,
password_confirmation: user_password, password_confirmation: user_password,
name: user_full_name) name: @user_full_name)
@user.type = 'SuperAdmin' if @super_admin @user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
-60
View File
@@ -1,60 +0,0 @@
# The AgentBuilder class is responsible for creating a new agent.
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
# @param role [String] the role of the user, defaults to 'agent' if not provided.
# @param inviter [User] the user who is inviting the agent (Current.user in most cases).
# @param availability [String] the availability status of the user, defaults to 'offline' if not provided.
# @param auto_offline [Boolean] the auto offline status of the user.
pattr_initialize [:email, { name: '' }, :inviter, :account, { role: :agent }, { availability: :offline }, { auto_offline: false }]
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
send_confirmation_if_required
create_account_user
end
@user
end
private
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
user = User.find_by(email: email)
return user if user
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end
# Sends confirmation instructions if the user is persisted and not confirmed.
def send_confirmation_if_required
@user.send_confirmation_instructions if user_needs_confirmation?
end
# Checks if the user needs confirmation.
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
def user_needs_confirmation?
@user.persisted? && !@user.confirmed?
end
# Creates an account user linking the user to the current account.
def create_account_user
AccountUser.create!({
account_id: account.id,
user_id: @user.id,
inviter_id: inviter.id
}.merge({
role: role,
availability: availability,
auto_offline: auto_offline
}.compact))
end
end
@@ -1,53 +0,0 @@
class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def group_by_key
:user_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -1,17 +0,0 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
private
def group_by_key
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,49 +0,0 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end
def group_by_key
'conversations.team_id'
end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end
@@ -1,26 +1,16 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization before_action :check_authorization
before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create] before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create]
def index def index
@agents = agents @agents = agents
end end
def create def create; end
builder = AgentBuilder.new(
email: new_agent_params['email'],
name: new_agent_params['name'],
role: new_agent_params['role'],
availability: new_agent_params['availability'],
auto_offline: new_agent_params['auto_offline'],
inviter: current_user,
account: Current.account
)
builder.perform
end
def update def update
@agent.update!(agent_params.slice(:name).compact) @agent.update!(agent_params.slice(:name).compact)
@@ -33,25 +23,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def bulk_create
emails = params[:emails]
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
head :ok
end
private private
def check_authorization def check_authorization
@@ -62,34 +33,47 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id]) @agent = agents.find(params[:id])
end end
def find_user
@user = User.find_by(email: new_agent_params[:email])
end
# TODO: move this to a builder and combine the save account user method into a builder
# ensure the account user association is also created in a single transaction
def create_user
return @user.send_confirmation_instructions if @user
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
end
def save_account_user
AccountUser.create!({
account_id: Current.account.id,
user_id: @user.id,
inviter_id: current_user.id
}.merge({
role: new_agent_params[:role],
availability: new_agent_params[:availability],
auto_offline: new_agent_params[:auto_offline]
}.compact))
end
def agent_params def agent_params
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline) params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end end
def new_agent_params def new_agent_params
# intial string ensures the password requirements are met
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end end
def agents def agents
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end end
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def validate_limit def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end
def available_agent_count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end end
def delete_user_record(agent) def delete_user_record(agent)
@@ -2,7 +2,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
include DateRangeHelper include DateRangeHelper
before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread] before_action :fetch_notification, only: [:update, :destroy, :snooze]
before_action :set_primary_actor, only: [:read_all] before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -29,25 +29,11 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification render json: @notification
end end
def unread
@notification.update(read_at: nil)
render json: @notification
end
def destroy def destroy
@notification.destroy @notification.destroy
head :ok head :ok
end end
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
def unread_count def unread_count
@unread_count = notification_finder.unread_count @unread_count = notification_finder.unread_count
render json: @unread_count render json: @unread_count
+3 -22
View File
@@ -5,13 +5,11 @@ class Api::V1::AccountsController < Api::BaseController
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false only: [:create], raise: false
before_action :check_signup_enabled, only: [:create] before_action :check_signup_enabled, only: [:create]
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create] before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create] before_action :fetch_account, except: [:create]
before_action :check_authorization, except: [:create] before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail, rescue_from CustomExceptions::Account::InvalidEmail,
CustomExceptions::Account::InvalidParams,
CustomExceptions::Account::UserExists, CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors, CustomExceptions::Account::UserErrors,
with: :render_error_response with: :render_error_response
@@ -40,13 +38,11 @@ class Api::V1::AccountsController < Api::BaseController
def cache_keys def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok render json: { cache_keys: get_cache_keys }, status: :ok
end end
def update def update
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.custom_attributes.merge!(custom_attributes_params)
@account.save!
end end
def update_active_at def update_active_at
@@ -57,18 +53,7 @@ class Api::V1::AccountsController < Api::BaseController
private private
def ensure_account_name def get_cache_keys
# ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered
# this change is to align the behaviour with the v2 accounts controller
# since these values are not required directly there
return if account_params[:account_name].present?
return if account_params[:user_full_name].present?
raise CustomExceptions::Account::InvalidParams.new({})
end
def cache_keys_for_account
{ {
label: fetch_value_for_key(params[:id], Label.name.underscore), label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore), inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
@@ -85,10 +70,6 @@ class Api::V1::AccountsController < Api::BaseController
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end
+1 -12
View File
@@ -10,9 +10,7 @@ class Api::V1::ProfilesController < Api::BaseController
@user.update!(password_params.except(:current_password)) @user.update!(password_params.except(:current_password))
end end
@user.assign_attributes(profile_params) @user.update!(profile_params)
@user.custom_attributes.merge!(custom_attributes_params)
@user.save!
end end
def avatar def avatar
@@ -33,11 +31,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def resend_confirmation
@user.send_confirmation_instructions unless @user.confirmed?
head :ok
end
private private
def set_user def set_user
@@ -64,10 +57,6 @@ class Api::V1::ProfilesController < Api::BaseController
) )
end end
def custom_attributes_params
params.require(:profile).permit(:phone_number)
end
def password_params def password_params
params.require(:profile).permit( params.require(:profile).permit(
:current_password, :current_password,
@@ -1,36 +0,0 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
end
private
def check_authorization
authorize :report, :view?
end
def prepare_builder_params
@builder_params = {
since: permitted_params[:since],
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
data = builder.build
render json: data
end
def permitted_params
params.permit(:since, :until, :business_hours)
end
end
@@ -1,56 +0,0 @@
class Api::V2::AccountsController < Api::BaseController
include AuthHelper
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
before_action :check_signup_enabled, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors,
with: :render_error_response
def create
@user, @account = AccountBuilder.new(
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
).perform
fetch_account_and_user_info
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
end
private
def fetch_account_and_user_info; end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end
end
Api::V2::AccountsController.prepend_mod_with('Api::V2::AccountsController')
@@ -1,8 +1,7 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create], 'api/v1/accounts/conversations' => %w[toggle_status create],
'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/messages' => ['create']
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze }.freeze
def ensure_access_token def ensure_access_token
@@ -25,6 +25,6 @@ module EnsureCurrentAccountHelper
end end
def account_accessible_for_bot?(account) def account_accessible_for_bot?(account)
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end end
end end
@@ -22,24 +22,19 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
i.value = value i.value = value
i.save! i.save!
end end
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" # rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'App Configs updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
end end
private private
def set_config def set_config
@config = params[:config] || 'general' @config = params[:config]
end end
def allowed_configs def allowed_configs
@allowed_configs = case @config @allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
%w[ENABLE_ACCOUNT_SIGNUP]
end
end end
end end
@@ -28,7 +28,8 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end end
def sha def sha
@metrics['Git SHA'] = GIT_HASH sha = `git rev-parse HEAD`
@metrics['Git SHA'] = sha.presence || 'n/a'
end end
def postgres_status def postgres_status
+1 -6
View File
@@ -26,7 +26,6 @@ class NotificationFinder
def set_up def set_up
find_all_notifications find_all_notifications
filter_by_read_status
filter_by_status filter_by_status
end end
@@ -38,15 +37,11 @@ class NotificationFinder
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed' @notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
end end
def filter_by_read_status
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
end
def current_page def current_page
params[:page] || 1 params[:page] || 1
end end
def notifications def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc) @notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: :desc)
end end
end end
+10 -10
View File
@@ -15,8 +15,8 @@ module Api::V2::Accounts::HeatmapHelper
dates = data.pluck(:date).uniq.sort dates = data.pluck(:date).uniq.sort
# add the dates as the first row, leave an empty cell for the hour column # add the dates as the first row, leave an empty cell for the hour column
# e.g. ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03'] # e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03']
result_arr << (['Start of the hour'] + dates) result_arr << ([nil] + dates)
# group the data by hour, we do not need to sort it, because the data is already sorted # group the data by hour, we do not need to sort it, because the data is already sorted
# given it starts from the beginning of the day # given it starts from the beginning of the day
@@ -25,7 +25,7 @@ module Api::V2::Accounts::HeatmapHelper
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...] # value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
data.group_by { |d| d[:hour] }.each do |hour, items| data.group_by { |d| d[:hour] }.each do |hour, items|
# create a new row for each hour # create a new row for each hour
row = [format('%02d:00', hour)] row = [hour]
# group the items by date, so we can easily access the value for each date # group the items by date, so we can easily access the value for each date
# grouped values will be a hasg with the date as the key, and the value as the value # grouped values will be a hasg with the date as the key, and the value as the value
@@ -37,7 +37,7 @@ module Api::V2::Accounts::HeatmapHelper
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array)) row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
end end
# row will look like ['22:00', 0, 0, 1, 4, 6, 7, 4] # row will look like [22, 0, 0, 1, 4, 6, 7, 4]
# add the row to the result array # add the row to the result array
result_arr << row result_arr << row
@@ -46,12 +46,12 @@ module Api::V2::Accounts::HeatmapHelper
# return the resultant array # return the resultant array
# the result looks like this # the result looks like this
# [ # [
# ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03'], # [nil, '2023-01-01', '2023-1-02', '2023-01-03'],
# ['00:00', 0, 0, 0], # [0, 0, 0, 0],
# ['01:00', 0, 0, 0], # [1, 0, 0, 0],
# ['02:00', 0, 0, 0], # [2, 0, 0, 0],
# ['03:00', 0, 0, 0], # [3, 0, 0, 0],
# ['04:00', 0, 0, 0], # [4, 0, 0, 0],
# ] # ]
result_arr result_arr
end end
@@ -45,8 +45,12 @@ module Api::V2::Accounts::ReportsHelper
def generate_readable_report_metrics(report_metric) def generate_readable_report_metrics(report_metric)
[ [
report_metric[:conversations_count], report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format, time_to_minutes(report_metric[:avg_first_response_time]),
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format time_to_minutes(report_metric[:avg_resolution_time])
] ]
end end
def time_to_minutes(time_in_seconds)
(time_in_seconds / 60).to_i
end
end end
@@ -1,9 +0,0 @@
module SuperAdmin::AccountFeaturesHelper
def self.account_features
YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
end
def self.account_premium_features
account_features.filter { |feature| feature['premium'] }.pluck('name')
end
end
+1 -4
View File
@@ -2,13 +2,12 @@
<div <div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem" v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app" id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper" class="app-wrapper h-full flex-grow-0 min-h-0 w-full"
:class="{ 'app-rtl--wrapper': isRTLView }" :class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'" :dir="isRTLView ? 'rtl' : 'ltr'"
> >
<update-banner :latest-chatwoot-version="latestChatwootVersion" /> <update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId"> <template v-if="currentAccountId">
<pending-email-verification-banner />
<payment-pending-banner /> <payment-pending-banner />
<upgrade-banner /> <upgrade-banner />
</template> </template>
@@ -33,7 +32,6 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue'; import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue'; import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin'; import rtlMixin from 'shared/mixins/rtlMixin';
@@ -54,7 +52,6 @@ export default {
PaymentPendingBanner, PaymentPendingBanner,
WootSnackbarBox, WootSnackbarBox,
UpgradeBanner, UpgradeBanner,
PendingEmailVerificationBanner,
}, },
mixins: [rtlMixin], mixins: [rtlMixin],
-4
View File
@@ -98,8 +98,4 @@ export default {
}, },
}); });
}, },
resendConfirmation() {
const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url);
},
}; };
@@ -47,10 +47,6 @@ const endPoints = {
setActiveAccount: { setActiveAccount: {
url: '/api/v1/profile/set_active_account', url: '/api/v1/profile/set_active_account',
}, },
resendConfirmation: {
url: '/api/v1/profile/resend_confirmation',
},
}; };
export default page => { export default page => {
+2 -29
View File
@@ -6,15 +6,8 @@ class NotificationsAPI extends ApiClient {
super('notifications', { accountScoped: true }); super('notifications', { accountScoped: true });
} }
get({ page, status, type, sortOrder }) { get(page) {
return axios.get(this.url, { return axios.get(`${this.url}?page=${page}`);
params: {
page,
status,
type,
sort_order: sortOrder,
},
});
} }
getNotifications(contactId) { getNotifications(contactId) {
@@ -32,29 +25,9 @@ class NotificationsAPI extends ApiClient {
}); });
} }
unRead(id) {
return axios.post(`${this.url}/${id}/unread`);
}
readAll() { readAll() {
return axios.post(`${this.url}/read_all`); return axios.post(`${this.url}/read_all`);
} }
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
deleteAll({ type = 'all' }) {
return axios.post(`${this.url}/destroy_all`, {
type,
});
}
snooze({ id, snoozedUntil = null }) {
return axios.post(`${this.url}/${id}/snooze`, {
snoozed_until: snoozedUntil,
});
}
} }
export default new NotificationsAPI(); export default new NotificationsAPI();
@@ -28,20 +28,10 @@ describe('#NotificationAPI', () => {
}); });
it('#get', () => { it('#get', () => {
notificationsAPI.get({ notificationsAPI.get(1);
page: 1, expect(axiosMock.get).toHaveBeenCalledWith(
status: 'read', '/api/v1/notifications?page=1'
type: 'Conversation', );
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
status: 'read',
type: 'Conversation',
sort_order: 'desc',
},
});
}); });
it('#getNotifications', () => { it('#getNotifications', () => {
@@ -75,30 +65,5 @@ describe('#NotificationAPI', () => {
'/api/v1/notifications/read_all' '/api/v1/notifications/read_all'
); );
}); });
it('#snooze', () => {
notificationsAPI.snooze({ id: 1, snoozedUntil: 12332211 });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/notifications/1/snooze',
{
snoozed_until: 12332211,
}
);
});
it('#delete', () => {
notificationsAPI.delete(1);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/notifications/1');
});
it('#deleteAll', () => {
notificationsAPI.deleteAll({ type: 'all' });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/notifications/destroy_all',
{
type: 'all',
}
);
});
}); });
}); });
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50" class="conversations-list-wrap flex-basis-clamp flex-shrink-0 flex-basis-custom overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{ :class="{
hide: !showConversationList, hide: !showConversationList,
'list--full-width': isOnExpandedLayout, 'list--full-width': isOnExpandedLayout,
@@ -1,43 +0,0 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
action-button-icon="mail"
has-action-button
@click="resendVerificationEmail"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: { Banner },
mixins: [accountMixin, alertMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
bannerMessage() {
return this.$t('APP_GLOBAL.EMAIL_VERIFICATION_PENDING');
},
actionButtonMessage() {
return this.$t('APP_GLOBAL.RESEND_VERIFICATION_MAIL');
},
shouldShowBanner() {
return !this.currentUser.confirmed;
},
},
methods: {
resendVerificationEmail() {
this.$store.dispatch('resendConfirmation');
this.showAlert(this.$t('APP_GLOBAL.EMAIL_VERIFICATION_SENT'));
},
},
};
</script>
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white" class="banner flex items-center h-12 gap-4 text-white dark:text-white text-xs py-3 px-4 justify-center"
:class="bannerClasses" :class="bannerClasses"
> >
<span class="banner-message"> <span class="banner-message">
@@ -18,7 +18,7 @@
<woot-button <woot-button
v-if="hasActionButton" v-if="hasActionButton"
size="tiny" size="tiny"
:icon="actionButtonIcon" icon="arrow-right"
:variant="actionButtonVariant" :variant="actionButtonVariant"
color-scheme="primary" color-scheme="primary"
class-names="banner-action__button" class-names="banner-action__button"
@@ -67,10 +67,6 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
actionButtonIcon: {
type: String,
default: 'arrow-right',
},
colorScheme: { colorScheme: {
type: String, type: String,
default: '', default: '',
@@ -6,7 +6,6 @@
<conversation-header <conversation-header
v-if="currentChat.id" v-if="currentChat.id"
:chat="currentChat" :chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout" :show-back-button="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel" @contact-panel-toggle="onToggleContactPanel"
@@ -31,7 +30,6 @@
<messages-view <messages-view
v-if="currentChat.id" v-if="currentChat.id"
:inbox-id="inboxId" :inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel" @contact-panel-toggle="onToggleContactPanel"
/> />
@@ -82,10 +80,6 @@ export default {
default: '', default: '',
required: false, required: false,
}, },
isInboxView: {
type: Boolean,
default: false,
},
isContactPanelOpen: { isContactPanelOpen: {
type: Boolean, type: Boolean,
default: true, default: true,
@@ -66,7 +66,7 @@
</div> </div>
</div> </div>
<div <div
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0 rtl:relative rtl:left-6" class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }" :class="{ 'justify-end': isContactPanelOpen }"
> >
<more-actions :conversation-id="currentChat.id" /> <more-actions :conversation-id="currentChat.id" />
@@ -86,8 +86,7 @@ import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue'; import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default { export default {
components: { components: {
@@ -110,10 +109,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isInboxView: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
@@ -128,9 +123,6 @@ export default {
params: { accountId, inbox_id: inboxId, label, teamId }, params: { accountId, inbox_id: inboxId, label, teamId },
name, name,
} = this.$route; } = this.$route;
if (this.isInboxView) {
return frontendURL(`accounts/${accountId}/inbox`);
}
return conversationListPageURL({ return conversationListPageURL({
accountId, accountId,
inboxId, inboxId,
@@ -158,7 +150,7 @@ export default {
if (snoozedUntil) { if (snoozedUntil) {
return `${this.$t( return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL' 'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${snoozedReopenTime(snoozedUntil)}`; )} ${conversationReopenTime(snoozedUntil)}`;
} }
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY'); return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
}, },
@@ -12,10 +12,7 @@
variant="smooth" variant="smooth"
size="tiny" size="tiny"
color-scheme="secondary" color-scheme="secondary"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border" class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[9.5rem] md:top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
:class="
isInboxView ? 'top-52 md:top-40' : 'top-[9.5rem] md:top-[6.25rem]'
"
:icon="isRightOrLeftIcon" :icon="isRightOrLeftIcon"
@click="onToggleContactPanel" @click="onToggleContactPanel"
/> />
@@ -145,10 +142,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isInboxView: {
type: Boolean,
default: false,
},
}, },
data() { data() {
@@ -46,18 +46,5 @@ export default {
}, },
EXAMPLE_URL: 'https://example.com', EXAMPLE_URL: 'https://example.com',
EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook', EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook',
INBOX_SORT_BY: {
NEWEST: 'desc',
OLDEST: 'asc',
},
INBOX_DISPLAY_BY: {
SNOOZED: 'snoozed',
READ: 'read',
},
INBOX_FILTER_TYPE: {
STATUS: 'status',
TYPE: 'type',
SORT_ORDER: 'sort_order',
},
}; };
export const DEFAULT_REDIRECT_URL = '/app/'; export const DEFAULT_REDIRECT_URL = '/app/';
-1
View File
@@ -17,5 +17,4 @@ export const FEATURE_FLAGS = {
VOICE_RECORDER: 'voice_recorder', VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs', AUDIT_LOGS: 'audit_logs',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply', INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
INBOX_VIEW: 'inbox_view',
}; };
@@ -102,12 +102,3 @@ export const OPEN_AI_EVENTS = Object.freeze({
export const GENERAL_EVENTS = Object.freeze({ export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar', COMMAND_BAR: 'Used commandbar',
}); });
export const INBOX_EVENTS = Object.freeze({
OPEN_CONVERSATION_VIA_INBOX: 'Opened conversation via inbox',
MARK_NOTIFICATION_AS_READ: 'Marked notification as read',
MARK_ALL_NOTIFICATIONS_AS_READ: 'Marked all notifications as read',
MARK_NOTIFICATION_AS_UNREAD: 'Marked notification as unread',
DELETE_NOTIFICATION: 'Deleted notification',
DELETE_ALL_NOTIFICATIONS: 'Deleted all notifications',
});
@@ -24,7 +24,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.mentioned': this.onConversationMentioned, 'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated, 'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted, 'notification.deleted': this.onNotificationDeleted,
'notification.updated': this.onNotificationUpdated,
'first.reply.created': this.onFirstReplyCreated, 'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead, 'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated, 'conversation.updated': this.onConversationUpdated,
@@ -201,10 +200,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('notifications/deleteNotification', data); this.app.$store.dispatch('notifications/deleteNotification', data);
}; };
onNotificationUpdated = data => {
this.app.$store.dispatch('notifications/updateNotification', data);
};
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => { onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports'); bus.$emit('fetch_overview_reports');
@@ -86,6 +86,3 @@ export const getConversationDashboardRoute = routeName => {
return null; return null;
} }
}; };
export const isAInboxViewRoute = routeName =>
['inbox_view_conversation'].includes(routeName);
@@ -55,7 +55,7 @@ export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
return parsedDate ? getUnixTime(parsedDate) : null; return parsedDate ? getUnixTime(parsedDate) : null;
}; };
export const snoozedReopenTime = snoozedUntil => { export const conversationReopenTime = snoozedUntil => {
if (!snoozedUntil) { if (!snoozedUntil) {
return null; return null;
} }
@@ -5,7 +5,6 @@ import {
isAConversationRoute, isAConversationRoute,
routeIsAccessibleFor, routeIsAccessibleFor,
validateLoggedInRoutes, validateLoggedInRoutes,
isAInboxViewRoute,
} from '../routeHelpers'; } from '../routeHelpers';
describe('#getCurrentAccount', () => { describe('#getCurrentAccount', () => {
@@ -135,10 +134,3 @@ describe('getConversationDashboardRoute', () => {
expect(getConversationDashboardRoute('non_existent_route')).toBeNull(); expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
}); });
}); });
describe('isAInboxViewRoute', () => {
it('returns true if inbox view route name is provided', () => {
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
expect(isAInboxViewRoute('inbox_conversation')).toBe(false);
});
});
@@ -1,6 +1,6 @@
import { import {
findSnoozeTime, findSnoozeTime,
snoozedReopenTime, conversationReopenTime,
findStartOfNextWeek, findStartOfNextWeek,
findStartOfNextMonth, findStartOfNextMonth,
findNextDay, findNextDay,
@@ -88,13 +88,13 @@ describe('#Snooze Helpers', () => {
}); });
}); });
describe('snoozedReopenTime', () => { describe('conversationReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => { it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null); expect(conversationReopenTime(null)).toEqual(null);
}); });
it('should return formatted date if snoozedUntil is not nil', () => { it('should return formatted date if snoozedUntil is not nil', () => {
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual( expect(conversationReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am' '7 Jun, 9.00am'
); );
}); });
@@ -112,8 +112,7 @@
"REMOVE_LABEL": "Remove label from the conversation", "REMOVE_LABEL": "Remove label from the conversation",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"AI_ASSIST": "AI Assist", "AI_ASSIST": "AI Assist",
"APPEARANCE": "Appearance", "APPEARANCE": "Appearance"
"SNOOZE_NOTIFICATION": "Snooze Notification"
}, },
"COMMANDS": { "COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard", "GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
@@ -154,8 +153,7 @@
"CHANGE_APPEARANCE": "Change Appearance", "CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light", "LIGHT_MODE": "Light",
"DARK_MODE": "Dark", "DARK_MODE": "Dark",
"SYSTEM_MODE": "System", "SYSTEM_MODE": "System"
"SNOOZE_NOTIFICATION": "Snooze Notification"
} }
}, },
"DASHBOARD_APPS": { "DASHBOARD_APPS": {
@@ -1,60 +0,0 @@
{
"INBOX": {
"LIST": {
"TITLE": "Inbox",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group.",
"NO_NOTIFICATIONS": "No notifications",
"NOTE": "Notifications from all subscribed inboxes",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week"
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
"DELETE": "Delete notification"
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
"CONVERSATION_CREATION": "New conversation created",
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in"
},
"MENU_ITEM": {
"MARK_AS_READ": "Mark as read",
"MARK_AS_UNREAD": "Mark as unread",
"SNOOZE": "Snooze",
"DELETE": "Delete",
"MARK_ALL_READ": "Mark all as read",
"DELETE_ALL": "Delete all",
"DELETE_ALL_READ": "Delete all read"
},
"DISPLAY_MENU": {
"SORT": "Sort",
"DISPLAY": "Display :",
"SORT_OPTIONS": {
"NEWEST": "Newest",
"OLDEST": "Oldest",
"PRIORITY": "Priority"
},
"DISPLAY_OPTIONS": {
"SNOOZED": "Snoozed",
"READ": "Read",
"LABELS": "Labels",
"CONVERSATION_ID": "Conversation ID"
}
},
"ALERTS": {
"MARK_AS_READ": "Notification marked as read",
"MARK_AS_UNREAD": "Notification marked as unread",
"SNOOZE": "Notification snoozed",
"DELETE": "Notification deleted",
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
}
}
}
@@ -29,7 +29,6 @@ import settings from './settings.json';
import signup from './signup.json'; import signup from './signup.json';
import teamsSettings from './teamsSettings.json'; import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json'; import whatsappTemplates from './whatsappTemplates.json';
import inbox from './inbox.json';
export default { export default {
...advancedFilters, ...advancedFilters,
@@ -63,5 +62,4 @@ export default {
...signup, ...signup,
...teamsSettings, ...teamsSettings,
...whatsappTemplates, ...whatsappTemplates,
...inbox,
}; };
@@ -156,9 +156,6 @@
"TRIAL_MESSAGE": "days trial remaining.", "TRIAL_MESSAGE": "days trial remaining.",
"TRAIL_BUTTON": "Buy Now", "TRAIL_BUTTON": "Buy Now",
"DELETED_USER": "Deleted User", "DELETED_USER": "Deleted User",
"EMAIL_VERIFICATION_PENDING": "It seems that you haven't verified your email address yet. Please check your inbox for the verification email.",
"RESEND_VERIFICATION_MAIL": "Resend verification email",
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": { "ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended", "TITLE": "Account Suspended",
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information." "MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
@@ -196,7 +193,6 @@
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:", "CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
"SWITCH": "Switch", "SWITCH": "Switch",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"INBOX": "Inbox",
"ALL_CONVERSATIONS": "All Conversations", "ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Mentions", "MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating", "PARTICIPATING_CONVERSATIONS": "Participating",
@@ -52,77 +52,19 @@ describe('#dateFormat', () => {
}); });
describe('#shortTimestamp', () => { describe('#shortTimestamp', () => {
// Test cases when withAgo is false or not provided it('returns correct value', () => {
it('returns correct value without ago', () => {
expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual( expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual(
'now' 'now'
); );
expect(TimeMixin.methods.shortTimestamp('1 minute ago')).toEqual('1m'); expect(TimeMixin.methods.shortTimestamp(' minute ago')).toEqual('m');
expect(TimeMixin.methods.shortTimestamp('12 minutes ago')).toEqual('12m'); expect(TimeMixin.methods.shortTimestamp(' minutes ago')).toEqual('m');
expect(TimeMixin.methods.shortTimestamp('a minute ago')).toEqual('1m'); expect(TimeMixin.methods.shortTimestamp(' hour ago')).toEqual('h');
expect(TimeMixin.methods.shortTimestamp('an hour ago')).toEqual('1h'); expect(TimeMixin.methods.shortTimestamp(' hours ago')).toEqual('h');
expect(TimeMixin.methods.shortTimestamp('1 hour ago')).toEqual('1h'); expect(TimeMixin.methods.shortTimestamp(' day ago')).toEqual('d');
expect(TimeMixin.methods.shortTimestamp('2 hours ago')).toEqual('2h'); expect(TimeMixin.methods.shortTimestamp(' days ago')).toEqual('d');
expect(TimeMixin.methods.shortTimestamp('1 day ago')).toEqual('1d'); expect(TimeMixin.methods.shortTimestamp(' month ago')).toEqual('mo');
expect(TimeMixin.methods.shortTimestamp('a day ago')).toEqual('1d'); expect(TimeMixin.methods.shortTimestamp(' months ago')).toEqual('mo');
expect(TimeMixin.methods.shortTimestamp('3 days ago')).toEqual('3d'); expect(TimeMixin.methods.shortTimestamp(' year ago')).toEqual('y');
expect(TimeMixin.methods.shortTimestamp('a month ago')).toEqual('1mo'); expect(TimeMixin.methods.shortTimestamp(' years ago')).toEqual('y');
expect(TimeMixin.methods.shortTimestamp('1 month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('2 months ago')).toEqual('2mo');
expect(TimeMixin.methods.shortTimestamp('a year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('1 year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('4 years ago')).toEqual('4y');
});
// Test cases when withAgo is true
it('returns correct value with ago', () => {
expect(
TimeMixin.methods.shortTimestamp('less than a minute ago', true)
).toEqual('now');
expect(TimeMixin.methods.shortTimestamp('1 minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('12 minutes ago', true)).toEqual(
'12m ago'
);
expect(TimeMixin.methods.shortTimestamp('a minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('an hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('2 hours ago', true)).toEqual(
'2h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('a day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('3 days ago', true)).toEqual(
'3d ago'
);
expect(TimeMixin.methods.shortTimestamp('a month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('1 month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('2 months ago', true)).toEqual(
'2mo ago'
);
expect(TimeMixin.methods.shortTimestamp('a year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('1 year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('4 years ago', true)).toEqual(
'4y ago'
);
}); });
}); });
+17 -28
View File
@@ -28,36 +28,25 @@ export default {
const unixTime = fromUnixTime(time); const unixTime = fromUnixTime(time);
return format(unixTime, dateFormat); return format(unixTime, dateFormat);
}, },
shortTimestamp(time, withAgo = false) { shortTimestamp(time) {
// This function takes a time string and converts it to a short time string
// with the following format: 1m, 1h, 1d, 1mo, 1y
// The function also takes an optional boolean parameter withAgo
// which will add the word "ago" to the end of the time string
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
'a month ago': `1mo${suffix}`,
'a year ago': `1y${suffix}`,
};
// Check if the time string is one of the specific cases
if (timeMappings[time]) {
return timeMappings[time];
}
const convertToShortTime = time const convertToShortTime = time
.replace(/about|over|almost|/g, '') .replace(/about|over|almost|/g, '')
.replace(' minute ago', `m${suffix}`) .replace('less than a minute ago', 'now')
.replace(' minutes ago', `m${suffix}`) .replace(' minute ago', 'm')
.replace(' hour ago', `h${suffix}`) .replace(' minutes ago', 'm')
.replace(' hours ago', `h${suffix}`) .replace('a minute ago', 'm')
.replace(' day ago', `d${suffix}`) .replace('an hour ago', 'h')
.replace(' days ago', `d${suffix}`) .replace(' hour ago', 'h')
.replace(' month ago', `mo${suffix}`) .replace(' hours ago', 'h')
.replace(' months ago', `mo${suffix}`) .replace(' day ago', 'd')
.replace(' year ago', `y${suffix}`) .replace('a day ago', 'd')
.replace(' years ago', `y${suffix}`); .replace(' days ago', 'd')
.replace('a month ago', 'mo')
.replace(' months ago', 'mo')
.replace(' month ago', 'mo')
.replace('a year ago', 'y')
.replace(' year ago', 'y')
.replace(' years ago', 'y');
return convertToShortTime; return convertToShortTime;
}, },
}, },
@@ -71,5 +71,3 @@ export const ICON_APPEARANCE = `<svg role="img" class="ninja-icon ninja-icon--fl
export const ICON_LIGHT_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2Zm5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0Zm4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19Zm-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5Zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06Zm1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06l-1.5 1.5Zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06Zm-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06l1.5 1.5Z"/></svg>`; export const ICON_LIGHT_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2Zm5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0Zm4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19Zm-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5Zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06Zm1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06l-1.5 1.5Zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06Zm-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06l1.5 1.5Z"/></svg>`;
export const ICON_DARK_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A9.965 9.965 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.232-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.961 9.961 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662Zm-8.248-4.903c-1.25 2.389-3.31 4.1-6.817 5.499a8.49 8.49 0 0 0 2.152 1.766a8.502 8.502 0 0 0 8.502-14.725a8.484 8.484 0 0 0-2.792-1.015c.647 3.384.23 6.043-1.045 8.475Z"/></svg>`; export const ICON_DARK_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A9.965 9.965 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.232-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.961 9.961 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662Zm-8.248-4.903c-1.25 2.389-3.31 4.1-6.817 5.499a8.49 8.49 0 0 0 2.152 1.766a8.502 8.502 0 0 0 8.502-14.725a8.484 8.484 0 0 0-2.792-1.015c.647 3.384.23 6.043-1.045 8.475Z"/></svg>`;
export const ICON_SYSTEM_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M4.25 3A2.25 2.25 0 0 0 2 5.25v10.5A2.25 2.25 0 0 0 4.25 18H9.5v1.25c0 .69-.56 1.25-1.25 1.25h-.5a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-.5c-.69 0-1.25-.56-1.25-1.25V18h5.25A2.25 2.25 0 0 0 22 15.75V5.25A2.25 2.25 0 0 0 19.75 3H4.25ZM13 18v1.25c0 .45.108.875.3 1.25h-2.6c.192-.375.3-.8.3-1.25V18h2ZM3.5 5.25a.75.75 0 0 1 .75-.75h15.5a.75.75 0 0 1 .75.75V13h-17V5.25Zm0 9.25h17v1.25a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V14.5Z"/></svg>`; export const ICON_SYSTEM_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M4.25 3A2.25 2.25 0 0 0 2 5.25v10.5A2.25 2.25 0 0 0 4.25 18H9.5v1.25c0 .69-.56 1.25-1.25 1.25h-.5a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-.5c-.69 0-1.25-.56-1.25-1.25V18h5.25A2.25 2.25 0 0 0 22 15.75V5.25A2.25 2.25 0 0 0 19.75 3H4.25ZM13 18v1.25c0 .45.108.875.3 1.25h-2.6c.192-.375.3-.8.3-1.25V18h2ZM3.5 5.25a.75.75 0 0 1 .75-.75h15.5a.75.75 0 0 1 .75.75V13h-17V5.25Zm0 9.25h17v1.25a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V14.5Z"/></svg>`;
export const ICON_SNOOZE_NOTIFICATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 3.5c-3.104 0-6 2.432-6 6.25v4.153L4.682 17h14.67l-1.354-3.093V11.75a.75.75 0 0 1 1.5 0v1.843l1.381 3.156a1.25 1.25 0 0 1-1.145 1.751H15a3.002 3.002 0 0 1-6.003 0H4.305a1.25 1.25 0 0 1-1.15-1.739l1.344-3.164V9.75C4.5 5.068 8.103 2 12 2c.86 0 1.705.15 2.5.432a.75.75 0 0 1-.502 1.413A5.964 5.964 0 0 0 12 3.5ZM12 20c.828 0 1.5-.671 1.501-1.5h-3.003c0 .829.673 1.5 1.502 1.5Zm3.25-13h-2.5l-.101.007A.75.75 0 0 0 12.75 8.5h1.043l-1.653 2.314l-.055.09A.75.75 0 0 0 12.75 12h2.5l.102-.007a.75.75 0 0 0-.102-1.493h-1.042l1.653-2.314l.055-.09A.75.75 0 0 0 15.25 7Zm6-5h-3.5l-.101.007A.75.75 0 0 0 17.75 3.5h2.134l-2.766 4.347l-.05.09A.75.75 0 0 0 17.75 9h3.5l.102-.007A.75.75 0 0 0 21.25 7.5h-2.133l2.766-4.347l.05-.09A.75.75 0 0 0 21.25 2Z"/></svg>`;
@@ -15,6 +15,3 @@ export const CMD_REOPEN_CONVERSATION = 'CMD_REOPEN_CONVERSATION';
export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION'; export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION'; export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST'; export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
@@ -12,7 +12,6 @@
<script> <script>
import 'ninja-keys'; import 'ninja-keys';
import conversationHotKeysMixin from './conversationHotKeys'; import conversationHotKeysMixin from './conversationHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys'; import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys'; import appearanceHotKeys from './appearanceHotKeys';
import agentMixin from 'dashboard/mixins/agentMixin'; import agentMixin from 'dashboard/mixins/agentMixin';
@@ -26,7 +25,6 @@ export default {
adminMixin, adminMixin,
agentMixin, agentMixin,
conversationHotKeysMixin, conversationHotKeysMixin,
inboxHotKeysMixin,
conversationLabelMixin, conversationLabelMixin,
conversationTeamMixin, conversationTeamMixin,
appearanceHotKeys, appearanceHotKeys,
@@ -44,7 +42,6 @@ export default {
}, },
hotKeys() { hotKeys() {
return [ return [
...this.inboxHotKeys,
...this.conversationHotKeys, ...this.conversationHotKeys,
...this.goToCommandHotKeys, ...this.goToCommandHotKeys,
...this.goToAppearanceHotKeys, ...this.goToAppearanceHotKeys,
@@ -30,10 +30,7 @@ import {
UNMUTE_ACTION, UNMUTE_ACTION,
MUTE_ACTION, MUTE_ACTION,
} from './commandBarActions'; } from './commandBarActions';
import { import { isAConversationRoute } from '../../../helper/routeHelpers';
isAConversationRoute,
isAInboxViewRoute,
} from '../../../helper/routeHelpers';
export default { export default {
mixins: [aiMixin], mixins: [aiMixin],
watch: { watch: {
@@ -328,10 +325,7 @@ export default {
}, },
conversationHotKeys() { conversationHotKeys() {
if ( if (isAConversationRoute(this.$route.name)) {
isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name)
) {
const defaultConversationHotKeys = [ const defaultConversationHotKeys = [
...this.statusActions, ...this.statusActions,
...this.conversationAdditionalActions, ...this.conversationAdditionalActions,
@@ -1,81 +0,0 @@
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const INBOX_SNOOZE_EVENTS = [
{
id: 'snooze_notification',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -182,7 +182,6 @@ import { getCountryFlag } from 'dashboard/helper/flag';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import {
isAConversationRoute, isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute, getConversationDashboardRoute,
} from '../../../../helper/routeHelpers'; } from '../../../../helper/routeHelpers';
@@ -304,10 +303,6 @@ export default {
this.$router.push({ this.$router.push({
name: getConversationDashboardRoute(this.$route.name), name: getConversationDashboardRoute(this.$route.name),
}); });
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') { } else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({ this.$router.push({
name: 'contacts_dashboard', name: 'contacts_dashboard',
@@ -1,28 +1,9 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView'); const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default { export default {
routes: [ routes: [
{
path: frontendURL('accounts/:accountId/inbox-view'),
name: 'inbox_view',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/inbox-view/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{ {
path: frontendURL('accounts/:accountId/dashboard'), path: frontendURL('accounts/:accountId/dashboard'),
name: 'home', name: 'home',
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 z-50 bg-white dark:bg-slate-900" class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 z-10 bg-white dark:bg-slate-900"
> >
<div class="flex items-center"> <div class="flex items-center">
<woot-sidemenu-icon /> <woot-sidemenu-icon />
@@ -0,0 +1,70 @@
<script setup>
// import { defineProps } from 'vue';
import PriorityIcon from './components/PriorityIcon.vue';
import StatusIcon from './components/StatusIcon.vue';
import InboxNameAndId from './components/InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
// const props = defineProps({
// notificationItem: {
// type: Object,
// default: () => {},
// },
// });
const assigneeMeta = {
thumbnail: '',
name: 'Michael Johnson',
};
const { thumbnail, name } = assigneeMeta || {};
const status = 'open';
const priority = 'high';
const inboxTypeMessage = 'Mentioned by Michael';
const inboxMessage = 'What is the best way to get started?';
const inbox = {
inbox_id: 16787,
inbox_name: 'Chatwoot Support',
};
</script>
<template>
<div
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
>
<div class="flex relative items-center justify-between w-full">
<div
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" />
<div class="flex gap-2">
<PriorityIcon :priority="priority" />
<StatusIcon :status="status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[80%]">
<Thumbnail
v-if="assigneeMeta"
:src="thumbnail"
:username="name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
<span class="font-normal">{{ inboxMessage }}</span>
</span>
</div>
</div>
<span
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
10h ago
</span>
</div>
</div>
</template>
@@ -1,200 +0,0 @@
<template>
<div
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'"
>
<inbox-list-header @filter="onFilterChange" />
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in notifications"
:key="notificationItem.id"
:notification-item="notificationItem"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEmptyState"
class="text-center text-slate-400 text-sm dark:text-slate-400 p-4 font-medium"
>
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
</p>
<p
v-if="showEndOfListMessage"
class="text-center text-slate-400 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import alertMixin from 'shared/mixins/alertMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
},
mixins: [alertMixin, uiSettingsMixin],
props: {
conversationId: {
type: [String, Number],
default: 0,
},
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
data() {
return {
infiniteLoaderOptions: {
root: this.$refs.notificationList,
rootMargin: '100px 0px 100px 0px',
},
page: 1,
status: '',
type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
}),
inboxFilters() {
return {
page: this.page,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
};
},
notifications() {
return this.notification(this.inboxFilters);
},
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length;
},
showEndOfListMessage() {
return this.showEndOfList && this.notifications.length;
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
},
methods: {
fetchNotifications() {
this.page = 1;
this.$store.dispatch('notifications/clear');
const filter = this.inboxFilters;
this.$store.dispatch('notifications/index', filter);
},
redirectToInbox() {
if (!this.conversationId) return;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', {
page: this.page + 1,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
});
this.page += 1;
},
markNotificationAsRead(notification) {
this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notification;
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
});
},
markNotificationAsUnRead(notification) {
this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
this.redirectToInbox();
const { id } = notification;
this.$store
.dispatch('notifications/unread', {
id,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
});
},
deleteNotification(notification) {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.redirectToInbox();
this.$store
.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
});
},
onFilterChange(option) {
if (option.type === wootConstants.INBOX_FILTER_TYPE.STATUS) {
this.status = option.selected ? option.key : '';
}
if (option.type === wootConstants.INBOX_FILTER_TYPE.TYPE) {
this.type = option.selected ? option.key : '';
}
if (option.type === wootConstants.INBOX_FILTER_TYPE.SORT_ORDER) {
this.sortOrder = option.key;
}
this.fetchNotifications();
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.status = status;
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
},
},
};
</script>
@@ -1,217 +0,0 @@
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<inbox-list
v-show="showConversationList"
:conversation-id="conversationId"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div
v-if="showInboxMessageView"
class="flex flex-col h-full"
:class="isOnExpandedLayout ? 'w-full' : 'w-[calc(100%-360px)]'"
>
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationIndex"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
<div
v-if="!showInboxMessageView && !isOnExpandedLayout"
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
InboxList,
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
currentChat: 'getSelectedChat',
allConversation: 'getAllConversations',
uiFlags: 'notifications/getUIFlags',
}),
activeNotification() {
return this.notifications.find(
n => n.primary_actor.id === Number(this.conversationId)
);
},
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
this.currentAccountId,
FEATURE_FLAGS.INBOX_VIEW
);
},
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
isFetchingInitialData() {
return this.uiFlags.isFetching && !this.notifications.length;
},
showInboxMessageView() {
return (
Boolean(this.conversationId) &&
Boolean(this.currentChat.id) &&
!this.isFetchingInitialData
);
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationIndex() {
const conversationId = Number(this.conversationId);
const notificationIndex = this.notifications.findIndex(
n => n.primary_actor.id === conversationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.fetchConversationById();
},
},
},
mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
// TODO: Remove this code once inbox view feature is enabled for all accounts
if (!this.isInboxViewEnabled) {
this.$router.push({
name: 'home',
});
}
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.conversationId) return;
const chat = this.findConversation();
if (!chat) {
await this.$store.dispatch('getConversation', this.conversationId);
}
this.setActiveChat();
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
const conversationId = Number(this.conversationId);
return this.allConversation.find(c => c.id === conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { conversation_id: conversationId },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
@@ -1,236 +0,0 @@
<template>
<div
role="button"
class="flex flex-col ltr:pl-5 rtl:pl-3 rtl:pr-5 ltr:pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class="
isInboxCardActive
? 'bg-slate-25 dark:bg-slate-800 click-animation'
: 'bg-white dark:bg-slate-900'
"
@contextmenu="openContextMenu($event)"
@click="openConversation(notificationItem)"
>
<div class="flex relative items-center justify-between w-full">
<div
v-if="isUnread"
class="absolute ltr:-left-3.5 rtl:-right-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
<div class="flex gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[calc(100%-70px)]">
<Thumbnail
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
:username="assigneeMeta.name"
size="16px"
class="relative bottom-0.5"
/>
<div class="flex min-w-0">
<span
class="text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="isUnread ? 'font-medium' : 'font-normal'"
>
{{ pushTitle }}
</span>
</div>
</div>
<span
class="font-medium max-w-[60px] text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
<div v-if="snoozedUntilTime" class="flex items-center">
<span class="text-woot-500 dark:text-woot-500 text-xs font-medium">
{{ snoozedDisplayText }}
</span>
</div>
<inbox-context-menu
v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition"
:menu-items="menuItems"
@close="closeContextMenu"
@click="handleAction"
/>
</div>
</template>
<script>
import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue';
import InboxNameAndId from './InboxNameAndId.vue';
import InboxContextMenu from './InboxContextMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
PriorityIcon,
InboxContextMenu,
StatusIcon,
InboxNameAndId,
Thumbnail,
},
mixins: [timeMixin],
props: {
notificationItem: {
type: Object,
default: () => {},
},
},
data() {
return {
isContextMenuOpen: false,
contextMenuPosition: { x: null, y: null },
};
},
computed: {
primaryActor() {
return this.notificationItem?.primary_actor;
},
isInboxCardActive() {
return this.$route.params.conversation_id === this.primaryActor?.id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id
);
},
isUnread() {
return !this.notificationItem?.read_at;
},
meta() {
return this.primaryActor?.meta;
},
assigneeMeta() {
return this.meta?.assignee;
},
pushTitle() {
return this.$t(
`INBOX.TYPES.${this.notificationItem.notification_type.toUpperCase()}`
);
},
lastActivityAt() {
const dynamicTime = this.dynamicTime(
this.notificationItem?.last_activity_at
);
return this.shortTimestamp(dynamicTime, true);
},
menuItems() {
const items = [
{
key: 'delete',
label: this.$t('INBOX.MENU_ITEM.DELETE'),
},
];
if (!this.isUnread) {
items.push({
key: 'mark_as_unread',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_UNREAD'),
});
} else {
items.push({
key: 'mark_as_read',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_READ'),
});
}
return items;
},
snoozedUntilTime() {
const { snoozed_until: snoozedUntil } = this.notificationItem;
return snoozedUntil;
},
snoozedDisplayText() {
if (this.snoozedUntilTime) {
return `${this.$t('INBOX.LIST.SNOOZED_UNTIL')} ${snoozedReopenTime(
this.snoozedUntilTime
)}`;
}
return '';
},
},
unmounted() {
this.closeContextMenu();
},
methods: {
openConversation(notification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, inbox_id: inboxId },
notification_type: notificationType,
} = notification;
if (this.$route.params.conversation_id !== conversationId) {
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, conversation_id: conversationId },
});
}
},
closeContextMenu() {
this.isContextMenuOpen = false;
this.contextMenuPosition = { x: null, y: null };
},
openContextMenu(e) {
this.closeContextMenu();
e.preventDefault();
this.contextMenuPosition = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
this.isContextMenuOpen = true;
},
handleAction(key) {
switch (key) {
case 'mark_as_read':
this.$emit('mark-notification-as-read', this.notificationItem);
break;
case 'mark_as_unread':
this.$emit('mark-notification-as-unread', this.notificationItem);
break;
case 'delete':
this.$emit('delete-notification', this.notificationItem);
break;
default:
}
},
},
};
</script>
<style scoped>
.click-animation {
animation: click-animation 0.3s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>
@@ -1,46 +0,0 @@
<template>
<woot-context-menu
:x="contextMenuPosition.x"
:y="contextMenuPosition.y"
@close="handleClose"
>
<div
class="bg-white dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl"
>
<menu-item
v-for="item in menuItems"
:key="item.key"
:label="item.label"
@click="onMenuItemClick(item.key)"
/>
</div>
</woot-context-menu>
</template>
<script>
import MenuItem from './MenuItem.vue';
export default {
components: {
MenuItem,
},
props: {
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
},
methods: {
handleClose() {
this.$emit('close');
},
onMenuItemClick(key) {
this.$emit('click', key);
this.handleClose();
},
},
};
</script>
@@ -1,198 +0,0 @@
<template>
<div
class="flex flex-col bg-white z-50 dark:bg-slate-900 w-[170px] border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div class="flex items-center justify-between h-11 p-3 rounded-t-lg">
<div class="flex gap-1.5">
<fluent-icon
icon="arrow-sort"
type="outline"
size="16"
class="text-slate-700 dark:text-slate-100"
/>
<span class="font-medium text-xs text-slate-800 dark:text-slate-100">
{{ $t('INBOX.DISPLAY_MENU.SORT') }}
</span>
</div>
<div class="relative">
<div
role="button"
class="border h-5 flex gap-1 rounded-md items-center pr-1.5 pl-1 py-0.5 w-[70px] justify-between border-slate-100 dark:border-slate-700/50"
@click="openSortMenu"
>
<span class="text-xs font-medium text-slate-600 dark:text-slate-300">
{{ activeSortOption }}
</span>
<fluent-icon
icon="chevron-down"
size="12"
class="text-slate-600 dark:text-slate-200"
/>
</div>
<div
v-if="showSortMenu"
class="absolute flex flex-col gap-0.5 bg-white z-60 dark:bg-slate-800 rounded-md p-0.5 top-0 w-[70px] border border-slate-100 dark:border-slate-700/50"
>
<div
v-for="option in sortOptions"
:key="option.key"
role="button"
class="flex rounded-[4px] h-5 w-full items-center justify-between p-0.5 gap-1"
:class="{
'bg-woot-50 dark:bg-woot-700/50': activeSort === option.key,
}"
@click.stop="onSortOptionClick(option)"
>
<span
class="text-xs font-medium hover:text-woot-600 dark:hover:text-woot-600"
:class="{
'text-woot-600 dark:text-woot-600': activeSort === option.key,
'text-slate-600 dark:text-slate-300': activeSort !== option.key,
}"
>
{{ option.name }}
</span>
<fluent-icon
v-if="activeSort === option.key"
icon="checkmark"
size="14"
class="text-woot-600 dark:text-woot-600"
/>
</div>
</div>
</div>
</div>
<div>
<span
class="font-medium text-xs py-4 px-3 text-slate-400 dark:text-slate-400"
>
{{ $t('INBOX.DISPLAY_MENU.DISPLAY') }}
</span>
<div
class="flex flex-col divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div
v-for="option in displayOptions"
:key="option.key"
class="flex items-center px-3 py-2 gap-1.5 h-9"
>
<input
:id="option.key"
type="checkbox"
:name="option.key"
:checked="option.selected"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
@change="updateDisplayOption(option)"
/>
<label
:for="option.key"
class="text-xs font-medium text-slate-800 !ml-0 !mr-0 dark:text-slate-100"
>
{{ option.name }}
</label>
</div>
</div>
</div>
</div>
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default {
mixins: [uiSettingsMixin],
data() {
return {
showSortMenu: false,
displayOptions: [
{
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.SNOOZED'),
key: wootConstants.INBOX_DISPLAY_BY.SNOOZED,
selected: false,
type: wootConstants.INBOX_FILTER_TYPE.STATUS,
},
{
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.READ'),
key: wootConstants.INBOX_DISPLAY_BY.READ,
selected: false,
type: wootConstants.INBOX_FILTER_TYPE.TYPE,
},
],
sortOptions: [
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.NEWEST'),
key: wootConstants.INBOX_SORT_BY.NEWEST,
type: wootConstants.INBOX_FILTER_TYPE.SORT_ORDER,
},
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.OLDEST'),
key: wootConstants.INBOX_SORT_BY.OLDEST,
type: wootConstants.INBOX_FILTER_TYPE.SORT_ORDER,
},
],
activeSort: wootConstants.INBOX_SORT_BY.NEWEST,
activeDisplayFilter: {
status: '',
type: '',
},
};
},
computed: {
activeSortOption() {
return (
this.sortOptions.find(option => option.key === this.activeSort)?.name ||
''
);
},
},
mounted() {
this.setSavedFilter();
},
methods: {
updateDisplayOption(option) {
this.displayOptions.forEach(displayOption => {
if (displayOption.key === option.key) {
displayOption.selected = !option.selected;
this.activeDisplayFilter[displayOption.type] = displayOption.selected
? displayOption.key
: '';
this.saveSelectedDisplayFilter();
this.$emit('filter', option);
}
});
},
openSortMenu() {
this.showSortMenu = !this.showSortMenu;
},
onSortOptionClick(option) {
this.activeSort = option.key;
this.showSortMenu = false;
this.saveSelectedDisplayFilter();
this.$emit('filter', option);
},
saveSelectedDisplayFilter() {
this.updateUISettings({
inbox_filter_by: {
...this.activeDisplayFilter,
sort_by: this.activeSort || wootConstants.INBOX_SORT_BY.NEWEST,
},
});
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.activeSort = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.displayOptions.forEach(option => {
option.selected =
option.type === wootConstants.INBOX_FILTER_TYPE.STATUS
? option.key === status
: option.key === type;
this.activeDisplayFilter[option.type] = option.selected
? option.key
: '';
});
},
},
};
</script>
@@ -1,145 +0,0 @@
<template>
<div
class="flex gap-2 py-2 ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<pagination-button
v-if="totalLength > 1"
:total-length="totalLength"
:current-index="currentIndex"
@next="onClickNext"
@prev="onClickPrev"
/>
<div v-else />
<div class="flex items-center gap-2">
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
@click="openSnoozeNotificationModal"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
</woot-button>
<woot-button
icon="delete"
size="small"
color-scheme="secondary"
variant="hollow"
@click="deleteNotification"
>
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
</woot-button>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { getUnixTime } from 'date-fns';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import PaginationButton from './PaginationButton.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
PaginationButton,
CustomSnoozeModal,
},
mixins: [alertMixin],
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
activeNotification: {
type: Object,
default: null,
},
},
data() {
return {
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({
meta: 'notifications/getMeta',
}),
},
mounted() {
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
openSnoozeNotificationModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
snoozeNotification(snoozedUntil) {
this.$store
.dispatch('notifications/snooze', {
id: this.activeNotification?.id,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
});
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
}
},
deleteNotification() {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.$store
.dispatch('notifications/delete', {
notification: this.activeNotification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
});
this.$router.push({ name: 'inbox_view' });
},
onClickNext() {
this.$emit('next');
},
onClickPrev() {
this.$emit('prev');
},
},
};
</script>
@@ -1,123 +0,0 @@
<template>
<div
class="flex w-full ltr:pl-4 rtl:pl-2 rtl:pr-4 ltr:pr-2 py-2 h-14 justify-between items-center border-b border-slate-50 dark:border-slate-800/50"
>
<div class="flex items-center gap-1.5">
<h1 class="font-medium text-slate-900 dark:text-slate-25 text-xl">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
<div
role="button"
class="flex gap-1 items-center py-1 px-2 border border-slate-100 dark:border-slate-700/50 rounded-md"
@click="openInboxDisplayMenu"
>
<span
class="text-slate-600 dark:text-slate-200 text-xs text-center font-medium"
>
{{ $t('INBOX.LIST.DISPLAY_DROPDOWN') }}
</span>
<fluent-icon
icon="chevron-down"
size="12"
class="text-slate-600 dark:text-slate-200"
/>
</div>
<inbox-display-menu
v-if="showInboxDisplayMenu"
v-on-clickaway="openInboxDisplayMenu"
class="absolute top-8"
@filter="onFilterChange"
/>
</div>
</div>
<div class="flex relative gap-1 items-center">
<!-- <woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="filter"
@click="openInboxFilter"
/> -->
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="mail-inbox"
@click="openInboxOptionsMenu"
/>
<inbox-option-menu
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9"
@option-click="onInboxOptionMenuClick"
/>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import InboxOptionMenu from './InboxOptionMenu.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxDisplayMenu from './InboxDisplayMenu.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
InboxOptionMenu,
InboxDisplayMenu,
},
mixins: [clickaway, alertMixin],
data() {
return {
showInboxDisplayMenu: false,
showInboxOptionMenu: false,
};
},
methods: {
markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_ALL_READ'));
});
},
deleteAll() {
this.$store.dispatch('notifications/deleteAll').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE_ALL'));
});
},
deleteAllRead() {
this.$store.dispatch('notifications/deleteAllRead').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE_ALL_READ'));
});
},
openInboxDisplayMenu() {
this.showInboxDisplayMenu = !this.showInboxDisplayMenu;
},
openInboxOptionsMenu() {
this.showInboxOptionMenu = !this.showInboxOptionMenu;
},
onInboxOptionMenuClick(key) {
this.showInboxOptionMenu = false;
if (key === 'mark_all_read') {
this.markAllRead();
}
if (key === 'delete_all') {
this.deleteAll();
}
if (key === 'delete_all_read') {
this.deleteAllRead();
}
},
onFilterChange(option) {
this.$emit('filter', option);
this.showInboxDisplayMenu = false;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
},
},
};
</script>
<style></style>
@@ -1,43 +1,33 @@
<script> <script setup>
import { getInboxClassByType } from 'dashboard/helper/inbox'; import { defineProps } from 'vue';
export default { const props = defineProps({
props: { inbox: {
inbox: { type: Object,
type: Object, default: () => {},
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
}, },
computed: { });
inboxIcon() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox; const { inbox } = props;
const classByType = getInboxClassByType(type, phoneNumber); const { inbox_id: inboxId, inbox_name: inboxName } = inbox;
return classByType;
},
},
};
</script> </script>
<template> <template>
<div <div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-700/50 divide-x divide-slate-100 dark:divide-slate-700/50 bg-none" class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none"
> >
<div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5"> <div class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon <fluent-icon
class="text-slate-600 dark:text-slate-300" class="text-slate-600 dark:text-slate-300"
:icon="inboxIcon" icon="globe-desktop"
size="14" size="14"
/> />
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs"> <span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inbox.name }} {{ inboxName }}
</span> </span>
</div> </div>
<div class="flex items-center py-0.5 px-1.5"> <div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs"> <span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ conversationId }} {{ inboxId }}
</span> </span>
</div> </div>
</div> </div>
@@ -1,46 +0,0 @@
<template>
<div
class="flex flex-col gap-1 bg-white z-50 dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div class="flex flex-col">
<menu-item
v-for="item in menuItems"
:key="item.key"
:label="item.label"
@click="onClick(item.key)"
/>
</div>
</div>
</template>
<script>
import MenuItem from './MenuItem.vue';
export default {
components: {
MenuItem,
},
data() {
return {
menuItems: [
{
key: 'mark_all_read',
label: this.$t('INBOX.MENU_ITEM.MARK_ALL_READ'),
},
{
key: 'delete_all',
label: this.$t('INBOX.MENU_ITEM.DELETE_ALL'),
},
{
key: 'delete_all_read',
label: this.$t('INBOX.MENU_ITEM.DELETE_ALL_READ'),
},
],
};
},
methods: {
onClick(key) {
this.$emit('option-click', key);
},
},
};
</script>
@@ -1,25 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
label: {
type: String,
default: '',
},
});
const emits = defineEmits(['click']);
const onMenuItemClick = () => {
emits('click');
};
</script>
<template>
<div
role="button"
class="py-1 px-2 w-full h-8 font-medium text-xs text-slate-800 dark:text-slate-100 flex items-center whitespace-nowrap text-ellipsis overflow-hidden hover:text-woot-600 dark:hover:text-woot-500 cursor-pointer rounded-md"
@click.stop="onMenuItemClick"
>
{{ label }}
</div>
</template>
@@ -1,70 +0,0 @@
<template>
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
<woot-button
size="tiny"
variant="hollow"
color-scheme="secondary"
icon="chevron-up"
:disabled="isUpDisabled"
@click="handleUpClick"
/>
<woot-button
size="tiny"
variant="hollow"
color-scheme="secondary"
icon="chevron-down"
:disabled="isDownDisabled"
@click="handleDownClick"
/>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600">
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
v-if="totalLength > 1"
class="text-sm text-slate-400 relative -top-px"
>
/
</span>
<span v-if="totalLength > 1" class="text-sm text-slate-400">
{{ totalLength }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
},
computed: {
isUpDisabled() {
return this.currentIndex === 1;
},
isDownDisabled() {
return this.currentIndex === this.totalLength || this.totalLength <= 1;
},
},
methods: {
handleUpClick() {
if (this.currentIndex > 1) {
this.$emit('prev');
}
},
handleDownClick() {
if (this.currentIndex < this.totalLength) {
this.$emit('next');
}
},
},
};
</script>
@@ -1,25 +1,20 @@
<script> <script setup>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages'; import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
export default { import { defineProps } from 'vue';
props: {
priority: { const props = defineProps({
type: String, priority: {
default: '', type: String,
}, default: '',
}, },
data() { });
return {
CONVERSATION_PRIORITY,
};
},
};
</script> </script>
<template> <template>
<div class="inline-flex items-center justify-center rounded-md"> <div class="inline-flex items-center justify-center rounded-md">
<!-- High Priority --> <!-- High Priority -->
<svg <svg
v-if="priority === CONVERSATION_PRIORITY.HIGH" v-if="props.priority === CONVERSATION_PRIORITY.HIGH"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -34,7 +29,7 @@ export default {
<!-- Low Priority --> <!-- Low Priority -->
<svg <svg
v-if="priority === CONVERSATION_PRIORITY.LOW" v-if="props.priority === CONVERSATION_PRIORITY.LOW"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -49,7 +44,7 @@ export default {
<!-- Medium Priority --> <!-- Medium Priority -->
<svg <svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM" v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -64,7 +59,7 @@ export default {
<!-- Urgent Priority --> <!-- Urgent Priority -->
<svg <svg
v-if="priority === CONVERSATION_PRIORITY.URGENT" v-if="props.priority === CONVERSATION_PRIORITY.URGENT"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -1,8 +1,20 @@
<script setup>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
status: {
type: String,
default: '',
},
});
</script>
<template> <template>
<div class="inline-flex items-center justify-center rounded-md"> <div class="inline-flex items-center justify-center rounded-md">
<!-- Pending --> <!-- Pending -->
<svg <svg
v-if="status === CONVERSATION_STATUS.PENDING" v-if="props.status === CONVERSATION_STATUS.PENDING"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
width="18" width="18"
height="18" height="18"
@@ -17,7 +29,7 @@
</svg> </svg>
<!-- Open --> <!-- Open -->
<svg <svg
v-if="status === CONVERSATION_STATUS.OPEN" v-if="props.status === CONVERSATION_STATUS.OPEN"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
width="19" width="19"
height="19" height="19"
@@ -33,7 +45,7 @@
<!-- Snoozed --> <!-- Snoozed -->
<svg <svg
v-if="status === CONVERSATION_STATUS.SNOOZED" v-if="props.status === CONVERSATION_STATUS.SNOOZED"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
width="18" width="18"
height="18" height="18"
@@ -49,7 +61,7 @@
<!-- Resolved --> <!-- Resolved -->
<svg <svg
v-if="status === CONVERSATION_STATUS.RESOLVED" v-if="props.status === CONVERSATION_STATUS.RESOLVED"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -66,21 +78,3 @@
</svg> </svg>
</div> </div>
</template> </template>
<script>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
props: {
status: {
type: String,
default: '',
},
},
data() {
return {
CONVERSATION_STATUS,
};
},
};
</script>
@@ -188,7 +188,6 @@ export default {
notificationType, notificationType,
}); });
this.$store.dispatch('notifications/read', { this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId, primaryActorId,
primaryActorType, primaryActorType,
unreadCount: this.meta.unreadCount, unreadCount: this.meta.unreadCount,
@@ -58,7 +58,6 @@ export default {
notificationType, notificationType,
}); });
this.$store.dispatch('notifications/read', { this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId, primaryActorId,
primaryActorType, primaryActorType,
unreadCount: this.meta.unreadCount, unreadCount: this.meta.unreadCount,
@@ -130,23 +130,15 @@ export default {
this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE')); this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
this.onClose(); this.onClose();
} catch (error) { } catch (error) {
const { const { response: { data: { error: errorResponse = '' } = {} } = {} } =
response: { error;
data: {
error: errorResponse = '',
attributes: attributes = [],
message: attrError = '',
} = {},
} = {},
} = error;
let errorMessage = ''; let errorMessage = '';
if (error.response.status === 422 && !attributes.includes('base')) { if (error.response.status === 422) {
errorMessage = this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE'); errorMessage = this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
} else { } else {
errorMessage = this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE'); errorMessage = this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE');
} }
this.showAlert(errorResponse || attrError || errorMessage); this.showAlert(errorResponse || errorMessage);
} }
}, },
}, },
@@ -143,6 +143,9 @@ export default {
imap_login: this.login, imap_login: this.login,
imap_password: this.password, imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled, imap_enable_ssl: this.isSSLEnabled,
imap_inbox_synced_at: this.isIMAPEnabled
? new Date().toISOString()
: undefined,
}, },
}; };
@@ -4,9 +4,6 @@ import AccountAPI from '../../api/account';
import EnterpriseAccountAPI from '../../api/enterprise/account'; import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api'; import { throwErrorMessage } from '../utils/api';
const findRecordById = ($state, id) =>
$state.records.find(record => record.id === Number(id)) || {};
const state = { const state = {
records: [], records: [],
uiFlags: { uiFlags: {
@@ -33,15 +30,10 @@ export const getters = {
return true; return true;
} }
const { features = {} } = findRecordById($state, id); const { features = {} } =
$state.records.find(record => record.id === Number(id)) || {};
return features[featureName] || false; return features[featureName] || false;
}, },
// There are some features which can be enabled/disabled globally
isFeatureEnabledGlobally: $state => (id, featureName) => {
const { features = {} } = findRecordById($state, id);
return features[featureName] || false;
},
}; };
export const actions = { export const actions = {
@@ -181,14 +181,6 @@ export const actions = {
// Ignore error // Ignore error
} }
}, },
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
} catch (error) {
// Ignore error
}
},
}; };
// mutations // mutations
@@ -8,7 +8,6 @@ import {
buildConversationList, buildConversationList,
isOnMentionsView, isOnMentionsView,
isOnUnattendedView, isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers'; } from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions'; import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions'; import messageTranslateActions from './actions/messageTranslateActions';
@@ -322,12 +321,12 @@ const actions = {
inbox_id: inboxId, inbox_id: inboxId,
meta: { sender }, meta: { sender },
} = conversation; } = conversation;
const hasAppliedFilters = !!appliedFilters.length; const hasAppliedFilters = !!appliedFilters.length;
const isMatchingInboxFilter = const isMatchingInboxFilter =
!currentInbox || Number(currentInbox) === inboxId; !currentInbox || Number(currentInbox) === inboxId;
if ( if (
!hasAppliedFilters && !hasAppliedFilters &&
!isOnFoldersView(rootState) &&
!isOnMentionsView(rootState) && !isOnMentionsView(rootState) &&
!isOnUnattendedView(rootState) && !isOnUnattendedView(rootState) &&
isMatchingInboxFilter isMatchingInboxFilter
@@ -30,14 +30,6 @@ export const isOnUnattendedView = ({ route: { name: routeName } }) => {
return UNATTENDED_ROUTES.includes(routeName); return UNATTENDED_ROUTES.includes(routeName);
}; };
export const isOnFoldersView = ({ route: { name: routeName } }) => {
const FOLDER_ROUTES = [
'folder_conversations',
'conversations_through_folders',
];
return FOLDER_ROUTES.includes(routeName);
};
export const buildConversationList = ( export const buildConversationList = (
context, context,
requestPayload, requestPayload,
@@ -1,4 +1,4 @@
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers'; import { isOnMentionsView } from '../actionHelpers';
describe('#isOnMentionsView', () => { describe('#isOnMentionsView', () => {
it('return valid responses when passing the state', () => { it('return valid responses when passing the state', () => {
@@ -10,17 +10,3 @@ describe('#isOnMentionsView', () => {
); );
}); });
}); });
describe('#isOnFoldersView', () => {
it('return valid responses when passing the state', () => {
expect(isOnFoldersView({ route: { name: 'folder_conversations' } })).toBe(
true
);
expect(
isOnFoldersView({ route: { name: 'conversations_through_folders' } })
).toBe(true);
expect(isOnFoldersView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});
@@ -9,7 +9,7 @@ export const actions = {
data: { data: {
data: { payload, meta }, data: { payload, meta },
}, },
} = await NotificationsAPI.get({ page }); } = await NotificationsAPI.get(page);
commit(types.CLEAR_NOTIFICATIONS); commit(types.CLEAR_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS, payload); commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta); commit(types.SET_NOTIFICATIONS_META, meta);
@@ -18,29 +18,6 @@ export const actions = {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }); commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
} }
}, },
index: async ({ commit }, { page = 1, status, type, sortOrder } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get({
page,
status,
type,
sortOrder,
});
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
if (payload.length < 15) {
commit(types.SET_ALL_NOTIFICATIONS_LOADED);
}
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
unReadCount: async ({ commit } = {}) => { unReadCount: async ({ commit } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true }); commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
try { try {
@@ -53,26 +30,14 @@ export const actions = {
}, },
read: async ( read: async (
{ commit }, { commit },
{ id, primaryActorType, primaryActorId, unreadCount } { primaryActorType, primaryActorId, unreadCount }
) => { ) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try { try {
await NotificationsAPI.read(primaryActorType, primaryActorId); await NotificationsAPI.read(primaryActorType, primaryActorId);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1); commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.READ_NOTIFICATION, { id, read_at: new Date() }); commit(types.UPDATE_NOTIFICATION, primaryActorId);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) { } catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); throw new Error(error);
}
},
unread: async ({ commit }, { id }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.unRead(id);
commit(types.READ_NOTIFICATION, { id, read_at: null });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} }
}, },
readAll: async ({ commit }) => { readAll: async ({ commit }) => {
@@ -88,75 +53,10 @@ export const actions = {
} }
}, },
delete: async ({ commit }, { notification, count, unreadCount }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.delete(notification.id);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.DELETE_NOTIFICATION, { notification, count, unreadCount });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAllRead: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'read',
});
commit(types.DELETE_READ_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'all',
});
commit(types.DELETE_ALL_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
snooze: async ({ commit }, { id, snoozedUntil }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
const response = await NotificationsAPI.snooze({
id,
snoozedUntil,
});
const {
data: { snoozed_until = null },
} = response;
commit(types.SNOOZE_NOTIFICATION, {
id,
snoozed_until,
});
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
updateNotification: async ({ commit }, data) => {
commit(types.UPDATE_NOTIFICATION, data);
},
addNotification({ commit }, data) { addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data); commit(types.ADD_NOTIFICATION, data);
}, },
deleteNotification({ commit }, data) { deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data); commit(types.DELETE_NOTIFICATION, data);
}, },
clear({ commit }) {
commit(types.CLEAR_NOTIFICATIONS);
},
}; };
@@ -1,19 +1,7 @@
import { applyInboxPageFilters, sortComparator } from './helpers';
export const getters = { export const getters = {
getNotifications($state) { getNotifications($state) {
return Object.values($state.records).sort((n1, n2) => n2.id - n1.id); return Object.values($state.records).sort((n1, n2) => n2.id - n1.id);
}, },
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const filteredNotifications = Object.values($state.records).filter(
notification => applyInboxPageFilters(notification, filters)
);
const sortedNotifications = filteredNotifications.sort((a, b) =>
sortComparator(a, b, sortOrder)
);
return sortedNotifications;
},
getUIFlags($state) { getUIFlags($state) {
return $state.uiFlags; return $state.uiFlags;
}, },
@@ -1,45 +0,0 @@
export const filterByStatus = (snoozedUntil, filterStatus) =>
filterStatus === 'snoozed' ? !!snoozedUntil : !snoozedUntil;
export const filterByType = (readAt, filterType) =>
filterType === 'read' ? !!readAt : !readAt;
export const filterByTypeAndStatus = (
readAt,
snoozedUntil,
filterType,
filterStatus
) => {
const shouldFilterByStatus = filterByStatus(snoozedUntil, filterStatus);
const shouldFilterByType = filterByType(readAt, filterType);
return shouldFilterByStatus && shouldFilterByType;
};
export const applyInboxPageFilters = (notification, filters) => {
const { status, type } = filters;
const { read_at: readAt, snoozed_until: snoozedUntil } = notification;
if (status && type)
return filterByTypeAndStatus(readAt, snoozedUntil, type, status);
if (status && !type) return filterByStatus(snoozedUntil, status);
if (!status && type) return filterByType(readAt, type);
return true;
};
const INBOX_SORT_OPTIONS = {
newest: 'desc',
oldest: 'asc',
};
const sortConfig = {
newest: (a, b) => b.created_at - a.created_at,
oldest: (a, b) => a.created_at - b.created_at,
};
export const sortComparator = (a, b, sortOrder) => {
const sortDirection = INBOX_SORT_OPTIONS[sortOrder];
if (sortOrder === 'newest' || sortOrder === 'oldest') {
return sortConfig[sortOrder](a, b, sortDirection);
}
return 0;
};
@@ -13,9 +13,7 @@ const state = {
isFetching: false, isFetching: false,
isFetchingItem: false, isFetchingItem: false,
isUpdating: false, isUpdating: false,
isDeleting: false,
isUpdatingUnreadCount: false, isUpdatingUnreadCount: false,
isAllNotificationsLoaded: false,
}, },
}; };
@@ -10,7 +10,6 @@ export const mutations = {
}, },
[types.CLEAR_NOTIFICATIONS]: $state => { [types.CLEAR_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {}); Vue.set($state, 'records', {});
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', false);
}, },
[types.SET_NOTIFICATIONS_META]: ($state, data) => { [types.SET_NOTIFICATIONS_META]: ($state, data) => {
const { const {
@@ -34,8 +33,12 @@ export const mutations = {
}); });
}); });
}, },
[types.READ_NOTIFICATION]: ($state, { id, read_at }) => { [types.UPDATE_NOTIFICATION]: ($state, primaryActorId) => {
Vue.set($state.records[id], 'read_at', read_at); Object.values($state.records).forEach(item => {
if (item.primary_actor_id === primaryActorId) {
Vue.set($state.records[item.id], 'read_at', true);
}
});
}, },
[types.UPDATE_ALL_NOTIFICATIONS]: $state => { [types.UPDATE_ALL_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => { Object.values($state.records).forEach(item => {
@@ -52,37 +55,10 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount); Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count); Vue.set($state.meta, 'count', count);
}, },
[types.UPDATE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
Vue.set($state.records, notification.id, {
...($state.records[notification.id] || {}),
...notification,
});
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.DELETE_NOTIFICATION]($state, data) { [types.DELETE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data; const { notification, unread_count: unreadCount, count } = data;
Vue.delete($state.records, notification.id); Vue.delete($state.records, notification.id);
Vue.set($state.meta, 'unreadCount', unreadCount); Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count); Vue.set($state.meta, 'count', count);
}, },
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true);
},
[types.DELETE_READ_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
if (item.read_at) {
Vue.delete($state.records, item.id);
}
});
},
[types.DELETE_ALL_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
},
[types.SNOOZE_NOTIFICATION]: ($state, { id, snoozed_until }) => {
Vue.set($state.records[id], 'snoozed_until', snoozed_until);
},
}; };
@@ -4,10 +4,6 @@ const accountData = {
id: 1, id: 1,
name: 'Company one', name: 'Company one',
locale: 'en', locale: 'en',
features: {
auto_resolve_conversations: false,
agent_management: false,
},
}; };
describe('#getters', () => { describe('#getters', () => {
@@ -33,32 +29,4 @@ describe('#getters', () => {
isDeleting: false, isDeleting: false,
}); });
}); });
it('isFeatureEnabledonAccount', () => {
const state = {
records: [accountData],
};
const rootGetters = {
getCurrentUser: {
type: 'SuperAdmin',
},
};
expect(
getters.isFeatureEnabledonAccount(
state,
null,
null,
rootGetters
)(1, 'auto_resolve_conversations')
).toEqual(true);
});
it('isFeatureEnabledGlobally', () => {
const state = {
records: [accountData],
};
expect(
getters.isFeatureEnabledGlobally(state)(1, 'auto_resolve_conversations')
).toEqual(false);
});
}); });
@@ -181,26 +181,6 @@ describe('#actions', () => {
expect(dispatch.mock.calls).toEqual([]); expect(dispatch.mock.calls).toEqual([]);
}); });
it('doesnot send mutation if the view is conversation folders', () => {
const conversation = {
id: 1,
messages: [],
meta: { sender: { id: 1, name: 'john-doe' } },
inbox_id: 1,
};
actions.addConversation(
{
commit,
rootState: { route: { name: 'folder_conversations' } },
dispatch,
state: { currentInbox: 1, appliedFilters: [{ id: 'random-filter' }] },
},
conversation
);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
it('sends correct mutations', () => { it('sends correct mutations', () => {
const conversation = { const conversation = {
id: 1, id: 1,
@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../notifications/actions'; import { actions } from '../../notifications/actions';
import types from '../../../mutation-types'; import types from '../../../mutation-types';
const commit = jest.fn(); const commit = jest.fn();
global.axios = axios; global.axios = axios;
jest.mock('axios'); jest.mock('axios');
@@ -38,38 +39,6 @@ describe('#actions', () => {
}); });
}); });
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: {
data: {
payload: [{ id: 1 }],
meta: { count: 3, current_page: 1, unread_count: 2 },
},
},
});
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS, [{ id: 1 }]],
[
types.SET_NOTIFICATIONS_META,
{ count: 3, current_page: 1, unread_count: 2 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
[types.SET_ALL_NOTIFICATIONS_LOADED],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#unReadCount', () => { describe('#unReadCount', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: 1 }); axios.get.mockResolvedValue({ data: 1 });
@@ -93,91 +62,18 @@ describe('#actions', () => {
describe('#read', () => { describe('#read', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({}); axios.post.mockResolvedValue({});
await actions.read( await actions.read({ commit }, { unreadCount: 2, primaryActorId: 1 });
{ commit },
{ id: 1, unreadCount: 2, primaryActorId: 1 }
);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 1], [types.SET_NOTIFICATIONS_UNREAD_COUNT, 1],
[types.READ_NOTIFICATION, { id: 1, read_at: expect.any(Date) }], [types.UPDATE_NOTIFICATION, 1],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]); ]);
}); });
it('sends correct actions if API is error', async () => { it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' }); axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.read({ commit })).rejects.toThrow(Error); await expect(actions.read({ commit })).rejects.toThrow(Error);
await actions.read(
{ commit },
{ id: 1, unreadCount: 2, primaryActorId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
}); });
}); });
describe('#unread', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.unread({ commit }, { id: 1 });
expect(commit.mock.calls).toEqual([
['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: true }],
['READ_NOTIFICATION', { id: 1, read_at: null }],
['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.unread({ commit })).rejects.toThrow(Error);
await actions.unread({ commit }, { id: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.delete(
{ commit },
{
notification: { id: 1 },
count: 2,
unreadCount: 1,
}
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 0],
[
types.DELETE_NOTIFICATION,
{ notification: { id: 1 }, count: 2, unreadCount: 1 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit })).rejects.toThrow(Error);
await actions.delete(
{ commit },
{
notification: { id: 1 },
count: 2,
unreadCount: 1,
}
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('#readAll', () => { describe('#readAll', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: 1 }); axios.post.mockResolvedValue({ data: 1 });
@@ -211,76 +107,4 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('clear', () => {
it('sends correct actions', async () => {
await actions.clear({ commit });
expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]);
});
});
describe('deleteAllRead', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('deleteAll', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('snooze', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({
data: { snoozed_until: '20 Jan, 5.04pm' },
});
await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SNOOZE_NOTIFICATION, { id: 1, snoozed_until: '20 Jan, 5.04pm' }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
}); });
@@ -16,32 +16,6 @@ describe('#getters', () => {
]); ]);
}); });
it('getFilteredNotifications', () => {
const state = {
records: {
1: { id: 1, read_at: '2024-02-07T11:42:39.988Z', snoozed_until: null },
2: { id: 2, read_at: null, snoozed_until: null },
3: {
id: 3,
read_at: '2024-02-07T11:42:39.988Z',
snoozed_until: '2024-02-07T11:42:39.988Z',
},
},
};
const filters = {
type: 'read',
status: 'snoozed',
sortOrder: 'desc',
};
expect(getters.getFilteredNotifications(state)(filters)).toEqual([
{
id: 3,
read_at: '2024-02-07T11:42:39.988Z',
snoozed_until: '2024-02-07T11:42:39.988Z',
},
]);
});
it('getUIFlags', () => { it('getUIFlags', () => {
const state = { const state = {
uiFlags: { uiFlags: {
@@ -1,200 +0,0 @@
import {
filterByStatus,
filterByType,
filterByTypeAndStatus,
applyInboxPageFilters,
sortComparator,
} from '../../notifications/helpers';
const notifications = [
{
id: 1,
read_at: '2024-02-07T11:42:39.988Z',
snoozed_until: null,
created_at: 1707328400,
},
{
id: 2,
read_at: null,
snoozed_until: null,
created_at: 1707233688,
},
{
id: 3,
read_at: '2024-01-07T11:42:39.988Z',
snoozed_until: null,
created_at: 1707233672,
},
{
id: 4,
read_at: null,
snoozed_until: '2024-02-08T03:30:00.000Z',
created_at: 1707233667,
},
{
id: 5,
read_at: '2024-02-07T10:42:39.988Z',
snoozed_until: '2024-02-08T03:30:00.000Z',
created_at: 1707233662,
},
{
id: 6,
read_at: null,
snoozed_until: '2024-02-08T03:30:00.000Z',
created_at: 1707233561,
},
];
describe('#filterByStatus', () => {
it('returns the notifications with snoozed status', () => {
const filters = { status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByStatus(notification.snoozed_until, filters.status)
).toEqual(notification.snoozed_until !== null);
});
});
it('returns true if the notification is snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[3].snoozed_until, filters.status)
).toEqual(true);
});
it('returns false if the notification is not snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[2].snoozed_until, filters.status)
).toEqual(false);
});
});
describe('#filterByType', () => {
it('returns the notifications with read status', () => {
const filters = { type: 'read' };
notifications.forEach(notification => {
expect(filterByType(notification.read_at, filters.type)).toEqual(
notification.read_at !== null
);
});
});
it('returns true if the notification is read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[0].read_at, filters.type)).toEqual(true);
});
it('returns false if the notification is not read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[1].read_at, filters.type)).toEqual(false);
});
});
describe('#filterByTypeAndStatus', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
).toEqual(
notification.read_at !== null && notification.snoozed_until !== null
);
});
});
it('returns true if the notification is read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[4].read_at,
notifications[4].snoozed_until,
filters.type,
filters.status
)
).toEqual(true);
});
it('returns false if the notification is not read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[3].read_at,
notifications[3].snoozed_until,
filters.type,
filters.status
)
).toEqual(false);
});
});
describe('#applyInboxPageFilters', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
);
});
});
it('returns the notifications with type only', () => {
const filters = { type: 'read', status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByType(notification.read_at, filters.type)
);
});
});
it('returns the notifications with status only', () => {
const filters = { type: null, status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByStatus(notification.snoozed_until, filters.status)
);
});
});
it('returns true if there are no filters', () => {
const filters = { type: null, status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(true);
});
});
});
describe('#sortComparator', () => {
it('returns the notifications sorted by newest', () => {
const sortOrder = 'newest';
const sortedNotifications = [...notifications].sort((a, b) =>
sortComparator(a, b, sortOrder)
);
const expectedOrder = [
notifications[0],
notifications[1],
notifications[2],
notifications[3],
notifications[4],
notifications[5],
].sort((a, b) => b.created_at - a.created_at);
expect(sortedNotifications).toEqual(expectedOrder);
});
it('returns the notifications sorted by oldest', () => {
const sortOrder = 'oldest';
const sortedNotifications = [...notifications].sort((a, b) =>
sortComparator(a, b, sortOrder)
);
const expectedOrder = [
notifications[0],
notifications[1],
notifications[2],
notifications[3],
notifications[4],
notifications[5],
].sort((a, b) => a.created_at - b.created_at);
expect(sortedNotifications).toEqual(expectedOrder);
});
});
@@ -12,10 +12,7 @@ describe('#mutations', () => {
describe('#CLEAR_NOTIFICATIONS', () => { describe('#CLEAR_NOTIFICATIONS', () => {
it('clear notifications', () => { it('clear notifications', () => {
const state = { const state = { records: { 1: { id: 1 } } };
records: { 1: { id: 1 } },
uiFlags: { isAllNotificationsLoaded: true },
};
mutations[types.CLEAR_NOTIFICATIONS](state); mutations[types.CLEAR_NOTIFICATIONS](state);
expect(state.records).toEqual({}); expect(state.records).toEqual({});
}); });
@@ -75,10 +72,7 @@ describe('#mutations', () => {
1: { id: 1, primary_actor_id: 1 }, 1: { id: 1, primary_actor_id: 1 },
}, },
}; };
mutations[types.READ_NOTIFICATION](state, { mutations[types.UPDATE_NOTIFICATION](state, 1);
id: 1,
read_at: true,
});
expect(state.records).toEqual({ expect(state.records).toEqual({
1: { id: 1, primary_actor_id: 1, read_at: true }, 1: { id: 1, primary_actor_id: 1, read_at: true },
}); });
@@ -147,40 +141,4 @@ describe('#mutations', () => {
expect(state.meta.count).toEqual(232); expect(state.meta.count).toEqual(232);
}); });
}); });
describe('#SET_ALL_NOTIFICATIONS_LOADED', () => {
it('set all notifications loaded', () => {
const state = { uiFlags: { isAllNotificationsLoaded: false } };
mutations[types.SET_ALL_NOTIFICATIONS_LOADED](state);
expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true });
});
});
describe('#DELETE_READ_NOTIFICATIONS', () => {
it('delete read notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_READ_NOTIFICATIONS](state);
expect(state.records).toEqual({
2: { id: 2, primary_actor_id: 2 },
});
});
});
describe('#DELETE_ALL_NOTIFICATIONS', () => {
it('delete all notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_ALL_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
});
}); });

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