Merge branch 'develop' into self-hosted/external-enhancemd

This commit is contained in:
Pranav Raj S 2022-07-21 15:45:23 +05:30
commit b9a5b15de1
1132 changed files with 26100 additions and 4664 deletions

View file

@ -101,6 +101,10 @@ jobs:
name: Rubocop
command: bundle exec rubocop
# - run:
# name: Brakeman
# command: bundle exec brakeman
- run:
name: eslint
command: yarn run eslint

View file

@ -1,6 +1,6 @@
# pre-build stage
ARG VARIANT=3
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
@ -11,23 +11,36 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
# [Option] Install Node.js
ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh
zsh \
git-flow \
npm
# Install rbenv and ruby
ARG RUBY_VERSION="3.0.4"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Install overmind
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
@ -35,11 +48,25 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN yarn
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# set up node js
RUN npm install npm@latest -g && \
npm install n -g && \
n latest
RUN npm install --global yarn
RUN yarn

View file

@ -23,17 +23,18 @@
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [8025],
//your application may need to listen on all interfaces (0.0.0.0) not just localhost for it to be available externally. Defaults to []
"appPort": [3000, 3035],
"forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"3035": {
"label": "Webpack Dev Server"
},
"8025": {
"label": "Mailhog UI"
}
},
}
}

View file

@ -6,3 +6,8 @@ sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.de
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
# codespaces make the ports public
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME

View file

@ -195,3 +195,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
# If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server
ENABLE_PUSH_RELAY_SERVER=true
# Stripe API key
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

View file

@ -1,5 +1,10 @@
module.exports = {
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
],
parserOptions: {
parser: 'babel-eslint',
ecmaVersion: 2020,
@ -19,18 +24,32 @@ module.exports = {
'jsx-a11y/label-has-for': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'import/no-unresolved': 'off',
'vue/max-attributes-per-line': ['error', {
'singleline': 20,
'multiline': {
'max': 1,
'allowFirstLine': false
'vue/max-attributes-per-line': [
'error',
{
singleline: 20,
multiline: {
max: 1,
allowFirstLine: false,
},
}],
'vue/html-self-closing': 'off',
"vue/no-v-html": 'off',
},
],
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
'vue/no-v-html': 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'],
'no-console': 'error'
'no-console': 'error',
},
settings: {
'import/resolver': {
@ -41,12 +60,10 @@ module.exports = {
},
env: {
browser: true,
node: true,
jest: true,
jasmine: true
node: true,
},
globals: {
__WEBPACK_ENV__: true,
bus: true,
},
};

View file

@ -4,6 +4,7 @@ import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
@ -15,6 +16,7 @@ Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({

22
Gemfile
View file

@ -4,7 +4,7 @@ ruby '3.0.4'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails'
gem 'rails', '~>6.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@ -78,7 +78,7 @@ gem 'wisper', '2.0.0'
# TODO: bump up gem to 2.0
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.32.0'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty'
@ -89,10 +89,6 @@ gem 'slack-ruby-client'
# for dialogflow integrations
gem 'google-cloud-dialogflow'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
##-- apm and error monitoring ---#
gem 'ddtrace'
gem 'newrelic_rpm'
@ -128,6 +124,12 @@ gem 'html2text'
# to calculate working hours
gem 'working_hours'
# full text search for articles
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
@ -156,12 +158,10 @@ group :test do
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'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'climate_control'

View file

@ -1,6 +1,6 @@
GIT
remote: https://github.com/chatwoot/devise-secure_password
revision: de11e8765654b8242d42101ee9c8ffc8126f7975
revision: d777b04f12652d576b1272b8f39857e3e0b3fc26
specs:
devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0)
@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.5.1)
actionpack (= 6.1.5.1)
activesupport (= 6.1.5.1)
actioncable (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.5.1)
actionpack (= 6.1.5.1)
activejob (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
actionmailbox (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
mail (>= 2.7.1)
actionmailer (6.1.5.1)
actionpack (= 6.1.5.1)
actionview (= 6.1.5.1)
activejob (= 6.1.5.1)
activesupport (= 6.1.5.1)
actionmailer (6.1.6.1)
actionpack (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activesupport (= 6.1.6.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.5.1)
actionview (= 6.1.5.1)
activesupport (= 6.1.5.1)
actionpack (6.1.6.1)
actionview (= 6.1.6.1)
activesupport (= 6.1.6.1)
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.5.1)
actionpack (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
actiontext (6.1.6.1)
actionpack (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
nokogiri (>= 1.8.5)
actionview (6.1.5.1)
activesupport (= 6.1.5.1)
actionview (6.1.6.1)
activesupport (= 6.1.6.1)
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.5.1)
activesupport (= 6.1.5.1)
activejob (6.1.6.1)
activesupport (= 6.1.6.1)
globalid (>= 0.3.6)
activemodel (6.1.5.1)
activesupport (= 6.1.5.1)
activerecord (6.1.5.1)
activemodel (= 6.1.5.1)
activesupport (= 6.1.5.1)
activerecord-import (1.3.0)
activemodel (6.1.6.1)
activesupport (= 6.1.6.1)
activerecord (6.1.6.1)
activemodel (= 6.1.6.1)
activesupport (= 6.1.6.1)
activerecord-import (1.4.0)
activerecord (>= 4.2)
activestorage (6.1.5.1)
actionpack (= 6.1.5.1)
activejob (= 6.1.5.1)
activerecord (= 6.1.5.1)
activesupport (= 6.1.5.1)
activestorage (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activesupport (= 6.1.6.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.5.1)
activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -91,20 +91,20 @@ GEM
ast (2.4.2)
attr_extras (6.2.5)
aws-eventstream (1.2.0)
aws-partitions (1.556.0)
aws-sdk-core (3.126.2)
aws-partitions (1.605.0)
aws-sdk-core (3.131.2)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.54.0)
aws-sdk-core (~> 3, >= 3.126.0)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.57.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.112.0)
aws-sdk-core (~> 3, >= 3.126.0)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0)
aws-sigv4 (1.5.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@ -117,31 +117,31 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
bcrypt (3.1.16)
bcrypt (3.1.18)
bindex (0.8.1)
bootsnap (1.10.3)
bootsnap (1.12.0)
msgpack (~> 1.2)
brakeman (5.2.1)
brakeman (5.2.3)
browser (5.3.1)
builder (3.2.4)
bullet (7.0.1)
bullet (7.0.2)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
bundler-audit
bundler-audit (0.9.0.1)
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
climate_control (1.0.1)
climate_control (1.1.1)
coderay (1.1.3)
commonmarker (0.23.4)
commonmarker (0.23.5)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
rexml
crass (1.0.6)
cypress-on-rails (1.12.1)
cypress-on-rails (1.13.1)
rack
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
@ -151,10 +151,12 @@ GEM
database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1)
ddtrace (0.54.2)
debase-ruby_core_source (<= 0.10.14)
ddtrace (1.2.0)
debase-ruby_core_source (= 0.10.16)
libddprof (~> 0.6.0.1.0)
libddwaf (~> 1.3.0.2.0)
msgpack
debase-ruby_core_source (0.10.14)
debase-ruby_core_source (0.10.16)
declarative (0.0.20)
devise (4.8.1)
bcrypt (~> 3.0)
@ -176,54 +178,72 @@ GEM
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.3.0)
down (5.3.1)
addressable (~> 2.8)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
email_reply_trimmer (0.1.13)
erubi (1.10.0)
erubis (2.7.0)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (6.2.0)
factory_bot (6.2.1)
activesupport (>= 5.0.0)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
faker (2.21.0)
i18n (>= 1.8.11, < 2)
faraday (1.10.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fcm (1.0.5)
faraday (~> 1)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5)
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.3)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
gapic-common (0.3.4)
google-protobuf (~> 3.12, >= 3.12.2)
googleapis-common-protos (>= 1.3.9, < 2.0)
googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9)
grpc (~> 1.25)
geocoder (1.7.3)
gapic-common (0.10.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
googleapis-common-protos (>= 1.3.12, < 2.a)
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.0)
gli (2.21.0)
globalid (1.0.0)
activesupport (>= 5.0)
google-apis-core (0.4.2)
google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -232,23 +252,27 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.10.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.11.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-iamcredentials_v1 (0.13.0)
google-apis-core (>= 0.7, < 2.a)
google-apis-storage_v1 (0.18.0)
google-apis-core (>= 0.7, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow (1.2.0)
google-cloud-core (~> 1.5)
google-cloud-dialogflow-v2 (~> 0.1)
google-cloud-dialogflow-v2 (0.6.4)
gapic-common (~> 0.3)
google-cloud-dialogflow (1.5.0)
google-cloud-core (~> 1.6)
google-cloud-dialogflow-v2 (>= 0.15, < 2.a)
google-cloud-dialogflow-v2 (0.17.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-location (>= 0.0, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.2.0)
google-cloud-storage (1.36.1)
google-cloud-location (0.2.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.37.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
@ -256,32 +280,32 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.19.4)
google-protobuf (3.19.4-x86_64-darwin)
google-protobuf (3.19.4-x86_64-linux)
google-protobuf (3.21.2)
google-protobuf (3.21.2-x86_64-darwin)
google-protobuf (3.21.2-x86_64-linux)
googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.3.0)
googleapis-common-protos-types (1.3.2)
google-protobuf (~> 3.14)
googleauth (0.17.1)
faraday (>= 0.17.3, < 2.0)
googleauth (1.2.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.15)
groupdate (6.0.1)
signet (>= 0.16, < 2.a)
groupdate (6.1.0)
activesupport (>= 5.2)
grpc (1.43.1)
google-protobuf (~> 3.18)
grpc (1.47.0)
google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0)
grpc (1.43.1-universal-darwin)
google-protobuf (~> 3.18)
grpc (1.47.0-x86_64-darwin)
google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0)
grpc (1.43.1-x86_64-linux)
google-protobuf (~> 3.18)
grpc (1.47.0-x86_64-linux)
google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (0.2.25)
@ -295,13 +319,13 @@ GEM
html2text (0.2.1)
nokogiri (~> 1.6)
http-accept (1.7.0)
http-cookie (1.0.4)
http-cookie (1.0.5)
domain_name (~> 0.5)
httparty (0.20.0)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.10.0)
i18n (1.11.0)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@ -309,20 +333,20 @@ GEM
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.0)
jquery-rails (4.4.0)
jmespath (1.6.1)
jquery-rails (4.5.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.6.1)
json (2.6.2)
json_refs (0.1.7)
hana
json_schemer (0.2.19)
json_schemer (0.2.21)
ecma-re-validator (~> 0.3)
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.3.0)
jwt (2.4.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -335,21 +359,31 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
koala (3.1.0)
koala (3.2.0)
addressable
faraday (< 2)
json (>= 1.8)
rexml
launchy (2.5.0)
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
line-bot-api (1.23.0)
liquid (5.1.0)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
libddprof (0.6.0.1.0)
libddprof (0.6.0.1.0-x86_64-linux)
libddwaf (1.3.0.2.0)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.25.0)
liquid (5.3.0)
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.17.0)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -364,37 +398,39 @@ GEM
mini_magick (4.11.0)
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.15.0)
mock_redis (0.30.0)
minitest (5.16.2)
mock_redis (0.32.0)
ruby2_keywords
momentjs-rails (2.29.1.1)
railties (>= 3.1)
msgpack (1.4.5)
msgpack (1.5.3)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
multipart-post (2.2.3)
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
netrc (0.11.0)
newrelic_rpm (8.7.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.6)
nokogiri (1.13.7)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.6-arm64-darwin)
nokogiri (1.13.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.6-x86_64-darwin)
nokogiri (1.13.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.6-x86_64-linux)
nokogiri (1.13.7-x86_64-linux)
racc (~> 1.4)
oauth (0.5.8)
oauth (0.5.10)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.21.0)
parser (3.1.1.0)
parallel (1.22.1)
parser (3.1.2.0)
ast (~> 2.4.1)
path_expander (1.1.0)
pg (1.3.2)
pg (1.4.1)
pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
procore-sift (0.16.0)
rails (> 4.2.0)
pry (0.14.1)
@ -402,46 +438,46 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
public_suffix (4.0.7)
puma (5.6.4)
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
rack (2.2.3.1)
rack-attack (6.6.0)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-proxy (0.7.2)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.1.5.1)
actioncable (= 6.1.5.1)
actionmailbox (= 6.1.5.1)
actionmailer (= 6.1.5.1)
actionpack (= 6.1.5.1)
actiontext (= 6.1.5.1)
actionview (= 6.1.5.1)
activejob (= 6.1.5.1)
activemodel (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
rack-test (2.0.2)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (6.1.6.1)
actioncable (= 6.1.6.1)
actionmailbox (= 6.1.6.1)
actionmailer (= 6.1.6.1)
actionpack (= 6.1.6.1)
actiontext (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activemodel (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
bundler (>= 1.15.0)
railties (= 6.1.5.1)
railties (= 6.1.6.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
railties (6.1.5.1)
actionpack (= 6.1.5.1)
activesupport (= 6.1.5.1)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
@ -450,11 +486,11 @@ GEM
rb-fsevent (0.11.1)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis (4.6.0)
redis-namespace (1.8.1)
redis (4.7.1)
redis-namespace (1.8.2)
redis (>= 3.0.4)
regexp_parser (2.2.1)
representable (3.1.1)
regexp_parser (2.5.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
@ -468,16 +504,12 @@ GEM
netrc (~> 0.8)
retriable (3.1.2)
rexml (3.2.5)
rspec (3.11.0)
rspec-core (~> 3.11.0)
rspec-expectations (~> 3.11.0)
rspec-mocks (~> 3.11.0)
rspec-core (3.11.0)
rspec-support (~> 3.11.0)
rspec-expectations (3.11.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-mocks (3.11.0)
rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-rails (5.0.3)
@ -489,26 +521,27 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.11.0)
rubocop (1.25.1)
rubocop (1.31.2)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.15.1, < 2.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.18.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.16.0)
rubocop-ast (1.19.1)
parser (>= 3.1.1.0)
rubocop-performance (1.13.2)
rubocop-performance (1.14.2)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.13.2)
rubocop-rails (2.15.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.8.0)
rubocop (~> 1.19)
rubocop-rspec (2.12.1)
rubocop (~> 1.31)
ruby-progressbar (1.11.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
@ -516,7 +549,7 @@ GEM
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_parser (3.18.1)
ruby_parser (3.19.1)
sexp_processor (~> 4.16)
sassc (2.4.0)
ffi (~> 1.9)
@ -526,37 +559,37 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
scout_apm (5.1.1)
scout_apm (5.2.0)
parser
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.3.0)
sentry-rails (5.3.1)
railties (>= 5.0)
sentry-ruby-core (~> 5.3.0)
sentry-ruby (5.3.0)
sentry-ruby-core (~> 5.3.1)
sentry-ruby (5.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-ruby-core (= 5.3.0)
sentry-ruby-core (5.3.0)
sentry-ruby-core (= 5.3.1)
sentry-ruby-core (5.3.1)
concurrent-ruby
sentry-sidekiq (5.3.0)
sentry-ruby-core (~> 5.3.0)
sentry-sidekiq (5.3.1)
sentry-ruby-core (~> 5.3.1)
sidekiq (>= 3.0)
sexp_processor (4.16.0)
sexp_processor (4.16.1)
shoulda-matchers (5.1.0)
activesupport (>= 5.2.0)
sidekiq (6.4.1)
sidekiq (6.4.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-cron (1.4.0)
sidekiq-cron (1.6.0)
fugit (~> 1)
sidekiq (>= 4.2.1)
signet (0.16.0)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simplecov (0.17.1)
@ -574,7 +607,7 @@ GEM
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0)
sprockets (4.0.3)
sprockets (4.1.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
@ -583,31 +616,32 @@ GEM
sprockets (>= 3.0.0)
squasher (0.6.2)
statsd-ruby (1.5.0)
telephone_number (1.4.13)
stripe (6.5.0)
telephone_number (1.4.16)
thor (1.2.1)
tilt (2.0.10)
time_diff (0.3.0)
activesupport
i18n
trailblazer-option (0.1.2)
twilio-ruby (5.32.0)
faraday (~> 1.0.0)
twilio-ruby (5.68.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0)
twitty (0.1.4)
oauth
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.5)
tzinfo-data (1.2022.1)
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.8)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
unf_ext (0.0.8.2)
unicode-display_width (2.2.0)
uniform_notifier (1.16.0)
uri_template (0.7.0)
valid_email2 (4.0.3)
activemodel (>= 3.2)
@ -639,7 +673,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4)
zeitwerk (2.6.0)
PLATFORMS
arm64-darwin-20
@ -681,7 +715,6 @@ DEPENDENCIES
faker
fcm
flag_shih_tzu
flay
foreman
geocoder
google-cloud-dialogflow
@ -706,6 +739,7 @@ DEPENDENCIES
mock_redis
newrelic_rpm
pg
pg_search
procore-sift
pry-rails
puma
@ -713,12 +747,11 @@ DEPENDENCIES
rack-attack
rack-cors
rack-timeout
rails
rails (~> 6.1)
redis
redis-namespace
responders
rest-client
rspec
rspec-rails (~> 5.0.0)
rubocop
rubocop-performance
@ -737,9 +770,10 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
stripe
telephone_number
time_diff
twilio-ruby (~> 5.32.0)
twilio-ruby (~> 5.66)
twitty
tzinfo-data
uglifier
@ -755,4 +789,4 @@ RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.3.10
2.3.14

View file

@ -26,6 +26,7 @@ ___
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
<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%2Fuptime.json" alt="uptime"></a>
<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>
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p>
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>

1
VERSION_CW Normal file
View file

@ -0,0 +1 @@
2.6.0

1
VERSION_CWCTL Normal file
View file

@ -0,0 +1 @@
2.1.0

View file

@ -1,7 +1,16 @@
# retain_original_contact_name: false / true
# In case of setUser we want to update the name of the identified contact,
# which is the default behaviour
#
# But, In case of contact merge during prechat form contact update.
# We don't want to update the name of the identified original contact.
class ContactIdentifyAction
pattr_initialize [:contact!, :params!]
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform
@attributes_to_update = [:identifier, :name, :email, :phone_number]
ActiveRecord::Base.transaction do
merge_if_existing_identified_contact
merge_if_existing_email_contact
@ -18,49 +27,89 @@ class ContactIdentifyAction
end
def merge_if_existing_identified_contact
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
return unless merge_contacts?(existing_identified_contact, :identifier)
process_contact_merge(existing_identified_contact)
end
def merge_if_existing_email_contact
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
return unless merge_contacts?(existing_email_contact, :email)
process_contact_merge(existing_email_contact)
end
def merge_if_existing_phone_number_contact
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact)
return unless merge_contacts?(existing_phone_number_contact, :phone_number)
return unless mergable_phone_contact?
process_contact_merge(existing_phone_number_contact)
end
def process_contact_merge(mergee_contact)
@contact = merge_contact(mergee_contact, @contact)
@attributes_to_update.delete(:name) if retain_original_contact_name
end
def existing_identified_contact
return if params[:identifier].blank?
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier])
@existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier])
end
def existing_email_contact
return if params[:email].blank?
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
@existing_email_contact ||= account.contacts.find_by(email: params[:email])
end
def existing_phone_number_contact
return if params[:phone_number].blank?
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number])
@existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
end
def merge_contacts?(existing_contact, _contact)
existing_contact && existing_contact.id != @contact.id
def merge_contacts?(existing_contact, key)
return if existing_contact.blank?
return true if params[:identifier].blank?
# we want to prevent merging contacts with different identifiers
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
# we will remove attribute from update list
@attributes_to_update.delete(key)
return false
end
true
end
# case: contact 1: email: 1@test.com, phone: 123456789
# params: email: 2@test.com, phone: 123456789
# we don't want to overwrite 1@test.com since email parameter takes higer priority
def mergable_phone_contact?
return true if params[:email].blank?
if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email]
@attributes_to_update.delete(:phone_number)
return false
end
true
end
def update_contact
@contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }))
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def merge_contact(base_contact, merge_contact)
return base_contact if base_contact.id == merge_contact.id
ContactMergeAction.new(
account: account,
base_contact: base_contact,
@ -69,14 +118,14 @@ class ContactIdentifyAction
end
def custom_attributes
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes
return @contact.custom_attributes if params[:custom_attributes].blank?
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
end
def additional_attributes
if params[:additional_attributes]
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
else
@contact.additional_attributes
end
return @contact.additional_attributes if params[:additional_attributes].blank?
(@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
end
end

View file

@ -9,12 +9,15 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock!
# We won't send campaigns if a conversation is already present
return if @contact_inbox.reload.conversations.present?
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
end
@conversation
rescue StandardError => e
Rails.logger.info(e.message)
nil
end
private

View file

@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
@inbox.channel.authorization_error!
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true

View file

@ -73,6 +73,10 @@ class Messages::MessageBuilder
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
end
def template_params
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
end
def message_sender
return if @params[:sender_type] != 'AgentBot'
@ -91,6 +95,6 @@ class Messages::MessageBuilder
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
end

View file

@ -54,6 +54,9 @@ class Messages::Messenger::MessageBuilder
def fetch_story_link(attachment)
message = attachment.message
result = get_story_object_from_source_id(message.source_id)
return if result.blank?
story_id = result['story']['mention']['id']
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
@ -68,6 +71,11 @@ class Messages::Messenger::MessageBuilder
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}

View file

@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :description, :outgoing_url)
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end
end

View file

@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end
end

View file

@ -0,0 +1,52 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create]
def index
@articles = @portal.articles
@articles = @articles.search(list_params) if params[:payload].present?
end
def create
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def edit; end
def show; end
def update
@article.update!(article_params)
end
def destroy
@article.destroy!
head :ok
end
private
def fetch_article
@article = @portal.articles.find(params[:id])
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def article_params
params.require(:article).permit(
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status
)
end
def list_params
params.require(:payload).permit(
:category_slug, :locale, :query, :page
)
end
end

View file

@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def create
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@automation_rule.conditions = params[:conditions]
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def update
ActiveRecord::Base.transaction do
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.save!
automation_rule_update
process_attachments
rescue StandardError => e
@ -67,10 +66,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end

View file

@ -1,17 +1,28 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
def index
@categories = @portal.categories
@categories = @portal.categories.search(params)
end
def create
@category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
@category.save!
end
def show; end
def update
@category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any?
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
@category.save!
end
def destroy
@ -29,9 +40,13 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def related_categories_records
@portal.categories.where(id: params[:category][:related_category_ids])
end
def category_params
params.require(:category).permit(
:name, :description, :position
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
)
end
end

View file

@ -27,6 +27,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
end
def phone_number
return if permitted_params[:phone_number].blank?
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
end
@ -38,6 +40,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
@twilio_channel = Current.account.twilio_sms.create!(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
phone_number: phone_number,
medium: medium
)
@ -49,7 +52,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name, :medium
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium
)
end
end

View file

@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
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 :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index
@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params)
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar
end
end
def update
@contact.assign_attributes(contact_update_params)
@contact.save!
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
end
def destroy
@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok
end
def avatar
@contact.avatar.purge if @contact.avatar.attached?
@contact
end
private
# TODO: Move this to a finder class
@ -131,19 +138,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
end
def contact_params
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
def permitted_params
params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
end
def contact_custom_attributes
return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes]
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
@contact.custom_attributes
end
def contact_update_params
# we want the merged custom attributes not the original one
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
end
def set_include_contact_inboxes
@ -158,6 +165,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def process_avatar
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present?
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
hash = Digest::MD5.hexdigest(params[:email])
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
::ContactAvatarJob.perform_later(@contact, gravatar_url)
end
end
def render_error(error, error_status)
render json: error, status: error_status
end

View file

@ -42,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
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]
@inbox.update!(permitted_params.except(:channel))
update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
if @inbox.inbox_type == 'Email'
begin
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
@inbox.channel.reauthorized!
end
@ -58,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
update_channel_feature_flags
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
def agent_bot
@agent_bot = @inbox.agent_bot
end
@ -89,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def inbox_name(channel)
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
permitted_params[:name]
end
def create_channel
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
@ -109,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save!
end
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
end
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, :allow_messages_after_resolved,
*inbox_attributes,
channel: [:type, *channel_attributes]
)
end
@ -129,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
}[permitted_params[:channel][:type]]
end
def account_channels_method
{
'web_widget' => Current.account.web_widgets,
'api' => Current.account.api_channels,
'email' => Current.account.email_channels,
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
@ -148,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
[]
end
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View file

@ -0,0 +1,51 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy]
def index
@macros = Macro.with_visibility(current_user, params)
end
def create
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = params[:actions]
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
end
def show; end
def destroy
@macro.destroy!
head :ok
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
def macros_with_user
permitted_params.merge(updated_by_id: current_user.id)
end
def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id])
end
end

View file

@ -1,18 +1,36 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
include ::FileTypeHelper
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
def index
@portals = Current.account.portals
end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show; end
def create
@portal = Current.account.portals.create!(portal_params)
@portal = Current.account.portals.build(portal_params)
render json: { error: @portal.errors.messages }, status: :unprocessable_entity and return unless @portal.valid?
@portal.save!
process_attached_logo
end
def update
@portal.update!(portal_params)
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
process_attached_logo
rescue StandardError => e
Rails.logger.error e
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@ -20,6 +38,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def archive
@portal.update(archive: true)
head :ok
end
def process_attached_logo
@portal.logo.attach(params[:logo])
end
private
def fetch_portal
@ -32,7 +59,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, config: { allowed_locales: [] }
)
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
end

View file

@ -44,38 +44,6 @@ class Api::V1::Widget::BaseController < ApplicationController
}
end
def update_contact(email)
contact_with_email = @current_account.contacts.find_by(email: email)
if contact_with_email
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_email,
mergee_contact: @contact
).perform
else
@contact.update!(email: email)
update_contact_name
end
end
def update_contact_phone_number(phone_number)
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
if contact_with_phone_number
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_phone_number,
mergee_contact: @contact
).perform
else
@contact.update!(phone_number: phone_number)
update_contact_name
end
end
def update_contact_name
@contact.update!(name: contact_name) if contact_name.present?
end
def contact_email
permitted_params.dig(:contact, :email)&.downcase
end

View file

@ -1,14 +1,27 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac, only: [:update]
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
def show; end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
)
@contact = contact_identify_action.perform
identify_contact(@contact)
end
def set_user
contact = nil
if a_different_contact?
@contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget)
contact = @contact_inbox.contact
else
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact)
end
# TODO : clean up this with proper routes delete contacts/custom_attributes
@ -20,12 +33,23 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
private
def process_hmac
def identify_contact(contact)
contact_identify_action = ContactIdentifyAction.new(
contact: contact,
params: permitted_params.to_h.deep_symbolize_keys,
discard_invalid_attrs: true
)
@contact = contact_identify_action.perform
end
def a_different_contact?
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
def validate_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?

View file

@ -14,8 +14,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def process_update_contact
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true
).perform
end
def update_last_seen

View file

@ -15,7 +15,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def update
if @message.content_type == 'input_email'
@message.update!(submitted_email: contact_email)
update_contact(contact_email)
ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email }
).perform
else
@message.update!(message_update_params[:message])
end

View file

@ -1,7 +1,7 @@
class ApplicationController < ActionController::Base
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include Pundit
include Pundit::Authorization
include SwitchLocale
skip_before_action :verify_authenticity_token
@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
Current.user = @user
end
def current_subscription
@subscription ||= Current.account.subscription
end
def pundit_user
{
user: Current.user,

View file

@ -0,0 +1,20 @@
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
# This concern handles the token verification step.
module MetaTokenVerifyConcern
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
Rails.logger.info("#{service.capitalize} webhook verified")
render json: params['hub.challenge']
else
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
end
end
private
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
end

View file

@ -14,8 +14,7 @@ class DashboardController < ActionController::Base
def set_global_config
@global_config = GlobalConfig.get(
'LOGO',
'LOGO_THUMBNAIL',
'LOGO', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL',
'TERMS_URL',
@ -30,7 +29,8 @@ class DashboardController < ActionController::Base
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE'
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV'
).merge(app_config)
end
@ -48,7 +48,8 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v13.0'
FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?
}
end
end

View file

@ -14,7 +14,7 @@ class Platform::Api::V1::UsersController < PlatformController
def login
encoded_email = ERB::Util.url_encode(@resource.email)
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
end
def show; end

View file

@ -0,0 +1,25 @@
class Public::Api::V1::Portals::ArticlesController < ApplicationController
before_action :set_portal
before_action :set_article, only: [:show]
def index
@articles = @portal.articles
@articles = @articles.search(list_params) if params[:payload].present?
end
def show; end
private
def set_article
@article = @portal.articles.find(params[:id])
end
def set_portal
@portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false)
end
def list_params
params.require(:payload).permit(:query)
end
end

View file

@ -0,0 +1,20 @@
class Public::Api::V1::Portals::CategoriesController < PublicController
before_action :set_portal
before_action :set_category, only: [:show]
def index
@categories = @portal.categories
end
def show; end
private
def set_category
@category = @portal.categories.find_by!(slug: params[:slug])
end
def set_portal
@portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false)
end
end

View file

@ -0,0 +1,11 @@
class Public::Api::V1::PortalsController < PublicController
before_action :set_portal
def show; end
private
def set_portal
@portal = ::Portal.find_by!(slug: params[:slug], archived: false)
end
end

View file

@ -7,7 +7,7 @@ class Twilio::CallbackController < ApplicationController
private
def permitted_params
def permitted_params # rubocop:disable Metrics/MethodLength
params.permit(
:ApiVersion,
:SmsSid,
@ -25,7 +25,8 @@ class Twilio::CallbackController < ApplicationController
:ToCountry,
:FromState,
:MediaUrl0,
:MediaContentType0
:MediaContentType0,
:MessagingServiceSid
)
end
end

View file

@ -1,15 +1,5 @@
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
class Webhooks::InstagramController < ActionController::API
include MetaTokenVerifyConcern
def events
Rails.logger.info('Instagram webhook received events')
@ -24,7 +14,7 @@ class Webhooks::InstagramController < ApplicationController
private
def valid_instagram_token?(token)
def valid_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end
end

View file

@ -1,6 +1,16 @@
class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
private
def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
end

View file

@ -1,5 +1,7 @@
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
class WidgetsController < ActionController::Base
include WidgetHelper
before_action :set_global_config
before_action :set_web_widget
before_action :set_token
@ -40,11 +42,8 @@ class WidgetsController < ActionController::Base
def build_contact
return if @contact.present?
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end
def additional_attributes

View file

@ -2,6 +2,11 @@ class ConversationFinder
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
SORT_OPTIONS = {
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at'
}.with_indifferent_access
# assumptions
# inbox_id if not given, take from all conversations, else specific to inbox
@ -133,10 +138,7 @@ class ConversationFinder
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
if params[:conversation_type] == 'mention'
@conversations.page(current_page)
else
@conversations.latest.page(current_page)
end
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
@conversations.send(sort_by).page(current_page)
end
end

View file

@ -1,4 +1,10 @@
module Api::V1::InboxesHelper
def inbox_name(channel)
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
permitted_params[:name]
end
def validate_email_channel(attributes)
channel_data = permitted_params(attributes)[:channel]
@ -19,8 +25,7 @@ module Api::V1::InboxesHelper
enable_ssl: channel_data[:imap_enable_ssl] }
end
Mail.connection do # rubocop:disable:block
end
check_imap_connection(channel_data)
end
def validate_smtp(channel_data)
@ -32,10 +37,29 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp)
end
def check_imap_connection(channel_data)
Mail.connection {} # rubocop:disable:block
rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e
raise StandardError, I18n.t('errors.inboxes.imap.no_response_error')
rescue Errno::EHOSTUNREACH => e
raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error')
rescue Net::OpenTimeout => e
raise StandardError,
I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port])
rescue Net::IMAP::Error => e
raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error')
rescue StandardError => e
raise StandardError, e.message
ensure
Rails.logger.error e if e.present?
end
def check_smtp_connection(channel_data, smtp)
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish unless smtp&.nil?
smtp.finish
end
def set_smtp_encryption(channel_data, smtp)
@ -74,4 +98,22 @@ module Api::V1::InboxesHelper
context.verify_mode = openssl_verify_mode
context
end
def account_channels_method
{
'web_widget' => Current.account.web_widgets,
'api' => Current.account.api_channels,
'email' => Current.account.email_channels,
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end

View file

@ -0,0 +1,9 @@
module WidgetHelper
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
token = ::Widget::TokenService.new(payload: payload).generate_token
[contact_inbox, token]
end
end

View file

@ -2,7 +2,7 @@
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<transition name="fade" mode="out-in">
<router-view></router-view>
<router-view />
</transition>
<add-account-modal
:show="showAddAccountModal"

View file

@ -15,6 +15,11 @@ class ApiClient {
baseUrl() {
let url = this.apiVersion;
if (this.options.enterprise) {
url = `/enterprise${url}`;
}
if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'

View file

@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class BulkActionsAPI extends ApiClient {
constructor() {
super('bulk_actions', { accountScoped: true });
}
}
export default new BulkActionsAPI();

View file

@ -71,6 +71,10 @@ class ContactAPI extends ApiClient {
custom_attributes: customAttributes,
});
}
destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
}
export default new ContactAPI();

View file

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class EnterpriseAccountAPI extends ApiClient {
constructor() {
super('', { accountScoped: true, enterprise: true });
}
checkout() {
return axios.post(`${this.url}checkout`);
}
subscription() {
return axios.post(`${this.url}subscription`);
}
}
export default new EnterpriseAccountAPI();

View file

@ -0,0 +1,31 @@
import accountAPI from '../account';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../../specs/apiSpecHelper';
describe('#enterpriseAccountAPI', () => {
it('creates correct instance', () => {
expect(accountAPI).toBeInstanceOf(ApiClient);
expect(accountAPI).toHaveProperty('get');
expect(accountAPI).toHaveProperty('show');
expect(accountAPI).toHaveProperty('create');
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
});
describeWithAPIMock('API calls', context => {
it('#checkout', () => {
accountAPI.checkout();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/checkout'
);
});
it('#subscription', () => {
accountAPI.subscription();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/subscription'
);
});
});
});

View file

@ -10,6 +10,7 @@ export const buildCreatePayload = ({
files,
ccEmails = '',
bccEmails = '',
templateParams,
}) => {
let payload;
if (files && files.length !== 0) {
@ -32,6 +33,7 @@ export const buildCreatePayload = ({
content_attributes: contentAttributes,
cc_emails: ccEmails,
bcc_emails: bccEmails,
template_params: templateParams,
};
}
return payload;
@ -51,6 +53,7 @@ class MessageApi extends ApiClient {
files,
ccEmails = '',
bccEmails = '',
templateParams,
}) {
return axios({
method: 'post',
@ -63,6 +66,7 @@ class MessageApi extends ApiClient {
files,
ccEmails,
bccEmails,
templateParams,
}),
});
}

View file

@ -0,0 +1,9 @@
import bulkActions from '../bulkActions';
import ApiClient from '../ApiClient';
describe('#BulkActionsAPI', () => {
it('creates correct instance', () => {
expect(bulkActions).toBeInstanceOf(ApiClient);
expect(bulkActions).toHaveProperty('create');
});
});

View file

@ -12,6 +12,7 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations');
expect(contactAPI).toHaveProperty('filter');
expect(contactAPI).toHaveProperty('destroyAvatar');
});
describeWithAPIMock('API calls', context => {
@ -100,6 +101,13 @@ describe('#ContactsAPI', () => {
queryPayload
);
});
it('#destroyAvatar', () => {
contactAPI.destroyAvatar(1);
expect(context.axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/contacts/1/avatar'
);
});
});
});

View file

@ -383,8 +383,8 @@ $form-button-radius: var(--border-radius-normal);
// 20. Label
// ---------
$label-background: $primary-color;
$label-color: $white;
$label-background: $white;
$label-color: $black;
$label-color-alt: $black;
$label-palette: $foundation-palette;
$label-font-size: $font-size-mini;

View file

@ -4,6 +4,7 @@
.page-sub-title {
font-size: $font-size-large;
word-wrap: break-word;
}
.block-title {

View file

@ -60,3 +60,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.flex-between {
align-items: center;
display: flex;
justify-content: space-between;
}

View file

@ -99,3 +99,7 @@ $ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
:root {
--dashboard-app-tabs-height: 3.9rem;
}

View file

@ -1,9 +1,34 @@
.tabs--container {
display: flex;
}
.tabs--container--with-border {
@include border-normal-bottom;
}
.tabs {
@include padding($zero $space-normal);
@include border-normal-bottom;
border-left-width: 0;
border-right-width: 0;
border-top-width: 0;
display: flex;
min-width: var(--space-mega);
}
.tabs--with-scroll {
max-width: calc(100% - 64px);
overflow: hidden;
padding: 0 var(--space-smaller);
}
.tabs--scroll-button {
align-items: center;
border-radius: 0;
cursor: pointer;
display: flex;
height: auto;
justify-content: center;
min-width: var(--space-large);
}
// Tab chat type
@ -22,6 +47,7 @@
.tabs-title {
@include margin($zero $space-slab);
flex-shrink: 0;
.badge {
background: $color-background;

View file

@ -1,6 +1,6 @@
<template>
<div class="conversations-list-wrap">
<slot></slot>
<slot />
<div
class="chat-list__top"
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
@ -53,8 +53,7 @@
size="small"
class="btn-filter"
@click="onToggleAdvanceFiltersModal"
>
</woot-button>
/>
</div>
</div>
@ -85,7 +84,19 @@
<p v-if="!chatListLoading && !conversationList.length" class="content-box">
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<conversation-bulk-actions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
@select-all-conversations="selectAllConversations"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
/>
<div ref="activeConversation" class="conversations-list">
<conversation-card
v-for="chat in conversationList"
@ -96,10 +107,13 @@
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
<div v-if="chatListLoading" class="text-center">
<span class="spinner"></span>
<span class="spinner" />
</div>
<woot-button
@ -112,11 +126,7 @@
</woot-button>
<p
v-if="
conversationList.length &&
hasCurrentPageEndReached &&
!chatListLoading
"
v-if="showEndOfListMessage"
class="text-center text-muted end-of-list-text"
>
{{ $t('CHAT_LIST.EOF') }}
@ -152,6 +162,8 @@ import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import {
hasPressedAltAndJKey,
@ -166,8 +178,9 @@ export default {
ChatFilter,
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
},
mixins: [timeMixin, conversationMixin, eventListenerMixins],
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
props: {
conversationInbox: {
type: [String, Number],
@ -202,6 +215,8 @@ export default {
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
};
},
computed: {
@ -217,6 +232,7 @@ export default {
conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
}),
isIframe() {
return window.self !== window.top;
@ -237,6 +253,13 @@ export default {
}
return {};
},
showEndOfListMessage() {
return (
this.conversationList.length &&
this.hasCurrentPageEndReached &&
!this.chatListLoading
);
},
assigneeTabItems() {
const ASSIGNEE_TYPE_TAB_KEYS = {
me: 'mineCount',
@ -346,6 +369,17 @@ export default {
}
return {};
},
allConversationsSelected() {
return (
this.conversationList.length === this.selectedConversations.length &&
this.conversationList.every(el =>
this.selectedConversations.includes(el.id)
)
);
},
uniqueInboxes() {
return [...new Set(this.selectedInboxes)];
},
},
watch: {
activeTeam() {
@ -379,6 +413,7 @@ export default {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
@ -444,6 +479,7 @@ export default {
}
},
resetAndFetchData() {
this.resetBulkActions();
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
@ -494,6 +530,7 @@ export default {
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
@ -501,6 +538,10 @@ export default {
}
}
},
resetBulkActions() {
this.selectedConversations = [];
this.selectedInboxes = [];
},
updateStatusType(index) {
if (this.activeStatus !== index) {
this.activeStatus = index;
@ -523,6 +564,80 @@ export default {
this.fetchConversations();
}
},
isConversationSelected(id) {
return this.selectedConversations.includes(id);
},
selectConversation(conversationId, inboxId) {
this.selectedConversations.push(conversationId);
this.selectedInboxes.push(inboxId);
},
deSelectConversation(conversationId, inboxId) {
this.selectedConversations = this.selectedConversations.filter(
item => item !== conversationId
);
this.selectedInboxes = this.selectedInboxes.filter(
item => item !== inboxId
);
},
selectAllConversations(check) {
if (check) {
this.selectedConversations = this.conversationList.map(item => item.id);
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
} else {
this.resetBulkActions();
}
},
async onAssignAgent(agent) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
fields: {
assignee_id: agent.id,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async onAssignLabels(labels) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
labels: {
add: labels,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
fields: {
status,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
},
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item).status === status;
});
},
},
};
</script>
@ -538,7 +653,7 @@ export default {
.conversations-list-wrap {
flex-shrink: 0;
width: 34rem;
overflow: hidden;
@include breakpoint(large up) {
width: 36rem;
}

View file

@ -98,4 +98,7 @@ export default {
width: 48rem;
}
}
.modal-big {
width: 60%;
}
</style>

View file

@ -4,10 +4,10 @@
<h2 class="page-sub-title">
{{ headerTitle }}
</h2>
<p v-if="headerContent" class="small-12 column">
<p v-if="headerContent" class="small-12 column wrap-content">
{{ headerContent }}
</p>
<slot></slot>
<slot />
</div>
</template>
@ -29,3 +29,8 @@ export default {
},
};
</script>
<style scoped lang="scss">
.wrap-content {
word-wrap: break-word;
}
</style>

View file

@ -20,8 +20,7 @@
color-scheme="warning"
icon="dismiss-circle"
@click="closeNotification"
>
</woot-button>
/>
</div>
</div>
</transition>

View file

@ -13,7 +13,7 @@
</p>
</div>
<div class="medium-6 small-12">
<slot></slot>
<slot />
</div>
</div>
</template>

View file

@ -7,7 +7,7 @@
:icon="icon"
/>
<spinner v-if="isLoading" />
<slot></slot>
<slot />
</button>
</template>

View file

@ -0,0 +1,63 @@
import ArticleItemComponent from './ArticleItem.vue';
const STATUS_LIST = {
published: 'published',
draft: 'draft',
archived: 'archived',
};
export default {
title: 'Components/Help Center',
component: ArticleItemComponent,
argTypes: {
title: {
defaultValue: 'Setup your account',
control: {
type: 'text',
},
},
readCount: {
defaultValue: 13,
control: {
type: 'number',
},
},
category: {
defaultValue: 'Getting started',
control: {
type: 'text',
},
},
status: {
defaultValue: 'Status',
control: {
type: 'select',
options: STATUS_LIST,
},
},
updatedAt: {
defaultValue: '1657255863',
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleItemComponent },
template:
'<article-item-component v-bind="$props" ></article-item-component>',
});
export const ArticleItem = Template.bind({});
ArticleItem.args = {
title: 'Setup your account',
author: {
name: 'John Doe',
},
category: 'Getting started',
readCount: 12,
status: 'published',
updatedAt: 1657255863,
};

View file

@ -0,0 +1,129 @@
<template>
<tr>
<td>
<div class="row--article-block">
<div class="article-block">
<h6 class="sub-block-title text-truncate">
<router-link class="article-name" :to="articlePath">
{{ title }}
</router-link>
</h6>
<div class="author">
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
<span class="name">{{ articleAuthorName }}</span>
</div>
</div>
</div>
</td>
<td>{{ category }}</td>
<td>{{ readCount }}</td>
<td>
<Label :title="status" :color-scheme="labelColor" />
</td>
<td>{{ lastUpdatedAt }}</td>
</tr>
</template>
<script>
import { frontendURL } from 'dashboard/helper/URLHelper';
import Label from 'dashboard/components/ui/Label';
import timeMixin from 'dashboard/mixins/time';
export default {
components: {
Label,
},
mixins: [timeMixin],
props: {
title: {
type: String,
default: '',
required: true,
},
author: {
type: Object,
default: () => {},
},
category: {
type: String,
default: '',
},
readCount: {
type: Number,
default: 0,
},
status: {
type: String,
default: 'draft',
values: ['archived', 'draft', 'published'],
},
updatedAt: {
type: Number,
default: 0,
},
},
computed: {
lastUpdatedAt() {
return this.dynamicTime(this.updatedAt);
},
articleAuthorName() {
return this.author.name;
},
labelColor() {
switch (this.status) {
case 'archived':
return 'secondary';
case 'draft':
return 'warning';
default:
return 'success';
}
},
articlePath() {
return frontendURL(`accounts/${this.accountId}/hc/articles/${this.id}`);
},
},
};
</script>
<style lang="scss" scoped>
td {
font-weight: var(--font-weight-normal);
color: var(--s-700);
font-size: var(--font-size-mini);
padding-left: 0;
}
.row--article-block {
align-items: center;
display: flex;
text-align: left;
.article-block {
min-width: 0;
}
.sub-block-title {
margin-bottom: 0;
}
.article-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-default);
margin: 0;
text-transform: capitalize;
color: var(--s-900);
}
.author {
.by {
font-weight: var(--font-weight-normal);
color: var(--s-500);
font-size: var(--font-size-small);
}
.name {
font-weight: var(--font-weight-medium);
color: var(--s-600);
font-size: var(--font-size-mini);
}
}
}
</style>

View file

@ -0,0 +1,72 @@
import ArticleTableComponent from './ArticleTable.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Help Center',
component: ArticleTableComponent,
argTypes: {
articles: {
defaultValue: [],
control: {
type: 'array',
},
},
articleCount: {
defaultValue: 10,
control: {
type: 'number',
},
},
currentPage: {
defaultValue: 1,
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleTableComponent },
template:
'<article-table-component @onPageChange="onPageChange" v-bind="$props" ></article-table-component>',
});
export const ArticleTable = Template.bind({});
ArticleTable.args = {
articles: [
{
title: 'Setup your account',
author: {
name: 'John Doe',
},
readCount: 13,
category: 'Getting started',
status: 'published',
updatedAt: 1657255863,
},
{
title: 'Docker Configuration',
author: {
name: 'Sam Manuel',
},
readCount: 13,
category: 'Engineering',
status: 'draft',
updatedAt: 1656658046,
},
{
title: 'Campaigns',
author: {
name: 'Sam Manuel',
},
readCount: 28,
category: 'Engineering',
status: 'archived',
updatedAt: 1657590446,
},
],
articleCount: 10,
currentPage: 1,
onPageChange: action('onPageChange'),
};

View file

@ -0,0 +1,84 @@
<template>
<div class="article-container">
<table>
<thead>
<tr>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}</th>
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }}</th>
</tr>
</thead>
<tr>
<td colspan="100%" class="horizontal-line" />
</tr>
<tbody>
<ArticleItem
v-for="article in articles"
:key="article.id"
:title="article.title"
:author="article.author"
:category="article.category"
:read-count="article.readCount"
:status="article.status"
:updated-at="article.updatedAt"
/>
</tbody>
</table>
<table-footer
:on-page-change="onPageChange"
:current-page="Number(currentPage)"
:total-count="articleCount"
/>
</div>
</template>
<script>
import ArticleItem from './ArticleItem.vue';
import TableFooter from 'dashboard/components/widgets/TableFooter';
export default {
components: {
ArticleItem,
TableFooter,
},
props: {
articles: {
type: Array,
default: () => {},
},
articleCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
},
methods: {
onPageChange() {
this.$emit('onPageChange');
},
},
};
</script>
<style lang="scss" scoped>
.article-container {
width: 100%;
table thead th {
font-weight: var(--font-weight-bold);
text-transform: capitalize;
color: var(--s-700);
font-size: var(--font-size-small);
padding-left: 0;
}
.horizontal-line {
border-bottom: 1px solid var(--color-border);
}
.footer {
padding: 0;
}
}
</style>

View file

@ -0,0 +1,34 @@
import { action } from '@storybook/addon-actions';
import EditArticle from './EditArticle.vue';
export default {
title: 'Components/Help Center',
component: EditArticle,
argTypes: {
article: {
defaultValue: {},
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { EditArticle },
template:
'<edit-article v-bind="$props" @focus="onFocus" @blur="onBlur"></edit-article>',
});
export const EditArticleView = Template.bind({});
EditArticleView.args = {
article: {
id: '1',
title: 'Lorem ipsum',
content:
'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.',
},
onFocus: action('focus'),
onBlur: action('blur'),
};

View file

@ -0,0 +1,95 @@
<template>
<div class="edit-article--container">
<input
v-model="articleTitle"
type="text"
class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
@focus="onFocus"
@blur="onBlur"
/>
<woot-message-editor
v-model="articleContent"
class="article-content"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
:is-format-mode="true"
:min-height="24"
@focus="onFocus"
@blur="onBlur"
/>
</div>
</template>
<script>
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
export default {
components: {
WootMessageEditor,
},
props: {
article: {
type: Object,
default: () => ({}),
},
},
data() {
return {
articleTitle: '',
articleContent: '',
};
},
mounted() {
this.articleTitle = this.article.title;
this.articleContent = this.article.content;
},
methods: {
onFocus() {
this.$emit('focus');
},
onBlur() {
this.$emit('blur');
},
},
};
</script>
<style lang="scss" scoped>
.edit-article--container {
margin: var(--space-medium) var(--space-giga);
min-width: 640px;
}
.article-heading {
font-size: var(--font-size-giga);
font-weight: var(--font-weight-bold);
min-height: var(--space-jumbo);
max-height: var(--space-jumbo);
border: 0px solid transparent;
padding: 0;
}
::v-deep {
.ProseMirror-menubar-wrapper {
.ProseMirror-menubar .ProseMirror-menuitem {
.ProseMirror-icon {
margin-right: var(--space-normal);
font-size: var(--font-size-small);
}
}
.ProseMirror-woot-style {
min-height: var(--space-giga);
max-height: 100%;
p {
font-size: var(--font-size-default);
line-height: 1.5;
}
li::marker {
font-size: var(--font-size-default);
}
}
}
}
</style>

View file

@ -0,0 +1,44 @@
import { action } from '@storybook/addon-actions';
import ArticleHeader from './ArticleHeader';
export default {
title: 'Components/Help Center/Header',
component: ArticleHeader,
argTypes: {
headerTitle: {
defaultValue: 'All articles',
control: {
type: 'text',
},
},
count: {
defaultValue: 112,
control: {
type: 'number',
},
},
selectedValue: {
defaultValue: 'Status',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleHeader },
template:
'<article-header v-bind="$props" @openModal="openFilterModal" @open="openDropdown" @close="closeDropdown" ></article-header>',
});
export const ArticleHeaderView = Template.bind({});
ArticleHeaderView.args = {
headerTitle: 'All articles',
count: 112,
selectedValue: 'Status',
openFilterModal: action('openedFilterModal'),
openDropdown: action('opened'),
closeDropdown: action('closed'),
};

View file

@ -0,0 +1,169 @@
<template>
<div class="header--wrap">
<div class="header-left--wrap">
<h3 class="page-title">{{ headerTitle }}</h3>
<span class="text-block-title count-view">{{ `(${count})` }}</span>
</div>
<div class="header-right--wrap">
<woot-button
class-names="article--buttons"
icon="filter"
color-scheme="secondary"
variant="hollow"
size="small"
@click="openFilterModal"
>
{{ $t('HELP_CENTER.HEADER.FILTER') }}
</woot-button>
<woot-button
class-names="article--buttons"
icon="arrow-sort"
color-scheme="secondary"
size="small"
variant="hollow"
@click="openDropdown"
>
{{ $t('HELP_CENTER.HEADER.SORT') }}
<span class="selected-value">
{{ selectedValue }}
<Fluent-icon class="dropdown-arrow" icon="chevron-down" size="14" />
</span>
</woot-button>
<div
v-if="showSortByDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
>
<woot-dropdown-menu>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="send-clock"
>
{{ 'Status' }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="dual-screen-clock"
>
{{ 'Created' }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="calendar-clock"
>
{{ 'Last edited' }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-button
v-tooltip.top-end="$t('HELP_CENTER.HEADER.SETTINGS_BUTTON')"
icon="settings"
class-names="article--buttons"
variant="hollow"
size="small"
color-scheme="secondary"
/>
<woot-button
class-names="article--buttons"
size="small"
color-scheme="primary"
>
{{ $t('HELP_CENTER.HEADER.NEW_BUTTON') }}
</woot-button>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
export default {
components: {
FluentIcon,
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway],
props: {
headerTitle: {
type: String,
default: '',
},
count: {
type: Number,
default: 0,
},
selectedValue: {
type: String,
default: '',
},
},
data() {
return {
showSortByDropdown: false,
};
},
methods: {
openFilterModal() {
this.$emit('openModal');
},
openDropdown() {
this.$emit('open');
this.showSortByDropdown = true;
},
closeDropdown() {
this.$emit('close');
this.showSortByDropdown = false;
},
},
};
</script>
<style scoped lang="scss">
.header--wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-small) var(--space-normal);
width: 100%;
height: var(--space-larger);
}
.header-left--wrap {
display: flex;
align-items: center;
}
.header-right--wrap {
display: flex;
align-items: center;
}
.count-view {
margin-left: var(--space-smaller);
}
.selected-value {
display: inline-flex;
margin-left: var(--space-smaller);
color: var(--b-900);
align-items: center;
}
.dropdown-arrow {
margin-left: var(--space-smaller);
}
.article--buttons {
margin-left: var(--space-smaller);
}
</style>

View file

@ -0,0 +1,39 @@
import { action } from '@storybook/addon-actions';
import EditArticleHeader from './EditArticleHeader';
export default {
title: 'Components/Help Center/Header',
component: EditArticleHeader,
argTypes: {
backButtonLabel: {
defaultValue: 'Articles',
control: {
type: 'text',
},
},
draftState: {
defaultValue: 'saving',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { EditArticleHeader },
template:
'<edit-article-header v-bind="$props" @back="onClickGoBack" @show="showPreview" @add="onClickAdd" @open="openSidebar" @close="closeSidebar" ></edit-article-header>',
});
export const EditArticleHeaderView = Template.bind({});
EditArticleHeaderView.args = {
backButtonLabel: 'Articles',
draftState: 'saving',
onClickGoBack: action('goBack'),
showPreview: action('previewOpened'),
onClickAdd: action('added'),
openSidebar: action('openedSidebar'),
closeSidebar: action('closedSidebar'),
};

View file

@ -0,0 +1,152 @@
<template>
<div class="header--wrap">
<div class="header-left--wrap">
<woot-button
icon="chevron-left"
class-names="article--buttons"
variant="clear"
color-scheme="primary"
@click="onClickGoBack"
>
{{ backButtonLabel }}
</woot-button>
</div>
<div class="header-right--wrap">
<span v-if="showDraftStatus" class="draft-status">
{{ draftStatusText }}
</span>
<woot-button
class-names="article--buttons"
icon="globe"
color-scheme="secondary"
variant="hollow"
size="small"
@click="showPreview"
>
{{ $t('HELP_CENTER.EDIT_HEADER.PREVIEW') }}
</woot-button>
<woot-button
class-names="article--buttons"
icon="add"
color-scheme="secondary"
variant="hollow"
size="small"
@click="onClickAdd"
>
{{ $t('HELP_CENTER.EDIT_HEADER.ADD_TRANSLATION') }}
</woot-button>
<woot-button
v-if="isSidebarOpen"
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.OPEN_SIDEBAR')"
icon="pane-open"
class-names="article--buttons"
variant="hollow"
size="small"
color-scheme="secondary"
@click="openSidebar"
/>
<woot-button
v-else
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.CLOSE_SIDEBAR')"
icon="pane-close"
class-names="article--buttons"
variant="hollow"
size="small"
color-scheme="secondary"
@click="closeSidebar"
/>
<woot-button
class-names="article--buttons"
size="small"
color-scheme="primary"
>
{{ $t('HELP_CENTER.EDIT_HEADER.PUBLISH_BUTTON') }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
props: {
backButtonLabel: {
type: String,
default: '',
},
draftState: {
type: String,
default: '',
},
},
data() {
return {
isSidebarOpen: true,
};
},
computed: {
isDraftStatusSavingOrSaved() {
return this.draftState === 'saving' || 'saved';
},
draftStatusText() {
if (this.draftState === 'saving') {
return this.$t('HELP_CENTER.EDIT_HEADER.SAVING');
}
if (this.draftState === 'saved') {
return this.$t('HELP_CENTER.EDIT_HEADER.SAVED');
}
return '';
},
showDraftStatus() {
return this.isDraftStatusSavingOrSaved;
},
},
methods: {
onClickGoBack() {
this.$emit('back');
},
showPreview() {
this.$emit('show');
},
onClickAdd() {
this.$emit('add');
},
openSidebar() {
this.$emit('open');
this.isSidebarOpen = true;
},
closeSidebar() {
this.$emit('close');
this.isSidebarOpen = false;
},
},
};
</script>
<style scoped lang="scss">
.header--wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-small) var(--space-normal);
width: 100%;
height: var(--space-larger);
}
.header-left--wrap {
display: flex;
align-items: center;
}
.header-right--wrap {
display: flex;
align-items: center;
}
.article--buttons {
margin-left: var(--space-smaller);
}
.draft-status {
margin-right: var(--space-smaller);
margin-left: var(--space-normal);
color: var(--s-400);
align-items: center;
font-size: var(--font-size-mini);
}
</style>

View file

@ -0,0 +1,134 @@
import { action } from '@storybook/addon-actions';
import Sidebar from './Sidebar';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default {
title: 'Components/Help Center/Sidebar',
component: { Sidebar, Thumbnail },
argTypes: {
thumbnailSrc: {
defaultValue: '',
control: {
type: 'text',
},
},
headerTitle: {
defaultValue: '',
control: {
type: 'text',
},
},
subTitle: {
defaultValue: '',
control: {
type: 'text',
},
},
accessibleMenuItems: [],
additionalSecondaryMenuItems: [],
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Sidebar },
template: '<sidebar v-bind="$props" @input="onSearch"></sidebar>',
});
export const HelpCenterSidebarView = Template.bind({});
HelpCenterSidebarView.args = {
onSearch: action('search'),
thumbnailSrc: '',
headerTitle: 'Help Center',
subTitle: 'English',
accessibleMenuItems: [
{
icon: 'book',
label: 'HELP_CENTER.ALL_ARTICLES',
key: 'helpcenter_all',
count: 199,
toState: 'accounts/1/articles/all',
toolTip: 'All Articles',
toStateName: 'helpcenter_all',
},
{
icon: 'pen',
label: 'HELP_CENTER.MY_ARTICLES',
key: 'helpcenter_mine',
count: 112,
toState: 'accounts/1/articles/mine',
toolTip: 'My articles',
toStateName: 'helpcenter_mine',
},
{
icon: 'draft',
label: 'HELP_CENTER.DRAFT',
key: 'helpcenter_draft',
count: 32,
toState: 'accounts/1/articles/draft',
toolTip: 'Draft',
toStateName: 'helpcenter_draft',
},
{
icon: 'archive',
label: 'HELP_CENTER.ARCHIVED',
key: 'helpcenter_archive',
count: 10,
toState: 'accounts/1/articles/archived',
toolTip: 'Archived',
toStateName: 'helpcenter_archive',
},
],
additionalSecondaryMenuItems: [
{
icon: 'folder',
label: 'HELP_CENTER.CATEGORY',
hasSubMenu: true,
key: 'category',
children: [
{
id: 1,
label: 'Getting started',
count: 12,
truncateLabel: true,
toState: 'accounts/1/articles/categories/new',
},
{
id: 2,
label: 'Channel',
count: 19,
truncateLabel: true,
toState: 'accounts/1/articles/categories/channel',
},
{
id: 3,
label: 'Feature',
count: 24,
truncateLabel: true,
toState: 'accounts/1/articles/categories/feature',
},
{
id: 4,
label: 'Advanced',
count: 8,
truncateLabel: true,
toState: 'accounts/1/articles/categories/advanced',
},
{
id: 5,
label: 'Mobile app',
count: 3,
truncateLabel: true,
toState: 'accounts/1/articles/categories/mobile-app',
},
{
id: 6,
label: 'Others',
count: 39,
truncateLabel: true,
toState: 'accounts/1/articles/categories/others',
},
],
},
],
};

View file

@ -0,0 +1,88 @@
<template>
<div class="main-nav secondary-menu">
<sidebar-header
:thumbnail-src="thumbnailSrc"
:header-title="headerTitle"
:sub-title="subTitle"
/>
<sidebar-search @input="onSearch" />
<!-- <transition-group name="menu-list" tag="ul" class="menu vertical"> -->
<div name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item
v-for="menuItem in accessibleMenuItems"
:key="menuItem.toState"
:menu-item="menuItem"
:is-help-center-sidebar="true"
/>
</div>
<div name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item
v-for="menuItem in additionalSecondaryMenuItems"
:key="menuItem.key"
:menu-item="menuItem"
:is-help-center-sidebar="true"
/>
</div>
<!-- </transition-group> -->
</div>
</template>
<script>
import SecondaryNavItem from 'dashboard/components/layout/sidebarComponents/SecondaryNavItem';
import SidebarSearch from './SidebarSearch';
import SidebarHeader from './SidebarHeader';
export default {
components: {
SecondaryNavItem,
SidebarSearch,
SidebarHeader,
},
props: {
thumbnailSrc: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
accessibleMenuItems: {
type: Array,
default: () => [],
},
additionalSecondaryMenuItems: {
type: Array,
default: () => [],
},
},
data() {
return {};
},
methods: {
onSearch(value) {
this.$emit('input', value);
},
},
};
</script>
<style scoped lang="scss">
.secondary-menu {
background: var(--white);
border-right: 1px solid var(--s-50);
height: 100%;
width: var(--space-giga);
flex-shrink: 0;
overflow: hidden;
padding: var(--space-small);
&:hover {
overflow: auto;
}
}
</style>

View file

@ -0,0 +1,113 @@
<template>
<div class="sidebar-header--wrap">
<div class="header-left--side">
<thumbnail
size="40px"
:src="thumbnailSrc"
:username="headerTitle"
variant="square"
/>
<div class="header-title--wrap">
<h4 class="sub-block-title title-view">{{ headerTitle }}</h4>
<span class="sub-title--view">{{ subTitle }}</span>
</div>
</div>
<div class="header-right--side">
<fluent-icon
icon="arrow-up-right"
size="28px"
class="pop-out--icon"
@click="popOutHelpCenter"
/>
<fluent-icon
icon="arrow-swap"
size="28px"
class="portal-switch--icon"
@click="openSwitchPortalModal"
/>
</div>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default {
components: {
Thumbnail,
},
props: {
thumbnailSrc: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
},
methods: {
popOutHelpCenter() {
this.$emit('pop-out');
},
openSwitchPortalModal() {
this.$emit('open');
},
},
};
</script>
<style lang="scss" scoped>
.sidebar-header--wrap {
display: flex;
height: var(--space-jumbo);
align-items: center;
justify-content: space-between;
padding: var(--space-normal) 0;
border-bottom: 1px solid var(--color-border-light);
}
.header-title--wrap {
display: flex;
align-items: flex-start;
flex-direction: column;
margin-left: var(--space-small);
}
.title-view {
margin-bottom: var(--space-zero);
}
.sub-title--view {
font-size: var(--font-size-mini);
color: var(--b-600);
}
.header-left--side {
display: flex;
align-items: center;
}
.header-right--side {
display: flex;
align-items: center;
}
.pop-out--icon {
padding: var(--space-smaller);
}
.portal-switch--icon {
padding: var(--space-smaller);
margin-left: var(--space-small);
&:hover {
cursor: pointer;
background: var(--s-50);
border-radius: var(--border-radius-normal);
}
}
</style>

View file

@ -0,0 +1,62 @@
<template>
<div class="search-input--wrap">
<div class="search-icon--wrap">
<fluent-icon icon="search" size="18" class="search-icon" />
</div>
<input
v-model="searchValue"
class="search-input"
:placeholder="$t('HELP_CENTER.SIDEBAR.SEARCH.PLACEHOLDER')"
@input="onSearch"
/>
</div>
</template>
<script>
export default {
data() {
return {
searchValue: '',
};
},
methods: {
onSearch(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
<style lang="scss" scoped>
.search-input--wrap {
display: flex;
padding: var(--space-small) var(--space-zero);
width: 100%;
}
.search-input {
width: 100%;
height: var(--space-large);
border-radius: var(--border-radius-normal);
background: var(--s-25);
font-size: var(--font-size-small);
padding: var(--space-small) var(--space-small) var(--space-small)
var(--space-large);
border: 1px solid var(--s-50);
&:focus {
border-color: var(--w-500);
}
}
.search-icon--wrap {
position: relative;
}
.search-icon {
position: absolute;
color: var(--s-500);
top: var(--space-small);
left: var(--space-small);
}
</style>

View file

@ -11,6 +11,7 @@
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
v-if="showSecondarySidebar"
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
@ -18,6 +19,7 @@
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
@ -50,6 +52,12 @@ export default {
SecondarySidebar,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
default: true,
},
},
data() {
return {
showOptionsMenu: false,
@ -63,6 +71,7 @@ export default {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
@ -194,6 +203,7 @@ export default {
display: flex;
min-height: 0;
height: 100%;
width: fit-content;
}
</style>

View file

@ -18,6 +18,7 @@ const settings = accountId => ({
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_integrations_dashboard_apps',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
@ -29,6 +30,7 @@ const settings = accountId => ({
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
'billing_settings_index',
'automation_list',
],
menuItems: [
@ -99,6 +101,14 @@ const settings = accountId => ({
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
{
icon: 'credit-card-person',
label: 'BILLING',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/billing`),
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',

View file

@ -83,7 +83,7 @@ export default {
border-bottom-right-radius: var(--border-radius-normal);
display: flex;
height: 100%;
justify-content: end;
justify-content: flex-end;
opacity: 1;
position: absolute;
right: 0;

View file

@ -55,6 +55,10 @@ export default {
type: String,
default: '',
},
isOnChatwootCloud: {
type: Boolean,
default: false,
},
},
computed: {
hasSecondaryMenu() {
@ -67,12 +71,18 @@ export default {
if (!this.currentRole) {
return [];
}
return this.menuConfig.menuItems.filter(
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
menuItem =>
window.roleWiseRoutes[this.currentRole].indexOf(
menuItem.toStateName
) > -1
);
return menuItemsFilteredByRole.filter(item => {
if (item.showOnlyOnCloud) {
return this.isOnChatwootCloud;
}
return true;
});
},
inboxSection() {
return {
@ -95,6 +105,7 @@ export default {
),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
reauthorizationRequired: inbox.reauthorization_required,
}))
.sort((a, b) =>
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1

View file

@ -26,10 +26,21 @@
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
{{ childItemCount }}
</span>
</span>
<span v-if="count" class="badge" :class="{ secondary: !isActive }">
{{ count }}
</span>
<span v-if="warningIcon" class="badge--icon">
<fluent-icon
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
class="inbox-icon"
:icon="warningIcon"
size="12"
/>
</span>
</a>
</li>
</router-link>
@ -57,10 +68,22 @@ export default {
type: String,
default: '',
},
warningIcon: {
type: String,
default: '',
},
count: {
type: String,
default: '',
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
childItemCount: {
type: Number,
default: 0,
},
},
computed: {
showIcon() {
@ -134,6 +157,7 @@ $label-badge-size: var(--space-slab);
height: $label-badge-size;
min-width: $label-badge-size;
margin-left: var(--space-smaller);
border: 1px solid var(--color-border-light);
}
.badge.secondary {
@ -142,4 +166,19 @@ $label-badge-size: var(--space-slab);
color: var(--s-600);
font-weight: var(--font-weight-bold);
}
.count-view {
background: var(--s-50);
border-radius: var(--border-radius-normal);
color: var(--s-600);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
</style>

View file

@ -1,8 +1,14 @@
<template>
<li class="sidebar-item">
<span v-if="hasSubMenu" class="secondary-menu--title fs-small">
<div v-if="hasSubMenu" class="secondary-menu--wrap">
<span class="secondary-menu--title fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<fluent-icon icon="search" class="submenu-icon" size="16" />
<fluent-icon icon="add" class="submenu-icon" size="16" />
</div>
</div>
<router-link
v-else
class="secondary-menu--title secondary-menu--link fs-small"
@ -15,6 +21,13 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="isHelpCenterSidebar"
class="count-view"
:class="computedClass"
>
{{ `${menuItem.count}` }}
</span>
<span
v-if="menuItem.label === 'AUTOMATION'"
data-view-component="true"
@ -34,6 +47,9 @@
:label-color="child.color"
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:is-help-center-sidebar="isHelpCenterSidebar"
:child-item-count="child.count"
/>
<router-link
v-if="showItem(menuItem)"
@ -63,7 +79,10 @@
import { mapGetters } from 'vuex';
import adminMixin from '../../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
import {
getInboxClassByType,
getInboxWarningIconClass,
} from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem';
@ -75,6 +94,10 @@ export default {
type: Object,
default: () => ({}),
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({ activeInbox: 'getSelectedInbox' }),
@ -116,7 +139,7 @@ export default {
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (this.hasSubMenu) {
if (
this.isInboxConversation ||
this.isTeamsSettings ||
@ -127,6 +150,8 @@ export default {
return 'is-active';
}
return ' ';
}
return '';
},
},
methods: {
@ -136,6 +161,15 @@ export default {
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
computedInboxErrorClass(child) {
const { type, reauthorizationRequired } = child;
if (!type) return '';
const warningClass = getInboxWarningIconClass(
type,
reauthorizationRequired
);
return warningClass;
},
newLinkClick(e, navigate) {
if (this.menuItem.newLinkRouteName) {
navigate(e);
@ -157,6 +191,11 @@ export default {
margin: var(--space-smaller) 0 0;
}
.secondary-menu--wrap {
display: flex;
justify-content: space-between;
}
.secondary-menu--title {
color: var(--s-600);
display: flex;
@ -229,6 +268,7 @@ export default {
color: var(--w-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
@ -242,4 +282,34 @@ export default {
color: var(--g-800);
border-color: var(--g-700);
}
.count-view {
background: var(--s-50);
border-radius: var(--border-radius-normal);
color: var(--s-600);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.submenu-icons {
display: flex;
align-items: center;
.submenu-icon {
margin-left: var(--space-small);
color: var(--s-600);
&:hover {
cursor: pointer;
color: var(--w-500);
}
}
}
</style>

View file

@ -30,8 +30,7 @@
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
>
</woot-button>
/>
</div>
</template>

View file

@ -1,13 +1,18 @@
<template>
<div :class="labelClass" :style="labelStyle" :title="description">
<button v-if="icon" class="label-action--button" @click="onClick">
<span v-if="icon" class="label-action--button">
<fluent-icon :icon="icon" size="12" class="label--icon" />
</button>
</span>
<span
v-if="variant === 'smooth'"
:style="{ background: color }"
class="label-color-dot"
/>
<span v-if="!href">{{ title }}</span>
<a v-else :href="href" :style="anchorStyle">{{ title }}</a>
<button
v-if="showClose"
class="label-action--button"
class="label-close--button "
:style="{ color: textColor }"
@click="onClick"
>
@ -48,14 +53,23 @@ export default {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: '',
},
variant: {
type: String,
default: '',
},
},
computed: {
textColor() {
return getContrastingTextColor(this.bgColor);
if (this.variant === 'smooth') return '';
return this.color || getContrastingTextColor(this.bgColor);
},
labelClass() {
return `label ${this.colorScheme} ${this.small ? 'small' : ''}`;
@ -94,9 +108,17 @@ export default {
font-weight: var(--font-weight-medium);
margin-right: var(--space-smaller);
margin-bottom: var(--space-smaller);
padding: var(--space-smaller);
background: var(--s-50);
color: var(--s-800);
border: 1px solid var(--s-75);
height: var(--space-medium);
&.small {
font-size: var(--font-size-micro);
padding: var(--space-micro) var(--space-smaller);
line-height: 1.2;
letter-spacing: 0.15px;
}
.label--icon {
@ -104,11 +126,6 @@ export default {
margin-right: var(--space-smaller);
}
.close--icon {
cursor: pointer;
margin-left: var(--space-smaller);
}
&.small .label--icon,
&.small .close--icon {
font-size: var(--font-size-nano);
@ -164,7 +181,31 @@ export default {
}
}
.label-close--button {
color: var(--s-800);
margin-bottom: var(--space-minus-micro);
margin-left: var(--space-smaller);
border-radius: var(--border-radius-small);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background: var(--s-100);
}
}
.label-action--button {
margin-bottom: var(--space-minus-micro);
}
.label-color-dot {
display: inline-block;
width: var(--space-one);
height: var(--space-one);
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
}
</style>

View file

@ -7,7 +7,7 @@
:aria-checked="value.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active: value }"></span>
<span aria-hidden="true" :class="{ active: value }" />
</button>
</template>

View file

@ -5,8 +5,61 @@ export default {
type: Number,
default: 0,
},
border: {
type: Boolean,
default: true,
},
render() {
},
data() {
return { hasScroll: false };
},
created() {
window.addEventListener('resize', this.computeScrollWidth);
},
beforeDestroy() {
window.removeEventListener('resize', this.computeScrollWidth);
},
mounted() {
this.computeScrollWidth();
},
methods: {
computeScrollWidth() {
const tabElement = this.$el.getElementsByClassName('tabs')[0];
this.hasScroll = tabElement.scrollWidth > tabElement.clientWidth;
},
onScrollClick(direction) {
const tabElement = this.$el.getElementsByClassName('tabs')[0];
let scrollPosition = tabElement.scrollLeft;
if (direction === 'left') {
scrollPosition -= 100;
} else {
scrollPosition += 100;
}
tabElement.scrollTo({
top: 0,
left: scrollPosition,
behavior: 'smooth',
});
},
createScrollButton(createElement, direction) {
if (!this.hasScroll) {
return false;
}
return createElement(
'button',
{
class: 'tabs--scroll-button button clear secondary button--only-icon',
on: { click: () => this.onScrollClick(direction) },
},
[
createElement('fluent-icon', {
props: { icon: `chevron-${direction}`, size: 16 },
}),
]
);
},
},
render(createElement) {
const Tabs = this.$slots.default
.filter(
node =>
@ -18,14 +71,21 @@ export default {
data.index = index;
return node;
});
const leftButton = this.createScrollButton(createElement, 'left');
const rightButton = this.createScrollButton(createElement, 'right');
return (
<ul
<div
class={{
tabs: true,
'tabs--container--with-border': this.border,
'tabs--container': true,
}}
>
{leftButton}
<ul class={{ tabs: true, 'tabs--with-scroll': this.hasScroll }}>
{Tabs}
</ul>
{rightButton}
</div>
);
},
};

View file

@ -1,6 +1,7 @@
<template>
<button
class="button"
:type="type"
:class="buttonClasses"
:disabled="isDisabled || isLoading"
@click="handleClick"
@ -13,7 +14,7 @@
:icon="icon"
:icon-size="iconSize"
/>
<span v-if="$slots.default" class="button__content"><slot></slot></span>
<span v-if="$slots.default" class="button__content"><slot /></span>
</button>
</template>
<script>
@ -24,6 +25,10 @@ export default {
name: 'WootButton',
components: { EmojiOrIcon, Spinner },
props: {
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: '',

View file

@ -77,7 +77,7 @@
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
></textarea>
/>
<p
v-if="v.action_params.$dirty && v.action_params.$error"
class="filter-error"

View file

@ -20,7 +20,7 @@
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
></textarea>
/>
</div>
</div>
</template>

View file

@ -40,13 +40,18 @@ export default {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'circle',
},
},
computed: {
style() {
let style = {
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius: this.rounded ? '50%' : 0,
borderRadius:
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
fontSize: `${Math.floor(this.size / 2.5)}px`,
};

View file

@ -215,6 +215,32 @@ export default {
this.$emit('input', { ...payload, query_operator: value });
},
},
custom_attribute_type: {
get() {
if (!this.customAttributeType) return '';
return this.customAttributeType;
},
set() {
const payload = this.value || {};
this.$emit('input', {
...payload,
custom_attribute_type: this.customAttributeType,
});
},
},
},
watch: {
customAttributeType: {
handler(value) {
if (
value === 'conversation_attribute' ||
value === 'contact_attribute'
) {
this.value.custom_attribute_type = this.customAttributeType;
} else this.value.custom_attribute_type = '';
},
immediate: true,
},
},
methods: {
removeFilter() {

View file

@ -7,7 +7,8 @@
:title="label.title"
:description="label.description"
:show-close="true"
:bg-color="label.color"
:color="label.color"
variant="smooth"
@click="removeItem"
/>
<div class="dropdown-wrap">

View file

@ -6,7 +6,7 @@
<p v-if="headerContent" class="small-12 column">
{{ headerContent }}
</p>
<slot></slot>
<slot />
</div>
</template>

View file

@ -0,0 +1,61 @@
import Thumbnail from './Thumbnail.vue';
export default {
title: 'Components/Thumbnail',
component: Thumbnail,
argTypes: {
src: {
control: {
type: 'text',
},
},
size: {
control: {
type: 'text',
},
},
badge: {
control: {
type: 'select',
options: ['fb', 'whatsapp', 'sms', 'twitter-tweet', 'twitter-dm'],
},
},
variant: {
control: {
type: 'select',
options: ['circle', 'square'],
},
},
username: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
status: {
defaultValue: 'circle',
control: {
type: 'select',
options: ['online', 'busy'],
},
},
hasBorder: {
control: {
type: 'boolean',
},
},
shouldShowStatusAlways: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Thumbnail },
template: '<thumbnail v-bind="$props" @click="onClick">{{label}}</thumbnail>',
});
export const Primary = Template.bind({});

View file

@ -12,6 +12,7 @@
:username="username"
:class="thumbnailClass"
:size="avatarSize"
:variant="variant"
/>
<img
v-if="badge === 'instagram_direct_message'"
@ -119,6 +120,10 @@ export default {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
@ -145,7 +150,9 @@ export default {
},
thumbnailClass() {
const classname = this.hasBorder ? 'border' : '';
return `user-thumbnail ${classname}`;
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${classname} ${variant}`;
},
},
watch: {
@ -173,6 +180,9 @@ export default {
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
border-radius: var(--border-radius-large);
}
height: 100%;
width: 100%;
box-sizing: border-box;

View file

@ -1,6 +1,6 @@
<template>
<div class="audio-wave-wrapper">
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin"></audio>
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin" />
</div>
</template>

View file

@ -10,7 +10,7 @@
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
/>
<div ref="editor"></div>
<div ref="editor" />
</div>
</template>

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