Merge branch 'develop' into feat/sla-3
This commit is contained in:
+11
-70
@@ -2,7 +2,6 @@ require:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
- rubocop-rspec
|
||||
inherit_from: .rubocop_todo.yml
|
||||
|
||||
Layout/LineLength:
|
||||
Max: 150
|
||||
@@ -12,7 +11,8 @@ Metrics/ClassLength:
|
||||
Exclude:
|
||||
- 'app/models/message.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 19
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
@@ -50,6 +50,7 @@ Lint/OrAssignmentToConstant:
|
||||
Exclude:
|
||||
- 'lib/redis/config.rb'
|
||||
Metrics/BlockLength:
|
||||
Max: 30
|
||||
Exclude:
|
||||
- spec/**/*
|
||||
- '**/routes.rb'
|
||||
@@ -102,84 +103,31 @@ RSpec/FactoryBot/SyntaxMethods:
|
||||
Enabled: false
|
||||
Naming/VariableNumber:
|
||||
Enabled: false
|
||||
Metrics/MethodLength:
|
||||
Naming/MemoizedInstanceVariableName:
|
||||
Exclude:
|
||||
- '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'
|
||||
- 'app/models/message.rb'
|
||||
Style/GuardClause:
|
||||
Exclude:
|
||||
- 'app/builders/account_builder.rb'
|
||||
- 'app/models/attachment.rb'
|
||||
- 'app/models/message.rb'
|
||||
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
||||
Metrics/AbcSize:
|
||||
Max: 26
|
||||
Exclude:
|
||||
- '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:
|
||||
Exclude:
|
||||
- 'app/models/channel/twitter_profile.rb'
|
||||
- 'app/models/webhook.rb'
|
||||
- 'app/models/contact.rb'
|
||||
- 'app/models/integrations/hook.rb'
|
||||
- 'app/models/canned_response.rb'
|
||||
- 'app/models/telegram_bot.rb'
|
||||
Rails/RenderInline:
|
||||
Exclude:
|
||||
- 'app/controllers/swagger_controller.rb'
|
||||
Performance/CollectionLiteralInLoop:
|
||||
Exclude:
|
||||
- 'db/migrate/20210315101919_enable_email_channel.rb'
|
||||
Rails/ThreeStateBooleanColumn:
|
||||
Exclude:
|
||||
- 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
|
||||
- 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
|
||||
- 'db/migrate/20200606132552_create_labels.rb'
|
||||
- 'db/migrate/20201027135006_create_working_hours.rb'
|
||||
- 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
|
||||
- 'db/migrate/20210114202310_create_teams.rb'
|
||||
- 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
|
||||
- 'db/migrate/20210428135041_add_campaigns.rb'
|
||||
- 'db/migrate/20210602182058_add_hmac_to_api_channel.rb'
|
||||
- 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb'
|
||||
- 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb'
|
||||
- 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb'
|
||||
- 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb'
|
||||
- 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb'
|
||||
- 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb'
|
||||
- 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb'
|
||||
- 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb'
|
||||
- 'db/migrate/20220511072655_add_archive_column_to_portal.rb'
|
||||
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
||||
RSpec/IndexedLet:
|
||||
Enabled: false
|
||||
@@ -187,6 +135,8 @@ RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
|
||||
# we should bring this down
|
||||
RSpec/MultipleExpectations:
|
||||
Max: 7
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 14
|
||||
|
||||
@@ -203,13 +153,4 @@ AllCops:
|
||||
- 'config/environments/**/*'
|
||||
- 'tmp/**/*'
|
||||
- 'storage/**/*'
|
||||
- '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
|
||||
- 'db/migrate/20230426130150_init_schema.rb'
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
# 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'
|
||||
@@ -39,6 +39,8 @@ gem 'rack-attack', '>= 6.7.0'
|
||||
gem 'down'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||
gem 'net-smtp', '~> 0.3.4'
|
||||
# Prevent CSV injection
|
||||
gem 'csv-safe'
|
||||
|
||||
@@ -74,7 +76,7 @@ gem 'devise_token_auth'
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.1'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
|
||||
+16
-15
@@ -105,12 +105,12 @@ GEM
|
||||
activerecord (>= 6.0, < 7.1)
|
||||
addressable (2.8.4)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
administrate (0.19.0)
|
||||
actionpack (>= 5.0)
|
||||
actionview (>= 5.0)
|
||||
activerecord (>= 5.0)
|
||||
jquery-rails (>= 4.0)
|
||||
kaminari (>= 1.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
activerecord (>= 6.0, < 8.0)
|
||||
jquery-rails (~> 4.6.0)
|
||||
kaminari (~> 1.2.2)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (1.0.1)
|
||||
@@ -277,7 +277,7 @@ GEM
|
||||
gli (2.21.1)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
gmail_xoauth (0.4.2)
|
||||
gmail_xoauth (0.4.3)
|
||||
oauth (>= 0.3.6)
|
||||
google-apis-core (0.11.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
@@ -461,7 +461,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.20.0)
|
||||
minitest (5.21.2)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -479,7 +479,7 @@ GEM
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.4.0)
|
||||
net-smtp (0.3.4)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
newrelic-sidekiq-metrics (1.6.2)
|
||||
@@ -488,14 +488,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.0)
|
||||
nokogiri (1.16.0)
|
||||
nokogiri (1.16.2)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.0-arm64-darwin)
|
||||
nokogiri (1.16.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.0-x86_64-darwin)
|
||||
nokogiri (1.16.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.0-x86_64-linux)
|
||||
nokogiri (1.16.2-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oauth (1.1.0)
|
||||
@@ -794,7 +794,7 @@ GEM
|
||||
valid_email2 (4.0.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.2)
|
||||
version_gem (1.1.3)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
@@ -840,7 +840,7 @@ DEPENDENCIES
|
||||
active_record_query_trace
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.1)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
annotate
|
||||
@@ -903,6 +903,7 @@ DEPENDENCIES
|
||||
meta_request
|
||||
mock_redis
|
||||
neighbor
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2017-2021 Chatwoot Inc.
|
||||
Copyright (c) 2017-2024 Chatwoot Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
padding: 4px 12px;
|
||||
|
||||
.icon-container {
|
||||
margin-right: 4px;
|
||||
margin-right: 2px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class AccountBuilder
|
||||
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
|
||||
if @user.nil?
|
||||
@@ -15,12 +15,22 @@ class AccountBuilder
|
||||
end
|
||||
[@user, @account]
|
||||
rescue StandardError => e
|
||||
puts e.inspect
|
||||
Rails.logger.debug e.inspect
|
||||
raise e
|
||||
end
|
||||
|
||||
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
|
||||
address = ValidEmail2::Address.new(@email)
|
||||
if address.valid? # && !address.disposable?
|
||||
@@ -39,7 +49,7 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create!(name: @account_name, locale: I18n.locale)
|
||||
@account = Account.create!(name: account_name, locale: I18n.locale)
|
||||
Current.account = @account
|
||||
end
|
||||
|
||||
@@ -64,7 +74,7 @@ class AccountBuilder
|
||||
@user = User.new(email: @email,
|
||||
password: user_password,
|
||||
password_confirmation: user_password,
|
||||
name: @user_full_name)
|
||||
name: user_full_name)
|
||||
@user.type = 'SuperAdmin' if @super_admin
|
||||
@user.confirm if @confirmed
|
||||
@user.save!
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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,16 +1,26 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index]
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :find_user, only: [:create]
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :create_user, only: [:create]
|
||||
before_action :save_account_user, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
end
|
||||
|
||||
def create; end
|
||||
def create
|
||||
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
|
||||
@agent.update!(agent_params.slice(:name).compact)
|
||||
@@ -23,6 +33,21 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
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
|
||||
)
|
||||
builder.perform
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
@@ -33,47 +58,34 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agent = agents.find(params[:id])
|
||||
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
|
||||
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
|
||||
end
|
||||
|
||||
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)
|
||||
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
|
||||
end
|
||||
|
||||
def agents
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
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
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -2,7 +2,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
RESULTS_PER_PAGE = 15
|
||||
include DateRangeHelper
|
||||
|
||||
before_action :fetch_notification, only: [:update, :destroy, :snooze]
|
||||
before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread]
|
||||
before_action :set_primary_actor, only: [:read_all]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -29,11 +29,25 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def unread
|
||||
@notification.update(read_at: nil)
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notification.destroy
|
||||
head :ok
|
||||
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
|
||||
@unread_count = notification_finder.unread_count
|
||||
render json: @unread_count
|
||||
|
||||
@@ -5,11 +5,13 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
|
||||
only: [:create], raise: false
|
||||
before_action :check_signup_enabled, only: [:create]
|
||||
before_action :ensure_account_name, 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::InvalidParams,
|
||||
CustomExceptions::Account::UserExists,
|
||||
CustomExceptions::Account::UserErrors,
|
||||
with: :render_error_response
|
||||
@@ -38,7 +40,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
def cache_keys
|
||||
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
|
||||
render json: { cache_keys: get_cache_keys }, status: :ok
|
||||
render json: { cache_keys: cache_keys_for_account }, status: :ok
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -53,7 +55,18 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def get_cache_keys
|
||||
def ensure_account_name
|
||||
# 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),
|
||||
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
|
||||
|
||||
@@ -31,6 +31,11 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def resend_confirmation
|
||||
@user.send_confirmation_instructions unless @user.confirmed?
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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],
|
||||
user: current_user
|
||||
).perform
|
||||
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
|
||||
@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
|
||||
@@ -22,19 +22,24 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
i.value = value
|
||||
i.save!
|
||||
end
|
||||
# rubocop:disable Rails/I18nLocaleTexts
|
||||
redirect_to super_admin_settings_path, notice: 'App Configs updated successfully'
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_config
|
||||
@config = params[:config]
|
||||
@config = params[:config] || 'general'
|
||||
end
|
||||
|
||||
def allowed_configs
|
||||
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
|
||||
@allowed_configs = case @config
|
||||
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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class NotificationFinder
|
||||
|
||||
def set_up
|
||||
find_all_notifications
|
||||
filter_by_read_status
|
||||
filter_by_status
|
||||
end
|
||||
|
||||
@@ -37,11 +38,15 @@ class NotificationFinder
|
||||
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
|
||||
end
|
||||
|
||||
def filter_by_read_status
|
||||
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
|
||||
end
|
||||
|
||||
def current_page
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def notifications
|
||||
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: :desc)
|
||||
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
@@ -2,12 +2,13 @@
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="app-wrapper h-full flex-grow-0 min-h-0 w-full"
|
||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
||||
:class="{ 'app-rtl--wrapper': isRTLView }"
|
||||
:dir="isRTLView ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<template v-if="currentAccountId">
|
||||
<pending-email-verification-banner />
|
||||
<payment-pending-banner />
|
||||
<upgrade-banner />
|
||||
</template>
|
||||
@@ -32,6 +33,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
@@ -52,6 +54,7 @@ export default {
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
|
||||
mixins: [rtlMixin],
|
||||
|
||||
@@ -29,11 +29,12 @@ export default {
|
||||
return fetchPromise;
|
||||
},
|
||||
hasAuthCookie() {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
},
|
||||
getAuthData() {
|
||||
if (this.hasAuthCookie()) {
|
||||
return Cookies.getJSON('cw_d_session_info');
|
||||
const savedAuthInfo = Cookies.get('cw_d_session_info');
|
||||
return JSON.parse(savedAuthInfo || '{}');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -97,4 +98,8 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
resendConfirmation() {
|
||||
const urlData = endPoints('resendConfirmation');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,6 +47,10 @@ const endPoints = {
|
||||
setActiveAccount: {
|
||||
url: '/api/v1/profile/set_active_account',
|
||||
},
|
||||
|
||||
resendConfirmation: {
|
||||
url: '/api/v1/profile/resend_confirmation',
|
||||
},
|
||||
};
|
||||
|
||||
export default page => {
|
||||
|
||||
@@ -6,8 +6,15 @@ class NotificationsAPI extends ApiClient {
|
||||
super('notifications', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(page) {
|
||||
return axios.get(`${this.url}?page=${page}`);
|
||||
get({ page, status, type, sortOrder }) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
status,
|
||||
type,
|
||||
sort_order: sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getNotifications(contactId) {
|
||||
@@ -25,9 +32,29 @@ class NotificationsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
unRead(id) {
|
||||
return axios.post(`${this.url}/${id}/unread`);
|
||||
}
|
||||
|
||||
readAll() {
|
||||
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();
|
||||
|
||||
@@ -28,10 +28,20 @@ describe('#NotificationAPI', () => {
|
||||
});
|
||||
|
||||
it('#get', () => {
|
||||
notificationsAPI.get(1);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/notifications?page=1'
|
||||
);
|
||||
notificationsAPI.get({
|
||||
page: 1,
|
||||
status: 'read',
|
||||
type: 'Conversation',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
|
||||
params: {
|
||||
page: 1,
|
||||
status: 'read',
|
||||
type: 'Conversation',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getNotifications', () => {
|
||||
@@ -65,5 +75,30 @@ describe('#NotificationAPI', () => {
|
||||
'/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>
|
||||
<div
|
||||
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="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||
:class="{
|
||||
hide: !showConversationList,
|
||||
'list--full-width': isOnExpandedLayout,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
<div
|
||||
class="banner flex items-center h-12 gap-4 text-white dark:text-white text-xs py-3 px-4 justify-center"
|
||||
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white"
|
||||
:class="bannerClasses"
|
||||
>
|
||||
<span class="banner-message">
|
||||
@@ -18,7 +18,7 @@
|
||||
<woot-button
|
||||
v-if="hasActionButton"
|
||||
size="tiny"
|
||||
icon="arrow-right"
|
||||
:icon="actionButtonIcon"
|
||||
:variant="actionButtonVariant"
|
||||
color-scheme="primary"
|
||||
class-names="banner-action__button"
|
||||
@@ -67,6 +67,10 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
actionButtonIcon: {
|
||||
type: String,
|
||||
default: 'arrow-right',
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<conversation-header
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:show-back-button="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
@@ -30,6 +31,7 @@
|
||||
<messages-view
|
||||
v-if="currentChat.id"
|
||||
:inbox-id="inboxId"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
@@ -80,6 +82,10 @@ export default {
|
||||
default: '',
|
||||
required: false,
|
||||
},
|
||||
isInboxView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
|
||||
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0 rtl:relative rtl:left-6"
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<more-actions :conversation-id="currentChat.id" />
|
||||
@@ -86,7 +86,8 @@ import MoreActions from './MoreActions.vue';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -109,6 +110,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInboxView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -123,6 +128,9 @@ export default {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = this.$route;
|
||||
if (this.isInboxView) {
|
||||
return frontendURL(`accounts/${accountId}/inbox`);
|
||||
}
|
||||
return conversationListPageURL({
|
||||
accountId,
|
||||
inboxId,
|
||||
@@ -150,7 +158,7 @@ export default {
|
||||
if (snoozedUntil) {
|
||||
return `${this.$t(
|
||||
'CONVERSATION.HEADER.SNOOZED_UNTIL'
|
||||
)} ${conversationReopenTime(snoozedUntil)}`;
|
||||
)} ${snoozedReopenTime(snoozedUntil)}`;
|
||||
}
|
||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
|
||||
<div :class="wrapClass">
|
||||
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
|
||||
<div
|
||||
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
|
||||
class="message-failed--alert"
|
||||
>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
|
||||
size="tiny"
|
||||
@@ -203,6 +206,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAnEmailInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
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="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="
|
||||
isInboxView ? 'top-52 md:top-40' : 'top-[9.5rem] md:top-[6.25rem]'
|
||||
"
|
||||
:icon="isRightOrLeftIcon"
|
||||
@click="onToggleContactPanel"
|
||||
/>
|
||||
@@ -33,6 +36,7 @@
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-an-email-inbox="isAnEmailChannel"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
@@ -141,6 +145,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInboxView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
|
||||
@@ -46,5 +46,18 @@ export default {
|
||||
},
|
||||
EXAMPLE_URL: 'https://example.com',
|
||||
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/';
|
||||
|
||||
@@ -17,4 +17,5 @@ export const FEATURE_FLAGS = {
|
||||
VOICE_RECORDER: 'voice_recorder',
|
||||
AUDIT_LOGS: 'audit_logs',
|
||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
};
|
||||
|
||||
@@ -103,8 +103,21 @@ export const GENERAL_EVENTS = Object.freeze({
|
||||
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',
|
||||
|
||||
});
|
||||
|
||||
export const SLA_EVENTS = Object.freeze({
|
||||
CREATE: 'Created an SLA',
|
||||
UPDATE: 'Updated an SLA',
|
||||
DELETED: 'Deleted an SLA',
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.mentioned': this.onConversationMentioned,
|
||||
'notification.created': this.onNotificationCreated,
|
||||
'notification.deleted': this.onNotificationDeleted,
|
||||
'notification.updated': this.onNotificationUpdated,
|
||||
'first.reply.created': this.onFirstReplyCreated,
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
@@ -200,6 +201,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('notifications/deleteNotification', data);
|
||||
};
|
||||
|
||||
onNotificationUpdated = data => {
|
||||
this.app.$store.dispatch('notifications/updateNotification', data);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onFirstReplyCreated = () => {
|
||||
bus.$emit('fetch_overview_reports');
|
||||
|
||||
@@ -86,3 +86,6 @@ export const getConversationDashboardRoute = routeName => {
|
||||
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;
|
||||
};
|
||||
export const conversationReopenTime = snoozedUntil => {
|
||||
export const snoozedReopenTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isAConversationRoute,
|
||||
routeIsAccessibleFor,
|
||||
validateLoggedInRoutes,
|
||||
isAInboxViewRoute,
|
||||
} from '../routeHelpers';
|
||||
|
||||
describe('#getCurrentAccount', () => {
|
||||
@@ -134,3 +135,10 @@ describe('getConversationDashboardRoute', () => {
|
||||
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 {
|
||||
findSnoozeTime,
|
||||
conversationReopenTime,
|
||||
snoozedReopenTime,
|
||||
findStartOfNextWeek,
|
||||
findStartOfNextMonth,
|
||||
findNextDay,
|
||||
@@ -88,13 +88,13 @@ describe('#Snooze Helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversationReopenTime', () => {
|
||||
describe('snoozedReopenTime', () => {
|
||||
it('should return nil if snoozedUntil is nil', () => {
|
||||
expect(conversationReopenTime(null)).toEqual(null);
|
||||
expect(snoozedReopenTime(null)).toEqual(null);
|
||||
});
|
||||
|
||||
it('should return formatted date if snoozedUntil is not nil', () => {
|
||||
expect(conversationReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
|
||||
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
|
||||
'7 Jun, 9.00am'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"REMOVE_LABEL": "Remove label from the conversation",
|
||||
"SETTINGS": "Settings",
|
||||
"AI_ASSIST": "AI Assist",
|
||||
"APPEARANCE": "Appearance"
|
||||
"APPEARANCE": "Appearance",
|
||||
"SNOOZE_NOTIFICATION": "Snooze Notification"
|
||||
},
|
||||
"COMMANDS": {
|
||||
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
|
||||
@@ -153,7 +154,8 @@
|
||||
"CHANGE_APPEARANCE": "Change Appearance",
|
||||
"LIGHT_MODE": "Light",
|
||||
"DARK_MODE": "Dark",
|
||||
"SYSTEM_MODE": "System"
|
||||
"SYSTEM_MODE": "System",
|
||||
"SNOOZE_NOTIFICATION": "Snooze Notification"
|
||||
}
|
||||
},
|
||||
"DASHBOARD_APPS": {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import signup from './signup.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import sla from './sla.json';
|
||||
import inbox from './inbox.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -64,4 +65,5 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...inbox,
|
||||
};
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"TRIAL_MESSAGE": "days trial remaining.",
|
||||
"TRAIL_BUTTON": "Buy Now",
|
||||
"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": {
|
||||
"TITLE": "Account Suspended",
|
||||
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
|
||||
@@ -193,6 +196,7 @@
|
||||
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
|
||||
"SWITCH": "Switch",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"INBOX": "Inbox",
|
||||
"ALL_CONVERSATIONS": "All Conversations",
|
||||
"MENTIONED_CONVERSATIONS": "Mentions",
|
||||
"PARTICIPATING_CONVERSATIONS": "Participating",
|
||||
|
||||
@@ -52,19 +52,77 @@ describe('#dateFormat', () => {
|
||||
});
|
||||
|
||||
describe('#shortTimestamp', () => {
|
||||
it('returns correct value', () => {
|
||||
// Test cases when withAgo is false or not provided
|
||||
it('returns correct value without ago', () => {
|
||||
expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual(
|
||||
'now'
|
||||
);
|
||||
expect(TimeMixin.methods.shortTimestamp(' minute ago')).toEqual('m');
|
||||
expect(TimeMixin.methods.shortTimestamp(' minutes ago')).toEqual('m');
|
||||
expect(TimeMixin.methods.shortTimestamp(' hour ago')).toEqual('h');
|
||||
expect(TimeMixin.methods.shortTimestamp(' hours ago')).toEqual('h');
|
||||
expect(TimeMixin.methods.shortTimestamp(' day ago')).toEqual('d');
|
||||
expect(TimeMixin.methods.shortTimestamp(' days ago')).toEqual('d');
|
||||
expect(TimeMixin.methods.shortTimestamp(' month ago')).toEqual('mo');
|
||||
expect(TimeMixin.methods.shortTimestamp(' months ago')).toEqual('mo');
|
||||
expect(TimeMixin.methods.shortTimestamp(' year ago')).toEqual('y');
|
||||
expect(TimeMixin.methods.shortTimestamp(' years ago')).toEqual('y');
|
||||
expect(TimeMixin.methods.shortTimestamp('1 minute ago')).toEqual('1m');
|
||||
expect(TimeMixin.methods.shortTimestamp('12 minutes ago')).toEqual('12m');
|
||||
expect(TimeMixin.methods.shortTimestamp('a minute ago')).toEqual('1m');
|
||||
expect(TimeMixin.methods.shortTimestamp('an hour ago')).toEqual('1h');
|
||||
expect(TimeMixin.methods.shortTimestamp('1 hour ago')).toEqual('1h');
|
||||
expect(TimeMixin.methods.shortTimestamp('2 hours ago')).toEqual('2h');
|
||||
expect(TimeMixin.methods.shortTimestamp('1 day ago')).toEqual('1d');
|
||||
expect(TimeMixin.methods.shortTimestamp('a day ago')).toEqual('1d');
|
||||
expect(TimeMixin.methods.shortTimestamp('3 days ago')).toEqual('3d');
|
||||
expect(TimeMixin.methods.shortTimestamp('a month ago')).toEqual('1mo');
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,25 +28,36 @@ export default {
|
||||
const unixTime = fromUnixTime(time);
|
||||
return format(unixTime, dateFormat);
|
||||
},
|
||||
shortTimestamp(time) {
|
||||
shortTimestamp(time, withAgo = false) {
|
||||
// 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
|
||||
.replace(/about|over|almost|/g, '')
|
||||
.replace('less than a minute ago', 'now')
|
||||
.replace(' minute ago', 'm')
|
||||
.replace(' minutes ago', 'm')
|
||||
.replace('a minute ago', 'm')
|
||||
.replace('an hour ago', 'h')
|
||||
.replace(' hour ago', 'h')
|
||||
.replace(' hours ago', 'h')
|
||||
.replace(' day ago', 'd')
|
||||
.replace('a day ago', 'd')
|
||||
.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');
|
||||
.replace(' minute ago', `m${suffix}`)
|
||||
.replace(' minutes ago', `m${suffix}`)
|
||||
.replace(' hour ago', `h${suffix}`)
|
||||
.replace(' hours ago', `h${suffix}`)
|
||||
.replace(' day ago', `d${suffix}`)
|
||||
.replace(' days ago', `d${suffix}`)
|
||||
.replace(' month ago', `mo${suffix}`)
|
||||
.replace(' months ago', `mo${suffix}`)
|
||||
.replace(' year ago', `y${suffix}`)
|
||||
.replace(' years ago', `y${suffix}`);
|
||||
return convertToShortTime;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,3 +71,5 @@ 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_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_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,3 +15,6 @@ export const CMD_REOPEN_CONVERSATION = 'CMD_REOPEN_CONVERSATION';
|
||||
export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
|
||||
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
|
||||
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
|
||||
|
||||
// Inbox Commands (Notifications)
|
||||
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<script>
|
||||
import 'ninja-keys';
|
||||
import conversationHotKeysMixin from './conversationHotKeys';
|
||||
import inboxHotKeysMixin from './inboxHotKeys';
|
||||
import goToCommandHotKeys from './goToCommandHotKeys';
|
||||
import appearanceHotKeys from './appearanceHotKeys';
|
||||
import agentMixin from 'dashboard/mixins/agentMixin';
|
||||
@@ -25,6 +26,7 @@ export default {
|
||||
adminMixin,
|
||||
agentMixin,
|
||||
conversationHotKeysMixin,
|
||||
inboxHotKeysMixin,
|
||||
conversationLabelMixin,
|
||||
conversationTeamMixin,
|
||||
appearanceHotKeys,
|
||||
@@ -42,6 +44,7 @@ export default {
|
||||
},
|
||||
hotKeys() {
|
||||
return [
|
||||
...this.inboxHotKeys,
|
||||
...this.conversationHotKeys,
|
||||
...this.goToCommandHotKeys,
|
||||
...this.goToAppearanceHotKeys,
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
UNMUTE_ACTION,
|
||||
MUTE_ACTION,
|
||||
} from './commandBarActions';
|
||||
import { isAConversationRoute } from '../../../helper/routeHelpers';
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
} from '../../../helper/routeHelpers';
|
||||
export default {
|
||||
mixins: [aiMixin],
|
||||
watch: {
|
||||
@@ -325,7 +328,10 @@ export default {
|
||||
},
|
||||
|
||||
conversationHotKeys() {
|
||||
if (isAConversationRoute(this.$route.name)) {
|
||||
if (
|
||||
isAConversationRoute(this.$route.name) ||
|
||||
isAInboxViewRoute(this.$route.name)
|
||||
) {
|
||||
const defaultConversationHotKeys = [
|
||||
...this.statusActions,
|
||||
...this.conversationAdditionalActions,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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,6 +182,7 @@ import { getCountryFlag } from 'dashboard/helper/flag';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
getConversationDashboardRoute,
|
||||
} from '../../../../helper/routeHelpers';
|
||||
|
||||
@@ -303,6 +304,10 @@ export default {
|
||||
this.$router.push({
|
||||
name: getConversationDashboardRoute(this.$route.name),
|
||||
});
|
||||
} else if (isAInboxViewRoute(this.$route.name)) {
|
||||
this.$router.push({
|
||||
name: 'inbox_view',
|
||||
});
|
||||
} else if (this.$route.name !== 'contacts_dashboard') {
|
||||
this.$router.push({
|
||||
name: 'contacts_dashboard',
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
const ConversationView = () => import('./ConversationView');
|
||||
const InboxView = () => import('../inbox/InboxView.vue');
|
||||
|
||||
export default {
|
||||
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'),
|
||||
name: 'home',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="my-0 py-0 px-4 grid grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
|
||||
class="my-0 py-0 px-4 grid grid-cols-6 z-10 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
|
||||
:class="{ draggable: onCategoryPage }"
|
||||
>
|
||||
<div
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<woot-sidemenu-icon />
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<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>
|
||||
@@ -0,0 +1,217 @@
|
||||
<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>
|
||||
@@ -0,0 +1,236 @@
|
||||
<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>
|
||||
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
@@ -0,0 +1,198 @@
|
||||
<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>
|
||||
@@ -0,0 +1,145 @@
|
||||
<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>
|
||||
@@ -0,0 +1,123 @@
|
||||
<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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
inboxIcon() {
|
||||
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
|
||||
const classByType = getInboxClassByType(type, phoneNumber);
|
||||
return classByType;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5">
|
||||
<fluent-icon
|
||||
class="text-slate-600 dark:text-slate-300"
|
||||
:icon="inboxIcon"
|
||||
size="14"
|
||||
/>
|
||||
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center py-0.5 px-1.5">
|
||||
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
|
||||
{{ conversationId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
@@ -0,0 +1,70 @@
|
||||
<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>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
export default {
|
||||
props: {
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONVERSATION_PRIORITY,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#FFC291" />
|
||||
</svg>
|
||||
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#DDDDDD" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#E5484D" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#E5484D" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#E5484D" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Pending -->
|
||||
<svg
|
||||
v-if="status === CONVERSATION_STATUS.PENDING"
|
||||
class="h-3.5 w-3.5"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.1 0.0449978V1.8558C4.5486 2.2986 1.8 5.328 1.8 9C1.8 12.9762 5.0238 16.2 9 16.2C10.6641 16.2 12.195 15.6357 13.4154 14.688L14.6961 15.9687C13.1445 17.2377 11.16 18 9 18C4.0293 18 0 13.9707 0 9C0 4.3335 3.5523 0.495898 8.1 0.0449978ZM17.955 9.9C17.775 11.7099 17.0604 13.3623 15.9687 14.6952L14.688 13.4154C15.462 12.4191 15.9804 11.2149 16.1442 9.9H17.9559H17.955ZM9.9018 0.0449978C14.1534 0.467098 17.5338 3.8484 17.9568 8.1H16.1451C15.7392 4.8438 13.158 2.2626 9.9018 1.8558V0.0440979V0.0449978Z"
|
||||
class="fill-[#B9BBC6]"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Open -->
|
||||
<svg
|
||||
v-if="status === CONVERSATION_STATUS.OPEN"
|
||||
class="h-3.5 w-3.5"
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.375 18.875C4.19733 18.875 0 14.6776 0 9.5C0 4.32233 4.19733 0.125 9.375 0.125C14.5526 0.125 18.75 4.32233 18.75 9.5C18.75 14.6776 14.5526 18.875 9.375 18.875ZM9.375 17C13.5172 17 16.875 13.6422 16.875 9.5C16.875 5.35786 13.5172 2 9.375 2C5.23286 2 1.875 5.35786 1.875 9.5C1.875 13.6422 5.23286 17 9.375 17Z"
|
||||
class="fill-[#ED8A5C]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Snoozed -->
|
||||
<svg
|
||||
v-if="status === CONVERSATION_STATUS.SNOOZED"
|
||||
class="h-3.5 w-3.5"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 18C4.0293 18 0 13.9707 0 9C0 4.0293 4.0293 0 9 0C13.9707 0 18 4.0293 18 9C18 13.9707 13.9707 18 9 18ZM2.9961 12.9825C3.58766 13.8676 4.36812 14.6105 5.28129 15.1577C6.19446 15.7049 7.21761 16.0428 8.27707 16.147C9.33652 16.2513 10.4059 16.1193 11.4082 15.7606C12.4105 15.4019 13.3208 14.8254 14.0736 14.0726C14.8263 13.3198 15.4027 12.4094 15.7613 11.4071C16.12 10.4047 16.2518 9.33532 16.1475 8.27588C16.0431 7.21644 15.7052 6.19332 15.1579 5.2802C14.6106 4.36707 13.8676 3.58668 12.9825 2.9952C13.3706 4.3796 13.383 5.84237 13.0186 7.23318C12.6542 8.62399 11.926 9.89269 10.9089 10.9089C9.89277 11.9258 8.62423 12.6539 7.23359 13.0183C5.84296 13.3828 4.38037 13.3704 2.9961 12.9825Z"
|
||||
class="fill-[#0B68CB]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Resolved -->
|
||||
<svg
|
||||
v-if="status === CONVERSATION_STATUS.RESOLVED"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="3 3 17.92 17.92"
|
||||
>
|
||||
<path
|
||||
d="M11.96 20.92C7.01152 20.92 3 16.9084 3 11.96C3 7.01152 7.01152 3 11.96 3C16.9084 3 20.92 7.01152 20.92 11.96C20.92 16.9084 16.9084 20.92 11.96 20.92ZM11.96 19.128C15.9188 19.128 19.128 15.9188 19.128 11.96C19.128 8.00122 15.9188 4.792 11.96 4.792C8.00122 4.792 4.792 8.00122 4.792 11.96C4.792 15.9188 8.00122 19.128 11.96 19.128Z"
|
||||
class="fill-[#5BB98C]"
|
||||
/>
|
||||
<path
|
||||
d="M11.9599 17.9333C15.2589 17.9333 17.9332 15.2589 17.9332 11.96C17.9332 8.66098 15.2589 5.98663 11.9599 5.98663C8.66092 5.98663 5.98657 8.66098 5.98657 11.96C5.98657 15.2589 8.66092 17.9333 11.9599 17.9333Z"
|
||||
class="fill-[#5BB98C]"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONVERSATION_STATUS,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+1
@@ -188,6 +188,7 @@ export default {
|
||||
notificationType,
|
||||
});
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id: notification.id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount: this.meta.unreadCount,
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ export default {
|
||||
notificationType,
|
||||
});
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id: notification.id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount: this.meta.unreadCount,
|
||||
|
||||
@@ -130,15 +130,23 @@ export default {
|
||||
this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const { response: { data: { error: errorResponse = '' } = {} } = {} } =
|
||||
error;
|
||||
const {
|
||||
response: {
|
||||
data: {
|
||||
error: errorResponse = '',
|
||||
attributes: attributes = [],
|
||||
message: attrError = '',
|
||||
} = {},
|
||||
} = {},
|
||||
} = error;
|
||||
|
||||
let errorMessage = '';
|
||||
if (error.response.status === 422) {
|
||||
if (error.response.status === 422 && !attributes.includes('base')) {
|
||||
errorMessage = this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
|
||||
} else {
|
||||
errorMessage = this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE');
|
||||
}
|
||||
this.showAlert(errorResponse || errorMessage);
|
||||
this.showAlert(errorResponse || attrError || errorMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -143,9 +143,6 @@ export default {
|
||||
imap_login: this.login,
|
||||
imap_password: this.password,
|
||||
imap_enable_ssl: this.isSSLEnabled,
|
||||
imap_inbox_synced_at: this.isIMAPEnabled
|
||||
? new Date().toISOString()
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import AccountAPI from '../../api/account';
|
||||
import EnterpriseAccountAPI from '../../api/enterprise/account';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
|
||||
const findRecordById = ($state, id) =>
|
||||
$state.records.find(record => record.id === Number(id)) || {};
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
@@ -30,10 +33,15 @@ export const getters = {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { features = {} } =
|
||||
$state.records.find(record => record.id === Number(id)) || {};
|
||||
const { features = {} } = findRecordById($state, id);
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -181,6 +181,14 @@ export const actions = {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
resendConfirmation: async () => {
|
||||
try {
|
||||
await authAPI.resendConfirmation();
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// mutations
|
||||
|
||||
@@ -9,7 +9,7 @@ export const actions = {
|
||||
data: {
|
||||
data: { payload, meta },
|
||||
},
|
||||
} = await NotificationsAPI.get(page);
|
||||
} = await NotificationsAPI.get({ page });
|
||||
commit(types.CLEAR_NOTIFICATIONS);
|
||||
commit(types.SET_NOTIFICATIONS, payload);
|
||||
commit(types.SET_NOTIFICATIONS_META, meta);
|
||||
@@ -18,6 +18,29 @@ export const actions = {
|
||||
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 } = {}) => {
|
||||
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
|
||||
try {
|
||||
@@ -30,14 +53,26 @@ export const actions = {
|
||||
},
|
||||
read: async (
|
||||
{ commit },
|
||||
{ primaryActorType, primaryActorId, unreadCount }
|
||||
{ id, primaryActorType, primaryActorId, unreadCount }
|
||||
) => {
|
||||
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
await NotificationsAPI.read(primaryActorType, primaryActorId);
|
||||
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
|
||||
commit(types.UPDATE_NOTIFICATION, primaryActorId);
|
||||
commit(types.READ_NOTIFICATION, { id, read_at: new Date() });
|
||||
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
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 }) => {
|
||||
@@ -53,10 +88,75 @@ 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) {
|
||||
commit(types.ADD_NOTIFICATION, data);
|
||||
},
|
||||
deleteNotification({ commit }, data) {
|
||||
commit(types.DELETE_NOTIFICATION, data);
|
||||
},
|
||||
clear({ commit }) {
|
||||
commit(types.CLEAR_NOTIFICATIONS);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { applyInboxPageFilters, sortComparator } from './helpers';
|
||||
|
||||
export const getters = {
|
||||
getNotifications($state) {
|
||||
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) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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,7 +13,9 @@ const state = {
|
||||
isFetching: false,
|
||||
isFetchingItem: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
isUpdatingUnreadCount: false,
|
||||
isAllNotificationsLoaded: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const mutations = {
|
||||
},
|
||||
[types.CLEAR_NOTIFICATIONS]: $state => {
|
||||
Vue.set($state, 'records', {});
|
||||
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', false);
|
||||
},
|
||||
[types.SET_NOTIFICATIONS_META]: ($state, data) => {
|
||||
const {
|
||||
@@ -33,12 +34,8 @@ export const mutations = {
|
||||
});
|
||||
});
|
||||
},
|
||||
[types.UPDATE_NOTIFICATION]: ($state, primaryActorId) => {
|
||||
Object.values($state.records).forEach(item => {
|
||||
if (item.primary_actor_id === primaryActorId) {
|
||||
Vue.set($state.records[item.id], 'read_at', true);
|
||||
}
|
||||
});
|
||||
[types.READ_NOTIFICATION]: ($state, { id, read_at }) => {
|
||||
Vue.set($state.records[id], 'read_at', read_at);
|
||||
},
|
||||
[types.UPDATE_ALL_NOTIFICATIONS]: $state => {
|
||||
Object.values($state.records).forEach(item => {
|
||||
@@ -55,10 +52,37 @@ export const mutations = {
|
||||
Vue.set($state.meta, 'unreadCount', unreadCount);
|
||||
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) {
|
||||
const { notification, unread_count: unreadCount, count } = data;
|
||||
Vue.delete($state.records, notification.id);
|
||||
Vue.set($state.meta, 'unreadCount', unreadCount);
|
||||
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,6 +4,10 @@ const accountData = {
|
||||
id: 1,
|
||||
name: 'Company one',
|
||||
locale: 'en',
|
||||
features: {
|
||||
auto_resolve_conversations: false,
|
||||
agent_management: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('#getters', () => {
|
||||
@@ -29,4 +33,32 @@ describe('#getters', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ jest.mock('../../../utils/api', () => ({
|
||||
getHeaderExpiry: jest.fn(),
|
||||
}));
|
||||
jest.mock('js-cookie', () => ({
|
||||
getJSON: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}));
|
||||
|
||||
const commit = jest.fn();
|
||||
@@ -155,14 +155,14 @@ describe('#actions', () => {
|
||||
|
||||
describe('#setUser', () => {
|
||||
it('sends correct actions if user is logged in', async () => {
|
||||
Cookies.getJSON.mockImplementation(() => true);
|
||||
Cookies.get.mockImplementation(() => true);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
expect(dispatch.mock.calls).toEqual([['validityCheck']]);
|
||||
});
|
||||
|
||||
it('sends correct actions if user is not logged in', async () => {
|
||||
Cookies.getJSON.mockImplementation(() => false);
|
||||
Cookies.get.mockImplementation(() => false);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.CLEAR_USER],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../notifications/actions';
|
||||
import types from '../../../mutation-types';
|
||||
|
||||
const commit = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
@@ -39,6 +38,38 @@ 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', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({ data: 1 });
|
||||
@@ -62,18 +93,91 @@ describe('#actions', () => {
|
||||
describe('#read', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({});
|
||||
await actions.read({ commit }, { unreadCount: 2, primaryActorId: 1 });
|
||||
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_UNREAD_COUNT, 1],
|
||||
[types.UPDATE_NOTIFICATION, 1],
|
||||
[types.READ_NOTIFICATION, { id: 1, read_at: expect.any(Date) }],
|
||||
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
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', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: 1 });
|
||||
@@ -107,4 +211,76 @@ 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,6 +16,32 @@ 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', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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,7 +12,10 @@ describe('#mutations', () => {
|
||||
|
||||
describe('#CLEAR_NOTIFICATIONS', () => {
|
||||
it('clear notifications', () => {
|
||||
const state = { records: { 1: { id: 1 } } };
|
||||
const state = {
|
||||
records: { 1: { id: 1 } },
|
||||
uiFlags: { isAllNotificationsLoaded: true },
|
||||
};
|
||||
mutations[types.CLEAR_NOTIFICATIONS](state);
|
||||
expect(state.records).toEqual({});
|
||||
});
|
||||
@@ -72,7 +75,10 @@ describe('#mutations', () => {
|
||||
1: { id: 1, primary_actor_id: 1 },
|
||||
},
|
||||
};
|
||||
mutations[types.UPDATE_NOTIFICATION](state, 1);
|
||||
mutations[types.READ_NOTIFICATION](state, {
|
||||
id: 1,
|
||||
read_at: true,
|
||||
});
|
||||
expect(state.records).toEqual({
|
||||
1: { id: 1, primary_actor_id: 1, read_at: true },
|
||||
});
|
||||
@@ -141,4 +147,40 @@ describe('#mutations', () => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +132,7 @@ export default {
|
||||
SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT',
|
||||
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
|
||||
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
|
||||
READ_NOTIFICATION: 'READ_NOTIFICATION',
|
||||
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
|
||||
DELETE_NOTIFICATION: 'DELETE_NOTIFICATION',
|
||||
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
|
||||
@@ -140,6 +141,10 @@ export default {
|
||||
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS',
|
||||
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
|
||||
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
|
||||
SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED',
|
||||
DELETE_READ_NOTIFICATIONS: 'DELETE_READ_NOTIFICATIONS',
|
||||
DELETE_ALL_NOTIFICATIONS: 'DELETE_ALL_NOTIFICATIONS',
|
||||
SNOOZE_NOTIFICATION: 'SNOOZE_NOTIFICATION',
|
||||
|
||||
// Contact Conversation
|
||||
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getHeaderExpiry = response =>
|
||||
|
||||
export const setAuthCredentials = response => {
|
||||
const expiryDate = getHeaderExpiry(response);
|
||||
Cookies.set('cw_d_session_info', response.headers, {
|
||||
Cookies.set('cw_d_session_info', JSON.stringify(response.headers), {
|
||||
expires: differenceInDays(expiryDate, new Date()),
|
||||
});
|
||||
setUser(response.data.data, expiryDate);
|
||||
|
||||
@@ -34,5 +34,12 @@ export const setCookieWithDomain = (
|
||||
domain: baseDomain,
|
||||
};
|
||||
|
||||
// if type of value is object, stringify it
|
||||
// this is because js-cookies 3.0 removed builtin json support
|
||||
// ref: https://github.com/js-cookie/js-cookie/releases/tag/v3.0.0
|
||||
if (typeof value === 'object') {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
|
||||
Cookies.set(name, value, cookieOptions);
|
||||
};
|
||||
|
||||
@@ -107,6 +107,26 @@ describe('setCookieWithDomain', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify the cookie value when setting', () => {
|
||||
setCookieWithDomain(
|
||||
'myCookie',
|
||||
{ value: 'cookieValue' },
|
||||
{
|
||||
baseDomain: 'example.com',
|
||||
}
|
||||
);
|
||||
|
||||
expect(Cookies.set).toHaveBeenCalledWith(
|
||||
'myCookie',
|
||||
JSON.stringify({ value: 'cookieValue' }),
|
||||
expect.objectContaining({
|
||||
expires: 365,
|
||||
sameSite: 'Lax',
|
||||
domain: 'example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
|
||||
setCookieWithDomain('myCookie', 'cookieValue', {
|
||||
expires: 7,
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
|
||||
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
|
||||
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
|
||||
"mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z",
|
||||
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
|
||||
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
|
||||
"mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z",
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="name"
|
||||
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
||||
:class="{ 'text-red-500': hasError }"
|
||||
>
|
||||
{{ label }}
|
||||
<slot />
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
:type="type"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:class="{
|
||||
'focus:ring-red-600 ring-red-600': hasError,
|
||||
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
|
||||
!hasError,
|
||||
}"
|
||||
class="block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
<span
|
||||
v-if="errorMessage && hasError"
|
||||
class="text-xs leading-2 text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<with-label
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
:type="type"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:class="{
|
||||
'focus:ring-red-600 ring-red-600': hasError,
|
||||
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
|
||||
!hasError,
|
||||
'px-3 py-3': spacing === 'base',
|
||||
'px-3 py-2 mb-0': spacing === 'compact',
|
||||
'pl-9': icon,
|
||||
}"
|
||||
class="block w-full border-none rounded-xl shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
</with-label>
|
||||
</template>
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
@@ -64,6 +60,10 @@ export default {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -76,6 +76,11 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spacing: {
|
||||
type: String,
|
||||
default: 'base',
|
||||
validator: value => ['base', 'compact'].includes(value),
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<with-label
|
||||
:label="label"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<woot-button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:variant="value === option.value ? '' : 'hollow'"
|
||||
:color-scheme="value === option.value ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
@click="$emit('input', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</with-label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<with-label
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<select
|
||||
:id="id"
|
||||
:selected="value"
|
||||
:name="name"
|
||||
:class="{
|
||||
'text-slate-400': !value,
|
||||
'text-slate-900 dark:text-slate-100': value,
|
||||
'pl-9': icon,
|
||||
}"
|
||||
class="mb-0 block w-full px-3 py-2 pr-6 border-0 rounded-xl shadow-sm outline-none appearance-none select-caret ring-1 ring-inset placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200"
|
||||
@input="onInput"
|
||||
>
|
||||
<option value="" disabled selected class="hidden">
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<slot>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</slot>
|
||||
</select>
|
||||
</with-label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.select-caret {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-origin: content-box;
|
||||
background-position: right -1rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 9px 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="name"
|
||||
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
||||
:class="{ 'text-red-500': hasError }"
|
||||
>
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center relative w-full">
|
||||
<fluent-icon
|
||||
v-if="icon"
|
||||
size="16"
|
||||
:icon="icon"
|
||||
class="absolute left-2 transform text-slate-400 dark:text-slate-600 w-5 h-5"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
<span
|
||||
v-if="errorMessage && hasError"
|
||||
class="text-xs text-red-400 leading-2"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@ import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export const hasAuthCookie = () => {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
};
|
||||
|
||||
const getSSOAccountPath = ({ ssoAccountId, user }) => {
|
||||
|
||||
@@ -10,10 +10,10 @@ jest.mock('dashboard/store/utils/api', () => ({
|
||||
jest.mock('../CommonHelper', () => ({ replaceRouteWithReload: jest.fn() }));
|
||||
|
||||
jest.mock('js-cookie', () => ({
|
||||
getJSON: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}));
|
||||
|
||||
Cookies.getJSON.mockReturnValueOnce(true).mockReturnValue(false);
|
||||
Cookies.get.mockReturnValueOnce(true).mockReturnValue(false);
|
||||
describe('#validateRouteAccess', () => {
|
||||
it('reset cookies and continues to the login page if the SSO parameters are present', () => {
|
||||
validateRouteAccess(
|
||||
@@ -40,7 +40,7 @@ describe('#validateRouteAccess', () => {
|
||||
});
|
||||
|
||||
it('redirects to dashboard if auth cookie is present', () => {
|
||||
Cookies.getJSON.mockImplementation(() => true);
|
||||
Cookies.get.mockImplementation(() => true);
|
||||
validateRouteAccess({ name: 'login' }, next);
|
||||
expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
|
||||
expect(replaceRouteWithReload).toHaveBeenCalledWith('/app/');
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 text-lg font-bold mb-6 relative before:absolute before:h-10 before:w-[1px] before:bg-slate-200 before:-bottom-8 before:left-[24px] hide-before-of-last"
|
||||
:class="{
|
||||
'text-woot-500 ': isActive,
|
||||
'text-slate-400': !isActive || isComplete,
|
||||
'before:bg-woot-500': !isActive && isComplete,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="grid w-8 h-8 border border-solid rounded-full place-content-center"
|
||||
:class="{
|
||||
'border-woot-500': !isActive || isComplete,
|
||||
'border-slate-200 dark:border-slate-600': !isActive && !isComplete,
|
||||
'text-woot-500': isComplete,
|
||||
}"
|
||||
>
|
||||
<fluent-icon v-if="isComplete" size="20" icon="checkmark" />
|
||||
<fluent-icon v-else size="20" :icon="icon" />
|
||||
</div>
|
||||
<span>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OnboardingStep',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isComplete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.hide-before-of-last:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,10 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
Channel::Whatsapp.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL',
|
||||
3.hours.ago).limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT).all.each do |channel|
|
||||
Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
|
||||
.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago)
|
||||
.limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
|
||||
.each do |channel|
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
def perform(channel)
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
with_lock(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) do
|
||||
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
|
||||
with_lock(key, 5.minutes) do
|
||||
process_email_for_channel(channel)
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
@@ -50,6 +51,11 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
|
||||
return if email_already_present?(channel, message_id)
|
||||
|
||||
if message_id.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
|
||||
return
|
||||
end
|
||||
|
||||
# Fetch the original mail content using the sequence no
|
||||
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
@@ -73,7 +79,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
message_ids_with_seq = []
|
||||
seq_nums.each_slice(10).each do |batch|
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]')
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
@@ -84,7 +90,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']).message_id
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
|
||||
message_ids_with_seq.push([data.seqno, message_id])
|
||||
end
|
||||
end
|
||||
@@ -96,7 +102,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
# created between yesterday and today and returns message sequence numbers.
|
||||
# Return <message set>
|
||||
def fetch_available_mail_sequence_numbers(imap_client)
|
||||
imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday])
|
||||
imap_client.search(['SINCE', yesterday])
|
||||
end
|
||||
|
||||
def fetch_mail_for_ms_provider(channel)
|
||||
@@ -160,8 +166,4 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
|
||||
def tomorrow
|
||||
(Time.zone.today + 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,21 +4,17 @@ class Internal::CheckNewVersionsJob < ApplicationJob
|
||||
def perform
|
||||
return unless Rails.env.production?
|
||||
|
||||
instance_info = ChatwootHub.sync_with_hub
|
||||
return unless instance_info
|
||||
|
||||
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, instance_info['version'])
|
||||
update_installation_config(key: 'INSTALLATION_PRICING_PLAN', value: instance_info['plan'])
|
||||
update_installation_config(key: 'INSTALLATION_PRICING_PLAN_QUANTITY', value: instance_info['plan_quantity'])
|
||||
update_installation_config(key: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN', value: instance_info['chatwoot_support_website_token'])
|
||||
update_installation_config(key: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH', value: instance_info['chatwoot_support_identifier_hash'])
|
||||
update_installation_config(key: 'CHATWOOT_SUPPORT_SCRIPT_URL', value: instance_info['chatwoot_support_script_url'])
|
||||
@instance_info = ChatwootHub.sync_with_hub
|
||||
update_version_info
|
||||
end
|
||||
|
||||
def update_installation_config(key:, value:)
|
||||
config = InstallationConfig.find_or_initialize_by(name: key)
|
||||
config.value = value
|
||||
config.locked = true
|
||||
config.save!
|
||||
private
|
||||
|
||||
def update_version_info
|
||||
return if @instance_info['version'].blank?
|
||||
|
||||
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, @instance_info['version'])
|
||||
end
|
||||
end
|
||||
|
||||
Internal::CheckNewVersionsJob.prepend_mod_with('Internal::CheckNewVersionsJob')
|
||||
|
||||
@@ -14,20 +14,36 @@
|
||||
class MutexApplicationJob < ApplicationJob
|
||||
class LockAcquisitionError < StandardError; end
|
||||
|
||||
def with_lock(key_format, *args)
|
||||
lock_key = format(key_format, *args)
|
||||
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
|
||||
lock_manager = Redis::LockManager.new
|
||||
|
||||
begin
|
||||
if lock_manager.lock(lock_key)
|
||||
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
|
||||
if lock_manager.lock(lock_key, timeout)
|
||||
log_attempt(lock_key, executions)
|
||||
yield
|
||||
# release the lock after the block has been executed
|
||||
lock_manager.unlock(lock_key)
|
||||
else
|
||||
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
|
||||
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
|
||||
handle_failed_lock_acquisition(lock_key)
|
||||
end
|
||||
ensure
|
||||
lock_manager.unlock(lock_key)
|
||||
rescue StandardError => e
|
||||
handle_error(e, lock_manager, lock_key)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_attempt(lock_key, executions)
|
||||
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
|
||||
end
|
||||
|
||||
def handle_error(err, lock_manager, lock_key)
|
||||
lock_manager.unlock(lock_key) unless err.is_a?(LockAcquisitionError)
|
||||
raise err
|
||||
end
|
||||
|
||||
def handle_failed_lock_acquisition(lock_key)
|
||||
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
|
||||
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Notification::DeleteNotificationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(user, type: :all)
|
||||
ActiveRecord::Base.transaction do
|
||||
if type == :all
|
||||
# Delete all notifications
|
||||
user.notifications.destroy_all
|
||||
elsif type == :read
|
||||
# Delete only read notifications
|
||||
user.notifications.where.not(read_at: nil).destroy_all
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,8 @@ class SendOnSlackJob < MutexApplicationJob
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(message, hook)
|
||||
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do
|
||||
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
|
||||
with_lock(key) do
|
||||
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,3 +24,5 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
Notification::RemoveOldNotificationJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
TriggerScheduledItemsJob.prepend_mod_with('TriggerScheduledItemsJob')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user