Compare commits
46
Commits
v4.2.0
...
mintlify-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0ff544f9 | ||
|
|
65ac19718f | ||
|
|
7808634432 | ||
|
|
f429db89b4 | ||
|
|
b5ebc47637 | ||
|
|
f916fb2924 | ||
|
|
dc335e88c9 | ||
|
|
b1120ae7fb | ||
|
|
443214e9a0 | ||
|
|
3ce026e2bc | ||
|
|
f42fddd38e | ||
|
|
22b5e12a53 | ||
|
|
03bde0a8aa | ||
|
|
3a0b5f387d | ||
|
|
9bd658137a | ||
|
|
f73c5ef0b8 | ||
|
|
f9fce5e2df | ||
|
|
c2d8e2ad77 | ||
|
|
03c0a7c62e | ||
|
|
d40a59f7fa | ||
|
|
72c5671e09 | ||
|
|
8c0885e1d2 | ||
|
|
e92f72b318 | ||
|
|
ea04a292ed | ||
|
|
deeb6d4e3f | ||
|
|
a3bf753f4c | ||
|
|
459f225559 | ||
|
|
99de5f4257 | ||
|
|
4b417ce9e7 | ||
|
|
8697a30dc5 | ||
|
|
8565341682 | ||
|
|
9bd7daeaf8 | ||
|
|
abe22c8649 | ||
|
|
743b73d0af | ||
|
|
dc7f1597e5 | ||
|
|
bc42aec68e | ||
|
|
3c8abd5b30 | ||
|
|
1602b071db | ||
|
|
4664402bf3 | ||
|
|
df7401f71c | ||
|
|
a07f2a7c1b | ||
|
|
af650af489 | ||
|
|
2ee63656e2 | ||
|
|
27ec791353 | ||
|
|
ccb3672ff8 | ||
|
|
54faefb876 |
@@ -73,15 +73,15 @@ jobs:
|
|||||||
libvips
|
libvips
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install RVM and Ruby 3.3.3
|
name: Install RVM and Ruby 3.4.4
|
||||||
command: |
|
command: |
|
||||||
sudo apt-get install -y gpg
|
sudo apt-get install -y gpg
|
||||||
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||||
\curl -sSL https://get.rvm.io | bash -s stable
|
\curl -sSL https://get.rvm.io | bash -s stable
|
||||||
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
rvm install "3.3.3"
|
rvm install "3.4.4"
|
||||||
rvm use 3.3.3 --default
|
rvm use 3.4.4 --default
|
||||||
gem install bundler -v 2.5.16
|
gem install bundler -v 2.5.16
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '23.7.0'
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: '3.3.3'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
USER_GID: '1000'
|
USER_GID: '1000'
|
||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '23.7.0'
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: '3.3.3'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
USER_GID: '1000'
|
USER_GID: '1000'
|
||||||
|
|||||||
+179
-8
@@ -1,7 +1,10 @@
|
|||||||
require:
|
plugins:
|
||||||
- rubocop-performance
|
- rubocop-performance
|
||||||
- rubocop-rails
|
- rubocop-rails
|
||||||
- rubocop-rspec
|
- rubocop-rspec
|
||||||
|
- rubocop-factory_bot
|
||||||
|
|
||||||
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
|
|
||||||
@@ -13,44 +16,61 @@ Metrics/ClassLength:
|
|||||||
Exclude:
|
Exclude:
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
- 'app/models/conversation.rb'
|
- 'app/models/conversation.rb'
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 19
|
Max: 19
|
||||||
|
Exclude:
|
||||||
|
- 'enterprise/lib/captain/agent.rb'
|
||||||
|
|
||||||
RSpec/ExampleLength:
|
RSpec/ExampleLength:
|
||||||
Max: 25
|
Max: 25
|
||||||
|
|
||||||
Style/Documentation:
|
Style/Documentation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/ExponentialNotation:
|
Style/ExponentialNotation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/FrozenStringLiteralComment:
|
Style/FrozenStringLiteralComment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/SymbolArray:
|
Style/SymbolArray:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
- 'app/dispatchers/dispatcher.rb'
|
- 'app/dispatchers/dispatcher.rb'
|
||||||
|
|
||||||
Style/GlobalVars:
|
Style/GlobalVars:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/initializers/01_redis.rb'
|
- 'config/initializers/01_redis.rb'
|
||||||
- 'config/initializers/rack_attack.rb'
|
- 'config/initializers/rack_attack.rb'
|
||||||
- 'lib/redis/alfred.rb'
|
- 'lib/redis/alfred.rb'
|
||||||
- 'lib/global_config.rb'
|
- 'lib/global_config.rb'
|
||||||
|
|
||||||
Style/ClassVars:
|
Style/ClassVars:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
|
|
||||||
Lint/MissingSuper:
|
Lint/MissingSuper:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/drops/base_drop.rb'
|
- 'app/drops/base_drop.rb'
|
||||||
|
|
||||||
Lint/SymbolConversion:
|
Lint/SymbolConversion:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Lint/EmptyBlock:
|
Lint/EmptyBlock:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
|
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
|
||||||
|
|
||||||
Lint/OrAssignmentToConstant:
|
Lint/OrAssignmentToConstant:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'lib/redis/config.rb'
|
- 'lib/redis/config.rb'
|
||||||
|
|
||||||
Metrics/BlockLength:
|
Metrics/BlockLength:
|
||||||
Max: 30
|
Max: 30
|
||||||
Exclude:
|
Exclude:
|
||||||
@@ -58,10 +78,16 @@ Metrics/BlockLength:
|
|||||||
- '**/routes.rb'
|
- '**/routes.rb'
|
||||||
- 'config/environments/*'
|
- 'config/environments/*'
|
||||||
- db/schema.rb
|
- db/schema.rb
|
||||||
|
|
||||||
Metrics/ModuleLength:
|
Metrics/ModuleLength:
|
||||||
Exclude:
|
Exclude:
|
||||||
- lib/seeders/message_seeder.rb
|
- lib/seeders/message_seeder.rb
|
||||||
- spec/support/slack_stubs.rb
|
- spec/support/slack_stubs.rb
|
||||||
|
|
||||||
|
Rails/HelperInstanceVariable:
|
||||||
|
Exclude:
|
||||||
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
|
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -71,74 +97,101 @@ Rails/ApplicationController:
|
|||||||
- 'app/controllers/platform_controller.rb'
|
- 'app/controllers/platform_controller.rb'
|
||||||
- 'app/controllers/public_controller.rb'
|
- 'app/controllers/public_controller.rb'
|
||||||
- 'app/controllers/survey/responses_controller.rb'
|
- 'app/controllers/survey/responses_controller.rb'
|
||||||
|
|
||||||
Rails/FindEach:
|
Rails/FindEach:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Include:
|
Include:
|
||||||
- 'app/**/*.rb'
|
- 'app/**/*.rb'
|
||||||
|
|
||||||
Rails/CompactBlank:
|
Rails/CompactBlank:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/EnvironmentVariableAccess:
|
Rails/EnvironmentVariableAccess:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/TimeZoneAssignment:
|
Rails/TimeZoneAssignment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/RedundantPresenceValidationOnBelongsTo:
|
Rails/RedundantPresenceValidationOnBelongsTo:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/InverseOf:
|
||||||
|
Exclude:
|
||||||
|
- enterprise/app/models/captain/assistant.rb
|
||||||
|
|
||||||
|
Rails/UniqueValidationWithoutIndex:
|
||||||
|
Exclude:
|
||||||
|
- app/models/canned_response.rb
|
||||||
|
- app/models/telegram_bot.rb
|
||||||
|
- enterprise/app/models/captain_inbox.rb
|
||||||
|
- 'app/models/channel/twitter_profile.rb'
|
||||||
|
- 'app/models/webhook.rb'
|
||||||
|
- 'app/models/contact.rb'
|
||||||
|
|
||||||
Style/ClassAndModuleChildren:
|
Style/ClassAndModuleChildren:
|
||||||
EnforcedStyle: compact
|
EnforcedStyle: compact
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/application.rb'
|
- 'config/application.rb'
|
||||||
- 'config/initializers/monkey_patches/*'
|
- 'config/initializers/monkey_patches/*'
|
||||||
|
|
||||||
Style/MapToHash:
|
Style/MapToHash:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/HashSyntax:
|
Style/HashSyntax:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
EnforcedStyle: no_mixed_keys
|
EnforcedStyle: no_mixed_keys
|
||||||
EnforcedShorthandSyntax: never
|
EnforcedShorthandSyntax: never
|
||||||
|
|
||||||
RSpec/NestedGroups:
|
RSpec/NestedGroups:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Max: 4
|
Max: 4
|
||||||
|
|
||||||
RSpec/MessageSpies:
|
RSpec/MessageSpies:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
RSpec/StubbedMock:
|
RSpec/StubbedMock:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
RSpec/FactoryBot/SyntaxMethods:
|
|
||||||
Enabled: false
|
|
||||||
Naming/VariableNumber:
|
Naming/VariableNumber:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Naming/MemoizedInstanceVariableName:
|
Naming/MemoizedInstanceVariableName:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
|
|
||||||
Style/GuardClause:
|
Style/GuardClause:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/builders/account_builder.rb'
|
- 'app/builders/account_builder.rb'
|
||||||
- 'app/models/attachment.rb'
|
- 'app/models/attachment.rb'
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
|
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 26
|
Max: 26
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/concerns/auth_helper.rb'
|
- 'app/controllers/concerns/auth_helper.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/integrations/hook.rb'
|
||||||
- 'app/models/canned_response.rb'
|
- 'app/models/canned_response.rb'
|
||||||
- 'app/models/telegram_bot.rb'
|
- 'app/models/telegram_bot.rb'
|
||||||
|
|
||||||
Rails/RenderInline:
|
Rails/RenderInline:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/swagger_controller.rb'
|
- 'app/controllers/swagger_controller.rb'
|
||||||
|
|
||||||
Rails/ThreeStateBooleanColumn:
|
Rails/ThreeStateBooleanColumn:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
||||||
|
|
||||||
RSpec/IndexedLet:
|
RSpec/IndexedLet:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
RSpec/NamedSubject:
|
RSpec/NamedSubject:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
# we should bring this down
|
# we should bring this down
|
||||||
RSpec/MultipleExpectations:
|
RSpec/MultipleExpectations:
|
||||||
Max: 7
|
Max: 7
|
||||||
|
|
||||||
RSpec/MultipleMemoizedHelpers:
|
RSpec/MultipleMemoizedHelpers:
|
||||||
Max: 14
|
Max: 14
|
||||||
|
|
||||||
@@ -166,3 +219,121 @@ AllCops:
|
|||||||
- 'tmp/**/*'
|
- 'tmp/**/*'
|
||||||
- 'storage/**/*'
|
- 'storage/**/*'
|
||||||
- 'db/migrate/20230426130150_init_schema.rb'
|
- 'db/migrate/20230426130150_init_schema.rb'
|
||||||
|
|
||||||
|
FactoryBot/SyntaxMethods:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Disable new rules causing errors
|
||||||
|
Layout/LeadingCommentSpace:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/ReturnNilInPredicateMethodDefinition:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantParentheses:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Performance/StringIdentifierArgument:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/EmptyLinesAroundExceptionHandlingKeywords:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/LiteralAsCondition:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantReturn:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/SpaceAroundOperators:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/EnvLocal:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/WhereRange:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/UselessConstantScoping:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/MultipleComparison:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Bundler/OrderedGems:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
RSpec/ExampleWording:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
RSpec/ReceiveMessages:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
FactoryBot/AssociationStyle:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/EnumSyntax:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/RedundantTypeConversion:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Additional rules to disable
|
||||||
|
Rails/RedundantActiveRecordAllMethod:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/TrailingEmptyLines:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/SafeNavigationChainLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/SafeNavigationConsistency:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/CopDirectiveSyntax:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Final set of rules to disable
|
||||||
|
FactoryBot/ExcessiveCreateList:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
RSpec/MissingExpectationTargetMethod:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Performance/InefficientHashSearch:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantSelfAssignmentBranch:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/YAMLFileRead:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/ExtraSpacing:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantFilterChain:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Performance/MapMethodChain:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/RootPathnameMethods:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/SuperArguments:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Final remaining rules to disable
|
||||||
|
Rails/Delegate:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/CaseLikeIf:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
FactoryBot/RedundantFactoryOption:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
FactoryBot/FactoryAssociationWithStrategy:
|
||||||
|
Enabled: false
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.3.3
|
3.4.4
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
ruby '3.3.3'
|
ruby '3.4.4'
|
||||||
|
|
||||||
##-- base gems for rails --##
|
##-- base gems for rails --##
|
||||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||||
gem 'rails', '~> 7.0.8.4'
|
gem 'rails', '~> 7.1'
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
gem 'bootsnap', require: false
|
gem 'bootsnap', require: false
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ gem 'liquid'
|
|||||||
gem 'commonmarker'
|
gem 'commonmarker'
|
||||||
# Validate Data against JSON Schema
|
# Validate Data against JSON Schema
|
||||||
gem 'json_schemer'
|
gem 'json_schemer'
|
||||||
|
# used in swagger build
|
||||||
|
gem 'json_refs'
|
||||||
# Rack middleware for blocking & throttling abusive requests
|
# Rack middleware for blocking & throttling abusive requests
|
||||||
gem 'rack-attack', '>= 6.7.0'
|
gem 'rack-attack', '>= 6.7.0'
|
||||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||||
@@ -196,9 +198,6 @@ group :development do
|
|||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
gem 'web-console', '>= 4.2.1'
|
gem 'web-console', '>= 4.2.1'
|
||||||
|
|
||||||
# used in swagger build
|
|
||||||
gem 'json_refs'
|
|
||||||
|
|
||||||
# When we want to squash migrations
|
# When we want to squash migrations
|
||||||
gem 'squasher'
|
gem 'squasher'
|
||||||
|
|
||||||
@@ -237,6 +236,7 @@ group :development, :test do
|
|||||||
gem 'rubocop-performance', require: false
|
gem 'rubocop-performance', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'rubocop-rails', require: false
|
||||||
gem 'rubocop-rspec', require: false
|
gem 'rubocop-rspec', require: false
|
||||||
|
gem 'rubocop-factory_bot', require: false
|
||||||
gem 'seed_dump'
|
gem 'seed_dump'
|
||||||
gem 'shoulda-matchers'
|
gem 'shoulda-matchers'
|
||||||
gem 'simplecov', '0.17.1', require: false
|
gem 'simplecov', '0.17.1', require: false
|
||||||
|
|||||||
+160
-140
@@ -25,76 +25,89 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.0.8.7)
|
actioncable (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (7.0.8.7)
|
zeitwerk (~> 2.6)
|
||||||
actionpack (= 7.0.8.7)
|
actionmailbox (7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activestorage (= 7.0.8.7)
|
activerecord (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activestorage (= 7.1.5.1)
|
||||||
|
activesupport (= 7.1.5.1)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
actionmailer (7.0.8.7)
|
actionmailer (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
actionview (= 7.0.8.7)
|
actionview (= 7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (7.0.8.7)
|
actionpack (7.1.5.1)
|
||||||
actionview (= 7.0.8.7)
|
actionview (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
rack (~> 2.0, >= 2.2.4)
|
nokogiri (>= 1.8.5)
|
||||||
|
racc
|
||||||
|
rack (>= 2.2.4)
|
||||||
|
rack-session (>= 1.0.1)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.6)
|
||||||
actiontext (7.0.8.7)
|
actiontext (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
activerecord (= 7.1.5.1)
|
||||||
activestorage (= 7.0.8.7)
|
activestorage (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.0.8.7)
|
actionview (7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-html-sanitizer (~> 1.6)
|
||||||
active_record_query_trace (1.8)
|
active_record_query_trace (1.8)
|
||||||
activejob (7.0.8.7)
|
activejob (7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.0.8.7)
|
activemodel (7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
activerecord (7.0.8.7)
|
activerecord (7.1.5.1)
|
||||||
activemodel (= 7.0.8.7)
|
activemodel (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
activerecord-import (1.4.1)
|
timeout (>= 0.4.0)
|
||||||
|
activerecord-import (2.1.0)
|
||||||
activerecord (>= 4.2)
|
activerecord (>= 4.2)
|
||||||
activestorage (7.0.8.7)
|
activestorage (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
activerecord (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
activesupport (7.1.5.1)
|
||||||
activesupport (7.0.8.7)
|
base64
|
||||||
|
benchmark (>= 0.3)
|
||||||
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
connection_pool (>= 2.2.5)
|
||||||
|
drb
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
|
logger (>= 1.4.2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
|
mutex_m
|
||||||
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
acts-as-taggable-on (9.0.1)
|
acts-as-taggable-on (12.0.0)
|
||||||
activerecord (>= 6.0, < 7.1)
|
activerecord (>= 7.1, < 8.1)
|
||||||
|
zeitwerk (>= 2.4, < 3.0)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
administrate (0.20.1)
|
administrate (0.20.1)
|
||||||
@@ -116,7 +129,7 @@ GEM
|
|||||||
annotate (3.2.0)
|
annotate (3.2.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 3.2, < 8.0)
|
||||||
rake (>= 10.4, < 14.0)
|
rake (>= 10.4, < 14.0)
|
||||||
ast (2.4.2)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
@@ -142,14 +155,15 @@ GEM
|
|||||||
statsd-ruby (~> 1.1)
|
statsd-ruby (~> 1.1)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
bigdecimal (3.1.8)
|
benchmark (0.4.0)
|
||||||
|
bigdecimal (3.1.9)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.16.0)
|
bootsnap (1.16.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (5.4.1)
|
brakeman (5.4.1)
|
||||||
browser (5.3.1)
|
browser (5.3.1)
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bullet (7.0.7)
|
bullet (8.0.7)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundle-audit (0.1.0)
|
bundle-audit (0.1.0)
|
||||||
@@ -161,8 +175,8 @@ GEM
|
|||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
concurrent-ruby (1.3.4)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.4.1)
|
connection_pool (2.5.3)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
@@ -176,16 +190,10 @@ GEM
|
|||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
datadog-ci (0.8.3)
|
|
||||||
msgpack
|
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
ddtrace (1.23.2)
|
ddtrace (0.48.0)
|
||||||
datadog-ci (~> 0.8.1)
|
ffi (~> 1.0)
|
||||||
debase-ruby_core_source (= 3.3.1)
|
|
||||||
libdatadog (~> 7.0.0.1.0)
|
|
||||||
libddwaf (~> 1.14.0.0.0)
|
|
||||||
msgpack
|
msgpack
|
||||||
debase-ruby_core_source (3.3.1)
|
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@@ -196,10 +204,10 @@ GEM
|
|||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise_token_auth (1.2.3)
|
devise_token_auth (1.2.5)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
devise (> 3.5.2, < 5)
|
devise (> 3.5.2, < 5)
|
||||||
rails (>= 4.2.0, < 7.2)
|
rails (>= 4.2.0, < 8.1)
|
||||||
diff-lcs (1.5.1)
|
diff-lcs (1.5.1)
|
||||||
digest-crc (0.6.5)
|
digest-crc (0.6.5)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
@@ -212,6 +220,7 @@ GEM
|
|||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
down (5.4.0)
|
down (5.4.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
|
drb (2.2.3)
|
||||||
dry-cli (1.1.0)
|
dry-cli (1.1.0)
|
||||||
ecma-re-validator (0.4.0)
|
ecma-re-validator (0.4.0)
|
||||||
regexp_parser (~> 2.2)
|
regexp_parser (~> 2.2)
|
||||||
@@ -254,7 +263,10 @@ GEM
|
|||||||
fcm (1.0.8)
|
fcm (1.0.8)
|
||||||
faraday (>= 1.0.0, < 3.0)
|
faraday (>= 1.0.0, < 3.0)
|
||||||
googleauth (~> 1)
|
googleauth (~> 1)
|
||||||
ffi (1.16.3)
|
ffi (1.17.2)
|
||||||
|
ffi (1.17.2-arm64-darwin)
|
||||||
|
ffi (1.17.2-x86_64-darwin)
|
||||||
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi-compiler (1.0.1)
|
ffi-compiler (1.0.1)
|
||||||
ffi (>= 1.0.0)
|
ffi (>= 1.0.0)
|
||||||
rake
|
rake
|
||||||
@@ -315,16 +327,13 @@ GEM
|
|||||||
google-cloud-translate-v3 (0.10.0)
|
google-cloud-translate-v3 (0.10.0)
|
||||||
gapic-common (>= 0.20.0, < 2.a)
|
gapic-common (>= 0.20.0, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-protobuf (3.25.5)
|
google-protobuf (3.25.7)
|
||||||
google-protobuf (3.25.5-arm64-darwin)
|
|
||||||
google-protobuf (3.25.5-x86_64-darwin)
|
|
||||||
google-protobuf (3.25.5-x86_64-linux)
|
|
||||||
googleapis-common-protos (1.6.0)
|
googleapis-common-protos (1.6.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
googleapis-common-protos-types (~> 1.7)
|
googleapis-common-protos-types (~> 1.7)
|
||||||
grpc (~> 1.41)
|
grpc (~> 1.41)
|
||||||
googleapis-common-protos-types (1.14.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
google-protobuf (~> 3.18)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
googleauth (1.11.2)
|
googleauth (1.11.2)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-env (~> 2.1)
|
google-cloud-env (~> 2.1)
|
||||||
@@ -334,17 +343,17 @@ GEM
|
|||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
groupdate (6.2.1)
|
groupdate (6.2.1)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
grpc (1.62.0)
|
grpc (1.72.0)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.62.0-arm64-darwin)
|
grpc (1.72.0-arm64-darwin)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.62.0-x86_64-darwin)
|
grpc (1.72.0-x86_64-darwin)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.62.0-x86_64-linux)
|
grpc (1.72.0-x86_64-linux)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
haikunator (1.1.1)
|
haikunator (1.1.1)
|
||||||
hairtrigger (1.0.0)
|
hairtrigger (1.0.0)
|
||||||
@@ -370,7 +379,7 @@ GEM
|
|||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
i18n (1.14.6)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.12.2)
|
image_processing (1.12.2)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
@@ -388,7 +397,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.6.3)
|
json (2.12.0)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -423,21 +432,13 @@ GEM
|
|||||||
faraday-multipart
|
faraday-multipart
|
||||||
json (>= 1.8)
|
json (>= 1.8)
|
||||||
rexml
|
rexml
|
||||||
|
language_server-protocol (3.17.0.5)
|
||||||
launchy (2.5.2)
|
launchy (2.5.2)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
letter_opener (1.8.1)
|
letter_opener (1.8.1)
|
||||||
launchy (>= 2.2, < 3)
|
launchy (>= 2.2, < 3)
|
||||||
libdatadog (7.0.0.1.0)
|
|
||||||
libdatadog (7.0.0.1.0-x86_64-linux)
|
|
||||||
libddwaf (1.14.0.0.0)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.14.0.0.0-arm64-darwin)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.14.0.0.0-x86_64-darwin)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.14.0.0.0-x86_64-linux)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
line-bot-api (1.28.0)
|
line-bot-api (1.28.0)
|
||||||
|
lint_roller (1.1.0)
|
||||||
liquid (5.4.0)
|
liquid (5.4.0)
|
||||||
listen (3.8.0)
|
listen (3.8.0)
|
||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
@@ -445,7 +446,7 @@ GEM
|
|||||||
llhttp-ffi (0.4.0)
|
llhttp-ffi (0.4.0)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
logger (1.6.0)
|
logger (1.7.0)
|
||||||
lograge (0.14.0)
|
lograge (0.14.0)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
@@ -471,10 +472,10 @@ GEM
|
|||||||
mini_magick (4.12.0)
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.8)
|
mini_portile2 (2.8.8)
|
||||||
minitest (5.25.4)
|
minitest (5.25.5)
|
||||||
mock_redis (0.36.0)
|
mock_redis (0.36.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
msgpack (1.7.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
multipart-post (2.3.0)
|
multipart-post (2.3.0)
|
||||||
@@ -545,14 +546,16 @@ GEM
|
|||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
parallel (1.23.0)
|
parallel (1.27.0)
|
||||||
parser (3.2.2.1)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
|
racc
|
||||||
pg (1.5.3)
|
pg (1.5.3)
|
||||||
pg_search (2.3.6)
|
pg_search (2.3.6)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
pgvector (0.1.1)
|
pgvector (0.1.1)
|
||||||
|
prism (1.4.0)
|
||||||
procore-sift (1.0.0)
|
procore-sift (1.0.0)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
pry (0.14.2)
|
pry (0.14.2)
|
||||||
@@ -567,7 +570,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.14)
|
rack (2.2.15)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -581,23 +584,28 @@ GEM
|
|||||||
rack (~> 2.2, >= 2.2.4)
|
rack (~> 2.2, >= 2.2.4)
|
||||||
rack-proxy (0.7.7)
|
rack-proxy (0.7.7)
|
||||||
rack
|
rack
|
||||||
|
rack-session (1.0.2)
|
||||||
|
rack (< 3)
|
||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rack-timeout (0.6.3)
|
rack-timeout (0.6.3)
|
||||||
rails (7.0.8.7)
|
rackup (1.0.1)
|
||||||
actioncable (= 7.0.8.7)
|
rack (< 3)
|
||||||
actionmailbox (= 7.0.8.7)
|
webrick
|
||||||
actionmailer (= 7.0.8.7)
|
rails (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actioncable (= 7.1.5.1)
|
||||||
actiontext (= 7.0.8.7)
|
actionmailbox (= 7.1.5.1)
|
||||||
actionview (= 7.0.8.7)
|
actionmailer (= 7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activemodel (= 7.0.8.7)
|
actiontext (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
actionview (= 7.1.5.1)
|
||||||
activestorage (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activemodel (= 7.1.5.1)
|
||||||
|
activerecord (= 7.1.5.1)
|
||||||
|
activestorage (= 7.1.5.1)
|
||||||
|
activesupport (= 7.1.5.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.0.8.7)
|
railties (= 7.1.5.1)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -605,13 +613,14 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.1)
|
rails-html-sanitizer (1.6.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (7.0.8.7)
|
railties (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
method_source
|
irb
|
||||||
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
zeitwerk (~> 2.5)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.2.1)
|
rake (13.2.1)
|
||||||
rb-fsevent (0.11.2)
|
rb-fsevent (0.11.2)
|
||||||
@@ -623,7 +632,7 @@ GEM
|
|||||||
connection_pool
|
connection_pool
|
||||||
redis-namespace (1.10.0)
|
redis-namespace (1.10.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
regexp_parser (2.8.0)
|
regexp_parser (2.10.0)
|
||||||
reline (0.3.6)
|
reline (0.3.6)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
@@ -643,7 +652,7 @@ GEM
|
|||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
reverse_markdown (2.1.1)
|
reverse_markdown (2.1.1)
|
||||||
nokogiri
|
nokogiri
|
||||||
rexml (3.3.9)
|
rexml (3.4.1)
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.2)
|
rspec-expectations (3.13.2)
|
||||||
@@ -663,30 +672,36 @@ GEM
|
|||||||
rspec-support (3.13.1)
|
rspec-support (3.13.1)
|
||||||
rspec_junit_formatter (0.6.0)
|
rspec_junit_formatter (0.6.0)
|
||||||
rspec-core (>= 2, < 4, != 2.12.0)
|
rspec-core (>= 2, < 4, != 2.12.0)
|
||||||
rubocop (1.50.2)
|
rubocop (1.75.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
lint_roller (~> 1.1.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.2.0.0)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rexml (>= 3.2.5, < 4.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-ast (>= 1.28.0, < 2.0)
|
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.28.1)
|
rubocop-ast (1.44.1)
|
||||||
parser (>= 3.2.1.0)
|
parser (>= 3.3.7.2)
|
||||||
rubocop-capybara (2.18.0)
|
prism (~> 1.4)
|
||||||
rubocop (~> 1.41)
|
rubocop-factory_bot (2.27.1)
|
||||||
rubocop-performance (1.17.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop-ast (>= 0.4.0)
|
rubocop-performance (1.25.0)
|
||||||
rubocop-rails (2.19.1)
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
|
rubocop-ast (>= 1.38.0, < 2.0)
|
||||||
|
rubocop-rails (2.32.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-rspec (2.21.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop (~> 1.33)
|
rubocop-rspec (3.6.0)
|
||||||
rubocop-capybara (~> 2.17)
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
ruby-openai (7.3.1)
|
ruby-openai (7.3.1)
|
||||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||||
faraday (>= 1)
|
faraday (>= 1)
|
||||||
@@ -816,8 +831,10 @@ GEM
|
|||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.8.2)
|
unf_ext (0.0.8.2)
|
||||||
unicode-display_width (2.4.2)
|
unicode-display_width (3.1.4)
|
||||||
uniform_notifier (1.16.0)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
|
unicode-emoji (4.0.4)
|
||||||
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.3)
|
uri (1.0.3)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
@@ -845,7 +862,9 @@ GEM
|
|||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket-driver (0.7.6)
|
webrick (1.9.1)
|
||||||
|
websocket-driver (0.7.7)
|
||||||
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
wisper (2.0.0)
|
wisper (2.0.0)
|
||||||
@@ -951,7 +970,7 @@ DEPENDENCIES
|
|||||||
rack-cors (= 2.0.0)
|
rack-cors (= 2.0.0)
|
||||||
rack-mini-profiler (>= 3.2.0)
|
rack-mini-profiler (>= 3.2.0)
|
||||||
rack-timeout
|
rack-timeout
|
||||||
rails (~> 7.0.8.4)
|
rails (~> 7.1)
|
||||||
redis
|
redis
|
||||||
redis-namespace
|
redis-namespace
|
||||||
responders (>= 3.1.1)
|
responders (>= 3.1.1)
|
||||||
@@ -960,6 +979,7 @@ DEPENDENCIES
|
|||||||
rspec-rails (>= 6.1.5)
|
rspec-rails (>= 6.1.5)
|
||||||
rspec_junit_formatter
|
rspec_junit_formatter
|
||||||
rubocop
|
rubocop
|
||||||
|
rubocop-factory_bot
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
@@ -997,7 +1017,7 @@ DEPENDENCIES
|
|||||||
working_hours
|
working_hours
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.3p89
|
ruby 3.4.4p34
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.16
|
2.5.16
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ class AccountBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_email
|
def validate_email
|
||||||
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
|
Account::SignUpEmailValidationService.new(@email).perform
|
||||||
|
|
||||||
address = ValidEmail2::Address.new(@email)
|
|
||||||
if address.valid? && !address.disposable?
|
|
||||||
true
|
|
||||||
else
|
|
||||||
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_user
|
def validate_user
|
||||||
@@ -81,21 +74,4 @@ class AccountBuilder
|
|||||||
@user.confirm if @confirmed
|
@user.confirm if @confirmed
|
||||||
@user.save!
|
@user.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def domain_blocked?
|
|
||||||
domain = @email.split('@').last
|
|
||||||
|
|
||||||
blocked_domains.each do |blocked_domain|
|
|
||||||
return true if domain.match?(blocked_domain)
|
|
||||||
end
|
|
||||||
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def blocked_domains
|
|
||||||
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
|
|
||||||
return [] if domains.blank?
|
|
||||||
|
|
||||||
domains.split("\n").map(&:strip)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ class ContactInboxBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_contact_inbox
|
def create_contact_inbox
|
||||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
attrs = {
|
||||||
contact_id: @contact.id,
|
contact_id: @contact.id,
|
||||||
inbox_id: @inbox.id,
|
inbox_id: @inbox.id,
|
||||||
source_id: @source_id
|
source_id: @source_id
|
||||||
)
|
}
|
||||||
|
|
||||||
|
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
|
||||||
rescue ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotUnique
|
||||||
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
||||||
update_old_contact_inbox
|
update_old_contact_inbox
|
||||||
|
|||||||
@@ -152,11 +152,13 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
end
|
end
|
||||||
|
|
||||||
def message_already_exists?
|
def message_already_exists?
|
||||||
cw_message = conversation.messages.where(
|
find_message_by_source_id(@messaging[:message][:mid]).present?
|
||||||
source_id: @messaging[:message][:mid]
|
end
|
||||||
).first
|
|
||||||
|
|
||||||
cw_message.present?
|
def find_message_by_source_id(source_id)
|
||||||
|
return unless source_id
|
||||||
|
|
||||||
|
@message = Message.find_by(source_id: source_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def all_unsupported_files?
|
def all_unsupported_files?
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def article_params
|
def article_params
|
||||||
params.require(:article).permit(
|
params.require(:article).permit(
|
||||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||||
:locale, meta: [:title,
|
:locale, meta: [:title,
|
||||||
:description,
|
:description,
|
||||||
{ tags: [] }]
|
{ tags: [] }]
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
|||||||
Current.account
|
Current.account
|
||||||
).perform
|
).perform
|
||||||
|
|
||||||
# Only allow conversations from inboxes the user has access to
|
|
||||||
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
|
|
||||||
conversations = conversations.where(inbox_id: inbox_ids)
|
|
||||||
|
|
||||||
@conversations = conversations.order(last_activity_at: :desc).limit(20)
|
@conversations = conversations.order(last_activity_at: :desc).limit(20)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id,
|
||||||
|
label_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_hook
|
def fetch_hook
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
|||||||
@result = search('Message')
|
@result = search('Message')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def articles
|
||||||
|
@result = search('Article')
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def search(search_type)
|
def search(search_type)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
|
|
||||||
def sign_up_user
|
def sign_up_user
|
||||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
|
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||||
|
|
||||||
create_account_for_user
|
create_account_for_user
|
||||||
token = @resource.send(:set_reset_password_token)
|
token = @resource.send(:set_reset_password_token)
|
||||||
@@ -53,9 +53,11 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
).first
|
).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_business_account?
|
def validate_signup_email_is_business_domain?
|
||||||
# return true if the user is a business account, false if it is a gmail account
|
# return true if the user is a business account, false if it is a blocked domain account
|
||||||
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
|
Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform
|
||||||
|
rescue CustomExceptions::Account::InvalidEmail
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_account_for_user
|
def create_account_for_user
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class Platform::Api::V1::UsersController < PlatformController
|
class Platform::Api::V1::UsersController < PlatformController
|
||||||
# ref: https://stackoverflow.com/a/45190318/939299
|
# ref: https://stackoverflow.com/a/45190318/939299
|
||||||
# set resource is called for other actions already in platform controller
|
# set resource is called for other actions already in platform controller
|
||||||
# we want to add login to that chain as well
|
# we want to add login and token to that chain as well
|
||||||
before_action(only: [:login]) { set_resource }
|
before_action(only: [:login, :token]) { set_resource }
|
||||||
before_action(only: [:login]) { validate_platform_app_permissible }
|
before_action(only: [:login, :token]) { validate_platform_app_permissible }
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ class Platform::Api::V1::UsersController < PlatformController
|
|||||||
render json: { url: @resource.generate_sso_link }
|
render json: { url: @resource.generate_sso_link }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def token; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@resource.assign_attributes(user_update_params)
|
@resource.assign_attributes(user_update_params)
|
||||||
|
|
||||||
|
|||||||
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def allowed_configs
|
def allowed_configs
|
||||||
@allowed_configs = case @config
|
mapping = {
|
||||||
when 'facebook'
|
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||||
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
|
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||||
when 'shopify'
|
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||||
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
|
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||||
when 'microsoft'
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
when 'email'
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
}
|
||||||
when 'linear'
|
|
||||||
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
|
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||||
when 'instagram'
|
|
||||||
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
|
||||||
else
|
|
||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class ConversationFinder
|
|||||||
|
|
||||||
def find_conversation_by_inbox
|
def find_conversation_by_inbox
|
||||||
@conversations = current_account.conversations
|
@conversations = current_account.conversations
|
||||||
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
|
|
||||||
|
return unless params[:inbox_id]
|
||||||
|
|
||||||
|
@conversations = @conversations.where(inbox_id: @inbox_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_all_conversations
|
def find_all_conversations
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CopilotMessages extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/copilot_threads', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(threadId) {
|
||||||
|
return axios.get(`${this.url}/${threadId}/copilot_messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create({ threadId, ...rest }) {
|
||||||
|
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CopilotMessages();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CopilotThreads extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/copilot_threads', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CopilotThreads();
|
||||||
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
articles({ q, page = 1 }) {
|
||||||
|
return axios.get(`${this.url}/articles`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
page: page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SearchAPI();
|
export default new SearchAPI();
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
selectedContact: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [showDeleteSection, toggleDeleteSection] = useToggle();
|
||||||
|
const confirmDeleteContactDialogRef = ref(null);
|
||||||
|
|
||||||
|
const openConfirmDeleteContactDialog = () => {
|
||||||
|
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
|
sm
|
||||||
|
link
|
||||||
|
slate
|
||||||
|
class="hover:!no-underline text-n-slate-12"
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
trailing-icon
|
||||||
|
@click="toggleDeleteSection()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||||
|
:class="
|
||||||
|
showDeleteSection
|
||||||
|
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||||
|
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden min-h-0">
|
||||||
|
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||||
|
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
link
|
||||||
|
@click="openConfirmDeleteContactDialog()"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ConfirmContactDeleteDialog
|
||||||
|
ref="confirmDeleteContactDialogRef"
|
||||||
|
:selected-contact="selectedContact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||||
|
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||||
import countries from 'shared/constants/countries';
|
import countries from 'shared/constants/countries';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template #after>
|
<template #after>
|
||||||
<transition
|
<div
|
||||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
:class="
|
||||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
isExpanded
|
||||||
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
"
|
||||||
>
|
>
|
||||||
<div v-show="isExpanded" class="w-full">
|
<div class="overflow-hidden">
|
||||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||||
<ContactsForm
|
<ContactsForm
|
||||||
ref="contactsFormRef"
|
ref="contactsFormRef"
|
||||||
@@ -176,8 +177,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ContactDeleteSection
|
||||||
|
:selected-contact="{
|
||||||
|
id: props.id,
|
||||||
|
name: props.name,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+1
-5
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
|
|||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="alert"
|
type="alert"
|
||||||
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
||||||
:description="
|
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
|
||||||
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
|
|
||||||
contactName: props.selectedContact.name,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
||||||
@confirm="handleDialogConfirm"
|
@confirm="handleDialogConfirm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const STYLE_CONFIG = {
|
|||||||
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
|
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||||
ghost:
|
ghost:
|
||||||
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||||
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
solid:
|
solid:
|
||||||
|
|||||||
+23
-1
@@ -42,6 +42,7 @@ const initialState = {
|
|||||||
conversationFaqs: false,
|
conversationFaqs: false,
|
||||||
memories: false,
|
memories: false,
|
||||||
},
|
},
|
||||||
|
temperature: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
|
|||||||
conversationFaqs: config.feature_faq || false,
|
conversationFaqs: config.feature_faq || false,
|
||||||
memories: config.feature_memory || false,
|
memories: config.feature_memory || false,
|
||||||
};
|
};
|
||||||
|
state.temperature = config.temperature || 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBasicInfoUpdate = async () => {
|
const handleBasicInfoUpdate = async () => {
|
||||||
@@ -136,6 +138,7 @@ const handleInstructionsUpdate = async () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
config: {
|
config: {
|
||||||
...props.assistant.config,
|
...props.assistant.config,
|
||||||
|
temperature: state.temperature || 1,
|
||||||
instructions: state.instructions,
|
instructions: state.instructions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -212,7 +215,7 @@ watch(
|
|||||||
|
|
||||||
<!-- Instructions Section -->
|
<!-- Instructions Section -->
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.instructions"
|
v-model="state.instructions"
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||||
@@ -221,6 +224,25 @@ watch(
|
|||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 mt-4">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
v-model="state.temperature"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11 italic">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Avatar from '../avatar/Avatar.vue';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
message: {
|
message: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
supportAgent: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="space-y-1 text-n-slate-12">
|
||||||
<Avatar
|
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
||||||
:name="supportAgent.available_name"
|
<div class="break-words">
|
||||||
:src="supportAgent.avatar_url"
|
{{ message.content }}
|
||||||
:size="24"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
<div class="space-y-1 text-n-slate-12">
|
|
||||||
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
|
||||||
<div class="break-words">
|
|
||||||
{{ message.content }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
|||||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from '../avatar/Avatar.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
message: {
|
message: {
|
||||||
@@ -46,33 +45,25 @@ const useCopilotResponse = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||||
<Avatar
|
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||||
name="Captain Copilot"
|
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||||
icon-name="i-woot-captain"
|
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||||
:size="24"
|
</span>
|
||||||
rounded-full
|
<div
|
||||||
|
v-else
|
||||||
|
v-dompurify-html="messageContent"
|
||||||
|
class="prose-sm break-words"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
<div class="flex flex-row mt-1">
|
||||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
<Button
|
||||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
v-if="!hasEmptyMessageContent"
|
||||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||||
</span>
|
faded
|
||||||
<div
|
sm
|
||||||
v-else
|
slate
|
||||||
v-dompurify-html="messageContent"
|
@click="useCopilotResponse"
|
||||||
class="prose-sm break-words"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-row mt-1">
|
|
||||||
<Button
|
|
||||||
v-if="!hasEmptyMessageContent"
|
|
||||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
|
||||||
faded
|
|
||||||
sm
|
|
||||||
slate
|
|
||||||
@click="useCopilotResponse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import CopilotHeader from './CopilotHeader.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Captain/Copilot/CopilotHeader"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<!-- Default State -->
|
||||||
|
<Variant title="Default State">
|
||||||
|
<CopilotHeader />
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<!-- With New Conversation Button -->
|
||||||
|
<Variant title="With New Conversation Button">
|
||||||
|
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
|
||||||
|
<CopilotHeader :has-messages="true" />
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from '../button/Button.vue';
|
||||||
|
defineProps({
|
||||||
|
hasMessages: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
defineEmits(['reset', 'close']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-1">
|
||||||
|
<span class="font-medium text-sm text-n-slate-12">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.TITLE') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button
|
||||||
|
v-if="hasMessages"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
ghost
|
||||||
|
sm
|
||||||
|
@click="$emit('reset')"
|
||||||
|
/>
|
||||||
|
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,19 +13,16 @@ const sendMessage = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<form class="relative" @submit.prevent="sendMessage">
|
||||||
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
|
|
||||||
@submit.prevent="sendMessage"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
v-model="message"
|
v-model="message"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
||||||
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
|
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
|
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<i class="i-ph-arrow-up" />
|
<i class="i-ph-arrow-up" />
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import Icon from '../../components-next/icon/Icon.vue';
|
||||||
|
defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-3 rounded-lg bg-n-background/50 border border-n-weak hover:bg-n-background/80 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-sparkles"
|
||||||
|
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
|
||||||
|
/>
|
||||||
|
<div class="text-sm text-n-slate-11">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: 'Analyzing the user query',
|
||||||
|
reasoning: 'Breaking down the request into actionable steps',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: 'Searching codebase',
|
||||||
|
reasoning: 'Looking for relevant files and functions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: 'Generating response',
|
||||||
|
reasoning: 'Composing a helpful and accurate answer',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story title="Captain/Copilot/CopilotThinkingGroup" group="components">
|
||||||
|
<Variant title="Default">
|
||||||
|
<CopilotThinkingGroup :messages="messages" />
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="With Default Collapsed">
|
||||||
|
<!-- eslint-disable-next-line -->
|
||||||
|
<CopilotThinkingGroup :messages="messages" :default-collapsed="true" />
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from '../icon/Icon.vue';
|
||||||
|
import CopilotThinkingBlock from './CopilotThinkingBlock.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
messages: { type: Array, required: true },
|
||||||
|
defaultCollapsed: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const { t } = useI18n();
|
||||||
|
const isExpanded = ref(!props.defaultCollapsed);
|
||||||
|
|
||||||
|
const thinkingCount = computed(() => props.messages.length);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.defaultCollapsed,
|
||||||
|
newValue => {
|
||||||
|
if (newValue) {
|
||||||
|
isExpanded.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="group flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-11 transition-colors duration-200 -ml-3"
|
||||||
|
@click="isExpanded = !isExpanded"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
||||||
|
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{{ t('CAPTAIN.COPILOT.SHOW_STEPS') }}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center h-4 min-w-4 px-1 text-xs font-medium rounded-full bg-n-solid-3 text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ thinkingCount }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-show="isExpanded"
|
||||||
|
class="space-y-3 transition-all duration-200"
|
||||||
|
:class="{
|
||||||
|
'opacity-100': isExpanded,
|
||||||
|
'opacity-0 max-h-0 overflow-hidden': !isExpanded,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<CopilotThinkingBlock
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
:content="message.content"
|
||||||
|
:reasoning="message.reasoning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowContextMenu = computed(() => {
|
const shouldShowContextMenu = computed(() => {
|
||||||
return !(
|
return !props.contentAttributes?.isUnsupported;
|
||||||
props.status === MESSAGE_STATUS.FAILED ||
|
|
||||||
props.status === MESSAGE_STATUS.PROGRESS ||
|
|
||||||
props.contentAttributes?.isUnsupported
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBubble = computed(() => {
|
const isBubble = computed(() => {
|
||||||
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
|
|||||||
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
||||||
|
|
||||||
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
||||||
|
const isFailedOrProcessing =
|
||||||
|
props.status === MESSAGE_STATUS.FAILED ||
|
||||||
|
props.status === MESSAGE_STATUS.PROGRESS;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copy: hasText,
|
copy: hasText,
|
||||||
delete: hasText || hasAttachments,
|
delete:
|
||||||
cannedResponse: isOutgoing && hasText,
|
(hasText || hasAttachments) &&
|
||||||
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
|
!isFailedOrProcessing &&
|
||||||
|
!isMessageDeleted.value,
|
||||||
|
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
|
||||||
|
copyLink: !isFailedOrProcessing,
|
||||||
|
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
|
||||||
|
replyTo:
|
||||||
|
!props.private &&
|
||||||
|
props.inboxSupportsReplyTo.outgoing &&
|
||||||
|
!isFailedOrProcessing,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -499,8 +506,8 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
class="[grid-area:bubble] flex"
|
class="[grid-area:bubble] flex"
|
||||||
:class="{
|
:class="{
|
||||||
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||||
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
|
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||||
}"
|
}"
|
||||||
@contextmenu="openContextMenu($event)"
|
@contextmenu="openContextMenu($event)"
|
||||||
@@ -516,7 +523,7 @@ provideMessageContext({
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
v-if="isBubble && !isMessageDeleted"
|
v-if="isBubble"
|
||||||
:context-menu-position="contextMenuPosition"
|
:context-menu-position="contextMenuPosition"
|
||||||
:is-open="showContextMenu"
|
:is-open="showContextMenu"
|
||||||
:enabled-options="contextMenuEnabledOptions"
|
:enabled-options="contextMenuEnabledOptions"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const senderName = computed(() => {
|
|||||||
<Icon :icon="icon" class="text-white size-4" />
|
<Icon :icon="icon" class="text-white size-4" />
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1 overflow-hidden">
|
||||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||||
{{
|
{{
|
||||||
t(senderTranslationKey, {
|
t(senderTranslationKey, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownContainer,
|
DropdownContainer,
|
||||||
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
|||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||||
|
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
|
||||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||||
const statusList = computed(() => {
|
const statusList = computed(() => {
|
||||||
return [
|
return [
|
||||||
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function changeAvailabilityStatus(availability) {
|
function changeAvailabilityStatus(availability) {
|
||||||
|
if (isImpersonating.value) {
|
||||||
|
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
store.dispatch('updateAvailability', {
|
store.dispatch('updateAvailability', {
|
||||||
availability,
|
availability,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
watch,
|
watch,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
|
||||||
defineEmits,
|
defineEmits,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
@@ -44,7 +43,7 @@ import {
|
|||||||
useSnakeCase,
|
useSnakeCase,
|
||||||
} from 'dashboard/composables/useTransformKeys';
|
} from 'dashboard/composables/useTransformKeys';
|
||||||
import { useEmitter } from 'dashboard/composables/emitter';
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
import { useEventListener, useScrollLock } from '@vueuse/core';
|
import { useEventListener } from '@vueuse/core';
|
||||||
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
@@ -87,12 +86,8 @@ const store = useStore();
|
|||||||
|
|
||||||
const conversationListRef = ref(null);
|
const conversationListRef = ref(null);
|
||||||
const conversationDynamicScroller = ref(null);
|
const conversationDynamicScroller = ref(null);
|
||||||
const conversationListScrollableElement = computed(
|
|
||||||
() => conversationDynamicScroller.value?.$el
|
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||||
);
|
|
||||||
const conversationListScrollLock = useScrollLock(
|
|
||||||
conversationListScrollableElement
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||||
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
|
|||||||
|
|
||||||
function onContextMenuToggle(state) {
|
function onContextMenuToggle(state) {
|
||||||
isContextMenuOpen.value = state;
|
isContextMenuOpen.value = state;
|
||||||
conversationListScrollLock.value = state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelectAll(check) {
|
function toggleSelectAll(check) {
|
||||||
@@ -770,10 +764,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
conversationListScrollLock.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
provide('selectConversation', selectConversation);
|
provide('selectConversation', selectConversation);
|
||||||
provide('deSelectConversation', deSelectConversation);
|
provide('deSelectConversation', deSelectConversation);
|
||||||
provide('assignAgent', onAssignAgent);
|
provide('assignAgent', onAssignAgent);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||||
@@ -20,6 +21,10 @@ export default {
|
|||||||
AvailabilityStatusBadge,
|
AvailabilityStatusBadge,
|
||||||
NextButton,
|
NextButton,
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
return { isImpersonating };
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isStatusMenuOpened: false,
|
isStatusMenuOpened: false,
|
||||||
@@ -73,6 +78,13 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
changeAvailabilityStatus(availability) {
|
changeAvailabilityStatus(availability) {
|
||||||
|
if (this.isImpersonating) {
|
||||||
|
useAlert(
|
||||||
|
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isUpdating) {
|
if (this.isUpdating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
import {
|
||||||
import { useWindowSize, useElementBounding } from '@vueuse/core';
|
computed,
|
||||||
|
onMounted,
|
||||||
|
nextTick,
|
||||||
|
onUnmounted,
|
||||||
|
useTemplateRef,
|
||||||
|
inject,
|
||||||
|
} from 'vue';
|
||||||
|
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
|
||||||
|
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
|
||||||
@@ -11,27 +18,34 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const elementToLock = inject('contextMenuElementTarget', null);
|
||||||
|
|
||||||
const menuRef = useTemplateRef('menuRef');
|
const menuRef = useTemplateRef('menuRef');
|
||||||
|
|
||||||
|
const scrollLockElement = computed(() => {
|
||||||
|
if (!elementToLock?.value) return null;
|
||||||
|
return elementToLock.value?.$el;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLocked = useScrollLock(scrollLockElement);
|
||||||
|
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
|
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
|
||||||
|
|
||||||
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
|
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
|
||||||
|
const PADDING = 16;
|
||||||
// Initial position
|
// Initial position
|
||||||
let left = x;
|
let left = x;
|
||||||
let top = y;
|
let top = y;
|
||||||
|
|
||||||
// Boundary checks
|
// Boundary checks
|
||||||
const isOverflowingRight = left + menuW > windowW;
|
const isOverflowingRight = left + menuW > windowW - PADDING;
|
||||||
const isOverflowingBottom = top + menuH > windowH;
|
const isOverflowingBottom = top + menuH > windowH - PADDING;
|
||||||
|
|
||||||
// Adjust position if overflowing
|
// Adjust position if overflowing
|
||||||
if (isOverflowingRight) left = windowW - menuW;
|
if (isOverflowingRight) left = windowW - menuW - PADDING;
|
||||||
if (isOverflowingBottom) top = windowH - menuH;
|
if (isOverflowingBottom) top = windowH - menuH - PADDING;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: Math.max(0, left),
|
left: Math.max(PADDING, left),
|
||||||
top: Math.max(0, top),
|
top: Math.max(PADDING, top),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,8 +68,18 @@ const position = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
isLocked.value = true;
|
||||||
nextTick(() => menuRef.value?.focus());
|
nextTick(() => menuRef.value?.focus());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
isLocked.value = false;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isLocked.value = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -65,7 +89,7 @@ onMounted(() => {
|
|||||||
class="fixed outline-none z-[9999] cursor-pointer"
|
class="fixed outline-none z-[9999] cursor-pointer"
|
||||||
:style="position"
|
:style="position"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@blur="emit('close')"
|
@blur="handleClose"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -185,8 +185,17 @@ export default {
|
|||||||
contextMenuEnabledOptions() {
|
contextMenuEnabledOptions() {
|
||||||
return {
|
return {
|
||||||
copy: this.hasText,
|
copy: this.hasText,
|
||||||
delete: this.hasText || this.hasAttachments,
|
delete:
|
||||||
cannedResponse: this.isOutgoing && this.hasText,
|
(this.hasText || this.hasAttachments) &&
|
||||||
|
!this.isMessageDeleted &&
|
||||||
|
!this.isFailed,
|
||||||
|
cannedResponse:
|
||||||
|
this.isOutgoing && this.hasText && !this.isMessageDeleted,
|
||||||
|
copyLink: !this.isFailed || !this.isProcessing,
|
||||||
|
translate:
|
||||||
|
(!this.isFailed || !this.isProcessing) &&
|
||||||
|
!this.isMessageDeleted &&
|
||||||
|
this.hasText,
|
||||||
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
|
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -328,7 +337,7 @@ export default {
|
|||||||
return !this.sender.type || this.sender.type === 'agent_bot';
|
return !this.sender.type || this.sender.type === 'agent_bot';
|
||||||
},
|
},
|
||||||
shouldShowContextMenu() {
|
shouldShowContextMenu() {
|
||||||
return !(this.isFailed || this.isPending || this.isUnsupported);
|
return !this.isUnsupported;
|
||||||
},
|
},
|
||||||
showAvatar() {
|
showAvatar() {
|
||||||
if (this.isOutgoing || this.isTemplate) {
|
if (this.isOutgoing || this.isTemplate) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
import { ref, provide } from 'vue';
|
||||||
// composable
|
// composable
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
@@ -63,6 +63,7 @@ export default {
|
|||||||
emits: ['contactPanelToggle'],
|
emits: ['contactPanelToggle'],
|
||||||
setup() {
|
setup() {
|
||||||
const isPopOutReplyBox = ref(false);
|
const isPopOutReplyBox = ref(false);
|
||||||
|
const conversationPanelRef = ref(null);
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
const closePopOutReplyBox = () => {
|
const closePopOutReplyBox = () => {
|
||||||
@@ -98,6 +99,8 @@ export default {
|
|||||||
FEATURE_FLAGS.CHATWOOT_V4
|
FEATURE_FLAGS.CHATWOOT_V4
|
||||||
);
|
);
|
||||||
|
|
||||||
|
provide('contextMenuElementTarget', conversationPanelRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnterprise,
|
isEnterprise,
|
||||||
isPopOutReplyBox,
|
isPopOutReplyBox,
|
||||||
@@ -108,6 +111,7 @@ export default {
|
|||||||
fetchIntegrationsIfRequired,
|
fetchIntegrationsIfRequired,
|
||||||
fetchLabelSuggestions,
|
fetchLabelSuggestions,
|
||||||
showNextBubbles,
|
showNextBubbles,
|
||||||
|
conversationPanelRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -541,6 +545,7 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<NextMessageList
|
<NextMessageList
|
||||||
v-if="showNextBubbles"
|
v-if="showNextBubbles"
|
||||||
|
ref="conversationPanelRef"
|
||||||
class="conversation-panel"
|
class="conversation-panel"
|
||||||
:current-user-id="currentUserId"
|
:current-user-id="currentUserId"
|
||||||
:first-unread-id="unReadMessages[0]?.id"
|
:first-unread-id="unReadMessages[0]?.id"
|
||||||
@@ -572,7 +577,7 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</NextMessageList>
|
</NextMessageList>
|
||||||
<ul v-else class="conversation-panel">
|
<ul v-else ref="conversationPanelRef" class="conversation-panel">
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||||
<li class="min-h-[4rem]">
|
<li class="min-h-[4rem]">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useImpersonation } from '../useImpersonation';
|
||||||
|
|
||||||
|
vi.mock('shared/helpers/sessionStorage', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
|
|
||||||
|
describe('useImpersonation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if impersonation flag is set in session storage', () => {
|
||||||
|
SessionStorage.get.mockReturnValue(true);
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
expect(isImpersonating.value).toBe(true);
|
||||||
|
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||||
|
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if impersonation flag is not set in session storage', () => {
|
||||||
|
SessionStorage.get.mockReturnValue(false);
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
expect(isImpersonating.value).toBe(false);
|
||||||
|
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||||
|
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
|
|
||||||
|
export function useImpersonation() {
|
||||||
|
const isImpersonating = computed(() => {
|
||||||
|
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||||
|
});
|
||||||
|
return { isImpersonating };
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const SESSION_STORAGE_KEYS = {
|
||||||
|
IMPERSONATION_USER: 'impersonationUser',
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
|
|||||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
|
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
|
||||||
class ActionCableConnector extends BaseActionCableConnector {
|
class ActionCableConnector extends BaseActionCableConnector {
|
||||||
constructor(app, pubsubToken) {
|
constructor(app, pubsubToken) {
|
||||||
@@ -30,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
'conversation.read': this.onConversationRead,
|
'conversation.read': this.onConversationRead,
|
||||||
'conversation.updated': this.onConversationUpdated,
|
'conversation.updated': this.onConversationUpdated,
|
||||||
'account.cache_invalidated': this.onCacheInvalidate,
|
'account.cache_invalidated': this.onCacheInvalidate,
|
||||||
|
'copilot.message.created': this.onCopilotMessageCreated,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +56,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onPresenceUpdate = data => {
|
onPresenceUpdate = data => {
|
||||||
|
if (isImpersonating.value) return;
|
||||||
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
|
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
|
||||||
this.app.$store.dispatch('agents/updatePresence', data.users);
|
this.app.$store.dispatch('agents/updatePresence', data.users);
|
||||||
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
|
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
|
||||||
@@ -185,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.app.$store.dispatch('notifications/updateNotification', data);
|
this.app.$store.dispatch('notifications/updateNotification', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onCopilotMessageCreated = data => {
|
||||||
|
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||||
|
};
|
||||||
|
|
||||||
onCacheInvalidate = data => {
|
onCacheInvalidate = data => {
|
||||||
const keys = data.cache_keys;
|
const keys = data.cache_keys;
|
||||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||||
|
import ActionCableConnector from '../actionCable';
|
||||||
|
|
||||||
|
vi.mock('shared/helpers/mitt', () => ({
|
||||||
|
emitter: {
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/useImpersonation', () => ({
|
||||||
|
useImpersonation: () => ({
|
||||||
|
isImpersonating: { value: false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.chatwootConfig = {
|
||||||
|
websocketURL: 'wss://test.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ActionCableConnector - Copilot Tests', () => {
|
||||||
|
let store;
|
||||||
|
let actionCable;
|
||||||
|
let mockDispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDispatch = vi.fn();
|
||||||
|
store = {
|
||||||
|
$store: {
|
||||||
|
dispatch: mockDispatch,
|
||||||
|
getters: {
|
||||||
|
getCurrentAccountId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||||
|
});
|
||||||
|
describe('copilot event handlers', () => {
|
||||||
|
it('should register the copilot.message.created event handler', () => {
|
||||||
|
expect(Object.keys(actionCable.events)).toContain(
|
||||||
|
'copilot.message.created'
|
||||||
|
);
|
||||||
|
expect(actionCable.events['copilot.message.created']).toBe(
|
||||||
|
actionCable.onCopilotMessageCreated
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the copilot.message.created event through the ActionCable system', () => {
|
||||||
|
const copilotData = {
|
||||||
|
id: 2,
|
||||||
|
content: 'This is a copilot message from ActionCable',
|
||||||
|
conversation_id: 456,
|
||||||
|
created_at: '2025-05-27T15:58:04-06:00',
|
||||||
|
account_id: 1,
|
||||||
|
};
|
||||||
|
actionCable.onReceived({
|
||||||
|
event: 'copilot.message.created',
|
||||||
|
data: copilotData,
|
||||||
|
});
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith(
|
||||||
|
'copilotMessages/upsert',
|
||||||
|
copilotData
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,11 @@
|
|||||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ACCESS_TOKEN": {
|
||||||
|
"TITLE": "Access Token",
|
||||||
|
"DESCRIPTION": "Copy the access token and save it securely",
|
||||||
|
"COPY_SUCCESSFUL": "Access token copied to clipboard"
|
||||||
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"AVATAR": {
|
"AVATAR": {
|
||||||
"LABEL": "Bot avatar"
|
"LABEL": "Bot avatar"
|
||||||
|
|||||||
@@ -458,6 +458,10 @@
|
|||||||
"PLACEHOLDER": "Add Twitter"
|
"PLACEHOLDER": "Add Twitter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"DELETE_CONTACT": {
|
||||||
|
"MESSAGE": "This action is permanent and irreversible.",
|
||||||
|
"BUTTON": "Delete now"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DETAILS": {
|
"DETAILS": {
|
||||||
@@ -467,7 +471,7 @@
|
|||||||
"DELETE_CONTACT": "Delete contact",
|
"DELETE_CONTACT": "Delete contact",
|
||||||
"DELETE_DIALOG": {
|
"DELETE_DIALOG": {
|
||||||
"TITLE": "Confirm Deletion",
|
"TITLE": "Confirm Deletion",
|
||||||
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?",
|
"DESCRIPTION": "Are you sure you want to delete this contact?",
|
||||||
"CONFIRM": "Yes, Delete",
|
"CONFIRM": "Yes, Delete",
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
||||||
|
|||||||
@@ -327,12 +327,14 @@
|
|||||||
"NAME": "Captain",
|
"NAME": "Captain",
|
||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
|
"TITLE": "Copilot",
|
||||||
"SEND_MESSAGE": "Send message...",
|
"SEND_MESSAGE": "Send message...",
|
||||||
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"USE": "Use this",
|
"USE": "Use this",
|
||||||
"RESET": "Reset",
|
"RESET": "Reset",
|
||||||
|
"SHOW_STEPS": "Show steps",
|
||||||
"SELECT_ASSISTANT": "Select Assistant",
|
"SELECT_ASSISTANT": "Select Assistant",
|
||||||
"PROMPTS": {
|
"PROMPTS": {
|
||||||
"SUMMARIZE": {
|
"SUMMARIZE": {
|
||||||
@@ -365,7 +367,6 @@
|
|||||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||||
},
|
},
|
||||||
"ENTERPRISE_PAYWALL": {
|
"ENTERPRISE_PAYWALL": {
|
||||||
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
|
|
||||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||||
},
|
},
|
||||||
@@ -408,6 +409,10 @@
|
|||||||
"PLACEHOLDER": "Enter assistant name",
|
"PLACEHOLDER": "Enter assistant name",
|
||||||
"ERROR": "The name is required"
|
"ERROR": "The name is required"
|
||||||
},
|
},
|
||||||
|
"TEMPERATURE": {
|
||||||
|
"LABEL": "Response Temperature",
|
||||||
|
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
|
||||||
|
},
|
||||||
"DESCRIPTION": {
|
"DESCRIPTION": {
|
||||||
"LABEL": "Description",
|
"LABEL": "Description",
|
||||||
"PLACEHOLDER": "Enter assistant description",
|
"PLACEHOLDER": "Enter assistant description",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"ALL": "All",
|
"ALL": "All",
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages",
|
||||||
|
"ARTICLES": "Articles"
|
||||||
},
|
},
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages",
|
||||||
|
"ARTICLES": "Articles"
|
||||||
},
|
},
|
||||||
"VIEW_MORE": "View more",
|
"VIEW_MORE": "View more",
|
||||||
"LOAD_MORE": "Load more",
|
"LOAD_MORE": "Load more",
|
||||||
|
|||||||
@@ -185,7 +185,8 @@
|
|||||||
"OFFLINE": "Offline"
|
"OFFLINE": "Offline"
|
||||||
},
|
},
|
||||||
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
|
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
|
||||||
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
|
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
|
||||||
|
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
|
||||||
},
|
},
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"LABEL": "Your email address",
|
"LABEL": "Your email address",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
emits: ['open', 'close', 'replyTo'],
|
emits: ['open', 'close', 'replyTo'],
|
||||||
setup() {
|
setup() {
|
||||||
const { getPlainText } = useMessageFormatter();
|
const { getPlainText } = useMessageFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPlainText,
|
getPlainText,
|
||||||
};
|
};
|
||||||
@@ -167,7 +168,7 @@ export default {
|
|||||||
</woot-modal>
|
</woot-modal>
|
||||||
<!-- Confirm Deletion -->
|
<!-- Confirm Deletion -->
|
||||||
<woot-delete-modal
|
<woot-delete-modal
|
||||||
v-if="showDeleteModal"
|
v-if="showDeleteModal && enabledOptions['delete']"
|
||||||
v-model:show="showDeleteModal"
|
v-model:show="showDeleteModal"
|
||||||
class="context-menu--delete-modal"
|
class="context-menu--delete-modal"
|
||||||
:on-close="closeDeleteModal"
|
:on-close="closeDeleteModal"
|
||||||
@@ -212,7 +213,7 @@ export default {
|
|||||||
@click.stop="handleCopy"
|
@click.stop="handleCopy"
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-if="enabledOptions['copy']"
|
v-if="enabledOptions['translate']"
|
||||||
:option="{
|
:option="{
|
||||||
icon: 'translate',
|
icon: 'translate',
|
||||||
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
|
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
|
||||||
@@ -222,6 +223,7 @@ export default {
|
|||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
v-if="enabledOptions['copyLink']"
|
||||||
:option="{
|
:option="{
|
||||||
icon: 'link',
|
icon: 'link',
|
||||||
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
|
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
|
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: { type: [String, Number], default: 0 },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
category: { type: String, default: '' },
|
||||||
|
locale: { type: String, default: '' },
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
portalSlug: { type: String, required: true },
|
||||||
|
accountId: { type: [String, Number], default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_LENGTH = 300;
|
||||||
|
|
||||||
|
const navigateTo = computed(() => {
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncatedContent = computed(() => {
|
||||||
|
if (!props.content) return props.description || '';
|
||||||
|
|
||||||
|
// Use MessageFormatter to properly convert markdown to plain text
|
||||||
|
const formatter = new MessageFormatter(props.content);
|
||||||
|
const plainText = formatter.plainText.trim();
|
||||||
|
|
||||||
|
return plainText.length > MAX_LENGTH
|
||||||
|
? `${plainText.substring(0, MAX_LENGTH)}...`
|
||||||
|
: plainText;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-link
|
||||||
|
:to="navigateTo"
|
||||||
|
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
|
||||||
|
</div>
|
||||||
|
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
|
||||||
|
{{ title }}
|
||||||
|
</h5>
|
||||||
|
<span
|
||||||
|
v-if="category"
|
||||||
|
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="truncatedContent"
|
||||||
|
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ truncatedContent }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
|
import SearchResultArticleItem from './SearchResultArticleItem.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
articles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isFetching: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SearchResultSection
|
||||||
|
:title="$t('SEARCH.SECTION.ARTICLES')"
|
||||||
|
:empty="!articles.length"
|
||||||
|
:query="query"
|
||||||
|
:show-title="showTitle"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
>
|
||||||
|
<ul v-if="articles.length" class="space-y-1.5 list-none">
|
||||||
|
<li v-for="article in articles" :key="article.id">
|
||||||
|
<SearchResultArticleItem
|
||||||
|
:id="article.id"
|
||||||
|
:title="article.title"
|
||||||
|
:description="article.description"
|
||||||
|
:content="article.content"
|
||||||
|
:portal-slug="article.portal_slug"
|
||||||
|
:locale="article.locale"
|
||||||
|
:account-id="accountId"
|
||||||
|
:category="article.category_name"
|
||||||
|
:status="article.status"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</SearchResultSection>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ROLES,
|
ROLES,
|
||||||
CONVERSATION_PERMISSIONS,
|
CONVERSATION_PERMISSIONS,
|
||||||
CONTACT_PERMISSIONS,
|
CONTACT_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
} from 'dashboard/constants/permissions.js';
|
} from 'dashboard/constants/permissions.js';
|
||||||
import {
|
import {
|
||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
@@ -22,6 +23,7 @@ import SearchTabs from './SearchTabs.vue';
|
|||||||
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
||||||
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
||||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||||
|
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -34,6 +36,7 @@ const pages = ref({
|
|||||||
contacts: 1,
|
contacts: 1,
|
||||||
conversations: 1,
|
conversations: 1,
|
||||||
messages: 1,
|
messages: 1,
|
||||||
|
articles: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
@@ -43,6 +46,7 @@ const conversationRecords = useMapGetter(
|
|||||||
'conversationSearch/getConversationRecords'
|
'conversationSearch/getConversationRecords'
|
||||||
);
|
);
|
||||||
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
||||||
|
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
||||||
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
||||||
|
|
||||||
const addTypeToRecords = (records, type) =>
|
const addTypeToRecords = (records, type) =>
|
||||||
@@ -57,6 +61,9 @@ const mappedConversations = computed(() =>
|
|||||||
const mappedMessages = computed(() =>
|
const mappedMessages = computed(() =>
|
||||||
addTypeToRecords(messageRecords, 'message')
|
addTypeToRecords(messageRecords, 'message')
|
||||||
);
|
);
|
||||||
|
const mappedArticles = computed(() =>
|
||||||
|
addTypeToRecords(articleRecords, 'article')
|
||||||
|
);
|
||||||
|
|
||||||
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ const sliceRecordsIfAllTab = items =>
|
|||||||
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
||||||
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
||||||
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
||||||
|
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
||||||
|
|
||||||
const filterByTab = tab =>
|
const filterByTab = tab =>
|
||||||
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
||||||
@@ -73,6 +81,7 @@ const filterByTab = tab =>
|
|||||||
const filterContacts = filterByTab('contacts');
|
const filterContacts = filterByTab('contacts');
|
||||||
const filterConversations = filterByTab('conversations');
|
const filterConversations = filterByTab('conversations');
|
||||||
const filterMessages = filterByTab('messages');
|
const filterMessages = filterByTab('messages');
|
||||||
|
const filterArticles = filterByTab('articles');
|
||||||
|
|
||||||
const userPermissions = computed(() =>
|
const userPermissions = computed(() =>
|
||||||
getUserPermissions(currentUser.value, currentAccountId.value)
|
getUserPermissions(currentUser.value, currentAccountId.value)
|
||||||
@@ -80,7 +89,12 @@ const userPermissions = computed(() =>
|
|||||||
|
|
||||||
const TABS_CONFIG = {
|
const TABS_CONFIG = {
|
||||||
all: {
|
all: {
|
||||||
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [
|
||||||
|
CONTACT_PERMISSIONS,
|
||||||
|
...ROLES,
|
||||||
|
...CONVERSATION_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
],
|
||||||
count: () => null, // No count for all tab
|
count: () => null, // No count for all tab
|
||||||
},
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
@@ -95,6 +109,10 @@ const TABS_CONFIG = {
|
|||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => mappedMessages.value.length,
|
count: () => mappedMessages.value.length,
|
||||||
},
|
},
|
||||||
|
articles: {
|
||||||
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
count: () => mappedArticles.value.length,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
@@ -123,6 +141,10 @@ const totalSearchResultsCount = computed(() => {
|
|||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => conversations.value.length + messages.value.length,
|
count: () => conversations.value.length + messages.value.length,
|
||||||
},
|
},
|
||||||
|
articles: {
|
||||||
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
count: () => articles.value.length,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return filterItemsByPermission(
|
return filterItemsByPermission(
|
||||||
permissionCounts,
|
permissionCounts,
|
||||||
@@ -138,12 +160,13 @@ const activeTabIndex = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFetchingAny = computed(() => {
|
const isFetchingAny = computed(() => {
|
||||||
const { contact, message, conversation, isFetching } = uiFlags.value;
|
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
||||||
return (
|
return (
|
||||||
isFetching ||
|
isFetching ||
|
||||||
contact.isFetching ||
|
contact.isFetching ||
|
||||||
message.isFetching ||
|
message.isFetching ||
|
||||||
conversation.isFetching
|
conversation.isFetching ||
|
||||||
|
article.isFetching
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +194,7 @@ const showLoadMore = computed(() => {
|
|||||||
contacts: mappedContacts.value,
|
contacts: mappedContacts.value,
|
||||||
conversations: mappedConversations.value,
|
conversations: mappedConversations.value,
|
||||||
messages: mappedMessages.value,
|
messages: mappedMessages.value,
|
||||||
|
articles: mappedArticles.value,
|
||||||
}[selectedTab.value];
|
}[selectedTab.value];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,10 +209,11 @@ const showViewMore = computed(() => ({
|
|||||||
conversations:
|
conversations:
|
||||||
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
||||||
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
||||||
|
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const clearSearchResult = () => {
|
const clearSearchResult = () => {
|
||||||
pages.value = { contacts: 1, conversations: 1, messages: 1 };
|
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
||||||
store.dispatch('conversationSearch/clearSearchResults');
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +239,7 @@ const loadMore = () => {
|
|||||||
contacts: 'conversationSearch/contactSearch',
|
contacts: 'conversationSearch/contactSearch',
|
||||||
conversations: 'conversationSearch/conversationSearch',
|
conversations: 'conversationSearch/conversationSearch',
|
||||||
messages: 'conversationSearch/messageSearch',
|
messages: 'conversationSearch/messageSearch',
|
||||||
|
articles: 'conversationSearch/articleSearch',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
||||||
@@ -328,6 +354,28 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</Policy>
|
</Policy>
|
||||||
|
|
||||||
|
<Policy
|
||||||
|
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||||
|
class="flex flex-col justify-center"
|
||||||
|
>
|
||||||
|
<SearchResultArticlesList
|
||||||
|
v-if="filterArticles"
|
||||||
|
:is-fetching="uiFlags.article.isFetching"
|
||||||
|
:articles="articles"
|
||||||
|
:query="query"
|
||||||
|
:show-title="isSelectedTabAll"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="showViewMore.articles"
|
||||||
|
:label="t(`SEARCH.VIEW_MORE`)"
|
||||||
|
icon="i-lucide-eye"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
outline
|
||||||
|
@click="selectedTab = 'articles'"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
|
||||||
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
|
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="!isSelectedTabAll"
|
v-if="!isSelectedTabAll"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ROLES,
|
ROLES,
|
||||||
CONVERSATION_PERMISSIONS,
|
CONVERSATION_PERMISSIONS,
|
||||||
CONTACT_PERMISSIONS,
|
CONTACT_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
} from 'dashboard/constants/permissions.js';
|
} from 'dashboard/constants/permissions.js';
|
||||||
|
|
||||||
import SearchView from './components/SearchView.vue';
|
import SearchView from './components/SearchView.vue';
|
||||||
@@ -12,7 +13,12 @@ export const routes = [
|
|||||||
path: frontendURL('accounts/:accountId/search'),
|
path: frontendURL('accounts/:accountId/search'),
|
||||||
name: 'search',
|
name: 'search',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
|
permissions: [
|
||||||
|
...ROLES,
|
||||||
|
...CONVERSATION_PERMISSIONS,
|
||||||
|
CONTACT_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
component: SearchView,
|
component: SearchView,
|
||||||
},
|
},
|
||||||
|
|||||||
+131
-45
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { required, helpers, url } from '@vuelidate/validators';
|
import { required, helpers, url } from '@vuelidate/validators';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
type: {
|
type: {
|
||||||
@@ -42,6 +45,9 @@ const formState = reactive({
|
|||||||
botAvatarUrl: '',
|
botAvatarUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showAccessToken, toggleAccessToken] = useToggle();
|
||||||
|
const accessToken = ref('');
|
||||||
|
|
||||||
const v$ = useVuelidate(
|
const v$ = useVuelidate(
|
||||||
{
|
{
|
||||||
botName: {
|
botName: {
|
||||||
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
|
|||||||
: uiFlags.value.isUpdating
|
: uiFlags.value.isUpdating
|
||||||
);
|
);
|
||||||
|
|
||||||
const dialogTitle = computed(() =>
|
const dialogTitle = computed(() => {
|
||||||
props.type === MODAL_TYPES.CREATE
|
if (showAccessToken.value) {
|
||||||
|
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.type === MODAL_TYPES.CREATE
|
||||||
? t('AGENT_BOTS.ADD.TITLE')
|
? t('AGENT_BOTS.ADD.TITLE')
|
||||||
: t('AGENT_BOTS.EDIT.TITLE')
|
: t('AGENT_BOTS.EDIT.TITLE');
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const dialogDescription = computed(() => {
|
||||||
|
if (showAccessToken.value) {
|
||||||
|
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
const confirmButtonLabel = computed(() =>
|
const confirmButtonLabel = computed(() =>
|
||||||
props.type === MODAL_TYPES.CREATE
|
props.type === MODAL_TYPES.CREATE
|
||||||
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
|
|||||||
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showAccessTokenInput = computed(
|
||||||
|
() =>
|
||||||
|
showAccessToken.value ||
|
||||||
|
props.type === MODAL_TYPES.EDIT ||
|
||||||
|
accessToken.value
|
||||||
|
);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
Object.assign(formState, {
|
Object.assign(formState, {
|
||||||
botName: '',
|
botName: '',
|
||||||
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
v$.value.$touch();
|
v$.value.$touch();
|
||||||
if (v$.value.$invalid) return;
|
if (v$.value.$invalid) return;
|
||||||
|
if (showAccessToken.value) return;
|
||||||
|
|
||||||
const botData = {
|
const botData = {
|
||||||
name: formState.botName,
|
name: formState.botName,
|
||||||
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
|
|||||||
? botData
|
? botData
|
||||||
: { id: props.selectedBot.id, data: botData };
|
: { id: props.selectedBot.id, data: botData };
|
||||||
|
|
||||||
await store.dispatch(
|
const response = await store.dispatch(
|
||||||
`agentBots/${isCreate ? 'create' : 'update'}`,
|
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||||
actionPayload
|
actionPayload
|
||||||
);
|
);
|
||||||
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
|
|||||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||||
useAlert(alertKey);
|
useAlert(alertKey);
|
||||||
|
|
||||||
dialogRef.value.close();
|
// Show access token after creation
|
||||||
|
if (isCreate) {
|
||||||
|
const { access_token: responseAccessToken, id } = response || {};
|
||||||
|
|
||||||
|
if (id && responseAccessToken) {
|
||||||
|
accessToken.value = responseAccessToken;
|
||||||
|
toggleAccessToken(true);
|
||||||
|
} else {
|
||||||
|
accessToken.value = '';
|
||||||
|
dialogRef.value.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialogRef.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorKey = isCreate
|
const errorKey = isCreate
|
||||||
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
const initializeForm = () => {
|
const initializeForm = () => {
|
||||||
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||||
const { name, description, outgoing_url, thumbnail, bot_config } =
|
const {
|
||||||
props.selectedBot;
|
name,
|
||||||
|
description,
|
||||||
|
outgoing_url: botUrl,
|
||||||
|
thumbnail,
|
||||||
|
bot_config: botConfig,
|
||||||
|
access_token: botAccessToken,
|
||||||
|
} = props.selectedBot;
|
||||||
formState.botName = name || '';
|
formState.botName = name || '';
|
||||||
formState.botDescription = description || '';
|
formState.botDescription = description || '';
|
||||||
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
|
formState.botUrl = botUrl || botConfig?.webhook_url || '';
|
||||||
formState.botAvatarUrl = thumbnail || '';
|
formState.botAvatarUrl = thumbnail || '';
|
||||||
|
|
||||||
|
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
|
||||||
|
accessToken.value = botAccessToken;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCopyToken = async value => {
|
||||||
|
await copyTextToClipboard(value);
|
||||||
|
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (!showAccessToken.value) v$.value?.$reset();
|
||||||
|
accessToken.value = '';
|
||||||
|
toggleAccessToken(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickClose = () => {
|
||||||
|
closeModal();
|
||||||
|
dialogRef.value.close();
|
||||||
|
};
|
||||||
|
|
||||||
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
||||||
|
|
||||||
defineExpose({ dialogRef });
|
defineExpose({ dialogRef });
|
||||||
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
|
|||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="edit"
|
type="edit"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
|
:description="dialogDescription"
|
||||||
:show-cancel-button="false"
|
:show-cancel-button="false"
|
||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
@close="v$.$reset()"
|
@close="closeModal"
|
||||||
>
|
>
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
<div class="mb-2 flex flex-col items-start">
|
<div
|
||||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
|
||||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
class="flex flex-col gap-4"
|
||||||
</span>
|
>
|
||||||
<Avatar
|
<div class="mb-2 flex flex-col items-start">
|
||||||
:src="formState.botAvatarUrl"
|
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||||
:name="formState.botName"
|
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||||
:size="68"
|
</span>
|
||||||
allow-upload
|
<Avatar
|
||||||
@upload="handleImageUpload"
|
:src="formState.botAvatarUrl"
|
||||||
@delete="handleAvatarDelete"
|
:name="formState.botName"
|
||||||
|
:size="68"
|
||||||
|
allow-upload
|
||||||
|
icon-name="i-lucide-bot-message-square"
|
||||||
|
@upload="handleImageUpload"
|
||||||
|
@delete="handleAvatarDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="bot-name"
|
||||||
|
v-model="formState.botName"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||||
|
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||||
|
:message="botNameError"
|
||||||
|
:message-type="botNameError ? 'error' : 'info'"
|
||||||
|
@blur="v$.botName.$touch()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
id="bot-description"
|
||||||
|
v-model="formState.botDescription"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||||
|
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="bot-url"
|
||||||
|
v-model="formState.botUrl"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||||
|
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||||
|
:message="botUrlError"
|
||||||
|
:message-type="botUrlError ? 'error' : 'info'"
|
||||||
|
@blur="v$.botUrl.$touch()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
|
||||||
v-model="formState.botName"
|
<label
|
||||||
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
v-if="type === MODAL_TYPES.EDIT"
|
||||||
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||||
:message="botNameError"
|
>
|
||||||
:message-type="botNameError ? 'error' : 'info'"
|
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
||||||
@blur="v$.botName.$touch()"
|
</label>
|
||||||
/>
|
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
|
||||||
|
</div>
|
||||||
<TextArea
|
|
||||||
v-model="formState.botDescription"
|
|
||||||
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
|
||||||
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="formState.botUrl"
|
|
||||||
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
|
||||||
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
|
||||||
:message="botUrlError"
|
|
||||||
:message-type="botUrlError ? 'error' : 'info'"
|
|
||||||
@blur="v$.botUrl.$touch()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||||
<NextButton
|
<NextButton
|
||||||
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
|
|||||||
slate
|
slate
|
||||||
type="reset"
|
type="reset"
|
||||||
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||||
@click="dialogRef.close()"
|
@click="onClickClose()"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
|
v-if="!showAccessToken"
|
||||||
type="submit"
|
type="submit"
|
||||||
data-testid="label-submit"
|
data-testid="label-submit"
|
||||||
:label="confirmButtonLabel"
|
:label="confirmButtonLabel"
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default {
|
|||||||
<div class="flex flex-col h-auto overflow-auto integration-hooks">
|
<div class="flex flex-col h-auto overflow-auto integration-hooks">
|
||||||
<woot-modal-header
|
<woot-modal-header
|
||||||
:header-title="integration.name"
|
:header-title="integration.name"
|
||||||
:header-content="integration.short_description || integration.description"
|
:header-content="integration.short_description"
|
||||||
/>
|
/>
|
||||||
<FormKit
|
<FormKit
|
||||||
v-model="values"
|
v-model="values"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const onClick = () => {
|
|||||||
<template #masked>
|
<template #masked>
|
||||||
<button
|
<button
|
||||||
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
|
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
|
||||||
|
type="button"
|
||||||
@click="toggleMasked"
|
@click="toggleMasked"
|
||||||
>
|
>
|
||||||
<fluent-icon :icon="maskIcon" :size="16" />
|
<fluent-icon :icon="maskIcon" :size="16" />
|
||||||
@@ -46,7 +47,7 @@ const onClick = () => {
|
|||||||
</template>
|
</template>
|
||||||
</woot-input>
|
</woot-input>
|
||||||
<FormButton
|
<FormButton
|
||||||
type="submit"
|
type="button"
|
||||||
size="large"
|
size="large"
|
||||||
icon="text-copy"
|
icon="text-copy"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
|
||||||
|
import { createStore } from './storeFactory';
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
name: 'CopilotMessages',
|
||||||
|
API: CopilotMessagesAPI,
|
||||||
|
getters: {
|
||||||
|
getMessagesByThreadId: state => copilotThreadId => {
|
||||||
|
return state.records.filter(
|
||||||
|
record => record.copilot_thread?.id === Number(copilotThreadId)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: mutationTypes => ({
|
||||||
|
upsert({ commit }, data) {
|
||||||
|
commit(mutationTypes.UPSERT, data);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
|
||||||
|
import { createStore } from './storeFactory';
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
name: 'CopilotThreads',
|
||||||
|
API: CopilotThreadsAPI,
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
|
||||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||||
|
import {
|
||||||
|
createRecord,
|
||||||
|
deleteRecord,
|
||||||
|
getRecords,
|
||||||
|
showRecord,
|
||||||
|
updateRecord,
|
||||||
|
} from './storeFactoryHelper';
|
||||||
|
|
||||||
export const generateMutationTypes = name => {
|
export const generateMutationTypes = name => {
|
||||||
const capitalizedName = name.toUpperCase();
|
const capitalizedName = name.toUpperCase();
|
||||||
@@ -10,6 +16,7 @@ export const generateMutationTypes = name => {
|
|||||||
EDIT: `EDIT_${capitalizedName}`,
|
EDIT: `EDIT_${capitalizedName}`,
|
||||||
DELETE: `DELETE_${capitalizedName}`,
|
DELETE: `DELETE_${capitalizedName}`,
|
||||||
SET_META: `SET_${capitalizedName}_META`,
|
SET_META: `SET_${capitalizedName}_META`,
|
||||||
|
UPSERT: `UPSERT_${capitalizedName}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +40,6 @@ export const createGetters = () => ({
|
|||||||
getMeta: state => state.meta,
|
getMeta: state => state.meta,
|
||||||
});
|
});
|
||||||
|
|
||||||
// store/mutations.js
|
|
||||||
export const createMutations = mutationTypes => ({
|
export const createMutations = mutationTypes => ({
|
||||||
[mutationTypes.SET_UI_FLAG](state, data) {
|
[mutationTypes.SET_UI_FLAG](state, data) {
|
||||||
state.uiFlags = {
|
state.uiFlags = {
|
||||||
@@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({
|
|||||||
[mutationTypes.ADD]: MutationHelpers.create,
|
[mutationTypes.ADD]: MutationHelpers.create,
|
||||||
[mutationTypes.EDIT]: MutationHelpers.update,
|
[mutationTypes.EDIT]: MutationHelpers.update,
|
||||||
[mutationTypes.DELETE]: MutationHelpers.destroy,
|
[mutationTypes.DELETE]: MutationHelpers.destroy,
|
||||||
|
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
|
||||||
});
|
});
|
||||||
|
|
||||||
// store/actions/crud.js
|
|
||||||
export const createCrudActions = (API, mutationTypes) => ({
|
export const createCrudActions = (API, mutationTypes) => ({
|
||||||
async get({ commit }, params = {}) {
|
get: getRecords(mutationTypes, API),
|
||||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
show: showRecord(mutationTypes, API),
|
||||||
try {
|
create: createRecord(mutationTypes, API),
|
||||||
const response = await API.get(params);
|
update: updateRecord(mutationTypes, API),
|
||||||
commit(mutationTypes.SET, response.data.payload);
|
delete: deleteRecord(mutationTypes, API),
|
||||||
commit(mutationTypes.SET_META, response.data.meta);
|
|
||||||
return response.data.payload;
|
|
||||||
} catch (error) {
|
|
||||||
return throwErrorMessage(error);
|
|
||||||
} finally {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async show({ commit }, id) {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
|
||||||
try {
|
|
||||||
const response = await API.show(id);
|
|
||||||
commit(mutationTypes.ADD, response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
return throwErrorMessage(error);
|
|
||||||
} finally {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async create({ commit }, dataObj) {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
|
||||||
try {
|
|
||||||
const response = await API.create(dataObj);
|
|
||||||
commit(mutationTypes.ADD, response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
return throwErrorMessage(error);
|
|
||||||
} finally {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async update({ commit }, { id, ...updateObj }) {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
|
||||||
try {
|
|
||||||
const response = await API.update(id, updateObj);
|
|
||||||
commit(mutationTypes.EDIT, response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
return throwErrorMessage(error);
|
|
||||||
} finally {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete({ commit }, id) {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
|
||||||
try {
|
|
||||||
await API.delete(id);
|
|
||||||
commit(mutationTypes.DELETE, id);
|
|
||||||
return id;
|
|
||||||
} catch (error) {
|
|
||||||
return throwErrorMessage(error);
|
|
||||||
} finally {
|
|
||||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createStore = options => {
|
export const createStore = options => {
|
||||||
const { name, API, actions } = options;
|
const { name, API, actions, getters } = options;
|
||||||
const mutationTypes = generateMutationTypes(name);
|
const mutationTypes = generateMutationTypes(name);
|
||||||
|
|
||||||
const customActions = actions ? actions(mutationTypes) : {};
|
const customActions = actions ? actions(mutationTypes) : {};
|
||||||
@@ -130,7 +77,10 @@ export const createStore = options => {
|
|||||||
return {
|
return {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state: createInitialState(),
|
state: createInitialState(),
|
||||||
getters: createGetters(),
|
getters: {
|
||||||
|
...createGetters(),
|
||||||
|
...(getters || {}),
|
||||||
|
},
|
||||||
mutations: createMutations(mutationTypes),
|
mutations: createMutations(mutationTypes),
|
||||||
actions: {
|
actions: {
|
||||||
...createCrudActions(API, mutationTypes),
|
...createCrudActions(API, mutationTypes),
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||||
|
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||||
|
import {
|
||||||
|
generateMutationTypes,
|
||||||
|
createInitialState,
|
||||||
|
createGetters,
|
||||||
|
createMutations,
|
||||||
|
createCrudActions,
|
||||||
|
createStore,
|
||||||
|
} from './storeFactory';
|
||||||
|
|
||||||
|
vi.mock('dashboard/store/utils/api', () => ({
|
||||||
|
throwErrorMessage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
|
||||||
|
set: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
setSingleRecord: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('storeFactory', () => {
|
||||||
|
describe('generateMutationTypes', () => {
|
||||||
|
it('generates correct mutation types with capitalized name', () => {
|
||||||
|
const result = generateMutationTypes('test');
|
||||||
|
expect(result).toEqual({
|
||||||
|
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
|
||||||
|
SET: 'SET_TEST',
|
||||||
|
ADD: 'ADD_TEST',
|
||||||
|
EDIT: 'EDIT_TEST',
|
||||||
|
DELETE: 'DELETE_TEST',
|
||||||
|
SET_META: 'SET_TEST_META',
|
||||||
|
UPSERT: 'UPSERT_TEST',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createInitialState', () => {
|
||||||
|
it('returns the correct initial state structure', () => {
|
||||||
|
const result = createInitialState();
|
||||||
|
expect(result).toEqual({
|
||||||
|
records: [],
|
||||||
|
meta: {},
|
||||||
|
uiFlags: {
|
||||||
|
fetchingList: false,
|
||||||
|
fetchingItem: false,
|
||||||
|
creatingItem: false,
|
||||||
|
updatingItem: false,
|
||||||
|
deletingItem: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGetters', () => {
|
||||||
|
it('returns getters with correct implementations', () => {
|
||||||
|
const getters = createGetters();
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
|
||||||
|
uiFlags: { fetchingList: true },
|
||||||
|
meta: { totalCount: 10, page: 1 },
|
||||||
|
};
|
||||||
|
expect(getters.getRecords(state)).toEqual([
|
||||||
|
{ id: 3 },
|
||||||
|
{ id: 2 },
|
||||||
|
{ id: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
|
||||||
|
expect(getters.getRecord(state)(4)).toEqual({});
|
||||||
|
|
||||||
|
expect(getters.getUIFlags(state)).toEqual({
|
||||||
|
fetchingList: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getters.getMeta(state)).toEqual({
|
||||||
|
totalCount: 10,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMutations', () => {
|
||||||
|
it('creates mutations with correct implementations', () => {
|
||||||
|
const mutationTypes = generateMutationTypes('test');
|
||||||
|
const mutations = createMutations(mutationTypes);
|
||||||
|
|
||||||
|
const state = { uiFlags: { fetchingList: false } };
|
||||||
|
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
|
||||||
|
expect(state.uiFlags).toEqual({ fetchingList: true });
|
||||||
|
|
||||||
|
const metaState = { meta: {} };
|
||||||
|
mutations[mutationTypes.SET_META](metaState, {
|
||||||
|
total_count: '10',
|
||||||
|
page: '2',
|
||||||
|
});
|
||||||
|
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
|
||||||
|
|
||||||
|
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
|
||||||
|
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
|
||||||
|
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
|
||||||
|
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
|
||||||
|
expect(mutations[mutationTypes.UPSERT]).toBe(
|
||||||
|
MutationHelpers.setSingleRecord
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCrudActions', () => {
|
||||||
|
let API;
|
||||||
|
let commit;
|
||||||
|
let mutationTypes;
|
||||||
|
let actions;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
API = {
|
||||||
|
get: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
commit = vi.fn();
|
||||||
|
mutationTypes = generateMutationTypes('test');
|
||||||
|
actions = createCrudActions(API, mutationTypes);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get action', () => {
|
||||||
|
it('handles successful API response', async () => {
|
||||||
|
const payload = [{ id: 1 }];
|
||||||
|
const meta = { total_count: 10, page: 1 };
|
||||||
|
API.get.mockResolvedValue({ data: { payload, meta } });
|
||||||
|
|
||||||
|
const result = await actions.get({ commit });
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingList: true,
|
||||||
|
});
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingList: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
const error = new Error('API Error');
|
||||||
|
API.get.mockRejectedValue(error);
|
||||||
|
throwErrorMessage.mockReturnValue('Error thrown');
|
||||||
|
|
||||||
|
const result = await actions.get({ commit });
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingList: true,
|
||||||
|
});
|
||||||
|
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingList: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual('Error thrown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('show action', () => {
|
||||||
|
it('handles successful API response', async () => {
|
||||||
|
const data = { id: 1, name: 'Test' };
|
||||||
|
API.show.mockResolvedValue({ data });
|
||||||
|
|
||||||
|
const result = await actions.show({ commit }, 1);
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingItem: true,
|
||||||
|
});
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
const error = new Error('API Error');
|
||||||
|
API.show.mockRejectedValue(error);
|
||||||
|
throwErrorMessage.mockReturnValue('Error thrown');
|
||||||
|
|
||||||
|
const result = await actions.show({ commit }, 1);
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingItem: true,
|
||||||
|
});
|
||||||
|
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
fetchingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual('Error thrown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create action', () => {
|
||||||
|
it('handles successful API response', async () => {
|
||||||
|
const data = { id: 1, name: 'Test' };
|
||||||
|
API.create.mockResolvedValue({ data });
|
||||||
|
|
||||||
|
const result = await actions.create({ commit }, { name: 'Test' });
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
creatingItem: true,
|
||||||
|
});
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
creatingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
const error = new Error('API Error');
|
||||||
|
API.create.mockRejectedValue(error);
|
||||||
|
throwErrorMessage.mockReturnValue('Error thrown');
|
||||||
|
|
||||||
|
const result = await actions.create({ commit }, { name: 'Test' });
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
creatingItem: true,
|
||||||
|
});
|
||||||
|
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
creatingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual('Error thrown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update action', () => {
|
||||||
|
it('handles successful API response', async () => {
|
||||||
|
const data = { id: 1, name: 'Updated' };
|
||||||
|
API.update.mockResolvedValue({ data });
|
||||||
|
|
||||||
|
const result = await actions.update(
|
||||||
|
{ commit },
|
||||||
|
{ id: 1, name: 'Updated' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
updatingItem: true,
|
||||||
|
});
|
||||||
|
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
updatingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
const error = new Error('API Error');
|
||||||
|
API.update.mockRejectedValue(error);
|
||||||
|
throwErrorMessage.mockReturnValue('Error thrown');
|
||||||
|
|
||||||
|
const result = await actions.update(
|
||||||
|
{ commit },
|
||||||
|
{ id: 1, name: 'Updated' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
updatingItem: true,
|
||||||
|
});
|
||||||
|
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
updatingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual('Error thrown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete action', () => {
|
||||||
|
it('handles successful API response', async () => {
|
||||||
|
API.delete.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await actions.delete({ commit }, 1);
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
deletingItem: true,
|
||||||
|
});
|
||||||
|
expect(API.delete).toHaveBeenCalledWith(1);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
deletingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
const error = new Error('API Error');
|
||||||
|
API.delete.mockRejectedValue(error);
|
||||||
|
throwErrorMessage.mockReturnValue('Error thrown');
|
||||||
|
|
||||||
|
const result = await actions.delete({ commit }, 1);
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
deletingItem: true,
|
||||||
|
});
|
||||||
|
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||||
|
deletingItem: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual('Error thrown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createStore', () => {
|
||||||
|
it('creates a complete store with default options', () => {
|
||||||
|
const API = {};
|
||||||
|
const store = createStore({ name: 'test', API });
|
||||||
|
|
||||||
|
expect(store.namespaced).toBe(true);
|
||||||
|
expect(store.state).toEqual(createInitialState());
|
||||||
|
expect(Object.keys(store.getters)).toEqual([
|
||||||
|
'getRecords',
|
||||||
|
'getRecord',
|
||||||
|
'getUIFlags',
|
||||||
|
'getMeta',
|
||||||
|
]);
|
||||||
|
expect(Object.keys(store.mutations)).toEqual([
|
||||||
|
'SET_TEST_UI_FLAG',
|
||||||
|
'SET_TEST_META',
|
||||||
|
'SET_TEST',
|
||||||
|
'ADD_TEST',
|
||||||
|
'EDIT_TEST',
|
||||||
|
'DELETE_TEST',
|
||||||
|
'UPSERT_TEST',
|
||||||
|
]);
|
||||||
|
expect(Object.keys(store.actions)).toEqual([
|
||||||
|
'get',
|
||||||
|
'show',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a store with custom actions and getters', () => {
|
||||||
|
const API = {};
|
||||||
|
const customGetters = { customGetter: () => 'custom' };
|
||||||
|
const customActions = () => ({
|
||||||
|
customAction: () => 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createStore({
|
||||||
|
name: 'test',
|
||||||
|
API,
|
||||||
|
getters: customGetters,
|
||||||
|
actions: customActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.getters).toHaveProperty('customGetter');
|
||||||
|
expect(store.actions).toHaveProperty('customAction');
|
||||||
|
expect(Object.keys(store.getters)).toEqual([
|
||||||
|
'getRecords',
|
||||||
|
'getRecord',
|
||||||
|
'getUIFlags',
|
||||||
|
'getMeta',
|
||||||
|
'customGetter',
|
||||||
|
]);
|
||||||
|
expect(Object.keys(store.actions)).toEqual([
|
||||||
|
'get',
|
||||||
|
'show',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'customAction',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
|
export const getRecords =
|
||||||
|
(mutationTypes, API) =>
|
||||||
|
async ({ commit }, params = {}) => {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||||
|
try {
|
||||||
|
const response = await API.get(params);
|
||||||
|
commit(mutationTypes.SET, response.data.payload);
|
||||||
|
commit(mutationTypes.SET_META, response.data.meta);
|
||||||
|
return response.data.payload;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showRecord =
|
||||||
|
(mutationTypes, API) =>
|
||||||
|
async ({ commit }, id) => {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
||||||
|
try {
|
||||||
|
const response = await API.show(id);
|
||||||
|
commit(mutationTypes.ADD, response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRecord =
|
||||||
|
(mutationTypes, API) =>
|
||||||
|
async ({ commit }, dataObj) => {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||||
|
try {
|
||||||
|
const response = await API.create(dataObj);
|
||||||
|
commit(mutationTypes.UPSERT, response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRecord =
|
||||||
|
(mutationTypes, API) =>
|
||||||
|
async ({ commit }, { id, ...updateObj }) => {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
||||||
|
try {
|
||||||
|
const response = await API.update(id, updateObj);
|
||||||
|
commit(mutationTypes.EDIT, response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRecord =
|
||||||
|
(mutationTypes, API) =>
|
||||||
|
async ({ commit }, id) => {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
||||||
|
try {
|
||||||
|
await API.delete(id);
|
||||||
|
commit(mutationTypes.DELETE, id);
|
||||||
|
return id;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -51,6 +51,9 @@ import captainDocuments from './captain/document';
|
|||||||
import captainResponses from './captain/response';
|
import captainResponses from './captain/response';
|
||||||
import captainInboxes from './captain/inboxes';
|
import captainInboxes from './captain/inboxes';
|
||||||
import captainBulkActions from './captain/bulkActions';
|
import captainBulkActions from './captain/bulkActions';
|
||||||
|
import copilotThreads from './captain/copilotThreads';
|
||||||
|
import copilotMessages from './captain/copilotMessages';
|
||||||
|
|
||||||
const plugins = [];
|
const plugins = [];
|
||||||
|
|
||||||
export default createStore({
|
export default createStore({
|
||||||
@@ -106,6 +109,8 @@ export default createStore({
|
|||||||
captainResponses,
|
captainResponses,
|
||||||
captainInboxes,
|
captainInboxes,
|
||||||
captainBulkActions,
|
captainBulkActions,
|
||||||
|
copilotThreads,
|
||||||
|
copilotMessages,
|
||||||
},
|
},
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import types from '../mutation-types';
|
|||||||
import authAPI from '../../api/auth';
|
import authAPI from '../../api/auth';
|
||||||
|
|
||||||
import { setUser, clearCookiesOnLogout } from '../utils/api';
|
import { setUser, clearCookiesOnLogout } from '../utils/api';
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
currentUser: {
|
currentUser: {
|
||||||
@@ -145,8 +147,15 @@ export const actions = {
|
|||||||
updateUISettings: async ({ commit }, params) => {
|
updateUISettings: async ({ commit }, params) => {
|
||||||
try {
|
try {
|
||||||
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
|
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
|
||||||
const response = await authAPI.updateUISettings(params);
|
|
||||||
commit(types.SET_CURRENT_USER, response.data);
|
const isImpersonating = SessionStorage.get(
|
||||||
|
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isImpersonating) {
|
||||||
|
const response = await authAPI.updateUISettings(params);
|
||||||
|
commit(types.SET_CURRENT_USER, response.data);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ export const initialState = {
|
|||||||
contactRecords: [],
|
contactRecords: [],
|
||||||
conversationRecords: [],
|
conversationRecords: [],
|
||||||
messageRecords: [],
|
messageRecords: [],
|
||||||
|
articleRecords: [],
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
isSearchCompleted: false,
|
isSearchCompleted: false,
|
||||||
contact: { isFetching: false },
|
contact: { isFetching: false },
|
||||||
conversation: { isFetching: false },
|
conversation: { isFetching: false },
|
||||||
message: { isFetching: false },
|
message: { isFetching: false },
|
||||||
|
article: { isFetching: false },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +29,9 @@ export const getters = {
|
|||||||
getMessageRecords(state) {
|
getMessageRecords(state) {
|
||||||
return state.messageRecords;
|
return state.messageRecords;
|
||||||
},
|
},
|
||||||
|
getArticleRecords(state) {
|
||||||
|
return state.articleRecords;
|
||||||
|
},
|
||||||
getUIFlags(state) {
|
getUIFlags(state) {
|
||||||
return state.uiFlags;
|
return state.uiFlags;
|
||||||
},
|
},
|
||||||
@@ -65,6 +70,7 @@ export const actions = {
|
|||||||
dispatch('contactSearch', { q }),
|
dispatch('contactSearch', { q }),
|
||||||
dispatch('conversationSearch', { q }),
|
dispatch('conversationSearch', { q }),
|
||||||
dispatch('messageSearch', { q }),
|
dispatch('messageSearch', { q }),
|
||||||
|
dispatch('articleSearch', { q }),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
@@ -108,6 +114,17 @@ export const actions = {
|
|||||||
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
|
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async articleSearch({ commit }, { q, page = 1 }) {
|
||||||
|
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
|
||||||
|
try {
|
||||||
|
const { data } = await SearchAPI.articles({ q, page });
|
||||||
|
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
} finally {
|
||||||
|
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
async clearSearchResults({ commit }) {
|
async clearSearchResults({ commit }) {
|
||||||
commit(types.CLEAR_SEARCH_RESULTS);
|
commit(types.CLEAR_SEARCH_RESULTS);
|
||||||
},
|
},
|
||||||
@@ -126,6 +143,9 @@ export const mutations = {
|
|||||||
[types.MESSAGE_SEARCH_SET](state, records) {
|
[types.MESSAGE_SEARCH_SET](state, records) {
|
||||||
state.messageRecords = [...state.messageRecords, ...records];
|
state.messageRecords = [...state.messageRecords, ...records];
|
||||||
},
|
},
|
||||||
|
[types.ARTICLE_SEARCH_SET](state, records) {
|
||||||
|
state.articleRecords = [...state.articleRecords, ...records];
|
||||||
|
},
|
||||||
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
||||||
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||||
},
|
},
|
||||||
@@ -141,10 +161,14 @@ export const mutations = {
|
|||||||
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||||
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
|
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
|
||||||
},
|
},
|
||||||
|
[types.ARTICLE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||||
|
state.uiFlags.article = { ...state.uiFlags.article, ...uiFlags };
|
||||||
|
},
|
||||||
[types.CLEAR_SEARCH_RESULTS](state) {
|
[types.CLEAR_SEARCH_RESULTS](state) {
|
||||||
state.contactRecords = [];
|
state.contactRecords = [];
|
||||||
state.conversationRecords = [];
|
state.conversationRecords = [];
|
||||||
state.messageRecords = [];
|
state.messageRecords = [];
|
||||||
|
state.articleRecords = [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ describe('#actions', () => {
|
|||||||
q: 'test',
|
q: 'test',
|
||||||
});
|
});
|
||||||
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
|
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +151,30 @@ describe('#actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#articleSearch', () => {
|
||||||
|
it('should handle successful article search', async () => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: { payload: { articles: [{ id: 1 }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.articleSearch({ commit }, { q: 'test', page: 1 });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
|
||||||
|
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failed article search', async () => {
|
||||||
|
axios.get.mockRejectedValue({});
|
||||||
|
await actions.articleSearch({ commit }, { q: 'test' });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#clearSearchResults', () => {
|
describe('#clearSearchResults', () => {
|
||||||
it('should commit clear search results mutation', () => {
|
it('should commit clear search results mutation', () => {
|
||||||
actions.clearSearchResults({ commit });
|
actions.clearSearchResults({ commit });
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ describe('#getters', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getArticleRecords', () => {
|
||||||
|
const state = {
|
||||||
|
articleRecords: [{ id: 1, title: 'Article 1' }],
|
||||||
|
};
|
||||||
|
expect(getters.getArticleRecords(state)).toEqual([
|
||||||
|
{ id: 1, title: 'Article 1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('getUIFlags', () => {
|
it('getUIFlags', () => {
|
||||||
const state = {
|
const state = {
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
@@ -45,6 +54,7 @@ describe('#getters', () => {
|
|||||||
contact: { isFetching: true },
|
contact: { isFetching: true },
|
||||||
message: { isFetching: false },
|
message: { isFetching: false },
|
||||||
conversation: { isFetching: false },
|
conversation: { isFetching: false },
|
||||||
|
article: { isFetching: false },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(getters.getUIFlags(state)).toEqual({
|
expect(getters.getUIFlags(state)).toEqual({
|
||||||
@@ -53,6 +63,7 @@ describe('#getters', () => {
|
|||||||
contact: { isFetching: true },
|
contact: { isFetching: true },
|
||||||
message: { isFetching: false },
|
message: { isFetching: false },
|
||||||
conversation: { isFetching: false },
|
conversation: { isFetching: false },
|
||||||
|
article: { isFetching: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,17 +101,39 @@ describe('#mutations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#ARTICLE_SEARCH_SET', () => {
|
||||||
|
it('should append new article records to existing ones', () => {
|
||||||
|
const state = { articleRecords: [{ id: 1 }] };
|
||||||
|
mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]);
|
||||||
|
expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => {
|
||||||
|
it('set article search UI flags correctly', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
article: { isFetching: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
|
||||||
|
expect(state.uiFlags.article).toEqual({ isFetching: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#CLEAR_SEARCH_RESULTS', () => {
|
describe('#CLEAR_SEARCH_RESULTS', () => {
|
||||||
it('should clear all search records', () => {
|
it('should clear all search records', () => {
|
||||||
const state = {
|
const state = {
|
||||||
contactRecords: [{ id: 1 }],
|
contactRecords: [{ id: 1 }],
|
||||||
conversationRecords: [{ id: 1 }],
|
conversationRecords: [{ id: 1 }],
|
||||||
messageRecords: [{ id: 1 }],
|
messageRecords: [{ id: 1 }],
|
||||||
|
articleRecords: [{ id: 1 }],
|
||||||
};
|
};
|
||||||
mutations[types.CLEAR_SEARCH_RESULTS](state);
|
mutations[types.CLEAR_SEARCH_RESULTS](state);
|
||||||
expect(state.contactRecords).toEqual([]);
|
expect(state.contactRecords).toEqual([]);
|
||||||
expect(state.conversationRecords).toEqual([]);
|
expect(state.conversationRecords).toEqual([]);
|
||||||
expect(state.messageRecords).toEqual([]);
|
expect(state.messageRecords).toEqual([]);
|
||||||
|
expect(state.articleRecords).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -317,8 +317,10 @@ export default {
|
|||||||
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
|
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
|
||||||
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
|
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
|
||||||
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
|
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
|
||||||
|
ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET',
|
||||||
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
|
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
|
||||||
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
|
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
|
||||||
|
ARTICLE_SEARCH_SET_UI_FLAG: 'ARTICLE_SEARCH_SET_UI_FLAG',
|
||||||
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
||||||
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
|
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
|
||||||
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
|
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
|||||||
import differenceInDays from 'date-fns/differenceInDays';
|
import differenceInDays from 'date-fns/differenceInDays';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import {
|
import {
|
||||||
ANALYTICS_IDENTITY,
|
ANALYTICS_IDENTITY,
|
||||||
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
|
|||||||
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
|
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearSessionStorageOnLogout = () => {
|
||||||
|
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteIndexedDBOnLogout = async () => {
|
export const deleteIndexedDBOnLogout = async () => {
|
||||||
let dbs = [];
|
let dbs = [];
|
||||||
try {
|
try {
|
||||||
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
|
|||||||
emitter.emit(ANALYTICS_RESET);
|
emitter.emit(ANALYTICS_RESET);
|
||||||
clearBrowserSessionCookies();
|
clearBrowserSessionCookies();
|
||||||
clearLocalStorageOnLogout();
|
clearLocalStorageOnLogout();
|
||||||
|
clearSessionStorageOnLogout();
|
||||||
const globalConfig = window.globalConfig || {};
|
const globalConfig = window.globalConfig || {};
|
||||||
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
|
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
|
||||||
window.location = logoutRedirectLink;
|
window.location = logoutRedirectLink;
|
||||||
|
|||||||
@@ -25,33 +25,71 @@ export const getHeadingsfromTheArticle = () => {
|
|||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts various input formats to URL objects.
|
||||||
|
* Handles URL objects, domain strings, relative paths, and full URLs.
|
||||||
|
* @param {string|URL} input - Input to convert to URL object
|
||||||
|
* @returns {URL|null} URL object or null if input is invalid
|
||||||
|
*/
|
||||||
|
const toURL = input => {
|
||||||
|
if (!input) return null;
|
||||||
|
if (input instanceof URL) return input;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof input === 'string' &&
|
||||||
|
!input.includes('://') &&
|
||||||
|
!input.startsWith('/')
|
||||||
|
) {
|
||||||
|
return new URL(`https://${input}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string' && input.startsWith('/')) {
|
||||||
|
return new URL(input, window.location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if two URLs belong to the same host by comparing their normalized URL objects.
|
||||||
|
* Handles various input formats including URL objects, domain strings, relative paths, and full URLs.
|
||||||
|
* Returns false if either URL cannot be parsed or normalized.
|
||||||
|
* @param {string|URL} url1 - First URL to compare
|
||||||
|
* @param {string|URL} url2 - Second URL to compare
|
||||||
|
* @returns {boolean} True if both URLs have the same host, false otherwise
|
||||||
|
*/
|
||||||
|
const isSameHost = (url1, url2) => {
|
||||||
|
try {
|
||||||
|
const urlObj1 = toURL(url1);
|
||||||
|
const urlObj2 = toURL(url2);
|
||||||
|
|
||||||
|
if (!urlObj1 || !urlObj2) return false;
|
||||||
|
|
||||||
|
return urlObj1.hostname === urlObj2.hostname;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const openExternalLinksInNewTab = () => {
|
export const openExternalLinksInNewTab = () => {
|
||||||
const { customDomain, hostURL } = window.portalConfig;
|
const { customDomain, hostURL } = window.portalConfig;
|
||||||
const isSameHost =
|
|
||||||
window.location.href.includes(customDomain) ||
|
|
||||||
window.location.href.includes(hostURL);
|
|
||||||
|
|
||||||
// Modify external links only on articles page
|
|
||||||
const isOnArticlePage =
|
const isOnArticlePage =
|
||||||
isSameHost && document.querySelector('#cw-article-content') !== null;
|
document.querySelector('#cw-article-content') !== null;
|
||||||
|
|
||||||
document.addEventListener('click', event => {
|
document.addEventListener('click', event => {
|
||||||
if (!isOnArticlePage) return;
|
if (!isOnArticlePage) return;
|
||||||
|
|
||||||
// Some of the links come wrapped in strong tag through prosemirror
|
const link = event.target.closest('a');
|
||||||
|
|
||||||
const isTagAnchor = event.target.tagName === 'A';
|
if (link) {
|
||||||
const isParentTagAnchor =
|
const currentLocation = window.location.href;
|
||||||
event.target.tagName === 'STRONG' &&
|
const linkHref = link.href;
|
||||||
event.target.parentNode.tagName === 'A';
|
|
||||||
|
|
||||||
if (isTagAnchor || isParentTagAnchor) {
|
|
||||||
const link = isTagAnchor ? event.target : event.target.parentNode;
|
|
||||||
|
|
||||||
|
// Check against current location and custom domains
|
||||||
const isInternalLink =
|
const isInternalLink =
|
||||||
link.hostname === window.location.hostname ||
|
isSameHost(linkHref, currentLocation) ||
|
||||||
link.href.includes(customDomain) ||
|
(customDomain && isSameHost(linkHref, customDomain)) ||
|
||||||
link.href.includes(hostURL);
|
(hostURL && isSameHost(linkHref, hostURL));
|
||||||
|
|
||||||
if (!isInternalLink) {
|
if (!isInternalLink) {
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
import { InitializationHelpers } from '../portalHelpers';
|
import {
|
||||||
|
InitializationHelpers,
|
||||||
|
openExternalLinksInNewTab,
|
||||||
|
} from '../portalHelpers';
|
||||||
|
|
||||||
describe('InitializationHelpers.navigateToLocalePage', () => {
|
describe('InitializationHelpers.navigateToLocalePage', () => {
|
||||||
let dom;
|
let dom;
|
||||||
@@ -44,3 +47,139 @@ describe('InitializationHelpers.navigateToLocalePage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('openExternalLinksInNewTab', () => {
|
||||||
|
let dom;
|
||||||
|
let document;
|
||||||
|
let window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dom = new JSDOM(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div id="cw-article-content">
|
||||||
|
<a href="https://external.com" id="external">External</a>
|
||||||
|
<a href="https://app.chatwoot.com/page" id="internal">Internal</a>
|
||||||
|
<a href="https://custom.domain.com/page" id="custom">Custom</a>
|
||||||
|
<a href="https://example.com" id="nested"><code>Code</code><strong>Bold</strong></a>
|
||||||
|
<ul>
|
||||||
|
<li>Visit the preferences centre here > <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ url: 'https://app.chatwoot.com/hc/article' }
|
||||||
|
);
|
||||||
|
|
||||||
|
document = dom.window.document;
|
||||||
|
window = dom.window;
|
||||||
|
|
||||||
|
window.portalConfig = {
|
||||||
|
customDomain: 'custom.domain.com',
|
||||||
|
hostURL: 'app.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.document = document;
|
||||||
|
global.window = window;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dom = null;
|
||||||
|
document = null;
|
||||||
|
window = null;
|
||||||
|
delete global.document;
|
||||||
|
delete global.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
const simulateClick = selector => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
const event = new window.MouseEvent('click', { bubbles: true });
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
return element.closest('a') || element;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('opens external links in new tab', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const link = simulateClick('#external');
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves internal links', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const internal = simulateClick('#internal');
|
||||||
|
const custom = simulateClick('#custom');
|
||||||
|
|
||||||
|
expect(internal.target).not.toBe('_blank');
|
||||||
|
expect(custom.target).not.toBe('_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles clicks on nested elements', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
simulateClick('#nested code');
|
||||||
|
simulateClick('#nested strong');
|
||||||
|
|
||||||
|
const link = document.getElementById('nested');
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles links inside list items with strong tags', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
// Click on the strong element inside the link in the list
|
||||||
|
simulateClick('#list-link strong');
|
||||||
|
|
||||||
|
const link = document.getElementById('list-link');
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens external links in a new tab even if customDomain is empty', () => {
|
||||||
|
window = dom.window;
|
||||||
|
window.portalConfig = {
|
||||||
|
hostURL: 'app.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.window = window;
|
||||||
|
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const link = simulateClick('#external');
|
||||||
|
const internal = simulateClick('#internal');
|
||||||
|
const custom = simulateClick('#custom');
|
||||||
|
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
|
||||||
|
expect(internal.target).not.toBe('_blank');
|
||||||
|
// this will be blank since the configs customDomain is empty
|
||||||
|
// which is a fair expectation
|
||||||
|
expect(custom.target).toBe('_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens external links in a new tab even if hostURL is empty', () => {
|
||||||
|
window = dom.window;
|
||||||
|
window.portalConfig = {
|
||||||
|
customDomain: 'custom.domain.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.window = window;
|
||||||
|
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const link = simulateClick('#external');
|
||||||
|
const internal = simulateClick('#internal');
|
||||||
|
const custom = simulateClick('#custom');
|
||||||
|
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
|
||||||
|
expect(internal.target).not.toBe('_blank');
|
||||||
|
expect(custom.target).not.toBe('_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export default {
|
||||||
|
clearAll() {
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
try {
|
||||||
|
const value = window.sessionStorage.getItem(key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
} catch (error) {
|
||||||
|
return window.sessionStorage.getItem(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} else {
|
||||||
|
window.sessionStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(key) {
|
||||||
|
window.sessionStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import SessionStorage from '../sessionStorage';
|
||||||
|
|
||||||
|
// Mocking sessionStorage
|
||||||
|
const sessionStorageMock = (() => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getItem: key => store[key] || null,
|
||||||
|
setItem: (key, value) => {
|
||||||
|
store[key] = String(value);
|
||||||
|
},
|
||||||
|
removeItem: key => delete store[key],
|
||||||
|
clear: () => {
|
||||||
|
store = {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'sessionStorage', {
|
||||||
|
value: sessionStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionStorage utility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearAll method', () => {
|
||||||
|
it('should clear all items from sessionStorage', () => {
|
||||||
|
sessionStorage.setItem('testKey1', 'testValue1');
|
||||||
|
sessionStorage.setItem('testKey2', 'testValue2');
|
||||||
|
|
||||||
|
SessionStorage.clearAll();
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('testKey1')).toBeNull();
|
||||||
|
expect(sessionStorage.getItem('testKey2')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get method', () => {
|
||||||
|
it('should retrieve and parse JSON values correctly', () => {
|
||||||
|
const testObject = { a: 1, b: 'test' };
|
||||||
|
sessionStorage.setItem('testKey', JSON.stringify(testObject));
|
||||||
|
|
||||||
|
expect(SessionStorage.get('testKey')).toEqual(testObject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent keys', () => {
|
||||||
|
expect(SessionStorage.get('nonExistentKey')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-JSON values by returning the raw value', () => {
|
||||||
|
sessionStorage.setItem('testKey', 'plain string value');
|
||||||
|
|
||||||
|
expect(SessionStorage.get('testKey')).toBe('plain string value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed JSON gracefully', () => {
|
||||||
|
sessionStorage.setItem('testKey', '{malformed:json}');
|
||||||
|
|
||||||
|
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set method', () => {
|
||||||
|
it('should store object values as JSON strings', () => {
|
||||||
|
const testObject = { a: 1, b: 'test' };
|
||||||
|
SessionStorage.set('testKey', testObject);
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('testKey')).toBe(
|
||||||
|
JSON.stringify(testObject)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store primitive values directly', () => {
|
||||||
|
SessionStorage.set('stringKey', 'test string');
|
||||||
|
expect(sessionStorage.getItem('stringKey')).toBe('test string');
|
||||||
|
|
||||||
|
SessionStorage.set('numberKey', 42);
|
||||||
|
expect(sessionStorage.getItem('numberKey')).toBe('42');
|
||||||
|
|
||||||
|
SessionStorage.set('booleanKey', true);
|
||||||
|
expect(sessionStorage.getItem('booleanKey')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values', () => {
|
||||||
|
SessionStorage.set('nullKey', null);
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('nullKey')).toBe('null');
|
||||||
|
expect(SessionStorage.get('nullKey')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined values', () => {
|
||||||
|
SessionStorage.set('undefinedKey', undefined);
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove method', () => {
|
||||||
|
it('should remove an item from sessionStorage', () => {
|
||||||
|
SessionStorage.set('testKey', 'testValue');
|
||||||
|
expect(SessionStorage.get('testKey')).toBe('testValue');
|
||||||
|
|
||||||
|
SessionStorage.remove('testKey');
|
||||||
|
|
||||||
|
expect(SessionStorage.get('testKey')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when removing a non-existent key', () => {
|
||||||
|
expect(() => {
|
||||||
|
SessionStorage.remove('nonExistentKey');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration of methods', () => {
|
||||||
|
it('should set, get, and remove values correctly', () => {
|
||||||
|
SessionStorage.set('testKey', { value: 'test' });
|
||||||
|
|
||||||
|
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
|
||||||
|
|
||||||
|
SessionStorage.remove('testKey');
|
||||||
|
expect(SessionStorage.get('testKey')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle impersonation flag (common use case)', () => {
|
||||||
|
SessionStorage.set('impersonationUser', true);
|
||||||
|
|
||||||
|
expect(SessionStorage.get('impersonationUser')).toBe(true);
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
|
||||||
|
|
||||||
|
SessionStorage.remove('impersonationUser');
|
||||||
|
expect(SessionStorage.get('impersonationUser')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
|
|||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { required, email } from '@vuelidate/validators';
|
import { required, email } from '@vuelidate/validators';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
// mixins
|
// mixins
|
||||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
|
|||||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
FormInput,
|
FormInput,
|
||||||
@@ -112,6 +115,14 @@ export default {
|
|||||||
this.loginApi.message = message;
|
this.loginApi.message = message;
|
||||||
useAlert(this.loginApi.message);
|
useAlert(this.loginApi.message);
|
||||||
},
|
},
|
||||||
|
handleImpersonation() {
|
||||||
|
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
|
||||||
|
if (impersonation) {
|
||||||
|
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
submitLogin() {
|
submitLogin() {
|
||||||
this.loginApi.hasErrored = false;
|
this.loginApi.hasErrored = false;
|
||||||
this.loginApi.showLoading = true;
|
this.loginApi.showLoading = true;
|
||||||
@@ -128,6 +139,7 @@ export default {
|
|||||||
|
|
||||||
login(credentials)
|
login(credentials)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
this.handleImpersonation();
|
||||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||||
})
|
})
|
||||||
.catch(response => {
|
.catch(response => {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
class Internal::DeleteAccountsJob < ApplicationJob
|
||||||
|
queue_as :scheduled_jobs
|
||||||
|
|
||||||
|
def perform
|
||||||
|
delete_expired_accounts
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def delete_expired_accounts
|
||||||
|
accounts_pending_deletion.each do |account|
|
||||||
|
AccountDeletionService.new(account: account).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def accounts_pending_deletion
|
||||||
|
Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL")
|
||||||
|
.select { |account| deletion_period_expired?(account) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def deletion_period_expired?(account)
|
||||||
|
deletion_time = account.custom_attributes['marked_for_deletion_at']
|
||||||
|
return false if deletion_time.blank?
|
||||||
|
|
||||||
|
DateTime.parse(deletion_time) <= Time.current
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -204,3 +204,5 @@ class ActionCableListener < BaseListener
|
|||||||
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
|
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ActionCableListener.prepend_mod_with('ActionCableListener')
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ApplicationMailbox < ActionMailbox::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def in_reply_to_matches?(in_reply_to)
|
def in_reply_to_matches?(in_reply_to)
|
||||||
Array.wrap(in_reply_to).any? { _1.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
|
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
|
||||||
end
|
end
|
||||||
|
|
||||||
# checks if follow this pattern send it to reply_mailbox
|
# checks if follow this pattern send it to reply_mailbox
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
|
||||||
|
def account_deleted(account)
|
||||||
|
return if instance_admin_email.blank?
|
||||||
|
|
||||||
|
subject = subject_for(account)
|
||||||
|
meta = build_meta(account)
|
||||||
|
|
||||||
|
send_notification(subject, to: instance_admin_email, meta: meta)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_meta(account)
|
||||||
|
deleted_users = params[:soft_deleted_users] || []
|
||||||
|
|
||||||
|
user_info_list = deleted_users.map do |user|
|
||||||
|
{
|
||||||
|
'user_id' => user[:id].to_s,
|
||||||
|
'user_email' => user[:original_email].to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
'instance_url' => instance_url,
|
||||||
|
'account_id' => account.id,
|
||||||
|
'account_name' => account.name,
|
||||||
|
'deleted_at' => format_time(Time.current.iso8601),
|
||||||
|
'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
|
||||||
|
'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
|
||||||
|
'soft_deleted_users' => user_info_list,
|
||||||
|
'deleted_user_count' => user_info_list.size
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_time(time_string)
|
||||||
|
return 'not specified' if time_string.blank?
|
||||||
|
|
||||||
|
Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
|
||||||
|
end
|
||||||
|
|
||||||
|
def subject_for(account)
|
||||||
|
"Account Deletion Notice for #{account.id} - #{account.name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_admin_email
|
||||||
|
GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_url
|
||||||
|
ENV.fetch('FRONTEND_URL', 'not available')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -97,8 +97,8 @@ class Account < ApplicationRecord
|
|||||||
|
|
||||||
has_one_attached :contacts_export
|
has_one_attached :contacts_export
|
||||||
|
|
||||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
||||||
enum status: { active: 0, suspended: 1 }
|
enum :status, { active: 0, suspended: 1 }
|
||||||
|
|
||||||
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
|
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module ActivityMessageHandler
|
|||||||
include LabelActivityMessageHandler
|
include LabelActivityMessageHandler
|
||||||
include SlaActivityMessageHandler
|
include SlaActivityMessageHandler
|
||||||
include TeamActivityMessageHandler
|
include TeamActivityMessageHandler
|
||||||
|
include CsatActivityMessageHandler
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module CsatActivityMessageHandler
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def create_csat_not_sent_activity_message
|
||||||
|
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||||
|
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -20,6 +20,10 @@ module SsoAuthenticatable
|
|||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_sso_link_with_impersonation
|
||||||
|
"#{generate_sso_link}&impersonation=true"
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def sso_token_key(token)
|
def sso_token_key(token)
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ class Conversation < ApplicationRecord
|
|||||||
additional_attributes&.dig('conversation_language')
|
additional_attributes&.dig('conversation_language')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Be aware: The precision of created_at and last_activity_at may differ from Ruby's Time precision.
|
||||||
|
# Our DB column (see schema) stores timestamps with second-level precision (no microseconds), so
|
||||||
|
# if you assign a Ruby Time with microseconds, the DB will truncate it. This may cause subtle differences
|
||||||
|
# if you compare or copy these values in Ruby, also in our specs
|
||||||
|
# So in specs rely on to be_with(1.second) instead of to eq()
|
||||||
|
# TODO: Migrate to use a timestamp with microsecond precision
|
||||||
def last_activity_at
|
def last_activity_at
|
||||||
self[:last_activity_at] || created_at
|
self[:last_activity_at] || created_at
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
|
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
|
||||||
#
|
#
|
||||||
class EmailTemplate < ApplicationRecord
|
class EmailTemplate < ApplicationRecord
|
||||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
||||||
enum template_type: { layout: 0, content: 1 }
|
enum :template_type, { layout: 0, content: 1 }
|
||||||
belongs_to :account, optional: true
|
belongs_to :account, optional: true
|
||||||
|
|
||||||
validates :name, uniqueness: { scope: :account }
|
validates :name, uniqueness: { scope: :account }
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class InstallationConfig < ApplicationRecord
|
|||||||
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
|
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
|
||||||
# FIX ME : fixes breakage of installation config. we need to migrate.
|
# FIX ME : fixes breakage of installation config. we need to migrate.
|
||||||
# Fix configuration in application.rb
|
# Fix configuration in application.rb
|
||||||
serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess
|
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
|
||||||
|
|
||||||
before_validation :set_lock
|
before_validation :set_lock
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
@@ -32,6 +32,10 @@ class InstallationConfig < ApplicationRecord
|
|||||||
after_commit :clear_cache
|
after_commit :clear_cache
|
||||||
|
|
||||||
def value
|
def value
|
||||||
|
# This is an extra hack again cause of the YAML serialization, in case of new object initialization in super admin
|
||||||
|
# It was throwing error as the default value of column '{}' was failing in deserialization.
|
||||||
|
return {}.with_indifferent_access if new_record? && @attributes['serialized_value']&.value_before_type_cast == '{}'
|
||||||
|
|
||||||
serialized_value[:value]
|
serialized_value[:value]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class Integrations::App
|
|||||||
def action
|
def action
|
||||||
case params[:id]
|
case params[:id]
|
||||||
when 'slack'
|
when 'slack'
|
||||||
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
|
client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
|
||||||
|
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||||
when 'linear'
|
when 'linear'
|
||||||
build_linear_action
|
build_linear_action
|
||||||
else
|
else
|
||||||
@@ -50,7 +51,7 @@ class Integrations::App
|
|||||||
def active?(account)
|
def active?(account)
|
||||||
case params[:id]
|
case params[:id]
|
||||||
when 'slack'
|
when 'slack'
|
||||||
ENV['SLACK_CLIENT_SECRET'].present?
|
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
||||||
when 'linear'
|
when 'linear'
|
||||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||||
when 'shopify'
|
when 'shopify'
|
||||||
|
|||||||
@@ -185,7 +185,13 @@ class Message < ApplicationRecord
|
|||||||
# move this to a presenter
|
# move this to a presenter
|
||||||
return self[:content] if !input_csat? || inbox.web_widget?
|
return self[:content] if !input_csat? || inbox.web_widget?
|
||||||
|
|
||||||
I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}")
|
survey_link = "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}"
|
||||||
|
|
||||||
|
if inbox.csat_config&.dig('message').present?
|
||||||
|
"#{inbox.csat_config['message']} #{survey_link}"
|
||||||
|
else
|
||||||
|
I18n.t('conversations.survey.response', link: survey_link)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def email_notifiable_message?
|
def email_notifiable_message?
|
||||||
|
|||||||
+2
-2
@@ -109,8 +109,8 @@ class User < ApplicationRecord
|
|||||||
self.email = email.try(:downcase)
|
self.email = email.try(:downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_devise_notification(notification, *args)
|
def send_devise_notification(notification, *)
|
||||||
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later
|
devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_password_and_uid
|
def set_password_and_uid
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Account::SignUpEmailValidationService
|
||||||
|
include CustomExceptions::Account
|
||||||
|
attr_reader :email
|
||||||
|
|
||||||
|
def initialize(email)
|
||||||
|
@email = email
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
address = ValidEmail2::Address.new(email)
|
||||||
|
|
||||||
|
raise InvalidEmail.new({ valid: false, disposable: nil }) unless address.valid?
|
||||||
|
|
||||||
|
raise InvalidEmail.new({ domain_blocked: true }) if domain_blocked?
|
||||||
|
|
||||||
|
raise InvalidEmail.new({ valid: true, disposable: true }) if address.disposable?
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def domain_blocked?
|
||||||
|
domain = email.split('@').last&.downcase
|
||||||
|
blocked_domains.any? { |blocked_domain| domain.match?(blocked_domain.downcase) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocked_domains
|
||||||
|
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
|
||||||
|
return [] if domains.blank?
|
||||||
|
|
||||||
|
domains.split("\n").map(&:strip)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
class AccountDeletionService
|
||||||
|
attr_reader :account, :soft_deleted_users
|
||||||
|
|
||||||
|
def initialize(account:)
|
||||||
|
@account = account
|
||||||
|
@soft_deleted_users = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
|
||||||
|
|
||||||
|
soft_delete_orphaned_users
|
||||||
|
send_compliance_notification
|
||||||
|
DeleteObjectJob.perform_later(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def send_compliance_notification
|
||||||
|
AdministratorNotifications::AccountComplianceMailer.with(
|
||||||
|
account: account,
|
||||||
|
soft_deleted_users: soft_deleted_users
|
||||||
|
).account_deleted(account).deliver_later
|
||||||
|
end
|
||||||
|
|
||||||
|
def soft_delete_orphaned_users
|
||||||
|
account.users.each do |user|
|
||||||
|
# Find all account_users for this user excluding the current account
|
||||||
|
other_accounts = user.account_users.where.not(account_id: account.id).count
|
||||||
|
|
||||||
|
# If user has no other accounts, soft delete them
|
||||||
|
next unless other_accounts.zero?
|
||||||
|
|
||||||
|
# Soft delete user by appending -deleted.com to email
|
||||||
|
original_email = user.email
|
||||||
|
user.email = "#{original_email}-deleted.com"
|
||||||
|
user.skip_reconfirmation!
|
||||||
|
user.save!
|
||||||
|
|
||||||
|
user_info = {
|
||||||
|
id: user.id.to_s,
|
||||||
|
original_email: original_email
|
||||||
|
}
|
||||||
|
|
||||||
|
soft_deleted_users << user_info
|
||||||
|
|
||||||
|
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -28,16 +28,6 @@ class Conversations::FilterService < FilterService
|
|||||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
|
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
|
||||||
)
|
)
|
||||||
|
|
||||||
account_user = @account.account_users.find_by(user_id: @user.id)
|
|
||||||
is_administrator = account_user&.role == 'administrator'
|
|
||||||
|
|
||||||
# Ensure we only include conversations from inboxes the user has access to
|
|
||||||
unless is_administrator
|
|
||||||
inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id)
|
|
||||||
conversations = conversations.where(inbox_id: inbox_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Apply permission-based filtering
|
|
||||||
Conversations::PermissionFilterService.new(
|
Conversations::PermissionFilterService.new(
|
||||||
conversations,
|
conversations,
|
||||||
@user,
|
@user,
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ class Conversations::PermissionFilterService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
# The base implementation simply returns all conversations
|
return conversations if user_role == 'administrator'
|
||||||
# Enterprise edition extends this with permission-based filtering
|
|
||||||
conversations
|
accessible_conversations
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def accessible_conversations
|
||||||
|
conversations.where(inbox: user.inboxes.where(account_id: account.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_user
|
||||||
|
AccountUser.find_by(account_id: account.id, user_id: user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_role
|
||||||
|
account_user&.role
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ class Crm::Leadsquared::LeadFinderService
|
|||||||
def find_by_phone_number(contact)
|
def find_by_phone_number(contact)
|
||||||
return if contact.phone_number.blank?
|
return if contact.phone_number.blank?
|
||||||
|
|
||||||
search_by_field(contact.phone_number)
|
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||||
|
|
||||||
|
return if lead_data.blank? || lead_data['Mobile'].nil?
|
||||||
|
|
||||||
|
search_by_field(lead_data['Mobile'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_by_field(value)
|
def search_by_field(value)
|
||||||
|
|||||||
@@ -20,11 +20,29 @@ class Crm::Leadsquared::Mappers::ContactMapper
|
|||||||
'FirstName' => contact.name.presence,
|
'FirstName' => contact.name.presence,
|
||||||
'LastName' => contact.last_name.presence,
|
'LastName' => contact.last_name.presence,
|
||||||
'EmailAddress' => contact.email.presence,
|
'EmailAddress' => contact.email.presence,
|
||||||
'Mobile' => contact.phone_number.presence,
|
'Mobile' => formatted_phone_number,
|
||||||
'Source' => brand_name
|
'Source' => brand_name
|
||||||
}.compact
|
}.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def formatted_phone_number
|
||||||
|
# it seems like leadsquared needs a different phone number format
|
||||||
|
# it's not documented anywhere, so don't bother trying to look up online
|
||||||
|
# After some trial and error, I figured out the format, its +<country_code>-<national_number>
|
||||||
|
return nil if contact.phone_number.blank?
|
||||||
|
|
||||||
|
parsed = TelephoneNumber.parse(contact.phone_number)
|
||||||
|
return contact.phone_number unless parsed.valid?
|
||||||
|
|
||||||
|
country_code = parsed.country.country_code
|
||||||
|
e164 = parsed.e164_number
|
||||||
|
e164 = e164.sub(/^\+/, '')
|
||||||
|
|
||||||
|
national_number = e164.sub(/^#{Regexp.escape(country_code)}/, '')
|
||||||
|
|
||||||
|
"+#{country_code}-#{national_number}"
|
||||||
|
end
|
||||||
|
|
||||||
def brand_name
|
def brand_name
|
||||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
|||||||
# so this limits it
|
# so this limits it
|
||||||
ACTIVITY_NOTE_MAX_SIZE = 1800
|
ACTIVITY_NOTE_MAX_SIZE = 1800
|
||||||
|
|
||||||
def self.map_conversation_activity(conversation)
|
def self.map_conversation_activity(hook, conversation)
|
||||||
new(conversation).conversation_activity
|
new(hook, conversation).conversation_activity
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.map_transcript_activity(conversation, messages = nil)
|
def self.map_transcript_activity(hook, conversation)
|
||||||
new(conversation, messages).transcript_activity
|
new(hook, conversation).transcript_activity
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(conversation, messages = nil)
|
def initialize(hook, conversation)
|
||||||
|
@hook = hook
|
||||||
|
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
@messages = messages
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_activity
|
def conversation_activity
|
||||||
@@ -41,14 +42,14 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :conversation, :messages
|
attr_reader :conversation
|
||||||
|
|
||||||
def formatted_creation_time
|
def formatted_creation_time
|
||||||
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
end
|
end
|
||||||
|
|
||||||
def transcript_messages
|
def transcript_messages
|
||||||
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
|
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_messages
|
def format_messages
|
||||||
@@ -77,8 +78,7 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def message_time(message)
|
def message_time(message)
|
||||||
# TODO: Figure out what timezone to send the time in
|
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
|
||||||
message.created_at.strftime('%Y-%m-%d %H:%M')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sender_name(message)
|
def sender_name(message)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
|||||||
activity_type: 'conversation',
|
activity_type: 'conversation',
|
||||||
activity_code_key: 'conversation_activity_code',
|
activity_code_key: 'conversation_activity_code',
|
||||||
metadata_key: 'created_activity_id',
|
metadata_key: 'created_activity_id',
|
||||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation)
|
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
|||||||
activity_type: 'transcript',
|
activity_type: 'transcript',
|
||||||
activity_code_key: 'transcript_activity_code',
|
activity_code_key: 'transcript_activity_code',
|
||||||
metadata_key: 'transcript_activity_id',
|
metadata_key: 'transcript_activity_id',
|
||||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation)
|
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ class Crm::Leadsquared::SetupService
|
|||||||
response = @client.get('Authentication.svc/UserByAccessKey.Get')
|
response = @client.get('Authentication.svc/UserByAccessKey.Get')
|
||||||
endpoint_host = response['LSQCommonServiceURLs']['api']
|
endpoint_host = response['LSQCommonServiceURLs']['api']
|
||||||
app_host = response['LSQCommonServiceURLs']['app']
|
app_host = response['LSQCommonServiceURLs']['app']
|
||||||
|
timezone = response['TimeZone']
|
||||||
|
|
||||||
endpoint_url = "https://#{endpoint_host}/v2/"
|
endpoint_url = "https://#{endpoint_host}/v2/"
|
||||||
app_url = "https://#{app_host}/"
|
app_url = "https://#{app_host}/"
|
||||||
|
|
||||||
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
|
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone })
|
||||||
|
|
||||||
# replace the clients
|
# replace the clients
|
||||||
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
|
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user