Merge branch 'develop' of https://github.com/chatwoot/chatwoot into chore/chat-list-design

This commit is contained in:
Nithin David 2022-06-20 16:19:02 +05:30
commit 52eb4c3183
1469 changed files with 35755 additions and 11179 deletions

View file

@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build working_directory: ~/build
docker: docker:
# specify the version you desire here # specify the version you desire here
- image: cimg/ruby:3.0.2-browsers - image: cimg/ruby:3.0.4-browsers
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
@ -40,14 +40,13 @@ jobs:
- restore_cache: - restore_cache:
keys: keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- chatwoot-bundle
- run: bundle install --frozen --path ~/.bundle - run: bundle install --frozen --path ~/.bundle
- save_cache: - save_cache:
paths: paths:
- ~/.bundle - ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way # Only necessary if app uses webpacker or yarn in some other way

View file

@ -5,4 +5,4 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
COPY . /workspace COPY . /workspace
RUN yarn && gem install bundler && bundle install RUN yarn && gem install bundler && bundle install

View file

@ -1,6 +1,6 @@
# pre-build stage
ARG VARIANT=3 ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT} FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000 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; \ && chmod -R $USER_UID:$USER_GID /home/vscode; \
fi 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 \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \ && apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \ libssl-dev \
zlib1g-dev \
gnupg2 \
tar \ tar \
tzdata \ tzdata \
postgresql-client \ postgresql-client \
libpq-dev \
yarn \ yarn \
git \ git \
imagemagick \ imagemagick \
tmux \ 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 # Install overmind
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ 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 \ && sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind && 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 # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
COPY . /workspace COPY . /workspace
RUN yarn
# set up ruby
COPY Gemfile Gemfile.lock ./ COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install 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 // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 1025,8025 mailhog
"forwardPorts": [8025], "forwardPorts": [8025, 3000, 3035],
//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],
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn", "postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "Rails Server" "label": "Rails Server"
}, },
"3035": {
"label": "Webpack Dev Server"
},
"8025": { "8025": {
"label": "Mailhog UI" "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 sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable # uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env 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

@ -161,13 +161,15 @@ USE_INBOX_AVATAR_FOR_BOT=true
## NewRelic ## NewRelic
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/ # https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
# NEW_RELIC_LICENSE_KEY= # NEW_RELIC_LICENSE_KEY=
# Set this to true to allow newrelic apm to send logs.
# This is turned off by default.
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
## Datadog ## Datadog
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL= # DD_TRACE_AGENT_URL=
## IP look up configuration ## IP look up configuration
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md ## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
## works only on accounts with ip look up feature enabled ## works only on accounts with ip look up feature enabled

View file

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

View file

@ -0,0 +1,62 @@
# #
# # This action will publish Chatwoot CE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ env.DOCKER_TAG }}

73
.github/workflows/run_foss_spec.yml vendored Normal file
View file

@ -0,0 +1,73 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.4 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: yarn check-files
run: yarn install --check-files
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation

View file

@ -1 +1 @@
3.0.2 3.0.4

21
Gemfile
View file

@ -1,6 +1,6 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.0.2' ruby '3.0.4'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
@ -97,14 +97,14 @@ gem 'brakeman'
gem 'ddtrace' gem 'ddtrace'
gem 'newrelic_rpm' gem 'newrelic_rpm'
gem 'scout_apm' gem 'scout_apm'
gem 'sentry-rails' gem 'sentry-rails', '~> 5.3'
gem 'sentry-ruby' gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq' gem 'sentry-sidekiq', '~> 5.3'
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 6.4.0' gem 'sidekiq', '~> 6.4.0'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron' gem 'sidekiq-cron', '~> 1.3'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
@ -125,6 +125,12 @@ gem 'procore-sift'
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
gem 'html2text' gem 'html2text'
# to calculate working hours
gem 'working_hours'
# full text search for articles
gem 'pg_search'
group :production, :staging do group :production, :staging do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
@ -153,11 +159,6 @@ group :test do
end end
group :development, :test do group :development, :test do
# TODO: is this needed ?
# errors thrown by devise password gem
gem 'flay'
gem 'rspec'
# for error thrown by devise password gem
gem 'active_record_query_trace' gem 'active_record_query_trace'
gem 'bundle-audit', require: false gem 'bundle-audit', require: false
gem 'byebug', platform: :mri gem 'byebug', platform: :mri

View file

@ -1,6 +1,6 @@
GIT GIT
remote: https://github.com/chatwoot/devise-secure_password remote: https://github.com/chatwoot/devise-secure_password
revision: de11e8765654b8242d42101ee9c8ffc8126f7975 revision: d777b04f12652d576b1272b8f39857e3e0b3fc26
specs: specs:
devise-secure_password (2.0.1) devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0) devise (>= 4.0.0, < 5.0.0)
@ -9,63 +9,63 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.4.7) actioncable (6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.7) actionmailbox (6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
activejob (= 6.1.4.7) activejob (= 6.1.5.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.5.1)
activestorage (= 6.1.4.7) activestorage (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.4.7) actionmailer (6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
actionview (= 6.1.4.7) actionview (= 6.1.5.1)
activejob (= 6.1.4.7) activejob (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.4.7) actionpack (6.1.5.1)
actionview (= 6.1.4.7) actionview (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.7) actiontext (6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.5.1)
activestorage (= 6.1.4.7) activestorage (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.4.7) actionview (6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.4.7) activejob (6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.4.7) activemodel (6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
activerecord (6.1.4.7) activerecord (6.1.5.1)
activemodel (= 6.1.4.7) activemodel (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
activerecord-import (1.3.0) activerecord-import (1.3.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (6.1.4.7) activestorage (6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
activejob (= 6.1.4.7) activejob (= 6.1.5.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
marcel (~> 1.0.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.4.7) activesupport (6.1.5.1)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -136,7 +136,7 @@ GEM
climate_control (1.0.1) climate_control (1.0.1)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.4) commonmarker (0.23.4)
concurrent-ruby (1.1.9) concurrent-ruby (1.1.10)
connection_pool (2.2.5) connection_pool (2.2.5)
crack (0.4.5) crack (0.4.5)
rexml rexml
@ -182,8 +182,7 @@ GEM
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.10.0) erubi (1.10.0)
erubis (2.7.0) et-orbi (1.2.7)
et-orbi (1.2.6)
tzinfo tzinfo
execjs (2.8.1) execjs (2.8.1)
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
@ -204,14 +203,9 @@ GEM
faraday (~> 1) faraday (~> 1)
ffi (1.15.5) ffi (1.15.5)
flag_shih_tzu (0.3.23) 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) foreman (0.87.2)
fugit (1.5.2) fugit (1.5.3)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4) raabro (~> 1.4)
gapic-common (0.3.4) gapic-common (0.3.4)
google-protobuf (~> 3.12, >= 3.12.2) google-protobuf (~> 3.12, >= 3.12.2)
@ -309,7 +303,7 @@ GEM
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.6.0) jmespath (1.6.1)
jquery-rails (4.4.0) jquery-rails (4.4.0)
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
@ -349,7 +343,7 @@ GEM
listen (3.7.1) listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.14.0) loofah (2.17.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
@ -376,16 +370,16 @@ GEM
net-http-persistent (4.0.1) net-http-persistent (4.0.1)
connection_pool (~> 2.2) connection_pool (~> 2.2)
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (8.4.0) newrelic_rpm (8.7.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.3) nokogiri (1.13.6)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-arm64-darwin) nokogiri (1.13.6-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-x86_64-darwin) nokogiri (1.13.6-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-x86_64-linux) nokogiri (1.13.6-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.8) oauth (0.5.8)
orm_adapter (0.5.0) orm_adapter (0.5.0)
@ -393,8 +387,10 @@ GEM
parallel (1.21.0) parallel (1.21.0)
parser (3.1.1.0) parser (3.1.1.0)
ast (~> 2.4.1) ast (~> 2.4.1)
path_expander (1.1.0)
pg (1.3.2) pg (1.3.2)
pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
procore-sift (0.16.0) procore-sift (0.16.0)
rails (> 4.2.0) rails (> 4.2.0)
pry (0.14.1) pry (0.14.1)
@ -403,13 +399,13 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.6.2) puma (5.6.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.6.0) racc (1.6.0)
rack (2.2.3) rack (2.2.3.1)
rack-attack (6.6.0) rack-attack (6.6.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
@ -419,31 +415,31 @@ GEM
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-timeout (0.6.0) rack-timeout (0.6.0)
rails (6.1.4.7) rails (6.1.5.1)
actioncable (= 6.1.4.7) actioncable (= 6.1.5.1)
actionmailbox (= 6.1.4.7) actionmailbox (= 6.1.5.1)
actionmailer (= 6.1.4.7) actionmailer (= 6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
actiontext (= 6.1.4.7) actiontext (= 6.1.5.1)
actionview (= 6.1.4.7) actionview (= 6.1.5.1)
activejob (= 6.1.4.7) activejob (= 6.1.5.1)
activemodel (= 6.1.4.7) activemodel (= 6.1.5.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.5.1)
activestorage (= 6.1.4.7) activestorage (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.4.7) railties (= 6.1.5.1)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2) rails-html-sanitizer (1.4.2)
loofah (~> 2.3) loofah (~> 2.3)
railties (6.1.4.7) railties (6.1.5.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.5.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.5.1)
method_source method_source
rake (>= 0.13) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
@ -468,10 +464,6 @@ GEM
netrc (~> 0.8) netrc (~> 0.8)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.5) 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-core (3.11.0)
rspec-support (~> 3.11.0) rspec-support (~> 3.11.0)
rspec-expectations (3.11.0) rspec-expectations (3.11.0)
@ -533,16 +525,16 @@ GEM
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (5.1.0) sentry-rails (5.3.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby-core (~> 5.1.0) sentry-ruby-core (~> 5.3.0)
sentry-ruby (5.1.0) sentry-ruby (5.3.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-ruby-core (= 5.1.0) sentry-ruby-core (= 5.3.0)
sentry-ruby-core (5.1.0) sentry-ruby-core (5.3.0)
concurrent-ruby concurrent-ruby
sentry-sidekiq (5.1.0) sentry-sidekiq (5.3.0)
sentry-ruby-core (~> 5.1.0) sentry-ruby-core (~> 5.3.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.16.0) sexp_processor (4.16.0)
shoulda-matchers (5.1.0) shoulda-matchers (5.1.0)
@ -551,8 +543,8 @@ GEM
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
sidekiq-cron (1.2.0) sidekiq-cron (1.4.0)
fugit (~> 1.1) fugit (~> 1)
sidekiq (>= 4.2.1) sidekiq (>= 4.2.1)
signet (0.16.0) signet (0.16.0)
addressable (~> 2.8) addressable (~> 2.8)
@ -636,6 +628,9 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4) zeitwerk (2.5.4)
PLATFORMS PLATFORMS
@ -678,7 +673,6 @@ DEPENDENCIES
faker faker
fcm fcm
flag_shih_tzu flag_shih_tzu
flay
foreman foreman
geocoder geocoder
google-cloud-dialogflow google-cloud-dialogflow
@ -703,6 +697,7 @@ DEPENDENCIES
mock_redis mock_redis
newrelic_rpm newrelic_rpm
pg pg
pg_search
procore-sift procore-sift
pry-rails pry-rails
puma puma
@ -715,7 +710,6 @@ DEPENDENCIES
redis-namespace redis-namespace
responders responders
rest-client rest-client
rspec
rspec-rails (~> 5.0.0) rspec-rails (~> 5.0.0)
rubocop rubocop
rubocop-performance rubocop-performance
@ -723,12 +717,12 @@ DEPENDENCIES
rubocop-rspec rubocop-rspec
scout_apm scout_apm
seed_dump seed_dump
sentry-rails sentry-rails (~> 5.3)
sentry-ruby sentry-ruby (~> 5.3)
sentry-sidekiq sentry-sidekiq (~> 5.3)
shoulda-matchers shoulda-matchers
sidekiq (~> 6.4.0) sidekiq (~> 6.4.0)
sidekiq-cron sidekiq-cron (~> 1.3)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client slack-ruby-client
spring spring
@ -746,9 +740,10 @@ DEPENDENCIES
webpacker (~> 5.x) webpacker (~> 5.x)
webpush webpush
wisper (= 2.0.0) wisper (= 2.0.0)
working_hours
RUBY VERSION RUBY VERSION
ruby 3.0.2p107 ruby 3.0.4p208
BUNDLED WITH BUNDLED WITH
2.3.8 2.3.15

View file

@ -1,5 +1,5 @@
class Campaigns::CampaignConversationBuilder class Campaigns::CampaignConversationBuilder
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes] pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
def perform def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id) @contact_inbox = ContactInbox.find(@contact_inbox_id)
@ -21,7 +21,8 @@ class Campaigns::CampaignConversationBuilder
def message_params def message_params
ActionController::Parameters.new({ ActionController::Parameters.new({
content: @campaign.message content: @campaign.message,
campaign_id: @campaign.id
}) })
end end
@ -32,7 +33,8 @@ class Campaigns::CampaignConversationBuilder
contact_id: @contact_inbox.contact_id, contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
campaign_id: @campaign.id, campaign_id: @campaign.id,
additional_attributes: conversation_additional_attributes additional_attributes: conversation_additional_attributes,
custom_attributes: custom_attributes || {}
} }
end end
end end

View file

@ -15,11 +15,10 @@ class ContactBuilder
end end
def create_contact_inbox(contact) def create_contact_inbox(contact)
::ContactInbox.create!( ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: contact.id, contact_id: contact.id,
inbox_id: inbox.id, inbox_id: inbox.id,
source_id: source_id, source_id: source_id
hmac_verified: hmac_verified || false
) )
end end

View file

@ -27,9 +27,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
ensure_contact_avatar ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError rescue Koala::Facebook::AuthenticationError
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" @inbox.channel.authorization_error!
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true true
end end
@ -43,7 +43,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if contact.present? return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url)) @contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) @contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
end end
def build_message def build_message
@ -128,10 +128,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
result = {} result = {}
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
# We don't need to capture this error as we don't care about contact params in case of echo messages # We don't need to capture this error as we don't care about contact params in case of echo messages
Sentry.capture_exception(e) unless @outgoing_echo ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
rescue StandardError => e rescue StandardError => e
result = {} result = {}
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end end
process_contact_params_result(result) process_contact_params_result(result)
end end

View file

@ -24,7 +24,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
raise raise
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true true
end end

View file

@ -9,6 +9,7 @@ class Messages::MessageBuilder
@user = user @user = user
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to) @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@ -64,6 +65,18 @@ class Messages::MessageBuilder
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {} @params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
end end
def automation_rule_id
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
end
def campaign_id
@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 def message_sender
return if @params[:sender_type] != 'AgentBot' return if @params[:sender_type] != 'AgentBot'
@ -82,6 +95,6 @@ class Messages::MessageBuilder
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id] echo_id: @params[:echo_id]
}.merge(external_created_at) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end end
end end

View file

@ -53,16 +53,7 @@ class Messages::Messenger::MessageBuilder
def fetch_story_link(attachment) def fetch_story_link(attachment)
message = attachment.message message = attachment.message
begin result = get_story_object_from_source_id(message.source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(message.source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
end
story_id = result['story']['mention']['id'] story_id = result['story']['mention']['id']
story_sender = result['from']['username'] story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender message.content_attributes[:story_sender] = story_sender
@ -70,4 +61,15 @@ class Messages::Messenger::MessageBuilder
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save! message.save!
end end
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
end end

View file

@ -4,6 +4,7 @@ class V2::ReportBuilder
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
AGENT_RESULTS_PER_PAGE = 25
def initialize(account, params) def initialize(account, params)
@account = account @account = account
@ -45,7 +46,7 @@ class V2::ReportBuilder
if params[:type].equal?(:account) if params[:type].equal?(:account)
conversations conversations
else else
agent_metrics agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
end end
end end
@ -79,20 +80,23 @@ class V2::ReportBuilder
end end
def agent_metrics def agent_metrics
users = @account.users account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
users = users.where(id: params[:user_id]) if params[:user_id].present? account_users.each_with_object([]) do |account_user, arr|
users.each_with_object([]) do |user, arr| @user = account_user.user
@user = user
arr << { arr << {
user: { id: user.id, name: user.name, thumbnail: user.avatar_url }, id: @user.id,
name: @user.name,
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations metric: conversations
} }
end end
end end
def conversations def conversations
@open_conversations = scope.conversations.open @open_conversations = scope.conversations.where(account_id: @account.id).open
first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = { metric = {
open: @open_conversations.count, open: @open_conversations.count,
unattended: @open_conversations.count - first_response_count unattended: @open_conversations.count - first_response_count

View file

@ -45,6 +45,8 @@ class RoomChannel < ApplicationCable::Channel
end end
def current_account def current_account
return if current_user.blank?
@current_account ||= if @current_user.is_a? Contact @current_account ||= if @current_user.is_a? Contact
@current_user.account @current_user.account
else else

View file

@ -0,0 +1,48 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :fetch_article, except: [:index, :create]
def index
@articles = @portal.articles
@articles.search(list_params) if params[:payload].present?
end
def create
@article = @portal.articles.create!(article_params)
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
)
end
def list_params
params.require(:payload).permit(
:category_slug, :locale, :query
)
end
end

View file

@ -0,0 +1,24 @@
class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController
before_action :fetch_inboxes
def index
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
member_ids
end
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
end
private
def fetch_inboxes
@inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids])
end
def permitted_params
params.permit(inbox_ids: [])
end
end

View file

@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def create def create
@automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions] @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? render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@ -17,12 +18,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule @automation_rule
end end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def show; end def show; end
def update def update
@automation_rule.update(automation_rules_permit) ActiveRecord::Base.transaction do
process_attachments automation_rule_update
@automation_rule process_attachments
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end end
def destroy def destroy
@ -37,20 +53,30 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule @automation_rule = new_rule
end end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private private
def process_attachments def automation_rule_update
return if params[:attachments].blank? @automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
params[:attachments].each do |uploaded_attachment| @automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.files.attach(uploaded_attachment) @automation_rule.save!
end
end end
def automation_rules_permit def automation_rules_permit
params.permit( params.permit(
:name, :description, :event_name, :account_id, :active, :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: [] }] actions: [:action_name, { action_params: [] }]
) )
end end

View file

@ -15,7 +15,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel) set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id) set_avatar(@facebook_inbox, page_id)
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e).capture_exception
end end
end end
@ -60,7 +60,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(access_token, fb_page) set_instagram_id(access_token, fb_page)
fb_page&.reauthorized! fb_page&.reauthorized!
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e).capture_exception
end end
end end

View file

@ -1,8 +1,9 @@
class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :fetch_category, except: [:index, :create] before_action :fetch_category, except: [:index, :create]
def index def index
@categories = @portal.categories @categories = @portal.categories.search(params)
end end
def create def create
@ -24,9 +25,13 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
@category = @portal.categories.find(params[:id]) @category = @portal.categories.find(params[:id])
end end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def category_params def category_params
params.require(:category).permit( params.require(:category).permit(
:name, :description, :position :name, :description, :position, :slug, :locale
) )
end end
end end

View file

@ -7,7 +7,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
build_inbox build_inbox
setup_webhooks if @twilio_channel.sms? setup_webhooks if @twilio_channel.sms?
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e)
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
end end

View file

@ -5,7 +5,7 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
private private
def conversation def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation.inbox, :show? authorize @conversation.inbox, :show?
end end
end end

View file

@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
RESULTS_PER_PAGE = 25 RESULTS_PER_PAGE = 25
before_action :check_authorization before_action :check_authorization
before_action :set_csat_survey_responses, only: [:index, :metrics] before_action :set_csat_survey_responses, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
before_action :set_current_page_surveys, only: [:index] before_action :set_current_page_surveys, only: [:index]
before_action :set_total_sent_messages_count, only: [:metrics] before_action :set_total_sent_messages_count, only: [:metrics]
@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@ratings_count = @csat_survey_responses.group(:rating).count @ratings_count = @csat_survey_responses.group(:rating).count
end end
def download
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv'
end
private private
def set_total_sent_messages_count def set_total_sent_messages_count

View file

@ -0,0 +1,44 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
def index; end
def show; end
def create
@dashboard_app = Current.account.dashboard_apps.create!(
permitted_payload.merge(user_id: Current.user.id)
)
end
def update
@dashboard_app.update!(permitted_payload)
end
def destroy
@dashboard_app.destroy!
head :no_content
end
private
def fetch_dashboard_apps
@dashboard_apps = Current.account.dashboard_apps
end
def fetch_dashboard_app
@dashboard_app = @dashboard_apps.find(permitted_params[:id])
end
def permitted_payload
params.require(:dashboard_app).permit(
:title,
content: [:url, :type]
)
end
def permitted_params
params.permit(:id)
end
end

View file

@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def show; end def show; end
# Deprecated: This API will be removed in 2.7.0
def assignable_agents def assignable_agents
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq
end end
@ -41,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update def update
@inbox.update(permitted_params.except(:channel)) @inbox.update!(permitted_params.except(:channel))
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type) channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes # Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank? return if permitted_params(channel_attributes)[:channel].blank?
if @inbox.inbox_type == 'Email' if @inbox.inbox_type == 'Email'
validate_email_channel(channel_attributes) begin
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
@inbox.channel.reauthorized! @inbox.channel.reauthorized!
end end
@ -57,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
update_channel_feature_flags update_channel_feature_flags
end 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 def agent_bot
@agent_bot = @inbox.agent_bot @agent_bot = @inbox.agent_bot
end end
@ -88,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def inbox_name(channel)
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
permitted_params[:name]
end
def create_channel def create_channel
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
@ -108,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save! @inbox.channel.save!
end 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 = []) def permitted_params(channel_attributes = [])
params.permit( params.permit(
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, *inbox_attributes,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
channel: [:type, *channel_attributes] channel: [:type, *channel_attributes]
) )
end end
@ -128,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
}[permitted_params[:channel][:type]] }[permitted_params[:channel][:type]]
end 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) def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence channel_type.constantize::EDITABLE_ATTRS.presence
@ -147,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
[] []
end end
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 end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View file

@ -1,9 +0,0 @@
class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController
before_action :portal
private
def portal
@portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id])
end
end

View file

@ -1,12 +1,14 @@
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
before_action :fetch_portal, except: [:index, :create] before_action :fetch_portal, except: [:index, :create]
def index def index
@portals = Current.account.kbase_portals @portals = Current.account.portals
end end
def show; end
def create def create
@portal = Current.account.kbase_portals.create!(portal_params) @portal = Current.account.portals.create!(portal_params)
end end
def update def update
@ -21,12 +23,16 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba
private private
def fetch_portal def fetch_portal
@portal = current_account.kbase_portals.find(params[:id]) @portal = Current.account.portals.find_by(slug: permitted_params[:id])
end
def permitted_params
params.permit(:id)
end end
def portal_params def portal_params
params.require(:portal).permit( params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
) )
end end
end end

View file

@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private private
def webhook_params def webhook_params
params.require(:webhook).permit(:inbox_id, :url) params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end end
def fetch_webhook def fetch_webhook

View file

@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController
twitter_consumer.consume twitter_consumer.consume
head :ok head :ok
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e).capture_exception
head :ok head :ok
end end

View file

@ -39,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController
browser: browser_params, browser: browser_params,
referer: permitted_params[:message][:referer_url], referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params initiated_at: timestamp_params
} },
custom_attributes: permitted_params[:custom_attributes].presence || {}
} }
end end
@ -52,16 +53,39 @@ class Api::V1::Widget::BaseController < ApplicationController
mergee_contact: @contact mergee_contact: @contact
).perform ).perform
else else
@contact.update!(email: email, name: contact_name) @contact.update!(email: email)
update_contact_name
end end
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 def contact_email
permitted_params[:contact][:email].downcase permitted_params.dig(:contact, :email)&.downcase
end end
def contact_name def contact_name
params[:contact][:name] || contact_email.split('@')[0] params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
end
def contact_phone_number
permitted_params.dig(:contact, :phone_number)
end end
def browser_params def browser_params

View file

@ -7,12 +7,17 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
update_contact(contact_email) if @contact.email.blank? && contact_email.present? process_update_contact
@conversation = create_conversation @conversation = create_conversation
conversation.messages.create(message_params) conversation.messages.create(message_params)
end end
end 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?
end
def update_last_seen def update_last_seen
head :ok && return if conversation.nil? head :ok && return if conversation.nil?
@ -45,7 +50,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def toggle_status def toggle_status
head :not_found && return if conversation.nil? return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved? unless conversation.resolved?
conversation.status = :resolved conversation.status = :resolved
conversation.save conversation.save
@ -60,6 +68,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def permitted_params def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id]) params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end end
end end

View file

@ -1,4 +1,5 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
before_action :check_authorization before_action :check_authorization
def index def index
@ -12,27 +13,23 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end end
def agents def agents
response.headers['Content-Type'] = 'text/csv' @report_data = generate_agents_report
response.headers['Content-Disposition'] = 'attachment; filename=agents_report.csv' generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/agents.csv.erb', format: 'csv'
end end
def inboxes def inboxes
response.headers['Content-Type'] = 'text/csv' @report_data = generate_inboxes_report
response.headers['Content-Disposition'] = 'attachment; filename=inboxes_report.csv' generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv'
end end
def labels def labels
response.headers['Content-Type'] = 'text/csv' @report_data = generate_labels_report
response.headers['Content-Disposition'] = 'attachment; filename=labels_report.csv' generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv'
end end
def teams def teams
response.headers['Content-Type'] = 'text/csv' @report_data = generate_teams_report
response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv' generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
end end
def conversations def conversations
@ -43,46 +40,53 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
private private
def generate_csv(filename, template)
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
render layout: false, template: template, format: 'csv'
end
def check_authorization def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator? raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end end
def current_summary_params def common_params
{ {
type: params[:type].to_sym, type: params[:type].to_sym,
id: params[:id], id: params[:id],
since: range[:current][:since], group_by: params[:group_by],
until: range[:current][:until], business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
group_by: params[:group_by]
} }
end end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params def previous_summary_params
{ common_params.merge({
type: params[:type].to_sym, since: range[:previous][:since],
id: params[:id], until: range[:previous][:until]
since: range[:previous][:since], })
until: range[:previous][:until],
group_by: params[:group_by]
}
end end
def report_params def report_params
{ common_params.merge({
metric: params[:metric], metric: params[:metric],
type: params[:type].to_sym, since: params[:since],
since: params[:since], until: params[:until],
until: params[:until], timezone_offset: params[:timezone_offset]
id: params[:id], })
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
}
end end
def conversation_params def conversation_params
{ {
type: params[:type].to_sym, type: params[:type].to_sym,
user_id: params[:user_id] user_id: params[:user_id],
page: params[:page].presence || 1
} }
end end

View file

@ -1,7 +1,7 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include DeviseTokenAuth::Concerns::SetUserByToken include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler include RequestExceptionHandler
include Pundit include Pundit::Authorization
include SwitchLocale include SwitchLocale
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token

View file

@ -9,8 +9,7 @@ module RequestExceptionHandler
def handle_with_exception def handle_with_exception
yield yield
rescue ActiveRecord::RecordNotFound => e rescue ActiveRecord::RecordNotFound
Sentry.capture_exception(e)
render_not_found_error('Resource could not be found') render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action') render_unauthorized('You are not authorized to do this action')

View file

@ -38,9 +38,13 @@ class DashboardController < ActionController::Base
end end
def app_config def app_config
{ APP_VERSION: Chatwoot.config[:version], {
APP_VERSION: Chatwoot.config[:version],
VAPID_PUBLIC_KEY: VapidService.public_key, VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') } FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?
}
end end
end end

View file

@ -21,6 +21,10 @@ class Platform::Api::V1::UsersController < PlatformController
def update def update
@resource.assign_attributes(user_update_params) @resource.assign_attributes(user_update_params)
# We are using devise's reconfirmable flow for changing emails
# But in case of platform APIs we don't want user to go through this extra step
@resource.skip_reconfirmation! if user_update_params[:email].present?
@resource.save! @resource.save!
end end

View file

@ -43,6 +43,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end end
def permitted_params def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {}) params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end end
end end

View file

@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base
before_action :ensure_widget_position before_action :ensure_widget_position
before_action :ensure_widget_type before_action :ensure_widget_type
before_action :ensure_widget_style before_action :ensure_widget_style
before_action :ensure_dark_mode
def index def index
render render
@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base
@widget_style = params[:widget_style] || 'standard' @widget_style = params[:widget_style] || 'standard'
end end
def ensure_dark_mode
@dark_mode = params[:dark_mode] || 'light'
end
def ensure_widget_position def ensure_widget_position
@widget_position = params[:position] || 'left' @widget_position = params[:position] || 'left'
end end

View file

@ -51,6 +51,7 @@ class ConversationFinder
filter_by_team if @team filter_by_team if @team
filter_by_labels if params[:labels] filter_by_labels if params[:labels]
filter_by_query if params[:q] filter_by_query if params[:q]
filter_by_reply_status
end end
def set_inboxes def set_inboxes
@ -90,6 +91,10 @@ class ConversationFinder
@conversations @conversations
end end
def filter_by_reply_status
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
end
def filter_by_query def filter_by_query
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]] allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%") @conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")

View file

@ -0,0 +1,15 @@
class EmailChannelFinder
def initialize(email_object)
@email_object = email_object
end
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
break if channel.present?
end
channel
end
end

View file

@ -22,7 +22,7 @@ class MessageFinder
def current_messages def current_messages
if @params[:before].present? if @params[:before].present?
messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
else else
messages.reorder('created_at desc').limit(20).reverse messages.reorder('created_at desc').limit(20).reverse
end end

View file

@ -1,4 +1,10 @@
module Api::V1::InboxesHelper 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) def validate_email_channel(attributes)
channel_data = permitted_params(attributes)[:channel] channel_data = permitted_params(attributes)[:channel]
@ -14,13 +20,12 @@ module Api::V1::InboxesHelper
Mail.defaults do Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address], retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port], port: channel_data[:imap_port],
user_name: channel_data[:imap_email], user_name: channel_data[:imap_login],
password: channel_data[:imap_password], password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] } enable_ssl: channel_data[:imap_enable_ssl] }
end end
Mail.connection do # rubocop:disable:block check_imap_connection(channel_data)
end
end end
def validate_smtp(channel_data) def validate_smtp(channel_data)
@ -29,8 +34,31 @@ module Api::V1::InboxesHelper
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port]) smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
set_smtp_encryption(channel_data, smtp) set_smtp_encryption(channel_data, smtp)
check_smtp_connection(channel_data, smtp)
end
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login) 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 unless smtp&.nil?
end end
@ -70,4 +98,22 @@ module Api::V1::InboxesHelper
context.verify_mode = openssl_verify_mode context.verify_mode = openssl_verify_mode
context context
end 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 end

View file

@ -0,0 +1,56 @@
module Api::V2::Accounts::ReportsHelper
def generate_agents_report
Current.account.users.map do |agent|
agent_report = generate_report({ type: :agent, id: agent.id })
[agent.name] + generate_readable_report_metrics(agent_report)
end
end
def generate_inboxes_report
Current.account.inboxes.map do |inbox|
inbox_report = generate_report({ type: :inbox, id: inbox.id })
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
end
end
def generate_teams_report
Current.account.teams.map do |team|
team_report = generate_report({ type: :team, id: team.id })
[team.name] + generate_readable_report_metrics(team_report)
end
end
def generate_labels_report
Current.account.labels.map do |label|
label_report = generate_report({ type: :label, id: label.id })
[label.title] + generate_readable_report_metrics(label_report)
end
end
def generate_report(report_params)
V2::ReportBuilder.new(
Current.account,
report_params.merge(
{
since: params[:since],
until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
).summary
end
private
def generate_readable_report_metrics(report_metric)
[
report_metric[:conversations_count],
time_to_minutes(report_metric[:avg_first_response_time]),
time_to_minutes(report_metric[:avg_resolution_time])
]
end
def time_to_minutes(time_in_seconds)
(time_in_seconds / 60).to_i
end
end

View file

@ -17,33 +17,39 @@ module ReportHelper
end end
def conversations_count def conversations_count
(get_grouped_values scope.conversations).count (get_grouped_values scope.conversations.where(account_id: account.id)).count
end end
def incoming_messages_count def incoming_messages_count
(get_grouped_values scope.messages.incoming.unscope(:order)).count (get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count
end end
def outgoing_messages_count def outgoing_messages_count
(get_grouped_values scope.messages.outgoing.unscope(:order)).count (get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count
end end
def resolutions_count def resolutions_count
(get_grouped_values scope.conversations.resolved).count (get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
end end
def avg_first_response_time def avg_first_response_time
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end end
def avg_resolution_time def avg_resolution_time
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end end
def avg_resolution_time_summary def avg_resolution_time_summary
avg_rt = scope.reporting_events reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range) .where(name: 'conversation_resolved', account_id: account.id, created_at: range)
.average(:value) avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_rt.blank? return 0 if avg_rt.blank?
@ -51,9 +57,9 @@ module ReportHelper
end end
def avg_first_response_time_summary def avg_first_response_time_summary
avg_frt = scope.reporting_events reporting_events = scope.reporting_events
.where(name: 'first_response', created_at: range) .where(name: 'first_response', account_id: account.id, created_at: range)
.average(:value) avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_frt.blank? return 0 if avg_frt.blank?

View file

@ -0,0 +1,50 @@
module ReportingEventHelper
def business_hours(inbox, from, to)
return 0 unless inbox.working_hours_enabled?
inbox_working_hours = configure_working_hours(inbox.working_hours)
return 0 if inbox_working_hours.blank?
# Configure working hours
WorkingHours::Config.working_hours = inbox_working_hours
# Configure timezone
WorkingHours::Config.time_zone = inbox.timezone
# Use inbox timezone to change from & to values.
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
end
private
def configure_working_hours(working_hours)
working_hours.each_with_object({}) do |working_hour, object|
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
end
end
def day(day_of_week)
week_days = {
0 => :sun,
1 => :mon,
2 => :tue,
3 => :wed,
4 => :thu,
5 => :fri,
6 => :sat
}
week_days[day_of_week]
end
def working_hour_range(working_hour)
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
end
def format_time(hour, minute)
hour = hour < 10 ? "0#{hour}" : hour
minute = minute < 10 ? "0#{minute}" : minute
"#{hour}:#{minute}"
end
end

View file

@ -1,8 +1,8 @@
<template> <template>
<div id="app" class="app-wrapper app-root"> <div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
<update-banner :latest-chatwoot-version="latestChatwootVersion" /> <update-banner :latest-chatwoot-version="latestChatwootVersion" />
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<router-view></router-view> <router-view />
</transition> </transition>
<add-account-modal <add-account-modal
:show="showAddAccountModal" :show="showAddAccountModal"
@ -11,21 +11,28 @@
<woot-snackbar-box /> <woot-snackbar-box />
<network-notification /> <network-notification />
</div> </div>
<loading-state v-else />
</template> </template>
<script> <script>
import { accountIdFromPathname } from './helper/URLHelper';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification'; import NetworkNotification from './components/NetworkNotification';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer'; import WootSnackbarBox from './components/SnackbarContainer';
import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
export default { export default {
name: 'App', name: 'App',
components: { components: {
AddAccountModal, AddAccountModal,
LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
WootSnackbarBox, WootSnackbarBox,
@ -43,13 +50,12 @@ export default {
getAccount: 'accounts/getAccount', getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get', globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
currentAccountId: 'getCurrentAccountId',
}), }),
hasAccounts() { hasAccounts() {
return ( const { accounts = [] } = this.currentUser || {};
this.currentUser && return accounts.length > 0;
this.currentUser.accounts &&
this.currentUser.accounts.length !== 0
);
}, },
}, },
@ -58,32 +64,37 @@ export default {
if (!this.hasAccounts) { if (!this.hasAccounts) {
this.showAddAccountModal = true; this.showAddAccountModal = true;
} }
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
}, },
}, },
mounted() { mounted() {
this.$store.dispatch('setUser');
this.setLocale(window.chatwootConfig.selectedLocale); this.setLocale(window.chatwootConfig.selectedLocale);
this.initializeAccount();
}, },
methods: { methods: {
setLocale(locale) { setLocale(locale) {
this.$root.$i18n.locale = locale; this.$root.$i18n.locale = locale;
}, },
async initializeAccount() { async initializeAccount() {
const { pathname } = window.location; await this.$store.dispatch('accounts/get');
const accountId = accountIdFromPathname(pathname); const {
locale,
if (accountId) { latest_chatwoot_version: latestChatwootVersion,
await this.$store.dispatch('accounts/get'); } = this.getAccount(this.currentAccountId);
const { const { pubsub_token: pubsubToken } = this.currentUser || {};
locale, this.setLocale(locale);
latest_chatwoot_version: latestChatwootVersion, this.latestChatwootVersion = latestChatwootVersion;
} = this.getAccount(accountId); vueActionCable.init(pubsubToken);
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
}
}, },
}, },
}; };

View file

@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class AssignableAgents extends ApiClient {
constructor() {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
});
}
}
export default new AssignableAgents();

View file

@ -1,6 +1,4 @@
/* eslint no-console: 0 */
/* global axios */ /* global axios */
/* eslint no-undef: "error" */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import endPoints from './endPoints'; import endPoints from './endPoints';
@ -61,41 +59,15 @@ export default {
}); });
return fetchPromise; return fetchPromise;
}, },
hasAuthCookie() {
isLoggedIn() { return !!Cookies.getJSON('cw_d_session_info');
const hasAuthCookie = !!Cookies.getJSON('auth_data');
const hasUserCookie = !!Cookies.getJSON('user');
return hasAuthCookie && hasUserCookie;
}, },
isAdmin() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user').role === 'administrator';
}
return false;
},
getAuthData() { getAuthData() {
if (this.isLoggedIn()) { if (this.hasAuthCookie()) {
return Cookies.getJSON('auth_data'); return Cookies.getJSON('cw_d_session_info');
} }
return false; return false;
}, },
getPubSubToken() {
if (this.isLoggedIn()) {
const user = Cookies.getJSON('user') || {};
const { pubsub_token: pubsubToken } = user;
return pubsubToken;
}
return null;
},
getCurrentUser() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user');
}
return null;
},
verifyPasswordToken({ confirmationToken }) { verifyPasswordToken({ confirmationToken }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios axios

View file

@ -9,6 +9,14 @@ class AutomationsAPI extends ApiClient {
clone(automationId) { clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`); return axios.post(`${this.url}/${automationId}/clone`);
} }
attachment(file) {
return axios.post(`${this.url}/attach_file`, file, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
} }
export default new AutomationsAPI(); export default new AutomationsAPI();

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

@ -18,6 +18,17 @@ class CSATReportsAPI extends ApiClient {
}); });
} }
download({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
sort: '-created_at',
user_ids,
},
});
}
getMetrics({ from, to, user_ids } = {}) { getMetrics({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/metrics`, { return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids }, params: { since: from, until: to, user_ids },

View file

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

View file

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

View file

@ -6,10 +6,6 @@ class Inboxes extends ApiClient {
super('inboxes', { accountScoped: true }); super('inboxes', { accountScoped: true });
} }
getAssignableAgents(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_agents`);
}
getCampaigns(inboxId) { getCampaigns(inboxId) {
return axios.get(`${this.url}/${inboxId}/campaigns`); return axios.get(`${this.url}/${inboxId}/campaigns`);
} }

View file

@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' }); super('reports', { accountScoped: true, apiVersion: 'v2' });
} }
getReports(metric, since, until, type = 'account', id, group_by) { getReports(
metric,
since,
until,
type = 'account',
id,
group_by,
business_hours
) {
return axios.get(`${this.url}`, { return axios.get(`${this.url}`, {
params: { params: {
metric, metric,
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
type, type,
id, id,
group_by, group_by,
business_hours,
timezone_offset: getTimeOffset(), timezone_offset: getTimeOffset(),
}, },
}); });
} }
getSummary(since, until, type = 'account', id, group_by) { getSummary(since, until, type = 'account', id, group_by, business_hours) {
return axios.get(`${this.url}/summary`, { return axios.get(`${this.url}/summary`, {
params: { params: {
since, since,
@ -30,31 +39,41 @@ class ReportsAPI extends ApiClient {
type, type,
id, id,
group_by, group_by,
business_hours,
}, },
}); });
} }
getAgentReports(since, until) { getConversationMetric(type = 'account', page = 1) {
return axios.get(`${this.url}/conversations`, {
params: {
type,
page,
},
});
}
getAgentReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/agents`, { return axios.get(`${this.url}/agents`, {
params: { since, until }, params: { since, until, business_hours: businessHours },
}); });
} }
getLabelReports(since, until) { getLabelReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/labels`, { return axios.get(`${this.url}/labels`, {
params: { since, until }, params: { since, until, business_hours: businessHours },
}); });
} }
getInboxReports(since, until) { getInboxReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/inboxes`, { return axios.get(`${this.url}/inboxes`, {
params: { since, until }, params: { since, until, business_hours: businessHours },
}); });
} }
getTeamReports(since, until) { getTeamReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/teams`, { return axios.get(`${this.url}/teams`, {
params: { since, until }, params: { since, until, business_hours: businessHours },
}); });
} }
} }

View file

@ -0,0 +1,18 @@
import assignableAgentsAPI from '../assignableAgents';
import describeWithAPIMock from './apiSpecHelper';
describe('#AssignableAgentsAPI', () => {
describeWithAPIMock('API calls', context => {
it('#getAssignableAgents', () => {
assignableAgentsAPI.get([1]);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/assignable_agents',
{
params: {
inbox_ids: [1],
},
}
);
});
});
});

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

@ -33,5 +33,23 @@ describe('#Reports API', () => {
} }
); );
}); });
it('#download', () => {
csatReportsAPI.download({
from: 1622485800,
to: 1623695400,
user_ids: 1,
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses/download',
{
params: {
since: 1622485800,
until: 1623695400,
user_ids: 1,
sort: '-created_at',
},
}
);
});
}); });
}); });

View file

@ -0,0 +1,13 @@
import dashboardAppsAPI from '../dashboardApps';
import ApiClient from '../ApiClient';
describe('#dashboardAppsAPI', () => {
it('creates correct instance', () => {
expect(dashboardAppsAPI).toBeInstanceOf(ApiClient);
expect(dashboardAppsAPI).toHaveProperty('get');
expect(dashboardAppsAPI).toHaveProperty('show');
expect(dashboardAppsAPI).toHaveProperty('create');
expect(dashboardAppsAPI).toHaveProperty('update');
expect(dashboardAppsAPI).toHaveProperty('delete');
});
});

View file

@ -10,17 +10,9 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('create'); expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update'); expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete'); expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableAgents');
expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getCampaigns');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
it('#getAssignableAgents', () => {
inboxesAPI.getAssignableAgents(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/1/assignable_agents'
);
});
it('#getCampaigns', () => { it('#getCampaigns', () => {
inboxesAPI.getCampaigns(2); inboxesAPI.getCampaigns(2);
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(

View file

@ -47,20 +47,25 @@ describe('#Reports API', () => {
}); });
it('#getAgentReports', () => { it('#getAgentReports', () => {
reportsAPI.getAgentReports(1621103400, 1621621800); reportsAPI.getAgentReports({
from: 1621103400,
to: 1621621800,
businessHours: true,
});
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/agents', '/api/v2/reports/agents',
{ {
params: { params: {
since: 1621103400, since: 1621103400,
until: 1621621800, until: 1621621800,
business_hours: true,
}, },
} }
); );
}); });
it('#getLabelReports', () => { it('#getLabelReports', () => {
reportsAPI.getLabelReports(1621103400, 1621621800); reportsAPI.getLabelReports({ from: 1621103400, to: 1621621800 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/labels', '/api/v2/reports/labels',
{ {
@ -73,7 +78,7 @@ describe('#Reports API', () => {
}); });
it('#getInboxReports', () => { it('#getInboxReports', () => {
reportsAPI.getInboxReports(1621103400, 1621621800); reportsAPI.getInboxReports({ from: 1621103400, to: 1621621800 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/inboxes', '/api/v2/reports/inboxes',
{ {
@ -86,7 +91,7 @@ describe('#Reports API', () => {
}); });
it('#getTeamReports', () => { it('#getTeamReports', () => {
reportsAPI.getTeamReports(1621103400, 1621621800); reportsAPI.getTeamReports({ from: 1621103400, to: 1621621800 });
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/teams', '/api/v2/reports/teams',
{ {
@ -97,5 +102,18 @@ describe('#Reports API', () => {
} }
); );
}); });
it('#getConversationMetric', () => {
reportsAPI.getConversationMetric('account');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/conversations',
{
params: {
type: 'account',
page: 1,
},
}
);
});
}); });
}); });

View file

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

View file

@ -2,6 +2,10 @@
margin-right: var(--space-small); margin-right: var(--space-small);
} }
.margin-bottom-small {
margin-bottom: var(--space-small);
}
.margin-right-smaller { .margin-right-smaller {
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
} }
@ -51,12 +55,14 @@
background-color: var(--white); background-color: var(--white);
} }
.text-y-800 {
color: var(--y-800);
}
.text-ellipsis { .text-ellipsis {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.flex-between {
align-items: center;
display: flex;
justify-content: space-between;
}

View file

@ -27,6 +27,16 @@
padding: 0 $space-small; padding: 0 $space-small;
} }
.video-js {
background: transparent;
// Override min-height : 50px in foundation
//
max-height: $space-mega * 2.4;
min-height: 4.8rem;
padding: var(--space-normal) 0 0;
resize: none;
}
>textarea { >textarea {
@include ghost-input(); @include ghost-input();
@include margin(0); @include margin(0);

View file

@ -78,5 +78,10 @@
font-size: $font-size-default; font-size: $font-size-default;
color: $color-gray; color: $color-gray;
} }
.business-hours {
margin: $space-normal;
text-align: center;
}
} }
} }

View file

@ -25,3 +25,21 @@
align-items: center; align-items: center;
display: flex; display: flex;
} }
.business-hours {
align-items: center;
display: flex;
justify-content: end;
margin-bottom: var(--space-normal);
margin-left: auto;
padding-right: var(--space-normal);
}
.business-hours-text {
font-size: var(--font-size-small);
}
.switch {
margin-bottom: var(--space-zero);
margin-left: var(--space-small);
}

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="conversations-list-wrap"> <div class="conversations-list-wrap">
<slot></slot> <slot />
<div <div
class="chat-list__top" class="chat-list__top"
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }" :class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
@ -53,8 +53,7 @@
size="small" size="small"
class="btn-filter" class="btn-filter"
@click="onToggleAdvanceFiltersModal" @click="onToggleAdvanceFiltersModal"
> />
</woot-button>
</div> </div>
</div> </div>
@ -84,7 +83,19 @@
<p v-if="!chatListLoading && !conversationList.length" class="content-box"> <p v-if="!chatListLoading && !conversationList.length" class="content-box">
{{ $t('CHAT_LIST.LIST.404') }} {{ $t('CHAT_LIST.LIST.404') }}
</p> </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"> <div ref="activeConversation" class="conversations-list">
<conversation-card <conversation-card
v-for="chat in conversationList" v-for="chat in conversationList"
@ -95,10 +106,13 @@
:chat="chat" :chat="chat"
:conversation-type="conversationType" :conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard" :show-assignee="showAssigneeInConversationCard"
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/> />
<div v-if="chatListLoading" class="text-center"> <div v-if="chatListLoading" class="text-center">
<span class="spinner"></span> <span class="spinner" />
</div> </div>
<woot-button <woot-button
@ -111,11 +125,7 @@
</woot-button> </woot-button>
<p <p
v-if=" v-if="showEndOfListMessage"
conversationList.length &&
hasCurrentPageEndReached &&
!chatListLoading
"
class="text-center text-muted end-of-list-text" class="text-center text-muted end-of-list-text"
> >
{{ $t('CHAT_LIST.EOF') }} {{ $t('CHAT_LIST.EOF') }}
@ -151,6 +161,8 @@ import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js'; import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews'; import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { import {
hasPressedAltAndJKey, hasPressedAltAndJKey,
@ -165,8 +177,9 @@ export default {
ChatFilter, ChatFilter,
ConversationAdvancedFilter, ConversationAdvancedFilter,
DeleteCustomViews, DeleteCustomViews,
ConversationBulkActions,
}, },
mixins: [timeMixin, conversationMixin, eventListenerMixins], mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
props: { props: {
conversationInbox: { conversationInbox: {
type: [String, Number], type: [String, Number],
@ -201,6 +214,8 @@ export default {
foldersQuery: {}, foldersQuery: {},
showAddFoldersModal: false, showAddFoldersModal: false,
showDeleteFoldersModal: false, showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
}; };
}, },
computed: { computed: {
@ -216,6 +231,7 @@ export default {
conversationStats: 'conversationStats/getStats', conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters', appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews', folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
}), }),
hasAppliedFilters() { hasAppliedFilters() {
return this.appliedFilters.length !== 0; return this.appliedFilters.length !== 0;
@ -233,12 +249,24 @@ export default {
} }
return {}; return {};
}, },
showEndOfListMessage() {
return (
this.conversationList.length &&
this.hasCurrentPageEndReached &&
!this.chatListLoading
);
},
assigneeTabItems() { assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => { const ASSIGNEE_TYPE_TAB_KEYS = {
const count = this.conversationStats[item.COUNT_KEY] || 0; me: 'mineCount',
unassigned: 'unAssignedCount',
all: 'allCount',
};
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
return { return {
key: item.KEY, key,
name: item.NAME, name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
count, count,
}; };
}); });
@ -337,6 +365,17 @@ export default {
} }
return {}; 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: { watch: {
activeTeam() { activeTeam() {
@ -370,6 +409,7 @@ export default {
if (this.$route.name !== 'home') { if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' }); this.$router.push({ name: 'home' });
} }
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload); this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset'); this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations'); this.$store.dispatch('emptyAllConversations');
@ -435,6 +475,7 @@ export default {
} }
}, },
resetAndFetchData() { resetAndFetchData() {
this.resetBulkActions();
this.$store.dispatch('conversationPage/reset'); this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations'); this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters'); this.$store.dispatch('clearConversationFilters');
@ -485,6 +526,7 @@ export default {
}, },
updateAssigneeTab(selectedTab) { updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) { if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
bus.$emit('clearSearchInput'); bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab; this.activeAssigneeTab = selectedTab;
if (!this.currentPage) { if (!this.currentPage) {
@ -492,6 +534,10 @@ export default {
} }
} }
}, },
resetBulkActions() {
this.selectedConversations = [];
this.selectedInboxes = [];
},
updateStatusType(index) { updateStatusType(index) {
if (this.activeStatus !== index) { if (this.activeStatus !== index) {
this.activeStatus = index; this.activeStatus = index;
@ -514,6 +560,80 @@ export default {
this.fetchConversations(); 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> </script>
@ -529,7 +649,7 @@ export default {
.conversations-list-wrap { .conversations-list-wrap {
flex-shrink: 0; flex-shrink: 0;
width: 34rem; width: 34rem;
overflow: hidden;
@include breakpoint(large up) { @include breakpoint(large up) {
width: 36rem; width: 36rem;
} }

View file

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

View file

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

View file

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

View file

@ -7,9 +7,13 @@
<p class="sub-head"> <p class="sub-head">
{{ subTitle }} {{ subTitle }}
</p> </p>
<p v-if="note">
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
{{ note }}
</p>
</div> </div>
<div class="medium-6 small-12"> <div class="medium-6 small-12">
<slot></slot> <slot />
</div> </div>
</div> </div>
</template> </template>
@ -25,6 +29,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
note: {
type: String,
default: '',
},
}, },
}; };
</script> </script>
@ -46,5 +54,9 @@ export default {
.title--section { .title--section {
padding-right: var(--space-large); padding-right: var(--space-large);
} }
.note {
font-weight: var(--font-weight-bold);
}
} }
</style> </style>

View file

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

View file

@ -57,3 +57,13 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
button:disabled {
opacity: 1;
background-color: var(--w-100);
&:hover {
background-color: var(--w-100);
}
}
</style>

View file

@ -53,7 +53,7 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability', getCurrentUserAvailability: 'getCurrentUserAvailability',
getCurrentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
}), }),
availabilityDisplayLabel() { availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -63,9 +63,6 @@ export default {
availabilityIndex availabilityIndex
]; ];
}, },
currentAccountId() {
return this.getCurrentAccountId;
},
currentUserAvailability() { currentUserAvailability() {
return this.getCurrentUserAvailability; return this.getCurrentUserAvailability;
}, },

View file

@ -19,6 +19,7 @@
:menu-config="activeSecondaryMenu" :menu-config="activeSecondaryMenu"
:current-role="currentRole" :current-role="currentRole"
@add-label="showAddLabelPopup" @add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/> />
</aside> </aside>
</template> </template>

View file

@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({ const reports = accountId => ({
parentNav: 'reports', parentNav: 'reports',
routes: [ routes: [
'settings_account_reports', 'account_overview_reports',
'conversation_reports',
'csat_reports', 'csat_reports',
'agent_reports', 'agent_reports',
'label_reports', 'label_reports',
@ -16,7 +17,14 @@ const reports = accountId => ({
label: 'REPORTS_OVERVIEW', label: 'REPORTS_OVERVIEW',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`), toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports', toStateName: 'account_overview_reports',
},
{
icon: 'chat',
label: 'REPORTS_CONVERSATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
toStateName: 'conversation_reports',
}, },
{ {
icon: 'emoji', icon: 'emoji',

View file

@ -1,14 +1,34 @@
<template> <template>
<div v-if="showShowCurrentAccountContext" class="account-context--group"> <div
v-if="showShowCurrentAccountContext"
class="account-context--group"
@mouseover="setShowSwitch"
@mouseleave="resetShowSwitch"
>
{{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }} {{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }}
<p class="account-context--name text-ellipsis"> <p class="account-context--name text-ellipsis">
{{ account.name }} {{ account.name }}
</p> </p>
<transition name="fade">
<div v-if="showSwitchButton" class="account-context--switch-group">
<woot-button
variant="clear"
icon="arrow-swap"
class="cursor-pointer"
@click="$emit('toggle-accounts')"
>
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
</div>
</transition>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
export default { export default {
data() {
return { showSwitchButton: false };
},
computed: { computed: {
...mapGetters({ ...mapGetters({
account: 'getCurrentAccount', account: 'getCurrentAccount',
@ -18,6 +38,14 @@ export default {
return this.userAccounts.length > 1 && this.account.name; return this.userAccounts.length > 1 && this.account.name;
}, },
}, },
methods: {
setShowSwitch() {
this.showSwitchButton = true;
},
resetShowSwitch() {
this.showSwitchButton = false;
},
},
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -27,10 +55,49 @@ export default {
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
padding: var(--space-small); padding: var(--space-small);
margin-bottom: var(--space-small); margin-bottom: var(--space-small);
width: 100%;
position: relative;
&:hover {
background: var(--b-100);
}
.account-context--name { .account-context--name {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
margin-bottom: 0; margin-bottom: 0;
} }
} }
.account-context--switch-group {
--overlay-shadow: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 50%
);
align-items: center;
background-image: var(--overlay-shadow);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
display: flex;
height: 100%;
justify-content: end;
opacity: 1;
position: absolute;
right: 0;
top: 0;
width: 100%;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 300ms ease;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style> </style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div v-if="hasSecondaryMenu" class="main-nav secondary-menu"> <div v-if="hasSecondaryMenu" class="main-nav secondary-menu">
<account-context /> <account-context @toggle-accounts="toggleAccountModal" />
<transition-group name="menu-list" tag="ul" class="menu vertical"> <transition-group name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item <secondary-nav-item
v-for="menuItem in accessibleMenuItems" v-for="menuItem in accessibleMenuItems"
@ -85,14 +85,21 @@ export default {
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`), toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
toStateName: 'settings_inbox_new', toStateName: 'settings_inbox_new',
newLinkRouteName: 'settings_inbox_new', newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({ children: this.inboxes
id: inbox.id, .map(inbox => ({
label: inbox.name, id: inbox.id,
truncateLabel: true, label: inbox.name,
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`), truncateLabel: true,
type: inbox.channel_type, toState: frontendURL(
phoneNumber: inbox.phone_number, `accounts/${this.accountId}/inbox/${inbox.id}`
})), ),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
reauthorizationRequired: inbox.reauthorization_required,
}))
.sort((a, b) =>
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
),
}; };
}, },
labelSection() { labelSection() {
@ -218,6 +225,9 @@ export default {
showAddLabelPopup() { showAddLabelPopup() {
this.$emit('add-label'); this.$emit('add-label');
}, },
toggleAccountModal() {
this.$emit('toggle-accounts');
},
}, },
}; };
</script> </script>

View file

@ -30,6 +30,14 @@
<span v-if="count" class="badge" :class="{ secondary: !isActive }"> <span v-if="count" class="badge" :class="{ secondary: !isActive }">
{{ count }} {{ count }}
</span> </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> </a>
</li> </li>
</router-link> </router-link>
@ -57,6 +65,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
warningIcon: {
type: String,
default: '',
},
count: { count: {
type: String, type: String,
default: '', default: '',

View file

@ -34,6 +34,7 @@
:label-color="child.color" :label-color="child.color"
:should-truncate="child.truncateLabel" :should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)" :icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
/> />
<router-link <router-link
v-if="showItem(menuItem)" v-if="showItem(menuItem)"
@ -63,7 +64,10 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import adminMixin from '../../../mixins/isAdmin'; import adminMixin from '../../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox'; import {
getInboxClassByType,
getInboxWarningIconClass,
} from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem'; import SecondaryChildNavItem from './SecondaryChildNavItem';
@ -136,6 +140,15 @@ export default {
const classByType = getInboxClassByType(type, phoneNumber); const classByType = getInboxClassByType(type, phoneNumber);
return classByType; return classByType;
}, },
computedInboxErrorClass(child) {
const { type, reauthorizationRequired } = child;
if (!type) return '';
const warningClass = getInboxWarningIconClass(
type,
reauthorizationRequired
);
return warningClass;
},
newLinkClick(e, navigate) { newLinkClick(e, navigate) {
if (this.menuItem.newLinkRouteName) { if (this.menuItem.newLinkRouteName) {
navigate(e); navigate(e);

View file

@ -30,8 +30,7 @@
icon="dismiss-circle" icon="dismiss-circle"
class-names="banner-action__button" class-names="banner-action__button"
@click="onClickClose" @click="onClickClose"
> />
</woot-button>
</div> </div>
</template> </template>
@ -112,10 +111,10 @@ export default {
} }
&.warning { &.warning {
background: var(--y-800); background: var(--y-600);
color: var(--s-600); color: var(--y-500);
a { a {
color: var(--s-600); color: var(--y-500);
} }
} }

View file

@ -1,13 +1,18 @@
<template> <template>
<div :class="labelClass" :style="labelStyle" :title="description"> <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" /> <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" class="label__title">{{ title }}</span> <span v-if="!href" class="label__title">{{ title }}</span>
<a v-else :href="href" :style="anchorStyle">{{ title }}</a> <a v-else :href="href" :style="anchorStyle">{{ title }}</a>
<button <button
v-if="showClose" v-if="showClose"
class="label-action--button" class="label-close--button "
:style="{ color: textColor }" :style="{ color: textColor }"
@click="onClick" @click="onClick"
> >
@ -56,9 +61,14 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
variant: {
type: String,
default: '',
},
}, },
computed: { computed: {
textColor() { textColor() {
if (this.variant === 'smooth') return '';
return this.color || getContrastingTextColor(this.bgColor); return this.color || getContrastingTextColor(this.bgColor);
}, },
labelClass() { labelClass() {
@ -98,6 +108,11 @@ export default {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
margin-bottom: 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);
text-shadow: 0.1px 0 rgb(0 0 0 / 5%), 0 0.1px rgb(0 0 0 / 5%), text-shadow: 0.1px 0 rgb(0 0 0 / 5%), 0 0.1px rgb(0 0 0 / 5%),
-0.1px 0 rgb(0 0 0 / 5%), 0 -0.1px rgb(0 0 0 / 5%); -0.1px 0 rgb(0 0 0 / 5%), 0 -0.1px rgb(0 0 0 / 5%);
@ -115,11 +130,6 @@ export default {
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
} }
.close--icon {
cursor: pointer;
margin-left: var(--space-smaller);
}
&.small .label--icon, &.small .label--icon,
&.small .close--icon { &.small .close--icon {
font-size: var(--font-size-nano); font-size: var(--font-size-nano);
@ -165,13 +175,28 @@ export default {
&.warning { &.warning {
background: var(--y-100); background: var(--y-100);
color: var(--y-900); color: var(--y-900);
a { a {
color: var(--y-900); color: var(--y-900);
} }
} }
} }
.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 { .label-action--button {
color: inherit; color: inherit;
display: inline-flex; display: inline-flex;
@ -183,4 +208,12 @@ export default {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.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> </style>

View file

@ -1,54 +1,66 @@
<template> <template>
<label class="switch" :class="classObject"> <button
<input type="button"
:id="id" class="toggle-button"
v-model="value" :class="{ active: value }"
class="switch-input" role="switch"
:name="name" :aria-checked="value.toString()"
:disabled="disabled" @click="onClick"
type="checkbox" >
/> <span aria-hidden="true" :class="{ active: value }" />
<div class="switch-paddle" :for="name"> </button>
<span class="show-for-sr">on off</span>
</div>
</label>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
disabled: Boolean, value: { type: Boolean, default: false },
type: { type: String, default: '' },
size: { type: String, default: '' },
checked: Boolean,
name: { type: String, default: '' },
id: { type: String, default: '' },
}, },
data() { methods: {
return { onClick() {
value: null, this.$emit('input', !this.value);
};
},
computed: {
classObject() {
const { type, size, value } = this;
return {
[`is-${type}`]: type,
[`${size}`]: size,
checked: value,
};
}, },
}, },
watch: {
value(val) {
this.$emit('input', val);
},
},
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', (this.value = !!this.checked));
},
}; };
</script> </script>
<style lang="scss" scoped>
.toggle-button {
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
background-color: var(--s-200);
border-radius: var(--border-radius-large);
border: 2px solid transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 19px;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 34px;
&.active {
background-color: var(--w-500);
}
span {
--space-one-point-five: 1.5rem;
background-color: var(--white);
border-radius: 100%;
box-shadow: var(--toggle-button-box-shadow);
display: inline-block;
height: var(--space-one-point-five);
transform: translate(0, 0);
transition-duration: 200ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: var(--space-one-point-five);
&.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
}
}
</style>

View file

@ -13,7 +13,7 @@
:icon="icon" :icon="icon"
:icon-size="iconSize" :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> </button>
</template> </template>
<script> <script>

View file

@ -7,7 +7,7 @@
<select <select
v-model="action_name" v-model="action_name"
class="action__question" class="action__question"
:class="{ 'full-width': !inputType }" :class="{ 'full-width': !showActionInput }"
@change="resetAction()" @change="resetAction()"
> >
<option <option
@ -18,7 +18,7 @@
{{ attribute.label }} {{ attribute.label }}
</option> </option>
</select> </select>
<div class="filter__answer--wrap"> <div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType"> <div v-if="inputType">
<div <div
v-if="inputType === 'multi_select'" v-if="inputType === 'multi_select'"
@ -52,6 +52,11 @@
class="answer--text-input" class="answer--text-input"
placeholder="Enter url" placeholder="Enter url"
/> />
<automation-action-file-input
v-if="inputType === 'attachment'"
v-model="action_params"
:initial-file-name="initialFileName"
/>
</div> </div>
</div> </div>
<woot-button <woot-button
@ -61,6 +66,18 @@
@click="removeAction" @click="removeAction"
/> />
</div> </div>
<automation-action-team-message-input
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<textarea
v-if="inputType === 'textarea'"
v-model="action_params"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
/>
<p <p
v-if="v.action_params.$dirty && v.action_params.$error" v-if="v.action_params.$dirty && v.action_params.$error"
class="filter-error" class="filter-error"
@ -71,7 +88,13 @@
</template> </template>
<script> <script>
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
import AutomationActionFileInput from './AutomationFileInput.vue';
export default { export default {
components: {
AutomationActionTeamMessageInput,
AutomationActionFileInput,
},
props: { props: {
value: { value: {
type: Object, type: Object,
@ -89,6 +112,14 @@ export default {
type: Object, type: Object,
default: () => null, default: () => null,
}, },
showActionInput: {
type: Boolean,
default: true,
},
initialFileName: {
type: String,
default: '',
},
}, },
computed: { computed: {
action_name: { action_name: {
@ -167,6 +198,7 @@ export default {
.filter__answer--wrap { .filter__answer--wrap {
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
flex-grow: 1; flex-grow: 1;
max-width: 50%;
input { input {
margin-bottom: 0; margin-bottom: 0;
@ -207,4 +239,7 @@ export default {
.multiselect { .multiselect {
margin-bottom: var(--space-zero); margin-bottom: var(--space-zero);
} }
.action-message {
margin: var(--space-small) 0 0;
}
</style> </style>

View file

@ -0,0 +1,62 @@
<template>
<div>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedTeams"
track-by="id"
label="name"
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="teams"
:allow-empty="false"
@input="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
</div>
</template>
<script>
export default {
// The value types are dynamic, hence prop validation removed to work with our action schema
// eslint-disable-next-line vue/require-prop-types
props: ['teams', 'value'],
data() {
return {
selectedTeams: [],
message: '',
};
},
mounted() {
const { team_ids: teamIds } = this.value;
this.selectedTeams = teamIds;
this.message = this.value.message;
},
methods: {
updateValue() {
this.$emit('input', {
team_ids: this.selectedTeams.map(team => team.id),
message: this.message,
});
},
},
};
</script>
<style scoped>
.multiselect {
margin: var(--space-smaller) var(--space-zero);
}
textarea {
margin-bottom: var(--space-zero);
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<label class="input-wrapper" :class="uploadState">
<input
v-if="uploadState !== 'processing'"
type="file"
name="attachment"
:class="uploadState === 'processing' ? 'disabled' : ''"
@change="onChangeFile"
/>
<spinner v-if="uploadState === 'processing'" />
<fluent-icon v-if="uploadState === 'idle'" icon="file-upload" />
<fluent-icon
v-if="uploadState === 'uploaded'"
icon="checkmark-circle"
type="outline"
class="success-icon"
/>
<fluent-icon
v-if="uploadState === 'failed'"
icon="dismiss-circle"
type="outline"
class="error-icon"
/>
<p class="file-button">{{ label }}</p>
</label>
</template>
<script>
import Spinner from 'shared/components/Spinner';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Spinner,
},
mixins: [alertMixin],
props: {
value: {
type: Array,
default: () => [],
},
initialFileName: {
type: String,
default: '',
},
},
data() {
return {
uploadState: 'idle',
label: this.$t('AUTOMATION.ATTACHMENT.LABEL_IDLE'),
};
},
mounted() {
if (this.initialFileName) {
this.label = this.initialFileName;
this.uploadState = 'uploaded';
}
},
methods: {
async onChangeFile(event) {
this.uploadState = 'processing';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
try {
const file = event.target.files[0];
const formData = new FormData();
formData.append('attachment', file, file.name);
const id = await this.$store.dispatch(
'automations/uploadAttachment',
formData
);
this.$emit('input', [id]);
this.uploadState = 'uploaded';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
} catch (error) {
this.uploadState = 'failed';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');
this.showAlert(this.$t('AUTOMATION.ATTACHMENT.UPLOAD_ERROR'));
}
},
},
};
</script>
<style scoped>
input[type='file'] {
display: none;
}
.input-wrapper {
display: flex;
height: 39px;
background-color: var(--white);
border-radius: var(--border-radius-small);
border: 1px dashed var(--w-100);
padding: var(--space-smaller) var(--space-small);
align-items: center;
font-size: var(--font-size-mini);
cursor: pointer;
}
.success-icon {
margin-right: var(--space-small);
color: var(--g-500);
}
.error-icon {
margin-right: var(--space-small);
color: var(--r-500);
}
.processing {
cursor: not-allowed;
opacity: 0.9;
}
.file-button {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
</style>

View file

@ -0,0 +1,64 @@
<template>
<div class="dashboard-app--container">
<div
v-for="(configItem, index) in config"
:key="index"
class="dashboard-app--list"
>
<iframe
v-if="configItem.type === 'frame' && configItem.url"
:id="`dashboard-app--frame-${index}`"
:src="configItem.url"
@load="() => onIframeLoad(index)"
/>
</div>
</div>
</template>
<script>
export default {
props: {
config: {
type: Array,
default: () => [],
},
currentChat: {
type: Object,
default: () => ({}),
},
},
computed: {
dashboardAppContext() {
return {
conversation: this.currentChat,
contact: this.$store.getters['contacts/getContact'](this.contactId),
};
},
contactId() {
return this.currentChat?.meta?.sender?.id;
},
},
methods: {
onIframeLoad(index) {
const frameElement = document.getElementById(
`dashboard-app--frame-${index}`
);
const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
},
},
};
</script>
<style scoped>
.dashboard-app--container,
.dashboard-app--list,
.dashboard-app--list iframe {
height: 100%;
width: 100%;
}
.dashboard-app--list iframe {
border: 0;
}
</style>

View file

@ -215,6 +215,32 @@ export default {
this.$emit('input', { ...payload, query_operator: value }); 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: { methods: {
removeFilter() { removeFilter() {

View file

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

View file

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

View file

@ -0,0 +1,50 @@
<template>
<span>
{{ textToBeDisplayed }}
<button class="show-more--button" @click="toggleShowMore">
{{ buttonLabel }}
</button>
</span>
</template>
<script>
export default {
props: {
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
},
data() {
return {
showMore: false,
};
},
computed: {
textToBeDisplayed() {
if (this.showMore) {
return this.text;
}
return this.text.slice(0, this.limit) + '...';
},
buttonLabel() {
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
},
},
methods: {
toggleShowMore() {
this.showMore = !this.showMore;
},
},
};
</script>
<style scoped>
.show-more--button {
color: var(--w-500);
}
</style>

View file

@ -220,7 +220,7 @@ export default {
} }
.user-online-status--busy { .user-online-status--busy {
background: var(--y-700); background: var(--y-500);
} }
.user-online-status--offline { .user-online-status--offline {

View file

@ -1,16 +1,29 @@
<template> <template>
<div class="audio-wave-wrapper"> <div class="audio-wave-wrapper">
<div id="audio-wave"></div> <audio id="audio-wave" class="video-js vjs-fill vjs-default-skin" />
</div> </div>
</template> </template>
<script> <script>
import WaveSurfer from 'wavesurfer.js'; import 'video.js/dist/video-js.css';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js'; import 'videojs-record/dist/css/videojs.record.css';
import RecordRTC from 'recordrtc';
import videojs from 'video.js';
import inboxMixin from '../../../../shared/mixins/inboxMixin'; import inboxMixin from '../../../../shared/mixins/inboxMixin';
import alertMixin from '../../../../shared/mixins/alertMixin'; import alertMixin from '../../../../shared/mixins/alertMixin';
import Recorder from 'opus-recorder';
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-record/dist/videojs.record.js';
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns';
WaveSurfer.microphone = MicrophonePlugin; WaveSurfer.microphone = MicrophonePlugin;
export default { export default {
@ -18,104 +31,116 @@ export default {
mixins: [inboxMixin, alertMixin], mixins: [inboxMixin, alertMixin],
data() { data() {
return { return {
wavesurfer: false, player: false,
recorder: false, recordingDateStarted: new Date(0),
recordingInterval: false,
recordingDateStarted: new Date().getTime(),
timeDuration: '00:00',
initialTimeDuration: '00:00', initialTimeDuration: '00:00',
options: { recorderOptions: {
container: '#audio-wave', debug: true,
backend: 'WebAudio', controls: true,
interact: true, bigPlayButton: false,
cursorWidth: 1, fluid: false,
plugins: [ controlBar: {
WaveSurfer.microphone.create({ deviceButton: false,
bufferSize: 4096, fullscreenToggle: false,
numberOfInputChannels: 1, cameraButton: false,
numberOfOutputChannels: 1, volumePanel: false,
constraints: { },
video: false, plugins: {
audio: true, wavesurfer: {
}, backend: 'WebAudio',
}), waveColor: '#1f93ff',
], progressColor: 'rgb(25, 118, 204)',
}, cursorColor: 'rgba(43, 51, 63, 0.7)',
optionsRecorder: { backgroundColor: 'none',
type: 'audio', barWidth: 1,
mimeType: 'audio/wav', cursorWidth: 1,
disableLogs: true, hideScrollbar: true,
recorderType: RecordRTC.StereoAudioRecorder, plugins: [
sampleRate: 44100, WaveSurfer.microphone.create({
numberOfAudioChannels: 2, bufferSize: 4096,
checkForInactiveTracks: true, numberOfInputChannels: 1,
bufferSize: 4096, numberOfOutputChannels: 1,
constraints: {
video: false,
audio: true,
},
}),
],
},
record: {
audio: true,
video: false,
displayMilliseconds: false,
maxLength: 300,
audioEngine: 'opus-recorder',
audioWorkerURL: encoderWorker,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
},
},
}, },
}; };
}, },
computed: { computed: {
isRecording() { isRecording() {
if (this.recorder) { return this.player && this.player.record().isRecording();
return this.recorder.getState() === 'recording';
}
return false;
}, },
}, },
mounted() { mounted() {
this.wavesurfer = WaveSurfer.create(this.options); window.Recorder = Recorder;
this.wavesurfer.on('play', this.playingRecorder); this.fireProgressRecord(this.initialTimeDuration);
this.wavesurfer.on('pause', this.pausedRecorder); this.player = videojs('#audio-wave', this.recorderOptions, () => {
this.wavesurfer.microphone.on('deviceReady', this.startRecording); this.$nextTick(() => {
this.wavesurfer.microphone.on('deviceError', this.deviceError); this.player.record().getDevice();
this.wavesurfer.microphone.start(); });
this.fireStateRecorderTimerChanged(this.initialTimeDuration); });
this.player.on('deviceReady', this.deviceReady);
this.player.on('deviceError', this.deviceError);
this.player.on('startRecord', this.startRecord);
this.player.on('stopRecord', this.stopRecord);
this.player.on('progressRecord', this.progressRecord);
this.player.on('finishRecord', this.finishRecord);
this.player.on('playbackFinish', this.playbackFinish);
}, },
beforeDestroy() { beforeDestroy() {
if (this.recorder) { if (this.player) {
this.recorder.destroy(); this.player.dispose();
} }
if (this.wavesurfer) { if (window.Recorder) {
this.wavesurfer.destroy(); window.Recorder = undefined;
} }
}, },
methods: { methods: {
startRecording(stream) { deviceReady() {
this.recorder = RecordRTC(stream, this.optionsRecorder); this.player.record().start();
this.recorder.onStateChanged = this.onStateRecorderChanged; },
this.recorder.startRecording(); startRecord() {
this.fireStateRecorderChanged('recording');
},
stopRecord() {
this.fireStateRecorderChanged('stopped');
},
finishRecord() {
const file = new File(
[this.player.recordedData],
this.player.recordedData.name,
{ type: this.player.recordedData.type }
);
this.fireRecorderBlob(file);
},
progressRecord() {
this.fireProgressRecord(this.formatTimeProgress());
}, },
stopAudioRecording() { stopAudioRecording() {
if (this.isRecording) { this.player.record().stop();
this.recorder.stopRecording(() => {
this.wavesurfer.microphone.stopDevice();
this.wavesurfer.loadBlob(this.recorder.getBlob());
this.wavesurfer.stop();
this.fireRecorderBlob(this.getAudioFile());
});
}
}, },
getAudioFile() { deviceError() {
if (this.hasAudio()) { const deviceError = this.player.deviceErrorCode;
return new File([this.recorder.getBlob()], this.getAudioFileName(), { const deviceErrorName = deviceError?.name.toLowerCase();
type: 'audio/wav',
});
}
return false;
},
hasAudio() {
return !(this.isRecording || this.wavesurfer.isPlaying());
},
playingRecorder() {
this.fireStateRecorderChanged('playing');
},
pausedRecorder() {
this.fireStateRecorderChanged('paused');
},
deviceError(err) {
if ( if (
err?.name && deviceErrorName?.includes('notallowederror') ||
(err.name.toLowerCase().includes('notallowederror') || deviceErrorName?.includes('permissiondeniederror')
err.name.toLowerCase().includes('permissiondeniederror'))
) { ) {
this.showAlert( this.showAlert(
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION') this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION')
@ -127,56 +152,37 @@ export default {
); );
} }
}, },
onStateRecorderChanged(state) { formatTimeProgress() {
// recording stopped inactive destroyed return format(
switch (state) { addSeconds(
case 'recording': new Date(this.recordingDateStarted.getTimezoneOffset() * 1000 * 60),
this.timerDurationChanged(); this.player.record().getDuration()
break; ),
case 'stopped': 'mm:ss'
this.timerDurationChanged(); );
break;
default:
break;
}
this.fireStateRecorderChanged(state);
},
timerDurationChanged() {
if (this.isRecording) {
this.recordingInterval = setInterval(() => {
this.calculateTimeDuration(
(new Date().getTime() - this.recordingDateStarted) / 1000
);
this.fireStateRecorderTimerChanged(this.timeDuration);
}, 1000);
} else {
clearInterval(this.recordingInterval);
}
},
calculateTimeDuration(secs) {
let hr = Math.floor(secs / 3600);
let min = Math.floor((secs - hr * 3600) / 60);
let sec = Math.floor(secs - hr * 3600 - min * 60);
if (min < 10) {
min = '0' + min;
}
if (sec < 10) {
sec = '0' + sec;
}
if (hr <= 0) {
this.timeDuration = min + ':' + sec;
} else {
if (hr < 10) {
hr = '0' + hr;
}
this.timeDuration = hr + ':' + min + ':' + sec;
}
}, },
playPause() { playPause() {
this.wavesurfer.playPause(); if (this.player.wavesurfer().surfer.isPlaying()) {
this.fireStateRecorderChanged('paused');
} else {
this.fireStateRecorderChanged('playing');
}
this.player.wavesurfer().surfer.playPause();
},
play() {
this.fireStateRecorderChanged('playing');
this.player.wavesurfer().play();
},
pause() {
this.fireStateRecorderChanged('paused');
this.player.wavesurfer().pause();
},
playbackFinish() {
this.fireStateRecorderChanged('paused');
this.player.wavesurfer().pause();
}, },
fireRecorderBlob(blob) { fireRecorderBlob(blob) {
this.$emit('recorder-blob', { this.$emit('finish-record', {
name: blob.name, name: blob.name,
type: blob.type, type: blob.type,
size: blob.size, size: blob.size,
@ -186,29 +192,8 @@ export default {
fireStateRecorderChanged(state) { fireStateRecorderChanged(state) {
this.$emit('state-recorder-changed', state); this.$emit('state-recorder-changed', state);
}, },
fireStateRecorderTimerChanged(duration) { fireProgressRecord(duration) {
this.$emit('state-recorder-timer-changed', duration); this.$emit('state-recorder-progress-changed', duration);
},
getAudioFileName() {
const d = new Date();
return `audio-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${this.getRandomString()}.wav`;
},
getRandomString() {
if (
window.crypto &&
window.crypto.getRandomValues &&
navigator.userAgent.indexOf('Safari') === -1
) {
let a = window.crypto.getRandomValues(new Uint32Array(3));
let token = '';
for (let i = 0, l = a.length; i < l; i += 1) {
token += a[i].toString(36);
}
return token.toLowerCase();
}
return (Math.random() * new Date().getTime())
.toString(36)
.replace(/\./g, '');
}, },
}, },
}; };
@ -217,7 +202,9 @@ export default {
<style lang="scss"> <style lang="scss">
.audio-wave-wrapper { .audio-wave-wrapper {
min-height: 8rem; min-height: 8rem;
max-height: 12rem; height: 8rem;
overflow: hidden; }
.video-js .vjs-control-bar {
background-color: transparent;
} }
</style> </style>

View file

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

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