Merge branch 'master' into feat/reorder-sidebar
This commit is contained in:
commit
02c66e5c1d
1460 changed files with 55576 additions and 11284 deletions
|
@ -7,7 +7,7 @@ defaults: &defaults
|
|||
working_directory: ~/build
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: cimg/ruby:3.0.2-node
|
||||
- image: cimg/ruby:3.0.2-browsers
|
||||
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
|
@ -77,6 +77,18 @@ jobs:
|
|||
paths:
|
||||
- cc-test-reporter
|
||||
|
||||
# verify swagger specification
|
||||
- run:
|
||||
name: Verify swagger API specification
|
||||
command: |
|
||||
bundle exec rake swagger:build
|
||||
if [[ `git status swagger/swagger.json --porcelain` ]]
|
||||
then
|
||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||
exit 1
|
||||
fi
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json
|
||||
# Database setup
|
||||
- run: yarn install --check-files
|
||||
- run: bundle exec rake db:create
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
version: "2"
|
||||
version: '2'
|
||||
plugins:
|
||||
rubocop:
|
||||
enabled: false
|
||||
|
@ -14,21 +14,33 @@ plugins:
|
|||
checks:
|
||||
similar-code:
|
||||
enabled: false
|
||||
method-count:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 32
|
||||
file-lines:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 300
|
||||
exclude_patterns:
|
||||
- "spec/"
|
||||
- "**/specs/"
|
||||
- "db/*"
|
||||
- "bin/**/*"
|
||||
- "db/**/*"
|
||||
- "config/**/*"
|
||||
- "public/**/*"
|
||||
- "vendor/**/*"
|
||||
- "node_modules/**/*"
|
||||
- "lib/tasks/auto_annotate_models.rake"
|
||||
- "app/test-matchers.js"
|
||||
- "docs/*"
|
||||
- "**/*.md"
|
||||
- "**/*.yml"
|
||||
- "app/javascript/dashboard/i18n/locale"
|
||||
- "**/*.stories.js"
|
||||
- "stories/"
|
||||
- 'spec/'
|
||||
- '**/specs/'
|
||||
- 'db/*'
|
||||
- 'bin/**/*'
|
||||
- 'db/**/*'
|
||||
- 'config/**/*'
|
||||
- 'public/**/*'
|
||||
- 'vendor/**/*'
|
||||
- 'node_modules/**/*'
|
||||
- 'lib/tasks/auto_annotate_models.rake'
|
||||
- 'app/test-matchers.js'
|
||||
- 'docs/*'
|
||||
- '**/*.md'
|
||||
- '**/*.yml'
|
||||
- 'app/javascript/dashboard/i18n/locale'
|
||||
- '**/*.stories.js'
|
||||
- 'stories/'
|
||||
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
|
||||
- 'app/javascript/shared/constants/countries.js'
|
||||
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
|
||||
|
|
12
.env.example
12
.env.example
|
@ -41,7 +41,7 @@ RAILS_MAX_THREADS=5
|
|||
|
||||
# The email from which all outgoing emails are sent
|
||||
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
|
||||
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
|
||||
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
|
||||
|
||||
|
||||
#SMTP domain key is set up for HELO checking
|
||||
|
@ -57,6 +57,9 @@ SMTP_AUTHENTICATION=
|
|||
SMTP_ENABLE_STARTTLS_AUTO=true
|
||||
# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html
|
||||
SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
# Comment out the following environment variables if required by your SMTP server
|
||||
# SMTP_TLS=
|
||||
# SMTP_SSL=
|
||||
|
||||
# Mail Incoming
|
||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||
|
@ -100,6 +103,9 @@ FB_VERIFY_TOKEN=
|
|||
FB_APP_SECRET=
|
||||
FB_APP_ID=
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
|
||||
IG_VERIFY_TOKEN=
|
||||
|
||||
# Twitter
|
||||
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
|
||||
TWITTER_APP_ID=
|
||||
|
@ -113,7 +119,7 @@ SLACK_CLIENT_SECRET=
|
|||
|
||||
### Change this env variable only if you are using a custom build mobile app
|
||||
## Mobile app env variables
|
||||
IOS_APP_ID=6C953F3RX2.com.chatwoot.app
|
||||
IOS_APP_ID=L7YLMN4634.com.chatwoot.app
|
||||
ANDROID_BUNDLE_ID=com.chatwoot.app
|
||||
|
||||
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
|
||||
|
@ -166,7 +172,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
|||
|
||||
## Rack Attack configuration
|
||||
## To prevent and throttle abusive requests
|
||||
# ENABLE_RACK_ATTACK=false
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
|
|
|
@ -28,6 +28,7 @@ module.exports = {
|
|||
}],
|
||||
'vue/html-self-closing': 'off',
|
||||
"vue/no-v-html": 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off']
|
||||
|
||||
},
|
||||
|
|
5
.husky/pre-commit
Executable file
5
.husky/pre-commit
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
npm run eslint
|
||||
bundle exec rubocop -a
|
||||
git add
|
4
.husky/pre-push
Executable file
4
.husky/pre-push
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
sh bin/validate_push
|
|
@ -11,9 +11,11 @@ Metrics/ClassLength:
|
|||
Max: 125
|
||||
Exclude:
|
||||
- 'app/models/conversation.rb'
|
||||
- 'app/models/contact.rb'
|
||||
- 'app/mailers/conversation_reply_mailer.rb'
|
||||
- 'app/models/message.rb'
|
||||
- 'app/builders/messages/facebook/message_builder.rb'
|
||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
|
@ -56,7 +58,7 @@ Metrics/BlockLength:
|
|||
- db/schema.rb
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- lib/woot_message_seeder.rb
|
||||
- lib/seeders/message_seeder.rb
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
|
@ -86,6 +88,7 @@ Naming/VariableNumber:
|
|||
Metrics/MethodLength:
|
||||
Exclude:
|
||||
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
|
||||
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
|
||||
Rails/CreateTableWithTimestamps:
|
||||
Exclude:
|
||||
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
|
||||
|
@ -101,6 +104,7 @@ Metrics/AbcSize:
|
|||
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
||||
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
|
||||
- 'app/controllers/api/v1/accounts/inboxes_controller.rb'
|
||||
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 7
|
||||
Exclude:
|
||||
|
|
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
hello@chatwoot.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
5
CONTRIBUTING.md
Normal file
5
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Contributing to Chatwoot
|
||||
|
||||
Thanks for taking the time to contribute! :tada::+1:
|
||||
|
||||
Please refer to our [Contributing Guide](https://www.chatwoot.com/docs/contributing-guide) for detailed instructions on how to contribute.
|
13
Gemfile
13
Gemfile
|
@ -121,6 +121,10 @@ gem 'hairtrigger'
|
|||
|
||||
gem 'procore-sift'
|
||||
|
||||
# parse email
|
||||
gem 'email_reply_trimmer'
|
||||
gem 'html2text'
|
||||
|
||||
group :production, :staging do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
|
@ -144,12 +148,20 @@ group :test do
|
|||
gem 'cypress-on-rails', '~> 1.0'
|
||||
# fast cleaning of database
|
||||
gem 'database_cleaner'
|
||||
# mock http calls
|
||||
gem 'webmock'
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
# TODO: is this needed ?
|
||||
# errors thrown by devise password gem
|
||||
gem 'flay'
|
||||
gem 'rspec'
|
||||
# for error thrown by devise password gem
|
||||
gem 'active_record_query_trace'
|
||||
gem 'bundle-audit', require: false
|
||||
gem 'byebug', platform: :mri
|
||||
gem 'climate_control'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'faker'
|
||||
gem 'listen'
|
||||
|
@ -165,5 +177,4 @@ group :development, :test do
|
|||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
gem 'webmock'
|
||||
end
|
||||
|
|
303
Gemfile.lock
303
Gemfile.lock
|
@ -9,63 +9,63 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
actioncable (6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
actionmailbox (6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
activejob (= 6.1.4.3)
|
||||
activerecord (= 6.1.4.3)
|
||||
activestorage (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
actionmailer (6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
actionview (= 6.1.4.3)
|
||||
activejob (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
actionpack (6.1.4.3)
|
||||
actionview (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
actiontext (6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
activerecord (= 6.1.4.3)
|
||||
activestorage (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
actionview (6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activejob (6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activerecord (6.1.4.1)
|
||||
activemodel (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activemodel (6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
activerecord (6.1.4.3)
|
||||
activemodel (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
activerecord-import (1.2.0)
|
||||
activerecord (>= 3.2)
|
||||
activestorage (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activestorage (6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
activejob (= 6.1.4.3)
|
||||
activerecord (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
marcel (~> 1.0.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.4.1)
|
||||
activesupport (6.1.4.3)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
|
@ -90,21 +90,21 @@ GEM
|
|||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.2)
|
||||
attr_extras (6.2.4)
|
||||
aws-eventstream (1.1.1)
|
||||
aws-partitions (1.482.0)
|
||||
aws-sdk-core (3.119.0)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.513.0)
|
||||
aws-sdk-core (3.121.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.239.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-kms (1.46.0)
|
||||
aws-sdk-core (~> 3, >= 3.119.0)
|
||||
aws-sdk-kms (1.49.0)
|
||||
aws-sdk-core (~> 3, >= 3.120.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.98.0)
|
||||
aws-sdk-core (~> 3, >= 3.119.0)
|
||||
aws-sdk-s3 (1.103.0)
|
||||
aws-sdk-core (~> 3, >= 3.120.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.2.4)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.4.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.1)
|
||||
azure-storage-common (~> 2.0)
|
||||
|
@ -119,28 +119,29 @@ GEM
|
|||
statsd-ruby (~> 1.1)
|
||||
bcrypt (3.1.16)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.7.7)
|
||||
bootsnap (1.9.1)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (5.1.1)
|
||||
browser (5.3.1)
|
||||
builder (3.2.4)
|
||||
bullet (6.1.4)
|
||||
bullet (6.1.5)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundle-audit (0.1.0)
|
||||
bundler-audit
|
||||
bundler-audit (0.8.0)
|
||||
bundler-audit (0.9.0.1)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
climate_control (1.0.1)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.22.0)
|
||||
commonmarker (0.23.2)
|
||||
concurrent-ruby (1.1.9)
|
||||
connection_pool (2.2.5)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cypress-on-rails (1.10.1)
|
||||
cypress-on-rails (1.11.0)
|
||||
rack
|
||||
database_cleaner (2.0.1)
|
||||
database_cleaner-active_record (~> 2.0.0)
|
||||
|
@ -150,7 +151,7 @@ GEM
|
|||
database_cleaner-core (2.0.1)
|
||||
datetime_picker_rails (0.0.7)
|
||||
momentjs-rails (>= 2.8.1)
|
||||
ddtrace (0.51.1)
|
||||
ddtrace (0.53.0)
|
||||
ffi (~> 1.0)
|
||||
msgpack
|
||||
declarative (0.0.20)
|
||||
|
@ -174,12 +175,14 @@ GEM
|
|||
dotenv-rails (2.7.6)
|
||||
dotenv (= 2.7.6)
|
||||
railties (>= 3.2)
|
||||
down (5.2.3)
|
||||
down (5.2.4)
|
||||
addressable (~> 2.8)
|
||||
ecma-re-validator (0.3.0)
|
||||
regexp_parser (~> 2.0)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.10.0)
|
||||
et-orbi (1.2.4)
|
||||
erubis (2.7.0)
|
||||
et-orbi (1.2.5)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
facebook-messenger (2.0.1)
|
||||
|
@ -190,7 +193,7 @@ GEM
|
|||
factory_bot_rails (6.2.0)
|
||||
factory_bot (~> 6.2.0)
|
||||
railties (>= 5.0.0)
|
||||
faker (2.18.0)
|
||||
faker (2.19.0)
|
||||
i18n (>= 1.6, < 2)
|
||||
faraday (1.0.1)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
|
@ -198,10 +201,15 @@ GEM
|
|||
faraday (~> 1.0)
|
||||
fcm (1.0.3)
|
||||
faraday (~> 1)
|
||||
ffi (1.15.3)
|
||||
ffi (1.15.4)
|
||||
flag_shih_tzu (0.3.23)
|
||||
flay (2.12.1)
|
||||
erubis (~> 2.7.0)
|
||||
path_expander (~> 1.0)
|
||||
ruby_parser (~> 3.0)
|
||||
sexp_processor (~> 4.0)
|
||||
foreman (0.87.2)
|
||||
fugit (1.5.0)
|
||||
fugit (1.5.2)
|
||||
et-orbi (~> 1.1, >= 1.1.8)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.3.4)
|
||||
|
@ -210,9 +218,9 @@ GEM
|
|||
googleapis-common-protos-types (>= 1.0.4, < 2.0)
|
||||
googleauth (~> 0.9)
|
||||
grpc (~> 1.25)
|
||||
geocoder (1.6.7)
|
||||
geocoder (1.7.0)
|
||||
gli (2.20.1)
|
||||
globalid (0.5.2)
|
||||
globalid (1.0.0)
|
||||
activesupport (>= 5.0)
|
||||
google-apis-core (0.4.1)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
|
@ -223,9 +231,9 @@ GEM
|
|||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.6.0)
|
||||
google-apis-iamcredentials_v1 (0.7.0)
|
||||
google-apis-core (>= 0.4, < 2.a)
|
||||
google-apis-storage_v1 (0.6.0)
|
||||
google-apis-storage_v1 (0.8.0)
|
||||
google-apis-core (>= 0.4, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
|
@ -238,7 +246,7 @@ GEM
|
|||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.5.0)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
google-cloud-errors (1.1.0)
|
||||
google-cloud-errors (1.2.0)
|
||||
google-cloud-storage (1.34.1)
|
||||
addressable (~> 2.5)
|
||||
digest-crc (~> 0.4)
|
||||
|
@ -247,32 +255,32 @@ GEM
|
|||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-protobuf (3.17.3)
|
||||
google-protobuf (3.17.3-universal-darwin)
|
||||
google-protobuf (3.17.3-x86_64-linux)
|
||||
googleapis-common-protos (1.3.11)
|
||||
google-protobuf (3.19.2)
|
||||
google-protobuf (3.19.2-x86_64-darwin)
|
||||
google-protobuf (3.19.2-x86_64-linux)
|
||||
googleapis-common-protos (1.3.12)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (>= 1.0.6, < 2.0)
|
||||
googleapis-common-protos-types (~> 1.2)
|
||||
grpc (~> 1.27)
|
||||
googleapis-common-protos-types (1.1.0)
|
||||
googleapis-common-protos-types (1.2.0)
|
||||
google-protobuf (~> 3.14)
|
||||
googleauth (0.17.0)
|
||||
googleauth (0.17.1)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.14)
|
||||
signet (~> 0.15)
|
||||
groupdate (5.2.2)
|
||||
activesupport (>= 5)
|
||||
grpc (1.38.0)
|
||||
google-protobuf (~> 3.15)
|
||||
grpc (1.41.0)
|
||||
google-protobuf (~> 3.17)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.38.0-universal-darwin)
|
||||
google-protobuf (~> 3.15)
|
||||
grpc (1.41.0-universal-darwin)
|
||||
google-protobuf (~> 3.17)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.38.0-x86_64-linux)
|
||||
google-protobuf (~> 3.15)
|
||||
grpc (1.41.0-x86_64-linux)
|
||||
google-protobuf (~> 3.17)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
haikunator (1.1.1)
|
||||
hairtrigger (0.2.24)
|
||||
|
@ -283,14 +291,16 @@ GEM
|
|||
hashdiff (1.0.1)
|
||||
hashie (4.1.0)
|
||||
hkdf (0.3.0)
|
||||
html2text (0.2.1)
|
||||
nokogiri (~> 1.6)
|
||||
http-accept (1.7.0)
|
||||
http-cookie (1.0.4)
|
||||
domain_name (~> 0.5)
|
||||
httparty (0.18.1)
|
||||
httparty (0.20.0)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.8.10)
|
||||
i18n (1.8.11)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.1)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
|
@ -310,7 +320,7 @@ GEM
|
|||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
jwt (2.2.3)
|
||||
jwt (2.3.0)
|
||||
kaminari (1.2.1)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.1)
|
||||
|
@ -331,28 +341,28 @@ GEM
|
|||
addressable (~> 2.7)
|
||||
letter_opener (1.7.0)
|
||||
launchy (~> 2.2)
|
||||
line-bot-api (1.21.0)
|
||||
liquid (5.0.1)
|
||||
listen (3.6.0)
|
||||
line-bot-api (1.22.0)
|
||||
liquid (5.1.0)
|
||||
listen (3.7.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
loofah (2.12.0)
|
||||
loofah (2.13.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (1.0.1)
|
||||
marcel (1.0.2)
|
||||
maxminddb (0.1.22)
|
||||
memoist (0.16.2)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.3.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2021.0704)
|
||||
mime-types-data (3.2021.0901)
|
||||
mini_magick (4.11.0)
|
||||
mini_mime (1.1.1)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.5.3)
|
||||
minitest (5.14.4)
|
||||
mock_redis (0.28.0)
|
||||
minitest (5.15.0)
|
||||
mock_redis (0.29.0)
|
||||
ruby2_keywords
|
||||
momentjs-rails (2.20.1)
|
||||
railties (>= 3.1)
|
||||
|
@ -363,7 +373,7 @@ GEM
|
|||
net-http-persistent (4.0.1)
|
||||
connection_pool (~> 2.2)
|
||||
netrc (0.11.0)
|
||||
newrelic_rpm (7.2.0)
|
||||
newrelic_rpm (8.0.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.11.7)
|
||||
mini_portile2 (~> 2.5.0)
|
||||
|
@ -377,9 +387,10 @@ GEM
|
|||
oauth (0.5.6)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.1)
|
||||
parallel (1.20.1)
|
||||
parallel (1.21.0)
|
||||
parser (3.0.2.0)
|
||||
ast (~> 2.4.1)
|
||||
path_expander (1.1.0)
|
||||
pg (1.2.3)
|
||||
procore-sift (0.16.0)
|
||||
rails (> 4.2.0)
|
||||
|
@ -389,12 +400,12 @@ GEM
|
|||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.6)
|
||||
puma (5.4.0)
|
||||
puma (5.5.2)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.0)
|
||||
pundit (2.1.1)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.5.2)
|
||||
racc (1.6.0)
|
||||
rack (2.2.3)
|
||||
rack-attack (6.5.0)
|
||||
rack (>= 1.0, < 3)
|
||||
|
@ -405,29 +416,29 @@ GEM
|
|||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-timeout (0.6.0)
|
||||
rails (6.1.4.1)
|
||||
actioncable (= 6.1.4.1)
|
||||
actionmailbox (= 6.1.4.1)
|
||||
actionmailer (= 6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
actiontext (= 6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activemodel (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
rails (6.1.4.3)
|
||||
actioncable (= 6.1.4.3)
|
||||
actionmailbox (= 6.1.4.3)
|
||||
actionmailer (= 6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
actiontext (= 6.1.4.3)
|
||||
actionview (= 6.1.4.3)
|
||||
activejob (= 6.1.4.3)
|
||||
activemodel (= 6.1.4.3)
|
||||
activerecord (= 6.1.4.3)
|
||||
activestorage (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.4.1)
|
||||
railties (= 6.1.4.3)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.1)
|
||||
rails-html-sanitizer (1.4.2)
|
||||
loofah (~> 2.3)
|
||||
railties (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
railties (6.1.4.3)
|
||||
actionpack (= 6.1.4.3)
|
||||
activesupport (= 6.1.4.3)
|
||||
method_source
|
||||
rake (>= 0.13)
|
||||
thor (~> 1.0)
|
||||
|
@ -454,6 +465,10 @@ GEM
|
|||
netrc (~> 0.8)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rspec (3.10.0)
|
||||
rspec-core (~> 3.10.0)
|
||||
rspec-expectations (~> 3.10.0)
|
||||
rspec-mocks (~> 3.10.0)
|
||||
rspec-core (3.10.1)
|
||||
rspec-support (~> 3.10.0)
|
||||
rspec-expectations (3.10.1)
|
||||
|
@ -462,7 +477,7 @@ GEM
|
|||
rspec-mocks (3.10.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.10.0)
|
||||
rspec-rails (5.0.1)
|
||||
rspec-rails (5.0.2)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
|
@ -471,35 +486,34 @@ GEM
|
|||
rspec-mocks (~> 3.10)
|
||||
rspec-support (~> 3.10)
|
||||
rspec-support (3.10.2)
|
||||
rubocop (1.18.4)
|
||||
rubocop (1.22.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.0.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml
|
||||
rubocop-ast (>= 1.8.0, < 2.0)
|
||||
rubocop-ast (>= 1.12.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.8.0)
|
||||
rubocop-ast (1.12.0)
|
||||
parser (>= 3.0.1.1)
|
||||
rubocop-performance (1.11.4)
|
||||
rubocop-performance (1.11.5)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
rubocop-rails (2.11.3)
|
||||
rubocop-rails (2.12.3)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-rspec (2.4.0)
|
||||
rubocop (~> 1.0)
|
||||
rubocop-ast (>= 1.1.0)
|
||||
rubocop-rspec (2.5.0)
|
||||
rubocop (~> 1.19)
|
||||
ruby-progressbar (1.11.0)
|
||||
ruby-vips (2.1.2)
|
||||
ruby-vips (2.1.3)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
ruby2ruby (2.4.4)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_parser (3.16.0)
|
||||
ruby_parser (3.17.0)
|
||||
sexp_processor (~> 4.15, >= 4.15.1)
|
||||
sassc (2.4.0)
|
||||
ffi (~> 1.9)
|
||||
|
@ -509,38 +523,38 @@ GEM
|
|||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
scout_apm (4.1.1)
|
||||
scout_apm (4.1.2)
|
||||
parser
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (4.6.4)
|
||||
sentry-rails (4.7.3)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby-core (~> 4.6.0)
|
||||
sentry-ruby (4.6.4)
|
||||
sentry-ruby-core (~> 4.7.0)
|
||||
sentry-ruby (4.7.3)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
faraday (>= 1.0)
|
||||
sentry-ruby-core (= 4.6.4)
|
||||
sentry-ruby-core (4.6.4)
|
||||
sentry-ruby-core (= 4.7.3)
|
||||
sentry-ruby-core (4.7.3)
|
||||
concurrent-ruby
|
||||
faraday
|
||||
sentry-sidekiq (4.6.4)
|
||||
sentry-ruby-core (~> 4.6.0)
|
||||
sentry-sidekiq (4.7.3)
|
||||
sentry-ruby-core (~> 4.7.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.15.3)
|
||||
shoulda-matchers (5.0.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (6.2.1)
|
||||
sidekiq (6.2.2)
|
||||
connection_pool (>= 2.2.2)
|
||||
rack (~> 2.0)
|
||||
redis (>= 4.2.0)
|
||||
sidekiq-cron (1.2.0)
|
||||
fugit (~> 1.1)
|
||||
sidekiq (>= 4.2.1)
|
||||
signet (0.15.0)
|
||||
addressable (~> 2.3)
|
||||
signet (0.16.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
|
@ -562,9 +576,9 @@ GEM
|
|||
sprockets (4.0.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.2.2)
|
||||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets-rails (3.4.2)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.6.2)
|
||||
statsd-ruby (1.5.0)
|
||||
|
@ -583,15 +597,15 @@ GEM
|
|||
oauth
|
||||
tzinfo (2.0.4)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2021.1)
|
||||
tzinfo-data (1.2021.3)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
uglifier (4.2.0)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.7)
|
||||
unicode-display_width (2.0.0)
|
||||
unf_ext (0.0.8)
|
||||
unicode-display_width (2.1.0)
|
||||
uniform_notifier (1.14.2)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (4.0.0)
|
||||
|
@ -604,11 +618,11 @@ GEM
|
|||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.13.0)
|
||||
addressable (>= 2.3.6)
|
||||
webmock (3.14.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webpacker (5.4.0)
|
||||
webpacker (5.4.3)
|
||||
activesupport (>= 5.2)
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
|
@ -621,7 +635,7 @@ GEM
|
|||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wisper (2.0.0)
|
||||
zeitwerk (2.4.2)
|
||||
zeitwerk (2.5.1)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
|
@ -647,6 +661,7 @@ DEPENDENCIES
|
|||
bullet
|
||||
bundle-audit
|
||||
byebug
|
||||
climate_control
|
||||
commonmarker
|
||||
cypress-on-rails (~> 1.0)
|
||||
database_cleaner
|
||||
|
@ -656,11 +671,13 @@ DEPENDENCIES
|
|||
devise_token_auth
|
||||
dotenv-rails
|
||||
down (~> 5.0)
|
||||
email_reply_trimmer
|
||||
facebook-messenger
|
||||
factory_bot_rails
|
||||
faker
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
flay
|
||||
foreman
|
||||
geocoder
|
||||
google-cloud-dialogflow
|
||||
|
@ -669,6 +686,7 @@ DEPENDENCIES
|
|||
haikunator
|
||||
hairtrigger
|
||||
hashie
|
||||
html2text
|
||||
image_processing
|
||||
jbuilder
|
||||
json_refs
|
||||
|
@ -696,6 +714,7 @@ DEPENDENCIES
|
|||
redis-namespace
|
||||
responders
|
||||
rest-client
|
||||
rspec
|
||||
rspec-rails (~> 5.0.0)
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
|
8
LICENSE
8
LICENSE
|
@ -1,7 +1,11 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-2021 Chatwoot Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE".
|
||||
* All third party components incorporated into the Chatwoot Software are licensed under the original license provided by the owner of the applicable component.
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
|
|
50
README.md
50
README.md
|
@ -6,7 +6,10 @@
|
|||
|
||||
<p align="center">
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
|
||||
<img alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
|
||||
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
|
||||
</a>
|
||||
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
|
||||
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
@ -17,7 +20,6 @@ ___
|
|||
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
||||
<img src="https://img.shields.io/github/license/chatwoot/chatwoot" alt="License">
|
||||
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
|
||||
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
|
||||
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
|
||||
|
@ -26,34 +28,39 @@ ___
|
|||
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
|
||||
</p>
|
||||
|
||||
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/>
|
||||
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
Chatwoot is an open-source omnichannel customer support software. The development of Chatwoot started in 2016. It failed to succeed as a business and eventually shut up shop in 2017. During 2019 #Hacktoberfest, the maintainers decided to make it open-source, instead of letting the code rust in a private repo. With a pleasant surprise, Chatwoot became a trending project on Hacker News and best of all, got lots of love from the community.
|
||||
Now, a failed project is back on track and the prospects are looking great. The team is back to working on the project and this time, we are building it in the open. Thanks to the ideas and contributions from the community.
|
||||
|
||||
|
||||
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
|
||||
|
||||
## Features
|
||||
|
||||
Chatwoot gives an integrated view of conversations happening in different communication channels.
|
||||
|
||||
It supports the following conversation channels:
|
||||
Chatwoot supports the following conversation channels:
|
||||
|
||||
- **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
|
||||
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
|
||||
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
|
||||
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
|
||||
- **Whatsapp**: Connect your Whatsapp business account and manage the conversation in Chatwoot
|
||||
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot
|
||||
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
|
||||
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
|
||||
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
|
||||
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
|
||||
- **API Channel**: Build custom communication channels using our API channel.
|
||||
- **Email (beta)**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
|
||||
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
|
||||
|
||||
And more.
|
||||
|
||||
Other features include:
|
||||
|
||||
- **Multi-brand inboxes**: Manage multiple brands or pages using a single dashboard.
|
||||
- **Private notes**: Inter team communication is possible using private notes in a conversation.
|
||||
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
|
||||
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
|
||||
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
|
||||
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
|
||||
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
|
||||
- **Conversation Labels**: Use conversation labelling to create custom workflows.
|
||||
- **Conversation Labels**: Use conversation labels to create custom workflows.
|
||||
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
|
||||
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot would send an email to the customer under the agent name so that the user can continue the conversation over the email.
|
||||
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
|
||||
- **Multi-lingual support**: Chatwoot supports 10+ languages.
|
||||
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoot’s webhooks and APIs.
|
||||
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
|
||||
|
@ -81,9 +88,18 @@ Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button
|
|||
|
||||
Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables.
|
||||
|
||||
|
||||
### DigitalOcean 1-Click Kubernetes deployment
|
||||
|
||||
Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
|
||||
|
||||
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
|
||||
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
|
||||
</a>
|
||||
|
||||
### Other deployment options
|
||||
|
||||
Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover.
|
||||
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
|
||||
|
||||
## Security
|
||||
|
||||
|
@ -102,4 +118,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
|||
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2021, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2022, Chatwoot Inc - Released under the MIT License.
|
||||
|
|
|
@ -42,17 +42,16 @@ class ContactMergeAction
|
|||
end
|
||||
|
||||
def merge_and_remove_mergee_contact
|
||||
mergable_attribute_keys = %w[identifier name email phone_number custom_attributes]
|
||||
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
|
||||
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
|
||||
mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank
|
||||
|
||||
# attributes in base contact are given preference
|
||||
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
|
||||
# retaining old pubsub token to notify the contacts that are listening
|
||||
mergee_pubsub_token = mergee_contact.pubsub_token
|
||||
|
||||
@mergee_contact.destroy!
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [mergee_pubsub_token])
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
|
||||
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
|
||||
@base_contact.update!(merged_attributes)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,7 +33,8 @@ class ContactBuilder
|
|||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
additional_attributes: contact_attributes[:additional_attributes]
|
||||
additional_attributes: contact_attributes[:additional_attributes],
|
||||
custom_attributes: contact_attributes[:custom_attributes]
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ class ContactInboxBuilder
|
|||
def perform
|
||||
@contact = Contact.find(contact_id)
|
||||
@inbox = @contact.account.inboxes.find(inbox_id)
|
||||
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type
|
||||
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
|
||||
|
||||
source_id = @source_id || generate_source_id
|
||||
create_contact_inbox(source_id) if source_id.present?
|
||||
|
@ -14,12 +14,20 @@ class ContactInboxBuilder
|
|||
|
||||
def generate_source_id
|
||||
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
|
||||
return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp'
|
||||
return @contact.email if @inbox.channel_type == 'Channel::Email'
|
||||
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def wa_source_id
|
||||
return unless @contact.phone_number
|
||||
|
||||
# whatsapp doesn't want the + in e164 format
|
||||
"#{@contact.phone_number}.delete('+')"
|
||||
end
|
||||
|
||||
def twilio_source_id
|
||||
return unless @contact.phone_number
|
||||
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
# based on this we are showing "not sent from chatwoot" message in frontend
|
||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
||||
|
||||
class Messages::Facebook::MessageBuilder
|
||||
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
attr_reader :response
|
||||
|
||||
def initialize(response, inbox, outgoing_echo: false)
|
||||
super()
|
||||
@response = response
|
||||
@inbox = inbox
|
||||
@outgoing_echo = outgoing_echo
|
||||
|
@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder
|
|||
|
||||
def build_message
|
||||
@message = conversation.messages.create!(message_params)
|
||||
|
||||
@attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def process_attachment(attachment)
|
||||
return if attachment['type'].to_sym == :template
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
attachment_file = Down.download(
|
||||
file_url
|
||||
)
|
||||
attachment.file.attach(
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_contact_avatar
|
||||
return if contact_params[:remote_avatar_url].blank?
|
||||
return if @contact.avatar.attached?
|
||||
|
@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder
|
|||
))
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
elsif file_type == :fallback
|
||||
params.merge!(fallback_params(attachment))
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def file_type_params(attachment)
|
||||
{
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
}
|
||||
end
|
||||
|
||||
def location_params(attachment)
|
||||
lat = attachment['payload']['coordinates']['lat']
|
||||
long = attachment['payload']['coordinates']['long']
|
||||
|
|
145
app/builders/messages/instagram/message_builder.rb
Normal file
145
app/builders/messages/instagram/message_builder.rb
Normal file
|
@ -0,0 +1,145 @@
|
|||
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
|
||||
# Assumptions
|
||||
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
|
||||
# based on this we are showing "not sent from chatwoot" message in frontend
|
||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
||||
|
||||
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
attr_reader :messaging
|
||||
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super()
|
||||
@messaging = messaging
|
||||
@inbox = inbox
|
||||
@outgoing_echo = outgoing_echo
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @inbox.channel.reauthorization_required?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attachments
|
||||
@messaging[:message][:attachments] || {}
|
||||
end
|
||||
|
||||
def message_type
|
||||
@outgoing_echo ? :outgoing : :incoming
|
||||
end
|
||||
|
||||
def message_identifier
|
||||
message[:mid]
|
||||
end
|
||||
|
||||
def message_source_id
|
||||
@outgoing_echo ? recipient_id : sender_id
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging[:sender][:id]
|
||||
end
|
||||
|
||||
def recipient_id
|
||||
@messaging[:recipient][:id]
|
||||
end
|
||||
|
||||
def message
|
||||
@messaging[:message]
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@messaging[:message][:text]
|
||||
end
|
||||
|
||||
def build_message
|
||||
return if @outgoing_echo && already_sent_from_chatwoot?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
|
||||
attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
))
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id,
|
||||
additional_attributes: {
|
||||
type: 'instagram_direct_message'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact
|
||||
}
|
||||
end
|
||||
|
||||
def already_sent_from_chatwoot?
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
|
||||
cw_message.present?
|
||||
end
|
||||
|
||||
### Sample response
|
||||
# {
|
||||
# "object": "instagram",
|
||||
# "entry": [
|
||||
# {
|
||||
# "id": "<IGID>",// ig id of the business
|
||||
# "time": 1569262486134,
|
||||
# "messaging": [
|
||||
# {
|
||||
# "sender": {
|
||||
# "id": "<IGSID>"
|
||||
# },
|
||||
# "recipient": {
|
||||
# "id": "<IGID>"
|
||||
# },
|
||||
# "timestamp": 1569262485349,
|
||||
# "message": {
|
||||
# "mid": "<MESSAGE_ID>",
|
||||
# "text": "<MESSAGE_CONTENT>"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ],
|
||||
# }
|
||||
end
|
|
@ -8,14 +8,17 @@ class Messages::MessageBuilder
|
|||
@conversation = conversation
|
||||
@user = user
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
|
||||
@attachments = params[:attachments]
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
|
||||
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
|
||||
end
|
||||
|
||||
def perform
|
||||
@message = @conversation.messages.build(message_params)
|
||||
process_attachments
|
||||
process_emails
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
|
@ -34,6 +37,16 @@ class Messages::MessageBuilder
|
|||
end
|
||||
end
|
||||
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
|
||||
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
|
||||
|
||||
@message.content_attributes[:cc_emails] = cc_emails
|
||||
@message.content_attributes[:bcc_emails] = bcc_emails
|
||||
end
|
||||
|
||||
def message_type
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
|
|
42
app/builders/messages/messenger/message_builder.rb
Normal file
42
app/builders/messages/messenger/message_builder.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
class Messages::Messenger::MessageBuilder
|
||||
def process_attachment(attachment)
|
||||
return if attachment['type'].to_sym == :template
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
attachment_file = Down.download(
|
||||
file_url
|
||||
)
|
||||
attachment.file.attach(
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
elsif file_type == :fallback
|
||||
params.merge!(fallback_params(attachment))
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def file_type_params(attachment)
|
||||
{
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
}
|
||||
end
|
||||
end
|
|
@ -68,15 +68,14 @@ class V2::ReportBuilder
|
|||
.count
|
||||
end
|
||||
|
||||
# unscoped removes all scopes added to a model previously
|
||||
def incoming_messages_count
|
||||
scope.messages.unscoped.where(account_id: account.id).incoming
|
||||
scope.messages.incoming.unscope(:order)
|
||||
.group_by_day(:created_at, range: range, default_value: 0)
|
||||
.count
|
||||
end
|
||||
|
||||
def outgoing_messages_count
|
||||
scope.messages.unscoped.where(account_id: account.id).outgoing
|
||||
scope.messages.outgoing.unscope(:order)
|
||||
.group_by_day(:created_at, range: range, default_value: 0)
|
||||
.count
|
||||
end
|
||||
|
|
|
@ -31,7 +31,7 @@ class RoomChannel < ApplicationCable::Channel
|
|||
|
||||
def current_user
|
||||
@current_user ||= if params[:user_id].blank?
|
||||
Contact.find_by!(pubsub_token: @pubsub_token)
|
||||
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
|
||||
else
|
||||
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
|
||||
end
|
||||
|
|
|
@ -2,6 +2,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
before_action :fetch_agent, except: [:create, :index]
|
||||
before_action :check_authorization
|
||||
before_action :find_user, only: [:create]
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :create_user, only: [:create]
|
||||
before_action :save_account_user, only: [:create]
|
||||
|
||||
|
@ -9,21 +10,18 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
@agents = agents
|
||||
end
|
||||
|
||||
def create; end
|
||||
|
||||
def update
|
||||
@agent.update!(agent_params.slice(:name).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@agent.current_account_user.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
def update
|
||||
@agent.update!(agent_params.except(:role))
|
||||
@agent.current_account_user.update!(role: agent_params[:role]) if agent_params[:role]
|
||||
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @agent }
|
||||
end
|
||||
|
||||
def create
|
||||
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @user }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
|
@ -47,26 +45,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def save_account_user
|
||||
AccountUser.create!(
|
||||
AccountUser.create!({
|
||||
account_id: Current.account.id,
|
||||
user_id: @user.id,
|
||||
role: new_agent_params[:role],
|
||||
inviter_id: current_user.id
|
||||
)
|
||||
}.merge({
|
||||
role: new_agent_params[:role],
|
||||
availability: new_agent_params[:availability],
|
||||
auto_offline: new_agent_params[:auto_offline]
|
||||
}.compact))
|
||||
end
|
||||
|
||||
def agent_params
|
||||
params.require(:agent).permit(:email, :name, :role)
|
||||
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
# intial string ensures the password requirements are met
|
||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||
params.require(:agent).permit(:email, :name, :role)
|
||||
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
|
||||
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
|
||||
end
|
||||
|
||||
def agents
|
||||
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@automation_rules = Current.account.automation_rules
|
||||
end
|
||||
|
||||
def create
|
||||
@automation_rule = Current.account.automation_rules.create(automation_rules_permit)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def automation_rules_permit
|
||||
params.permit(
|
||||
:name, :description, :event_name, :account_id,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
|
||||
actions: [:action_name, { action_params: [:intiated_at] }]
|
||||
)
|
||||
end
|
||||
end
|
|
@ -12,9 +12,10 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||
page_access_token: page_access_token
|
||||
)
|
||||
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
|
||||
set_instagram_id(page_access_token, facebook_channel)
|
||||
set_avatar(@facebook_inbox, page_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.info e
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
|
||||
end
|
||||
|
||||
def set_instagram_id(page_access_token, facebook_channel)
|
||||
fb_object = Koala::Facebook::API.new(page_access_token)
|
||||
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
|
||||
return if response['instagram_business_account'].blank?
|
||||
|
||||
instagram_id = response['instagram_business_account']['id']
|
||||
facebook_channel.update(instagram_id: instagram_id)
|
||||
end
|
||||
|
||||
# get params[:inbox_id], current_account. params[:omniauth_token]
|
||||
def reauthorize_page
|
||||
if @inbox&.facebook?
|
||||
|
@ -45,8 +55,13 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||
|
||||
def update_fb_page(fb_page_id, access_token)
|
||||
fb_page = get_fb_page(fb_page_id)
|
||||
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
|
||||
fb_page&.reauthorized!
|
||||
ActiveRecord::Base.transaction do
|
||||
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
|
||||
set_instagram_id(access_token, fb_page)
|
||||
fb_page&.reauthorized!
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
end
|
||||
|
||||
def get_fb_page(fb_page_id)
|
||||
|
@ -59,7 +74,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def long_lived_token(omniauth_token)
|
||||
koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['FB_APP_SECRET'])
|
||||
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
|
||||
koala.exchange_access_token_info(omniauth_token)['access_token']
|
||||
rescue StandardError => e
|
||||
Rails.logger.info e
|
||||
|
|
|
@ -28,7 +28,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def campaign_params
|
||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id,
|
||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {})
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,7 +33,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
|
|||
|
||||
def canned_responses
|
||||
if params[:search]
|
||||
Current.account.canned_responses.where('short_code ILIKE ?', "#{params[:search]}%")
|
||||
Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
|
||||
else
|
||||
Current.account.canned_responses
|
||||
end
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
class Api::V1::Accounts::Contacts::BaseController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_contact
|
||||
|
||||
private
|
||||
|
||||
def ensure_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
end
|
|
@ -1,5 +1,4 @@
|
|||
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_contact
|
||||
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
|
||||
before_action :ensure_inbox, only: [:create]
|
||||
|
||||
def create
|
||||
|
@ -13,8 +12,4 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
|
|||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def ensure_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
|
||||
def index
|
||||
@conversations = Current.account.conversations.includes(
|
||||
:assignee, :contact, :inbox, :taggings
|
||||
).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id])
|
||||
).where(inbox_id: inbox_ids, contact_id: @contact.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -14,8 +14,4 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
|||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:contact_id)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::Contacts::BaseController
|
||||
include LabelConcern
|
||||
|
||||
private
|
||||
|
||||
def model
|
||||
@model ||= Current.account.contacts.find(permitted_params[:contact_id])
|
||||
@model ||= @contact
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:contact_id, labels: [])
|
||||
params.permit(labels: [])
|
||||
end
|
||||
end
|
||||
|
|
32
app/controllers/api/v1/accounts/contacts/notes_controller.rb
Normal file
32
app/controllers/api/v1/accounts/contacts/notes_controller.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts::BaseController
|
||||
before_action :note, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@notes = @contact.notes.latest.includes(:user)
|
||||
end
|
||||
|
||||
def create
|
||||
@note = @contact.notes.create!(note_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@note.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@note.update(note_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def note
|
||||
@note ||= @contact.notes.find(params[:id])
|
||||
end
|
||||
|
||||
def note_params
|
||||
params.require(:note).permit(:content).merge({ contact_id: @contact.id, user_id: Current.user.id })
|
||||
end
|
||||
end
|
|
@ -1,17 +1,19 @@
|
|||
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
include Sift
|
||||
|
||||
sort_on :email, type: :string
|
||||
sort_on :name, type: :string
|
||||
sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :phone_number, type: :string
|
||||
sort_on :last_activity_at, type: :datetime
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
||||
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
||||
|
||||
RESULTS_PER_PAGE = 15
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search]
|
||||
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
|
@ -50,11 +52,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
|
||||
def show; end
|
||||
|
||||
def filter
|
||||
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts_with_conversation_count(contacts)
|
||||
end
|
||||
|
||||
def contactable_inboxes
|
||||
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
|
||||
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
|
||||
end
|
||||
|
||||
# TODO : refactor this method into dedicated contacts/custom_attributes controller class and routes
|
||||
def destroy_custom_attributes
|
||||
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
|
||||
@contact.save!
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(contact_params)
|
||||
|
@ -66,11 +81,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
def update
|
||||
@contact.assign_attributes(contact_update_params)
|
||||
@contact.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: {
|
||||
message: e.record.errors.full_messages.join(', '),
|
||||
contact: Current.account.contacts.find_by(email: contact_params[:email])
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -91,10 +101,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
def resolved_contacts
|
||||
return @resolved_contacts if @resolved_contacts
|
||||
|
||||
@resolved_contacts = Current.account.contacts
|
||||
.where.not(email: [nil, ''])
|
||||
.or(Current.account.contacts.where.not(phone_number: [nil, '']))
|
||||
.or(Current.account.contacts.where.not(identifier: [nil, '']))
|
||||
@resolved_contacts = Current.account.contacts.resolved_contacts
|
||||
|
||||
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
|
||||
@resolved_contacts
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
include Events::Types
|
||||
include DateRangeHelper
|
||||
|
||||
before_action :conversation, except: [:index, :meta, :search, :create]
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||
before_action :contact_inbox, only: [:create]
|
||||
|
||||
def index
|
||||
|
@ -31,6 +31,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
|
||||
def show; end
|
||||
|
||||
def filter
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
@conversations = result[:conversations]
|
||||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
def mute
|
||||
@conversation.mute!
|
||||
head :ok
|
||||
|
@ -60,17 +66,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
def toggle_typing_status
|
||||
case params[:typing_status]
|
||||
when 'on'
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON)
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
|
||||
when 'off'
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
|
||||
def update_last_seen
|
||||
@conversation.agent_last_seen_at = DateTime.now.utc
|
||||
@conversation.assignee_last_seen_at = DateTime.now.utc if assignee?
|
||||
@conversation.save!
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
|
||||
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def custom_attributes
|
||||
|
@ -86,9 +93,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
end
|
||||
|
||||
def trigger_typing_event(event)
|
||||
def trigger_typing_event(event, is_private)
|
||||
user = current_user.presence || @resource
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
|
||||
end
|
||||
|
||||
def conversation
|
||||
|
@ -130,7 +137,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_attributes,
|
||||
custom_attributes: custom_attributes,
|
||||
snoozed_until: params[:snoozed_until]
|
||||
snoozed_until: params[:snoozed_until],
|
||||
assignee_id: params[:assignee_id],
|
||||
team_id: params[:team_id]
|
||||
}.merge(status)
|
||||
end
|
||||
|
||||
|
|
|
@ -25,9 +25,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
|
|||
private
|
||||
|
||||
def fetch_custom_attributes_definitions
|
||||
@custom_attribute_definitions = Current.account.custom_attribute_definitions.where(
|
||||
attribute_model: permitted_params[:attribute_model] || DEFAULT_ATTRIBUTE_MODEL
|
||||
)
|
||||
@custom_attribute_definitions = Current.account.custom_attribute_definitions.with_attribute_model(permitted_params[:attribute_model])
|
||||
end
|
||||
|
||||
def fetch_custom_attribute_definition
|
||||
|
@ -41,7 +39,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
|
|||
:attribute_display_type,
|
||||
:attribute_key,
|
||||
:attribute_model,
|
||||
:default_value
|
||||
attribute_values: []
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :current_agents_ids, only: [:update]
|
||||
before_action :current_agents_ids, only: [:create, :update]
|
||||
|
||||
def create
|
||||
authorize @inbox, :create?
|
||||
ActiveRecord::Base.transaction do
|
||||
params[:user_ids].map { |user_id| @inbox.add_member(user_id) }
|
||||
agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) }
|
||||
end
|
||||
fetch_updated_agents
|
||||
end
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
include Api::V1::InboxesHelper
|
||||
before_action :fetch_inbox, except: [:index, :create]
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
|
@ -41,9 +42,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
def update
|
||||
@inbox.update(permitted_params.except(:channel))
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel]) if permitted_params(channel_attributes)[:channel].present?
|
||||
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
||||
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
|
@ -96,6 +102,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
when 'telegram'
|
||||
Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
when 'whatsapp'
|
||||
Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -110,23 +118,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
def permitted_params(channel_attributes = [])
|
||||
params.permit(
|
||||
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
channel: [:type, *channel_attributes]
|
||||
)
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
case channel_type
|
||||
when 'Channel::WebWidget'
|
||||
Channel::WebWidget::EDITABLE_ATTRS
|
||||
when 'Channel::Api'
|
||||
Channel::Api::EDITABLE_ATTRS
|
||||
when 'Channel::Email'
|
||||
Channel::Email::EDITABLE_ATTRS
|
||||
when 'Channel::Telegram'
|
||||
Channel::Telegram::EDITABLE_ATTRS
|
||||
when 'Channel::Line'
|
||||
Channel::Line::EDITABLE_ATTRS
|
||||
if channel_type.constantize.const_defined?('EDITABLE_ATTRS')
|
||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
|
|||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@team_members = params[:user_ids].map { |user_id| @team.add_member(user_id) }
|
||||
@team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) == 'false'
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
|
|
|
@ -7,6 +7,12 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
|||
render json: notification_subscription
|
||||
end
|
||||
|
||||
def destroy
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
class Api::V1::ProfilesController < Api::BaseController
|
||||
before_action :set_user
|
||||
|
||||
def show
|
||||
render partial: 'api/v1/models/user.json.jbuilder', locals: { resource: @user }
|
||||
end
|
||||
def show; end
|
||||
|
||||
def update
|
||||
if password_params[:password].present?
|
||||
|
@ -15,19 +13,31 @@ class Api::V1::ProfilesController < Api::BaseController
|
|||
@user.update!(profile_params)
|
||||
end
|
||||
|
||||
def avatar
|
||||
@user.avatar.attachment.destroy! if @user.avatar.attached?
|
||||
head :ok
|
||||
end
|
||||
|
||||
def availability
|
||||
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
def availability_params
|
||||
params.require(:profile).permit(:account_id, :availability)
|
||||
end
|
||||
|
||||
def profile_params
|
||||
params.require(:profile).permit(
|
||||
:email,
|
||||
:name,
|
||||
:display_name,
|
||||
:avatar,
|
||||
:availability,
|
||||
ui_settings: {}
|
||||
)
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
before_action :process_hmac
|
||||
before_action :process_hmac, only: [:update]
|
||||
|
||||
def show; end
|
||||
|
||||
|
@ -8,18 +8,35 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||
contact: @contact,
|
||||
params: permitted_params.to_h.deep_symbolize_keys
|
||||
)
|
||||
render json: contact_identify_action.perform
|
||||
@contact = contact_identify_action.perform
|
||||
end
|
||||
|
||||
# TODO : clean up this with proper routes delete contacts/custom_attributes
|
||||
def destroy_custom_attributes
|
||||
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
|
||||
@contact.save!
|
||||
render json: @contact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_hmac
|
||||
return if params[:identifier_hash].blank?
|
||||
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
|
||||
return unless should_verify_hmac?
|
||||
|
||||
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
|
||||
|
||||
@contact_inbox.update(hmac_verified: true)
|
||||
end
|
||||
|
||||
def should_verify_hmac?
|
||||
return false if params[:identifier_hash].blank? && !@web_widget.hmac_mandatory
|
||||
|
||||
# Taking an extra caution that the hmac is triggered whenever identifier is present
|
||||
return false if params[:custom_attributes].present? && params[:identifier].blank?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
|
||||
'sha256',
|
||||
|
|
|
@ -31,13 +31,18 @@ module RequestExceptionHandler
|
|||
render json: { error: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_payment_required(message)
|
||||
render json: { error: message }, status: :payment_required
|
||||
end
|
||||
|
||||
def render_internal_server_error(message)
|
||||
render json: { error: message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def render_record_invalid(exception)
|
||||
render json: {
|
||||
message: exception.record.errors.full_messages.join(', ')
|
||||
message: exception.record.errors.full_messages.join(', '),
|
||||
attributes: exception.record.errors.attribute_names
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
|
|
@ -27,9 +27,7 @@ class DashboardController < ActionController::Base
|
|||
'API_CHANNEL_THUMBNAIL',
|
||||
'ANALYTICS_TOKEN',
|
||||
'ANALYTICS_HOST'
|
||||
).merge(
|
||||
APP_VERSION: Chatwoot.config[:version]
|
||||
)
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
def ensure_installation_onboarding
|
||||
|
@ -39,4 +37,11 @@ class DashboardController < ActionController::Base
|
|||
def allow_iframe_requests
|
||||
response.headers.delete('X-Frame-Options')
|
||||
end
|
||||
|
||||
def app_config
|
||||
{ APP_VERSION: Chatwoot.config[:version],
|
||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,10 +28,7 @@ class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
|
|||
end
|
||||
|
||||
def create_reset_token_link(user)
|
||||
raw, enc = Devise.token_generator.generate(user.class, :reset_password_token)
|
||||
user.reset_password_token = enc
|
||||
user.reset_password_sent_at = Time.now.utc
|
||||
user.save(validate: false)
|
||||
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{raw}"
|
||||
token = user.send(:set_reset_password_token)
|
||||
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{token}"
|
||||
end
|
||||
end
|
||||
|
|
21
app/controllers/super_admin/app_configs_controller.rb
Normal file
21
app/controllers/super_admin/app_configs_controller.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
def show
|
||||
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
|
||||
# ref: https://github.com/rubocop/rubocop/issues/7767
|
||||
# rubocop:disable Style/HashTransformValues
|
||||
@fb_config = InstallationConfig.where(name: @allowed_configs)
|
||||
.pluck(:name, :serialized_value)
|
||||
.map { |name, serialized_value| [name, serialized_value['value']] }
|
||||
.to_h
|
||||
# rubocop:enable Style/HashTransformValues
|
||||
end
|
||||
|
||||
def create
|
||||
params['app_config'].each do |key, value|
|
||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||
i.value = value
|
||||
i.save!
|
||||
end
|
||||
redirect_to super_admin_app_config_url
|
||||
end
|
||||
end
|
30
app/controllers/webhooks/instagram_controller.rb
Normal file
30
app/controllers/webhooks/instagram_controller.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
class Webhooks::InstagramController < ApplicationController
|
||||
skip_before_action :authenticate_user!, raise: false
|
||||
skip_before_action :set_current_user
|
||||
|
||||
def verify
|
||||
if valid_instagram_token?(params['hub.verify_token'])
|
||||
Rails.logger.info('Instagram webhook verified')
|
||||
render json: params['hub.challenge']
|
||||
else
|
||||
render json: { error: 'Error; wrong verify token', status: 403 }
|
||||
end
|
||||
end
|
||||
|
||||
def events
|
||||
Rails.logger.info('Instagram webhook received events')
|
||||
if params['object'].casecmp('instagram').zero?
|
||||
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
|
||||
render json: :ok
|
||||
else
|
||||
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}")
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_instagram_token?(token)
|
||||
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
|
||||
end
|
||||
end
|
6
app/controllers/webhooks/whatsapp_controller.rb
Normal file
6
app/controllers/webhooks/whatsapp_controller.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
class Webhooks::WhatsappController < ActionController::API
|
||||
def process_payload
|
||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
||||
head :ok
|
||||
end
|
||||
end
|
|
@ -29,21 +29,21 @@ class WidgetsController < ActionController::Base
|
|||
def set_contact
|
||||
return if @auth_token_params[:source_id].nil?
|
||||
|
||||
contact_inbox = ::ContactInbox.find_by(
|
||||
@contact_inbox = ::ContactInbox.find_by(
|
||||
inbox_id: @web_widget.inbox.id,
|
||||
source_id: @auth_token_params[:source_id]
|
||||
)
|
||||
|
||||
@contact = contact_inbox ? contact_inbox.contact : nil
|
||||
@contact = @contact_inbox ? @contact_inbox.contact : nil
|
||||
end
|
||||
|
||||
def build_contact
|
||||
return if @contact.present?
|
||||
|
||||
contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
|
||||
@contact = contact_inbox.contact
|
||||
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
|
||||
@contact = @contact_inbox.contact
|
||||
|
||||
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
||||
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
||||
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||
end
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
|||
avatar_url: AvatarField,
|
||||
id: Field::Number,
|
||||
name: Field::String,
|
||||
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
|
||||
description: Field::String,
|
||||
outgoing_url: Field::String,
|
||||
created_at: Field::DateTime,
|
||||
|
@ -26,6 +27,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
|||
COLLECTION_ATTRIBUTES = %i[
|
||||
id
|
||||
avatar_url
|
||||
account
|
||||
name
|
||||
outgoing_url
|
||||
].freeze
|
||||
|
@ -34,7 +36,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
|||
# an array of attributes that will be displayed on the model's show page.
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
avatar_url
|
||||
account
|
||||
name
|
||||
description
|
||||
outgoing_url
|
||||
|
@ -45,6 +47,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
|||
# on the model's form (`new` and `edit`) pages.
|
||||
FORM_ATTRIBUTES = %i[
|
||||
name
|
||||
account
|
||||
description
|
||||
outgoing_url
|
||||
].freeze
|
||||
|
|
|
@ -15,7 +15,9 @@ class AsyncDispatcher < BaseDispatcher
|
|||
EventListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
WebhookListener.instance
|
||||
NotificationListener.instance,
|
||||
WebhookListener.instance,
|
||||
AutomationRuleListener.instance
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,6 @@ class SyncDispatcher < BaseDispatcher
|
|||
end
|
||||
|
||||
def listeners
|
||||
[ActionCableListener.instance, AgentBotListener.instance, NotificationListener.instance]
|
||||
[ActionCableListener.instance, AgentBotListener.instance]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,30 @@
|
|||
class ConversationDrop < BaseDrop
|
||||
include MessageFormatHelper
|
||||
|
||||
def display_id
|
||||
@obj.try(:display_id)
|
||||
end
|
||||
|
||||
def contact_name
|
||||
@obj.try(:contact).name.capitalize || 'Customer'
|
||||
end
|
||||
|
||||
def recent_messages
|
||||
@obj.try(:recent_messages).map do |message|
|
||||
{
|
||||
'sender' => message_sender_name(message.sender),
|
||||
'content' => render_message_content(transform_user_mention_content(message.content)),
|
||||
'attachments' => message.attachments.map(&:file_url)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message_sender_name(sender)
|
||||
return 'Bot' if sender.blank?
|
||||
return contact_name if sender.instance_of?(Contact)
|
||||
|
||||
sender&.available_name || sender&.name
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,2 +1,5 @@
|
|||
class InboxDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@ class MessageDrop < BaseDrop
|
|||
end
|
||||
|
||||
def text_content
|
||||
content = @obj.try(:content)
|
||||
transform_user_mention_content content
|
||||
content = @obj.try(:content) || ''
|
||||
render_message_content(transform_user_mention_content(content))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,6 +2,6 @@ require 'administrate/field/base'
|
|||
|
||||
class AvatarField < Administrate::Field::Base
|
||||
def avatar_url
|
||||
data.presence || '/admin/avatar.png'
|
||||
data.presence&.gsub('?d=404', '?d=mp')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -70,7 +70,12 @@ class ConversationFinder
|
|||
end
|
||||
|
||||
def find_all_conversations
|
||||
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
|
||||
if params[:conversation_type] == 'mention'
|
||||
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
|
||||
@conversations = current_account.conversations.where(id: conversation_ids)
|
||||
else
|
||||
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_by_assignee_type
|
||||
|
@ -121,8 +126,12 @@ class ConversationFinder
|
|||
|
||||
def conversations
|
||||
@conversations = @conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||
)
|
||||
@conversations.latest.page(current_page)
|
||||
if params[:conversation_type] == 'mention'
|
||||
@conversations.page(current_page)
|
||||
else
|
||||
@conversations.latest.page(current_page)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
33
app/helpers/api/v1/inboxes_helper.rb
Normal file
33
app/helpers/api/v1/inboxes_helper.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
module Api::V1::InboxesHelper
|
||||
def validate_email_channel(attributes)
|
||||
channel_data = permitted_params(attributes)[:channel]
|
||||
|
||||
validate_imap(channel_data)
|
||||
validate_smtp(channel_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_imap(channel_data)
|
||||
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
|
||||
|
||||
Mail.defaults do
|
||||
retriever_method :imap, { address: channel_data[:imap_address],
|
||||
port: channel_data[:imap_port],
|
||||
user_name: channel_data[:imap_email],
|
||||
password: channel_data[:imap_password],
|
||||
enable_ssl: channel_data[:imap_enable_ssl] }
|
||||
end
|
||||
|
||||
Mail.connection do # rubocop:disable:block
|
||||
end
|
||||
end
|
||||
|
||||
def validate_smtp(channel_data)
|
||||
return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled]
|
||||
|
||||
smtp = Net::SMTP.start(channel_data[:smtp_address], channel_data[:smtp_port], channel_data[:smtp_domain], channel_data[:smtp_email],
|
||||
channel_data[:smtp_password], :login)
|
||||
smtp.finish unless smtp&.nil?
|
||||
end
|
||||
end
|
|
@ -1,16 +1,29 @@
|
|||
module FileTypeHelper
|
||||
# NOTE: video, audio, image, etc are filetypes previewable in frontend
|
||||
def file_type(content_type)
|
||||
return :image if [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/tiff',
|
||||
'image/bmp'
|
||||
].include?(content_type)
|
||||
|
||||
return :video if content_type.include?('video/')
|
||||
return :image if image_file?(content_type)
|
||||
return :video if video_file?(content_type)
|
||||
return :audio if content_type.include?('audio/')
|
||||
|
||||
:file
|
||||
end
|
||||
|
||||
def image_file?(content_type)
|
||||
[
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/webp'
|
||||
].include?(content_type)
|
||||
end
|
||||
|
||||
def video_file?(content_type)
|
||||
[
|
||||
'video/ogg',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime'
|
||||
].include?(content_type)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
module MessageFormatHelper
|
||||
include RegexHelper
|
||||
|
||||
def transform_user_mention_content(message_content)
|
||||
message_content.gsub(MENTION_REGEX, '\1')
|
||||
end
|
||||
|
||||
def render_message_content(message_content)
|
||||
# rubocop:disable Rails/OutputSafety
|
||||
CommonMarker.render_html(message_content).html_safe
|
||||
# rubocop:enable Rails/OutputSafety
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
:has-accounts="hasAccounts"
|
||||
/>
|
||||
<woot-snackbar-box />
|
||||
<network-notification />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -15,6 +16,7 @@
|
|||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
import NetworkNotification from './components/NetworkNotification';
|
||||
import { accountIdFromPathname } from './helper/URLHelper';
|
||||
|
||||
export default {
|
||||
|
@ -23,6 +25,7 @@ export default {
|
|||
components: {
|
||||
WootSnackbarBox,
|
||||
AddAccountModal,
|
||||
NetworkNotification,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
18
app/javascript/dashboard/api/accountActions.js
Normal file
18
app/javascript/dashboard/api/accountActions.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AccountActions extends ApiClient {
|
||||
constructor() {
|
||||
super('actions', { accountScoped: true });
|
||||
}
|
||||
|
||||
merge(parentId, childId) {
|
||||
return axios.post(`${this.url}/contact_merge`, {
|
||||
base_contact_id: parentId,
|
||||
mergee_contact_id: childId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccountActions();
|
|
@ -6,8 +6,8 @@ class AttributeAPI extends ApiClient {
|
|||
super('custom_attribute_definitions', { accountScoped: true });
|
||||
}
|
||||
|
||||
getAttributesByModel(modelId) {
|
||||
return axios.get(`${this.url}?attribute_model=${modelId}`);
|
||||
getAttributesByModel() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -161,9 +161,13 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
updateAvailability({ availability }) {
|
||||
return axios.put(endPoints('profileUpdate').url, {
|
||||
profile: { availability },
|
||||
updateAvailability(availabilityData) {
|
||||
return axios.post(endPoints('availabilityUpdate').url, {
|
||||
profile: { ...availabilityData },
|
||||
});
|
||||
},
|
||||
|
||||
deleteAvatar() {
|
||||
return axios.delete(endPoints('deleteAvatar').url);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -2,7 +2,27 @@ import ApiClient from './ApiClient';
|
|||
|
||||
class ContactNotes extends ApiClient {
|
||||
constructor() {
|
||||
super('contact_notes', { accountScoped: true });
|
||||
super('notes', { accountScoped: true });
|
||||
this.contactId = null;
|
||||
}
|
||||
|
||||
get url() {
|
||||
return `${this.baseUrl()}/contacts/${this.contactId}/notes`;
|
||||
}
|
||||
|
||||
get(contactId) {
|
||||
this.contactId = contactId;
|
||||
return super.get();
|
||||
}
|
||||
|
||||
create(contactId, content) {
|
||||
this.contactId = contactId;
|
||||
return super.create({ content });
|
||||
}
|
||||
|
||||
delete(contactId, id) {
|
||||
this.contactId = contactId;
|
||||
return super.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -53,6 +53,11 @@ class ContactAPI extends ApiClient {
|
|||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
filter(page = 1, sortAttr = 'name', queryPayload) {
|
||||
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
|
||||
return axios.post(requestURL, queryPayload);
|
||||
}
|
||||
|
||||
importContacts(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('import_file', file);
|
||||
|
@ -60,6 +65,12 @@ class ContactAPI extends ApiClient {
|
|||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
destroyCustomAttributes(contactId, customAttributes) {
|
||||
return axios.post(`${this.url}/${contactId}/destroy_custom_attributes`, {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
|
|
@ -13,6 +13,9 @@ const endPoints = {
|
|||
profileUpdate: {
|
||||
url: '/api/v1/profile',
|
||||
},
|
||||
availabilityUpdate: {
|
||||
url: '/api/v1/profile/availability',
|
||||
},
|
||||
logout: {
|
||||
url: 'auth/sign_out',
|
||||
},
|
||||
|
@ -33,6 +36,10 @@ const endPoints = {
|
|||
},
|
||||
params: { omniauth_token: '' },
|
||||
},
|
||||
|
||||
deleteAvatar: {
|
||||
url: '/api/v1/profile/avatar',
|
||||
},
|
||||
};
|
||||
|
||||
export default page => {
|
||||
|
|
|
@ -6,7 +6,15 @@ class ConversationApi extends ApiClient {
|
|||
super('conversations', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ inboxId, status, assigneeType, page, labels, teamId }) {
|
||||
get({
|
||||
inboxId,
|
||||
status,
|
||||
assigneeType,
|
||||
page,
|
||||
labels,
|
||||
teamId,
|
||||
conversationType,
|
||||
}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
|
@ -15,6 +23,15 @@ class ConversationApi extends ApiClient {
|
|||
assignee_type: assigneeType,
|
||||
page,
|
||||
labels,
|
||||
conversation_type: conversationType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
filter(payload) {
|
||||
return axios.post(`${this.url}/filter`, payload.queryData, {
|
||||
params: {
|
||||
page: payload.page,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -51,9 +68,10 @@ class ConversationApi extends ApiClient {
|
|||
return axios.post(`${this.url}/${id}/update_last_seen`);
|
||||
}
|
||||
|
||||
toggleTyping({ conversationId, status }) {
|
||||
toggleTyping({ conversationId, status, isPrivate }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
|
||||
typing_status: status,
|
||||
is_private: isPrivate,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -65,7 +83,7 @@ class ConversationApi extends ApiClient {
|
|||
return axios.post(`${this.url}/${conversationId}/unmute`);
|
||||
}
|
||||
|
||||
meta({ inboxId, status, assigneeType, labels, teamId }) {
|
||||
meta({ inboxId, status, assigneeType, labels, teamId, conversationType }) {
|
||||
return axios.get(`${this.url}/meta`, {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
|
@ -73,6 +91,7 @@ class ConversationApi extends ApiClient {
|
|||
assignee_type: assigneeType,
|
||||
labels,
|
||||
team_id: teamId,
|
||||
conversation_type: conversationType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -80,6 +99,12 @@ class ConversationApi extends ApiClient {
|
|||
sendEmailTranscript({ conversationId, email }) {
|
||||
return axios.post(`${this.url}/${conversationId}/transcript`, { email });
|
||||
}
|
||||
|
||||
updateCustomAttributes({ conversationId, customAttributes }) {
|
||||
return axios.post(`${this.url}/${conversationId}/custom_attributes`, {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
|
|
@ -8,6 +8,8 @@ export const buildCreatePayload = ({
|
|||
contentAttributes,
|
||||
echoId,
|
||||
file,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
}) => {
|
||||
let payload;
|
||||
if (file) {
|
||||
|
@ -18,12 +20,16 @@ export const buildCreatePayload = ({
|
|||
}
|
||||
payload.append('private', isPrivate);
|
||||
payload.append('echo_id', echoId);
|
||||
payload.append('cc_emails', ccEmails);
|
||||
payload.append('bcc_emails', bccEmails);
|
||||
} else {
|
||||
payload = {
|
||||
content: message,
|
||||
private: isPrivate,
|
||||
echo_id: echoId,
|
||||
content_attributes: contentAttributes,
|
||||
cc_emails: ccEmails,
|
||||
bcc_emails: bccEmails,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
|
@ -41,6 +47,8 @@ class MessageApi extends ApiClient {
|
|||
contentAttributes,
|
||||
echo_id: echoId,
|
||||
file,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
|
@ -51,6 +59,8 @@ class MessageApi extends ApiClient {
|
|||
contentAttributes,
|
||||
echoId,
|
||||
file,
|
||||
ccEmails,
|
||||
bccEmails,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -35,6 +35,12 @@ class ReportsAPI extends ApiClient {
|
|||
params: { since, until },
|
||||
});
|
||||
}
|
||||
|
||||
getTeamReports(since, until) {
|
||||
return axios.get(`${this.url}/teams`, {
|
||||
params: { since, until },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
|
23
app/javascript/dashboard/api/specs/accountActions.spec.js
Normal file
23
app/javascript/dashboard/api/specs/accountActions.spec.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import accountActionsAPI from '../accountActions';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#ContactsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(accountActionsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(accountActionsAPI).toHaveProperty('merge');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#merge', () => {
|
||||
accountActionsAPI.merge(1, 2);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/actions/contact_merge',
|
||||
{
|
||||
base_contact_id: 1,
|
||||
mergee_contact_id: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -11,6 +11,7 @@ describe('#ContactsAPI', () => {
|
|||
expect(contactAPI).toHaveProperty('update');
|
||||
expect(contactAPI).toHaveProperty('delete');
|
||||
expect(contactAPI).toHaveProperty('getConversations');
|
||||
expect(contactAPI).toHaveProperty('filter');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
|
@ -60,6 +61,16 @@ describe('#ContactsAPI', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('#destroyCustomAttributes', () => {
|
||||
contactAPI.destroyCustomAttributes(1, ['cloudCustomer']);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/destroy_custom_attributes',
|
||||
{
|
||||
custom_attributes: ['cloudCustomer'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#importContacts', () => {
|
||||
const file = 'file';
|
||||
contactAPI.importContacts(file);
|
||||
|
@ -71,6 +82,24 @@ describe('#ContactsAPI', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#filter', () => {
|
||||
const queryPayload = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['fayaz'],
|
||||
query_operator: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
contactAPI.filter(1, 'name', queryPayload);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name',
|
||||
queryPayload
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ describe('#ConversationAPI', () => {
|
|||
expect(conversationAPI).toHaveProperty('unmute');
|
||||
expect(conversationAPI).toHaveProperty('meta');
|
||||
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
|
||||
expect(conversationAPI).toHaveProperty('filter');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
|
@ -160,5 +161,54 @@ describe('#ConversationAPI', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateCustomAttributes', () => {
|
||||
conversationAPI.updateCustomAttributes({
|
||||
conversationId: 45,
|
||||
customAttributes: { order_d: '1001' },
|
||||
});
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/45/custom_attributes',
|
||||
{
|
||||
custom_attributes: { order_d: '1001' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#filter', () => {
|
||||
const payload = {
|
||||
page: 1,
|
||||
queryData: {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['pending', 'resolved'],
|
||||
query_operator: 'and',
|
||||
},
|
||||
{
|
||||
attribute_key: 'assignee',
|
||||
filter_operator: 'equal_to',
|
||||
values: [3],
|
||||
query_operator: 'and',
|
||||
},
|
||||
{
|
||||
attribute_key: 'id',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['This is a test'],
|
||||
query_operator: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
conversationAPI.filter(payload);
|
||||
expect(
|
||||
context.axiosMock.post
|
||||
).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/filter',
|
||||
payload.queryData,
|
||||
{ params: { page: payload.page } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,12 +35,14 @@ describe('#ConversationAPI', () => {
|
|||
message: 'test content',
|
||||
echoId: 12,
|
||||
isPrivate: true,
|
||||
|
||||
file: new Blob(['test-content'], { type: 'application/pdf' }),
|
||||
});
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('content')).toEqual('test content');
|
||||
expect(formPayload.get('echo_id')).toEqual('12');
|
||||
expect(formPayload.get('private')).toEqual('true');
|
||||
expect(formPayload.get('cc_emails')).toEqual('');
|
||||
});
|
||||
|
||||
it('builds object payload if file is not available', () => {
|
||||
|
@ -56,6 +58,8 @@ describe('#ConversationAPI', () => {
|
|||
private: false,
|
||||
echo_id: 12,
|
||||
content_attributes: { in_reply_to: 12 },
|
||||
bcc_emails: '',
|
||||
cc_emails: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@ describe('#Reports API', () => {
|
|||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||
expect(reportsAPI).toHaveProperty('getLabelReports');
|
||||
expect(reportsAPI).toHaveProperty('getInboxReports');
|
||||
expect(reportsAPI).toHaveProperty('getTeamReports');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getAccountReports', () => {
|
||||
|
@ -82,5 +83,18 @@ describe('#Reports API', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getTeamReports', () => {
|
||||
reportsAPI.getTeamReports(1621103400, 1621621800);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/teams',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
BIN
app/javascript/dashboard/assets/images/channels/messenger.png
Normal file
BIN
app/javascript/dashboard/assets/images/channels/messenger.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
Before Width: | Height: | Size: 36 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 12 KiB |
|
@ -1,4 +1,3 @@
|
|||
|
||||
/* Enter and leave animations can use different */
|
||||
/* durations and timing functions. */
|
||||
.slide-fade-enter-active {
|
||||
|
@ -9,7 +8,8 @@
|
|||
transition: all .3s $ease-out-cubic;
|
||||
}
|
||||
|
||||
.slide-fade-enter, .slide-fade-leave-to {
|
||||
.slide-fade-enter,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
@ -22,22 +22,33 @@
|
|||
transform: translateX($space-medium);
|
||||
}
|
||||
|
||||
.conversations-list-enter-active, .conversations-list-leave-active {
|
||||
.conversations-list-enter-active,
|
||||
.conversations-list-leave-active {
|
||||
transition: all .25s $ease-out-cubic;
|
||||
}
|
||||
|
||||
.conversations-list-enter, .conversations-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
|
||||
.conversations-list-enter,
|
||||
.conversations-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX($space-medium);
|
||||
}
|
||||
|
||||
.menu-list-enter-active, .menu-list-leave-active {
|
||||
transition: all .2s $ease-out-cubic;
|
||||
.menu-list-enter-active,
|
||||
.menu-list-leave-active {
|
||||
transition: opacity .3s $ease-out-cubic,
|
||||
transform .2s $ease-out-cubic;
|
||||
}
|
||||
|
||||
.menu-list-enter, .menu-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
|
||||
|
||||
.menu-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX($space-medium);
|
||||
position: absolute;
|
||||
transform: translateX($space-small);
|
||||
}
|
||||
|
||||
.menu-list-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-$space-small);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
|
@ -48,8 +59,8 @@
|
|||
transition: all .3s $ease-out-cubic;
|
||||
}
|
||||
|
||||
.slide-up-enter, .slide-up-leave-to
|
||||
/* .slide-fade-leave-active for <2.1.8 */ {
|
||||
.slide-up-enter,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-$space-medium);
|
||||
opacity: 0;
|
||||
}
|
||||
|
@ -60,10 +71,10 @@
|
|||
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
|
||||
}
|
||||
|
||||
.menu-slide-enter, .menu-slide-leave-to
|
||||
/* .slide-fade-leave-active for <2.1.8 */ {
|
||||
transform: translateY($space-small);
|
||||
.menu-slide-enter,
|
||||
.menu-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY($space-small);
|
||||
}
|
||||
|
||||
|
||||
|
@ -75,10 +86,10 @@
|
|||
transition: all .1s $ease-out-sine;
|
||||
}
|
||||
|
||||
.toast-fade-enter, .toast-fade-leave-to
|
||||
/* .toast-fade-leave-active for <2.1.8 */ {
|
||||
transform: translateY(-$space-small);
|
||||
.toast-fade-enter,
|
||||
.toast-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-$space-small);
|
||||
}
|
||||
|
||||
.modal-fade-enter-active {
|
||||
|
@ -89,7 +100,21 @@
|
|||
transition: all .1s $ease-out-sine;
|
||||
}
|
||||
|
||||
.modal-fade-enter, .modal-fade-leave-to
|
||||
/* .slide-fade-leave-active for <2.1.8 */ {
|
||||
.modal-fade-enter,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.network-notification-fade-enter-active {
|
||||
transition: all .1s $ease-in-sine;
|
||||
}
|
||||
|
||||
.network-notification-fade-leave-active {
|
||||
transition: all .1s $ease-out-sine;
|
||||
}
|
||||
|
||||
.network-notification-fade-enter,
|
||||
.network-notification-fade-leave-to {
|
||||
transform: translateY(-$space-small);
|
||||
opacity: 0;
|
||||
}
|
||||
|
|
|
@ -9,10 +9,10 @@
|
|||
|
||||
.card {
|
||||
margin-bottom: var(--space-small);
|
||||
padding: var(--space-small);
|
||||
padding: var(--space-normal);
|
||||
}
|
||||
|
||||
.button-wrapper .button.link.grey-btn {
|
||||
.button-wrapper .button.grey-btn {
|
||||
margin-left: var(--space-normal);
|
||||
}
|
||||
|
||||
|
@ -21,7 +21,7 @@
|
|||
font-size: $font-size-mini;
|
||||
max-width: 15rem;
|
||||
padding: $space-smaller $space-small;
|
||||
z-index: 9999;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
code {
|
||||
|
@ -49,7 +49,21 @@ code {
|
|||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// remove when grid gutters are fixed
|
||||
.columns.with-right-space {
|
||||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
border-radius: var(--border-radius-normal);
|
||||
}
|
||||
|
||||
.padding-right-small {
|
||||
padding-right: var(--space-one);
|
||||
}
|
||||
|
||||
.margin-right-small {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
|
|
@ -45,6 +45,9 @@
|
|||
// 1. Global
|
||||
// ---------
|
||||
|
||||
// Disable contrast warnings in Foundation.
|
||||
$contrast-warnings: false;
|
||||
|
||||
$global-font-size: 10px;
|
||||
$global-width: 100%;
|
||||
$global-lineheight: 1.5;
|
||||
|
@ -219,9 +222,9 @@ $badge-background: $primary-color;
|
|||
$badge-color: $white;
|
||||
$badge-color-alt: $black;
|
||||
$badge-palette: $foundation-palette;
|
||||
$badge-padding: 0.3em;
|
||||
$badge-padding: var(--space-smaller);
|
||||
$badge-minwidth: 2.1em;
|
||||
$badge-font-size: 0.6rem;
|
||||
$badge-font-size: var(--font-size-nano);
|
||||
|
||||
// 10. Breadcrumbs
|
||||
// ---------------
|
||||
|
@ -400,7 +403,7 @@ $mediaobject-image-width-stacked: 100%;
|
|||
|
||||
$menu-margin: 0;
|
||||
$menu-margin-nested: $space-medium;
|
||||
$menu-item-padding: $space-one;
|
||||
$menu-item-padding: $space-slab;
|
||||
$menu-item-color-active: $white;
|
||||
$menu-item-background-active: $color-background;
|
||||
$menu-icon-spacing: 0.25rem;
|
||||
|
|
|
@ -55,6 +55,10 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,56 @@
|
|||
.margin-right-small {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.margin-right-smaller {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
.margin-left-minus-slab {
|
||||
margin-left: var(--space-minus-slab);
|
||||
}
|
||||
|
||||
.fs-small {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.fs-default {
|
||||
font-size: var(--font-size-default);
|
||||
}
|
||||
|
||||
.fw-medium {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.p-normal {
|
||||
padding: var(--space-normal);
|
||||
}
|
||||
|
||||
.overflow-scroll {
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.border-right {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.border-left {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.text-y-800 {
|
||||
color: var(--y-800);
|
||||
}
|
||||
|
|
|
@ -44,11 +44,14 @@ $woot-logo-padding: $space-large $space-two;
|
|||
$color-woot: #1f93ff;
|
||||
$color-gray: #6e6f73;
|
||||
$color-light-gray: #999a9b;
|
||||
$color-border: #e0e6ed;
|
||||
$color-border-light: #f0f4f5;
|
||||
$color-border-dark: #cad0d4;
|
||||
$color-background: #f4f6fb;
|
||||
$color-background-light: #f9fafc;
|
||||
|
||||
$color-border: var(--s-75);
|
||||
$color-border-light: var(--s-50);
|
||||
$color-border-dark: var(--s-100);
|
||||
|
||||
$color-background: var(--s-50);
|
||||
$color-background-light: var(--s-25);
|
||||
|
||||
$color-white: #fff;
|
||||
$color-body: #3c4858;
|
||||
$color-heading: #1f2d3d;
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
@import 'helper-classes';
|
||||
@import 'formulate';
|
||||
@import 'date-picker';
|
||||
@import 'utility-helpers';
|
||||
|
||||
@import 'foundation-sites/scss/foundation';
|
||||
@import '~bourbon/core/bourbon';
|
||||
|
@ -50,3 +49,4 @@
|
|||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
@import 'utility-helpers';
|
||||
|
|
|
@ -2,8 +2,9 @@
|
|||
@include elegant-card;
|
||||
@include border-light;
|
||||
box-sizing: content-box;
|
||||
padding: var(--space-small);
|
||||
width: fit-content;
|
||||
z-index: 999;
|
||||
z-index: var(--z-index-very-high);
|
||||
|
||||
&.dropdown-pane--open {
|
||||
display: block;
|
||||
|
|
|
@ -15,6 +15,10 @@
|
|||
.multiselect {
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
&.multiselect--disabled {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
>.multiselect__tags {
|
||||
border-color: $color-woot;
|
||||
|
@ -47,6 +51,10 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight {
|
||||
background: var(--white);
|
||||
color: var(--color-body);
|
||||
|
@ -209,3 +217,53 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-wrap--medium {
|
||||
$multiselect-height: 4.8rem;
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
background: var(--white);
|
||||
font-size: var(--font-size-small);
|
||||
height: $multiselect-height;
|
||||
min-height: $multiselect-height;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
height: $multiselect-height - $space-micro;
|
||||
min-height: $multiselect-height - $space-micro;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
margin: 0;
|
||||
padding: var(--space-smaller) var(--space-micro);
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
margin: 0;
|
||||
padding: var(--space-smaller) var(--space-micro);
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
min-height: $multiselect-height;
|
||||
}
|
||||
|
||||
.multiselect--disabled .multiselect__current,
|
||||
.multiselect--disabled .multiselect__select {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
@include background-white;
|
||||
@include flex;
|
||||
@include flex-align($x: justify, $y: middle);
|
||||
@include border-normal-bottom;
|
||||
border-bottom: 1px solid var(--s-50);
|
||||
height: $header-height;
|
||||
min-height: $header-height;
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ $default-button-height: 4.0rem;
|
|||
// @TODDO - Remove after moving all buttons to woot-button
|
||||
.icon+.button__content {
|
||||
padding-left: var(--space-small);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
|
@ -65,6 +66,32 @@ $default-button-height: 4.0rem;
|
|||
}
|
||||
}
|
||||
|
||||
&.clear {
|
||||
&.warning {
|
||||
color: var(--y-800);
|
||||
}
|
||||
|
||||
&.button--only-icon:hover {
|
||||
background: var(--w-50);
|
||||
|
||||
&.secondary {
|
||||
background: var(--s-50);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: var(--g-50);
|
||||
}
|
||||
|
||||
&.alert {
|
||||
background: var(--r-50);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: var(--y-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&.tiny {
|
||||
height: var(--space-medium);
|
||||
|
@ -103,7 +130,6 @@ $default-button-height: 4.0rem;
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -85,11 +85,6 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 27rem;
|
||||
|
||||
.small-icon {
|
||||
font-size: $font-size-mini;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation--meta {
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
.image,
|
||||
.video {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
|
@ -26,7 +27,13 @@
|
|||
}
|
||||
|
||||
.modal-image {
|
||||
max-width: 85%;
|
||||
max-height: 90%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.modal-video {
|
||||
max-height: 75vh;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
@ -35,11 +42,21 @@
|
|||
content: '';
|
||||
height: 20%;
|
||||
left: 0;
|
||||
opacity: .8;
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
.modal-container {
|
||||
width: auto;
|
||||
|
||||
.modal--close {
|
||||
z-index: var(--z-index-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list-wrap {
|
||||
|
@ -76,7 +93,7 @@
|
|||
|
||||
.status--filter {
|
||||
@include padding($zero null $zero $space-normal);
|
||||
@include margin($space-smaller $space-slab $zero $zero);
|
||||
@include margin($zero);
|
||||
background-color: $color-background-light;
|
||||
border: 1px solid $color-border;
|
||||
float: right;
|
||||
|
@ -93,7 +110,7 @@
|
|||
|
||||
.conversation-panel {
|
||||
@include flex;
|
||||
@include flex-weight(1);
|
||||
@include flex-weight(1 1 1px);
|
||||
@include margin($zero);
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
@ -118,7 +135,7 @@
|
|||
|
||||
&.unread--toast {
|
||||
+.right {
|
||||
margin-bottom: 0;
|
||||
margin-bottom: var(--space-micro);
|
||||
}
|
||||
|
||||
+.left {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
.error {
|
||||
|
||||
#{$all-text-inputs},
|
||||
select,
|
||||
.multiselect>.multiselect__tags {
|
||||
.multiselect > .multiselect__tags {
|
||||
@include thin-border(var(--r-400));
|
||||
}
|
||||
|
||||
|
@ -40,4 +39,8 @@ input {
|
|||
font-size: var(--font-size-small);
|
||||
height: var(--space-large);
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: var(--r-400);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
cursor: pointer;
|
||||
font-size: $font-size-big;
|
||||
line-height: $space-normal;
|
||||
padding: $space-normal $space-two;
|
||||
padding: $space-normal;
|
||||
position: absolute;
|
||||
right: $space-micro;
|
||||
top: $space-micro;
|
||||
|
@ -29,7 +29,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.page-top-bar {
|
||||
@include padding($space-large $space-large $zero);
|
||||
|
||||
|
@ -48,13 +47,16 @@
|
|||
position: relative;
|
||||
width: 60rem;
|
||||
|
||||
&.medium {
|
||||
max-width: 80%;
|
||||
width: 90rem;
|
||||
}
|
||||
|
||||
.content-box {
|
||||
@include padding($zero);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
h2 {
|
||||
color: $color-heading;
|
||||
font-size: $font-size-medium;
|
||||
|
@ -89,15 +91,19 @@
|
|||
button {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
&.justify-content-end {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-item {
|
||||
@include padding($space-large);
|
||||
|
||||
button {
|
||||
@include margin($zero);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.modal-enter,
|
||||
|
|
|
@ -21,10 +21,7 @@
|
|||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.margin-right-small {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.display-flex {
|
||||
.reports-option__wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
|
|
@ -6,12 +6,6 @@
|
|||
}
|
||||
|
||||
.sidebar {
|
||||
@include border-normal-right;
|
||||
@include background-white;
|
||||
@include full-height;
|
||||
@include margin(0);
|
||||
@include space-between-column;
|
||||
width: $nav-bar-width;
|
||||
z-index: 1024 - 1;
|
||||
|
||||
//logo
|
||||
|
@ -22,26 +16,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
a {
|
||||
border-radius: $space-smaller;
|
||||
color: $color-gray;
|
||||
font-size: $font-size-default;
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
.wrap,
|
||||
.child-icon {
|
||||
&:hover {
|
||||
color: $color-woot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active a .wrap {
|
||||
color: $color-woot;
|
||||
}
|
||||
}
|
||||
|
||||
.nested {
|
||||
a {
|
||||
font-size: $font-size-small;
|
||||
|
@ -83,34 +57,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
@include flex-weight(1);
|
||||
@include scroll-on-hover;
|
||||
padding: 0 $space-medium - $space-one;
|
||||
|
||||
a {
|
||||
&::before {
|
||||
margin-right: $space-slab;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
color: $color-gray;
|
||||
font-size: $font-size-medium;
|
||||
margin-top: $space-medium;
|
||||
|
||||
>span {
|
||||
margin-left: $space-one;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-title+ul>li>a {
|
||||
@include padding($space-micro null);
|
||||
color: $medium-gray;
|
||||
line-height: $global-lineheight;
|
||||
}
|
||||
|
||||
.hamburger--menu {
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="cw-accordion">
|
||||
<button class="cw-accordion--title" @click="$emit('click')">
|
||||
<button class="cw-accordion--title drag-handle" @click="$emit('click')">
|
||||
<div class="cw-accordion--title-wrap">
|
||||
<emoji-or-icon class="icon-or-emoji" :icon="icon" :emoji="emoji" />
|
||||
<h5>
|
||||
|
@ -10,12 +10,16 @@
|
|||
<div class="button-icon--wrap">
|
||||
<slot name="button" />
|
||||
<div class="chevron-icon__wrap">
|
||||
<i v-if="isOpen" class="ion-minus chevron-icon"></i>
|
||||
<i v-else class="ion-plus chevron-icon"></i>
|
||||
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
|
||||
<fluent-icon v-else size="24" icon="add" type="solid" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="isOpen" class="cw-accordion--content">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="cw-accordion--content"
|
||||
:class="{ compact: compact }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -33,6 +37,10 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
@ -58,10 +66,10 @@ export default {
|
|||
}
|
||||
.cw-accordion--title {
|
||||
align-items: center;
|
||||
background: var(--b-50);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
background: var(--s-50);
|
||||
border-bottom: 1px solid var(--s-100);
|
||||
border-top: 1px solid var(--s-100);
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
|
@ -106,5 +114,9 @@ export default {
|
|||
|
||||
.cw-accordion--content {
|
||||
padding: var(--space-normal);
|
||||
|
||||
&.compact {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,40 @@
|
|||
<template>
|
||||
l<template>
|
||||
<div class="conversations-list-wrap">
|
||||
<slot></slot>
|
||||
<div class="chat-list__top">
|
||||
<div class="chat-list__top" :class="{ filter__applied: hasAppliedFilters }">
|
||||
<h1 class="page-title text-truncate" :title="pageTitle">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<chat-filter @statusFilterChange="updateStatusType" />
|
||||
|
||||
<div class="filter--actions">
|
||||
<chat-filter
|
||||
v-if="!hasAppliedFilters"
|
||||
@statusFilterChange="updateStatusType"
|
||||
/>
|
||||
<woot-button
|
||||
v-else
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="alert"
|
||||
@click="resetAndFetchData"
|
||||
>
|
||||
{{ $t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="filter"
|
||||
size="small"
|
||||
class="btn-filter"
|
||||
@click="onToggleAdvanceFiltersModal"
|
||||
>
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<chat-type-tabs
|
||||
v-if="!isIframe"
|
||||
v-if="!hasAppliedFilters && !isIframe"
|
||||
:items="assigneeTabItems"
|
||||
:active-tab="activeAssigneeTab"
|
||||
class="tab--chat-type"
|
||||
|
@ -27,6 +52,7 @@
|
|||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:chat="chat"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
/>
|
||||
|
||||
|
@ -38,7 +64,7 @@
|
|||
v-if="!hasCurrentPageEndReached && !chatListLoading"
|
||||
variant="clear"
|
||||
size="expanded"
|
||||
@click="fetchConversations"
|
||||
@click="loadMoreConversations"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
|
||||
</woot-button>
|
||||
|
@ -54,6 +80,18 @@
|
|||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showAdvancedFilters"
|
||||
:on-close="onToggleAdvanceFiltersModal"
|
||||
size="medium"
|
||||
>
|
||||
<conversation-advanced-filter
|
||||
v-if="showAdvancedFilters"
|
||||
:filter-types="advancedFilterTypes"
|
||||
:on-close="onToggleAdvanceFiltersModal"
|
||||
@applyFilter="onApplyFilter"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -61,12 +99,16 @@
|
|||
import { mapGetters } from 'vuex';
|
||||
|
||||
import ChatFilter from './widgets/conversation/ChatFilter';
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard';
|
||||
import timeMixin from '../mixins/time';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from '../constants';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
|
||||
import {
|
||||
hasPressedAltAndJKey,
|
||||
hasPressedAltAndKKey,
|
||||
|
@ -77,6 +119,7 @@ export default {
|
|||
ChatTypeTabs,
|
||||
ConversationCard,
|
||||
ChatFilter,
|
||||
ConversationAdvancedFilter,
|
||||
},
|
||||
mixins: [timeMixin, conversationMixin, eventListenerMixins],
|
||||
props: {
|
||||
|
@ -92,11 +135,20 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
|
||||
activeStatus: wootConstants.STATUS_TYPE.OPEN,
|
||||
showAdvancedFilters: false,
|
||||
advancedFilterTypes: advancedFilterTypes.map(filter => ({
|
||||
...filter,
|
||||
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
})),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -110,10 +162,14 @@ export default {
|
|||
currentUserID: 'getCurrentUserID',
|
||||
activeInbox: 'getSelectedInbox',
|
||||
conversationStats: 'conversationStats/getStats',
|
||||
appliedFilters: 'getAppliedConversationFilters',
|
||||
}),
|
||||
isIframe() {
|
||||
return window.self !== window.top;
|
||||
},
|
||||
hasAppliedFilters() {
|
||||
return this.appliedFilters.length;
|
||||
},
|
||||
assigneeTabItems() {
|
||||
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
|
||||
const count = this.conversationStats[item.COUNT_KEY] || 0;
|
||||
|
@ -135,9 +191,17 @@ export default {
|
|||
this.activeAssigneeTab
|
||||
);
|
||||
},
|
||||
currentPageFilterKey() {
|
||||
return this.hasAppliedFilters ? 'appliedFilters' : this.activeAssigneeTab;
|
||||
},
|
||||
currentFiltersPage() {
|
||||
return this.$store.getters['conversationPage/getCurrentPageFilter'](
|
||||
this.currentPageFilterKey
|
||||
);
|
||||
},
|
||||
hasCurrentPageEndReached() {
|
||||
return this.$store.getters['conversationPage/getHasEndReached'](
|
||||
this.activeAssigneeTab
|
||||
this.currentPageFilterKey
|
||||
);
|
||||
},
|
||||
conversationFilters() {
|
||||
|
@ -148,6 +212,9 @@ export default {
|
|||
page: this.currentPage + 1,
|
||||
labels: this.label ? [this.label] : undefined,
|
||||
teamId: this.teamId ? this.teamId : undefined,
|
||||
conversationType: this.conversationType
|
||||
? this.conversationType
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
pageTitle() {
|
||||
|
@ -160,17 +227,24 @@ export default {
|
|||
if (this.label) {
|
||||
return `#${this.label}`;
|
||||
}
|
||||
if (this.conversationType === 'mention') {
|
||||
return this.$t('CHAT_LIST.MENTION_HEADING');
|
||||
}
|
||||
return this.$t('CHAT_LIST.TAB_HEADING');
|
||||
},
|
||||
conversationList() {
|
||||
let conversationList = [];
|
||||
const filters = this.conversationFilters;
|
||||
if (this.activeAssigneeTab === 'me') {
|
||||
conversationList = [...this.mineChatsList(filters)];
|
||||
} else if (this.activeAssigneeTab === 'unassigned') {
|
||||
conversationList = [...this.unAssignedChatsList(filters)];
|
||||
if (!this.hasAppliedFilters) {
|
||||
const filters = this.conversationFilters;
|
||||
if (this.activeAssigneeTab === 'me') {
|
||||
conversationList = [...this.mineChatsList(filters)];
|
||||
} else if (this.activeAssigneeTab === 'unassigned') {
|
||||
conversationList = [...this.unAssignedChatsList(filters)];
|
||||
} else {
|
||||
conversationList = [...this.allChatList(filters)];
|
||||
}
|
||||
} else {
|
||||
conversationList = [...this.allChatList(filters)];
|
||||
conversationList = [...this.chatLists];
|
||||
}
|
||||
|
||||
return conversationList;
|
||||
|
@ -192,6 +266,9 @@ export default {
|
|||
label() {
|
||||
this.resetAndFetchData();
|
||||
},
|
||||
conversationType() {
|
||||
this.resetAndFetchData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('setChatFilter', this.activeStatus);
|
||||
|
@ -202,6 +279,17 @@ export default {
|
|||
});
|
||||
},
|
||||
methods: {
|
||||
onApplyFilter(payload) {
|
||||
if (this.$route.name !== 'home') {
|
||||
this.$router.push({ name: 'home' });
|
||||
}
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
this.fetchFilteredConversations(payload);
|
||||
},
|
||||
onToggleAdvanceFiltersModal() {
|
||||
this.showAdvancedFilters = !this.showAdvancedFilters;
|
||||
},
|
||||
getKeyboardListenerParams() {
|
||||
const allConversations = this.$refs.activeConversation.querySelectorAll(
|
||||
'div.conversations-list div.conversation'
|
||||
|
@ -249,6 +337,7 @@ export default {
|
|||
resetAndFetchData() {
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
this.$store.dispatch('clearConversationFilters');
|
||||
this.fetchConversations();
|
||||
},
|
||||
fetchConversations() {
|
||||
|
@ -256,6 +345,23 @@ export default {
|
|||
.dispatch('fetchAllConversations', this.conversationFilters)
|
||||
.then(() => this.$emit('conversation-load'));
|
||||
},
|
||||
loadMoreConversations() {
|
||||
if (!this.hasAppliedFilters) {
|
||||
this.fetchConversations();
|
||||
} else {
|
||||
this.fetchFilteredConversations(this.appliedFilters);
|
||||
}
|
||||
},
|
||||
fetchFilteredConversations(payload) {
|
||||
let page = this.currentFiltersPage + 1;
|
||||
this.$store
|
||||
.dispatch('fetchFilteredConversations', {
|
||||
queryData: filterQueryGenerator(payload),
|
||||
page,
|
||||
})
|
||||
.then(() => this.$emit('conversation-load'));
|
||||
this.showAdvancedFilters = false;
|
||||
},
|
||||
updateAssigneeTab(selectedTab) {
|
||||
if (this.activeAssigneeTab !== selectedTab) {
|
||||
bus.$emit('clearSearchInput');
|
||||
|
@ -277,6 +383,7 @@ export default {
|
|||
|
||||
<style scoped lang="scss">
|
||||
@import '~dashboard/assets/scss/woot';
|
||||
|
||||
.spinner {
|
||||
margin-top: var(--space-normal);
|
||||
margin-bottom: var(--space-normal);
|
||||
|
@ -299,4 +406,17 @@ export default {
|
|||
flex-basis: 46rem;
|
||||
}
|
||||
}
|
||||
.filter--actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-filter {
|
||||
margin: 0 var(--space-smaller);
|
||||
}
|
||||
|
||||
.filter__applied {
|
||||
padding: var(--space-slab) 0 !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue