Merge branch 'develop' into self-hosted/external-enhancemd
This commit is contained in:
commit
b9a5b15de1
1132 changed files with 26100 additions and 4664 deletions
|
@ -100,6 +100,10 @@ jobs:
|
|||
- run:
|
||||
name: Rubocop
|
||||
command: bundle exec rubocop
|
||||
|
||||
# - run:
|
||||
# name: Brakeman
|
||||
# command: bundle exec brakeman
|
||||
|
||||
- run:
|
||||
name: eslint
|
||||
|
|
|
@ -5,4 +5,4 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
|
|||
# Do the set up required for chatwoot app
|
||||
WORKDIR /workspace
|
||||
COPY . /workspace
|
||||
RUN yarn && gem install bundler && bundle install
|
||||
RUN yarn && gem install bundler && bundle install
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# pre-build stage
|
||||
ARG VARIANT=3
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
|
||||
|
||||
ARG VARIANT=ubuntu-20.04
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
|
||||
|
||||
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
||||
ARG USER_UID=1000
|
||||
|
@ -11,23 +11,36 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
|||
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
||||
fi
|
||||
|
||||
# [Option] Install Node.js
|
||||
ARG INSTALL_NODE="true"
|
||||
ARG NODE_VERSION="lts/*"
|
||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
build-essential \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
gnupg2 \
|
||||
tar \
|
||||
tzdata \
|
||||
postgresql-client \
|
||||
libpq-dev \
|
||||
yarn \
|
||||
git \
|
||||
imagemagick \
|
||||
tmux \
|
||||
zsh
|
||||
zsh \
|
||||
git-flow \
|
||||
npm
|
||||
|
||||
# Install rbenv and ruby
|
||||
ARG RUBY_VERSION="3.0.4"
|
||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
||||
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
||||
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
|
||||
RUN git clone https://github.com/rbenv/ruby-build.git && \
|
||||
PREFIX=/usr/local ./ruby-build/install.sh
|
||||
|
||||
RUN rbenv install $RUBY_VERSION && \
|
||||
rbenv global $RUBY_VERSION && \
|
||||
rbenv versions
|
||||
|
||||
# Install overmind
|
||||
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
||||
|
@ -35,11 +48,25 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
|
|||
&& sudo mv overmind /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/overmind
|
||||
|
||||
|
||||
# Install gh
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
&& sudo apt update \
|
||||
&& sudo apt install gh
|
||||
|
||||
|
||||
# Do the set up required for chatwoot app
|
||||
WORKDIR /workspace
|
||||
COPY . /workspace
|
||||
RUN yarn
|
||||
|
||||
# set up ruby
|
||||
COPY Gemfile Gemfile.lock ./
|
||||
RUN gem install bundler && bundle install
|
||||
|
||||
# set up node js
|
||||
RUN npm install npm@latest -g && \
|
||||
npm install n -g && \
|
||||
n latest
|
||||
RUN npm install --global yarn
|
||||
RUN yarn
|
||||
|
|
|
@ -23,17 +23,18 @@
|
|||
// 5432 postgres
|
||||
// 6379 redis
|
||||
// 1025,8025 mailhog
|
||||
"forwardPorts": [8025],
|
||||
//your application may need to listen on all interfaces (0.0.0.0) not just localhost for it to be available externally. Defaults to []
|
||||
"appPort": [3000, 3035],
|
||||
"forwardPorts": [8025, 3000, 3035],
|
||||
|
||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Rails Server"
|
||||
},
|
||||
"3035": {
|
||||
"label": "Webpack Dev Server"
|
||||
},
|
||||
"8025": {
|
||||
"label": "Mailhog UI"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,3 +6,8 @@ sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.de
|
|||
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
||||
# uncomment the webpacker env variable
|
||||
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
|
||||
# fix the error with webpacker
|
||||
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
|
||||
|
||||
# codespaces make the ports public
|
||||
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
|
||||
|
|
|
@ -195,3 +195,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
|||
# If you want to use official mobile app,
|
||||
# the notifications would be relayed via a Chatwoot server
|
||||
ENABLE_PUSH_RELAY_SERVER=true
|
||||
|
||||
# Stripe API key
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
|
43
.eslintrc.js
43
.eslintrc.js
|
@ -1,5 +1,10 @@
|
|||
module.exports = {
|
||||
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
|
||||
extends: [
|
||||
'airbnb-base/legacy',
|
||||
'prettier',
|
||||
'plugin:vue/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
ecmaVersion: 2020,
|
||||
|
@ -19,18 +24,32 @@ module.exports = {
|
|||
'jsx-a11y/label-has-for': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'vue/max-attributes-per-line': ['error', {
|
||||
'singleline': 20,
|
||||
'multiline': {
|
||||
'max': 1,
|
||||
'allowFirstLine': false
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
singleline: 20,
|
||||
multiline: {
|
||||
max: 1,
|
||||
allowFirstLine: false,
|
||||
},
|
||||
},
|
||||
}],
|
||||
'vue/html-self-closing': 'off',
|
||||
"vue/no-v-html": 'off',
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always',
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error'
|
||||
'no-console': 'error',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
|
@ -41,12 +60,10 @@ module.exports = {
|
|||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
jasmine: true
|
||||
node: true,
|
||||
},
|
||||
globals: {
|
||||
__WEBPACK_ENV__: true,
|
||||
bus: true,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@ import Vuex from 'vuex';
|
|||
import VueI18n from 'vue-i18n';
|
||||
import Vuelidate from 'vuelidate';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||
|
||||
import WootUiKit from '../app/javascript/dashboard/components';
|
||||
import i18n from '../app/javascript/dashboard/i18n';
|
||||
|
@ -15,6 +16,7 @@ Vue.use(Vuelidate);
|
|||
Vue.use(WootUiKit);
|
||||
Vue.use(Vuex);
|
||||
Vue.component('multiselect', Multiselect);
|
||||
Vue.component('fluent-icon', FluentIcon);
|
||||
|
||||
const store = new Vuex.Store({});
|
||||
const i18nConfig = new VueI18n({
|
||||
|
|
22
Gemfile
22
Gemfile
|
@ -4,7 +4,7 @@ ruby '3.0.4'
|
|||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails'
|
||||
gem 'rails', '~>6.1'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
|
@ -78,7 +78,7 @@ gem 'wisper', '2.0.0'
|
|||
# TODO: bump up gem to 2.0
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.32.0'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty'
|
||||
|
@ -89,10 +89,6 @@ gem 'slack-ruby-client'
|
|||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow'
|
||||
|
||||
##--- gems for debugging and error reporting ---##
|
||||
# static analysis
|
||||
gem 'brakeman'
|
||||
|
||||
##-- apm and error monitoring ---#
|
||||
gem 'ddtrace'
|
||||
gem 'newrelic_rpm'
|
||||
|
@ -128,6 +124,12 @@ gem 'html2text'
|
|||
# to calculate working hours
|
||||
gem 'working_hours'
|
||||
|
||||
# full text search for articles
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe'
|
||||
|
||||
group :production, :staging do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
|
@ -156,12 +158,10 @@ group :test do
|
|||
end
|
||||
|
||||
group :development, :test do
|
||||
# TODO: is this needed ?
|
||||
# errors thrown by devise password gem
|
||||
gem 'flay'
|
||||
gem 'rspec'
|
||||
# for error thrown by devise password gem
|
||||
gem 'active_record_query_trace'
|
||||
##--- gems for debugging and error reporting ---##
|
||||
# static analysis
|
||||
gem 'brakeman'
|
||||
gem 'bundle-audit', require: false
|
||||
gem 'byebug', platform: :mri
|
||||
gem 'climate_control'
|
||||
|
|
436
Gemfile.lock
436
Gemfile.lock
|
@ -1,6 +1,6 @@
|
|||
GIT
|
||||
remote: https://github.com/chatwoot/devise-secure_password
|
||||
revision: de11e8765654b8242d42101ee9c8ffc8126f7975
|
||||
revision: d777b04f12652d576b1272b8f39857e3e0b3fc26
|
||||
specs:
|
||||
devise-secure_password (2.0.1)
|
||||
devise (>= 4.0.0, < 5.0.0)
|
||||
|
@ -9,63 +9,63 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
actioncable (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activestorage (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
actionmailbox (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activestorage (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
actionview (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
actionmailer (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
actionview (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.5.1)
|
||||
actionview (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
actionpack (6.1.6.1)
|
||||
actionview (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activestorage (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
actiontext (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activestorage (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
actionview (6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
activejob (6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
activerecord (6.1.5.1)
|
||||
activemodel (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
activerecord-import (1.3.0)
|
||||
activemodel (6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
activerecord (6.1.6.1)
|
||||
activemodel (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
activerecord-import (1.4.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
activestorage (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.5.1)
|
||||
activesupport (6.1.6.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
|
@ -91,20 +91,20 @@ GEM
|
|||
ast (2.4.2)
|
||||
attr_extras (6.2.5)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.556.0)
|
||||
aws-sdk-core (3.126.2)
|
||||
aws-partitions (1.605.0)
|
||||
aws-sdk-core (3.131.2)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.525.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-kms (1.54.0)
|
||||
aws-sdk-core (~> 3, >= 3.126.0)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.57.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.112.0)
|
||||
aws-sdk-core (~> 3, >= 3.126.0)
|
||||
aws-sdk-s3 (1.114.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.4.0)
|
||||
aws-sigv4 (1.5.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
|
@ -117,31 +117,31 @@ GEM
|
|||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
bcrypt (3.1.16)
|
||||
bcrypt (3.1.18)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.10.3)
|
||||
bootsnap (1.12.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (5.2.1)
|
||||
brakeman (5.2.3)
|
||||
browser (5.3.1)
|
||||
builder (3.2.4)
|
||||
bullet (7.0.1)
|
||||
bullet (7.0.2)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundle-audit (0.1.0)
|
||||
bundler-audit
|
||||
bundler-audit (0.9.0.1)
|
||||
bundler-audit (0.9.1)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
climate_control (1.0.1)
|
||||
climate_control (1.1.1)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.4)
|
||||
commonmarker (0.23.5)
|
||||
concurrent-ruby (1.1.10)
|
||||
connection_pool (2.2.5)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cypress-on-rails (1.12.1)
|
||||
cypress-on-rails (1.13.1)
|
||||
rack
|
||||
database_cleaner (2.0.1)
|
||||
database_cleaner-active_record (~> 2.0.0)
|
||||
|
@ -151,10 +151,12 @@ GEM
|
|||
database_cleaner-core (2.0.1)
|
||||
datetime_picker_rails (0.0.7)
|
||||
momentjs-rails (>= 2.8.1)
|
||||
ddtrace (0.54.2)
|
||||
debase-ruby_core_source (<= 0.10.14)
|
||||
ddtrace (1.2.0)
|
||||
debase-ruby_core_source (= 0.10.16)
|
||||
libddprof (~> 0.6.0.1.0)
|
||||
libddwaf (~> 1.3.0.2.0)
|
||||
msgpack
|
||||
debase-ruby_core_source (0.10.14)
|
||||
debase-ruby_core_source (0.10.16)
|
||||
declarative (0.0.20)
|
||||
devise (4.8.1)
|
||||
bcrypt (~> 3.0)
|
||||
|
@ -176,54 +178,72 @@ GEM
|
|||
dotenv-rails (2.7.6)
|
||||
dotenv (= 2.7.6)
|
||||
railties (>= 3.2)
|
||||
down (5.3.0)
|
||||
down (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
ecma-re-validator (0.4.0)
|
||||
regexp_parser (~> 2.2)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.10.0)
|
||||
erubis (2.7.0)
|
||||
et-orbi (1.2.7)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
facebook-messenger (2.0.1)
|
||||
httparty (~> 0.13, >= 0.13.7)
|
||||
rack (>= 1.4.5)
|
||||
factory_bot (6.2.0)
|
||||
factory_bot (6.2.1)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.2.0)
|
||||
factory_bot (~> 6.2.0)
|
||||
railties (>= 5.0.0)
|
||||
faker (2.19.0)
|
||||
i18n (>= 1.6, < 2)
|
||||
faraday (1.0.1)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
faker (2.21.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (1.10.0)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fcm (1.0.5)
|
||||
faraday (~> 1)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
ffi (1.15.5)
|
||||
flag_shih_tzu (0.3.23)
|
||||
flay (2.12.1)
|
||||
erubis (~> 2.7.0)
|
||||
path_expander (~> 1.0)
|
||||
ruby_parser (~> 3.0)
|
||||
sexp_processor (~> 4.0)
|
||||
foreman (0.87.2)
|
||||
fugit (1.5.3)
|
||||
et-orbi (~> 1, >= 1.2.7)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.3.4)
|
||||
google-protobuf (~> 3.12, >= 3.12.2)
|
||||
googleapis-common-protos (>= 1.3.9, < 2.0)
|
||||
googleapis-common-protos-types (>= 1.0.4, < 2.0)
|
||||
googleauth (~> 0.9)
|
||||
grpc (~> 1.25)
|
||||
geocoder (1.7.3)
|
||||
gapic-common (0.10.0)
|
||||
faraday (>= 1.9, < 3.a)
|
||||
faraday-retry (>= 1.0, < 3.a)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos (>= 1.3.12, < 2.a)
|
||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
geocoder (1.8.0)
|
||||
gli (2.21.0)
|
||||
globalid (1.0.0)
|
||||
activesupport (>= 5.0)
|
||||
google-apis-core (0.4.2)
|
||||
google-apis-core (0.7.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
|
@ -232,23 +252,27 @@ GEM
|
|||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.10.0)
|
||||
google-apis-core (>= 0.4, < 2.a)
|
||||
google-apis-storage_v1 (0.11.0)
|
||||
google-apis-core (>= 0.4, < 2.a)
|
||||
google-apis-iamcredentials_v1 (0.13.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-storage_v1 (0.18.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-dialogflow (1.2.0)
|
||||
google-cloud-core (~> 1.5)
|
||||
google-cloud-dialogflow-v2 (~> 0.1)
|
||||
google-cloud-dialogflow-v2 (0.6.4)
|
||||
gapic-common (~> 0.3)
|
||||
google-cloud-dialogflow (1.5.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
google-cloud-dialogflow-v2 (>= 0.15, < 2.a)
|
||||
google-cloud-dialogflow-v2 (0.17.0)
|
||||
gapic-common (>= 0.10, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.5.0)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
google-cloud-location (>= 0.0, < 2.a)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.2.0)
|
||||
google-cloud-storage (1.36.1)
|
||||
google-cloud-location (0.2.0)
|
||||
gapic-common (>= 0.10, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-storage (1.37.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
|
@ -256,32 +280,32 @@ GEM
|
|||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-protobuf (3.19.4)
|
||||
google-protobuf (3.19.4-x86_64-darwin)
|
||||
google-protobuf (3.19.4-x86_64-linux)
|
||||
google-protobuf (3.21.2)
|
||||
google-protobuf (3.21.2-x86_64-darwin)
|
||||
google-protobuf (3.21.2-x86_64-linux)
|
||||
googleapis-common-protos (1.3.12)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (~> 1.2)
|
||||
grpc (~> 1.27)
|
||||
googleapis-common-protos-types (1.3.0)
|
||||
googleapis-common-protos-types (1.3.2)
|
||||
google-protobuf (~> 3.14)
|
||||
googleauth (0.17.1)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
googleauth (1.2.0)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.15)
|
||||
groupdate (6.0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
groupdate (6.1.0)
|
||||
activesupport (>= 5.2)
|
||||
grpc (1.43.1)
|
||||
google-protobuf (~> 3.18)
|
||||
grpc (1.47.0)
|
||||
google-protobuf (~> 3.19)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.43.1-universal-darwin)
|
||||
google-protobuf (~> 3.18)
|
||||
grpc (1.47.0-x86_64-darwin)
|
||||
google-protobuf (~> 3.19)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.43.1-x86_64-linux)
|
||||
google-protobuf (~> 3.18)
|
||||
grpc (1.47.0-x86_64-linux)
|
||||
google-protobuf (~> 3.19)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
haikunator (1.1.1)
|
||||
hairtrigger (0.2.25)
|
||||
|
@ -295,13 +319,13 @@ GEM
|
|||
html2text (0.2.1)
|
||||
nokogiri (~> 1.6)
|
||||
http-accept (1.7.0)
|
||||
http-cookie (1.0.4)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
httparty (0.20.0)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.10.0)
|
||||
i18n (1.11.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
|
@ -309,20 +333,20 @@ GEM
|
|||
jbuilder (2.11.5)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.0)
|
||||
jquery-rails (4.4.0)
|
||||
jmespath (1.6.1)
|
||||
jquery-rails (4.5.0)
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.6.1)
|
||||
json (2.6.2)
|
||||
json_refs (0.1.7)
|
||||
hana
|
||||
json_schemer (0.2.19)
|
||||
json_schemer (0.2.21)
|
||||
ecma-re-validator (~> 0.3)
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
jwt (2.3.0)
|
||||
jwt (2.4.1)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
|
@ -335,21 +359,31 @@ GEM
|
|||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
koala (3.1.0)
|
||||
koala (3.2.0)
|
||||
addressable
|
||||
faraday (< 2)
|
||||
json (>= 1.8)
|
||||
rexml
|
||||
launchy (2.5.0)
|
||||
addressable (~> 2.7)
|
||||
letter_opener (1.7.0)
|
||||
launchy (~> 2.2)
|
||||
line-bot-api (1.23.0)
|
||||
liquid (5.1.0)
|
||||
letter_opener (1.8.1)
|
||||
launchy (>= 2.2, < 3)
|
||||
libddprof (0.6.0.1.0)
|
||||
libddprof (0.6.0.1.0-x86_64-linux)
|
||||
libddwaf (1.3.0.2.0)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.3.0.2.0-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.3.0.2.0-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.3.0.2.0-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.25.0)
|
||||
liquid (5.3.0)
|
||||
listen (3.7.1)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
loofah (2.17.0)
|
||||
loofah (2.18.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
|
@ -364,37 +398,39 @@ GEM
|
|||
mini_magick (4.11.0)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.8.0)
|
||||
minitest (5.15.0)
|
||||
mock_redis (0.30.0)
|
||||
minitest (5.16.2)
|
||||
mock_redis (0.32.0)
|
||||
ruby2_keywords
|
||||
momentjs-rails (2.29.1.1)
|
||||
railties (>= 3.1)
|
||||
msgpack (1.4.5)
|
||||
msgpack (1.5.3)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.1.1)
|
||||
multipart-post (2.2.3)
|
||||
net-http-persistent (4.0.1)
|
||||
connection_pool (~> 2.2)
|
||||
netrc (0.11.0)
|
||||
newrelic_rpm (8.7.0)
|
||||
newrelic_rpm (8.9.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.6)
|
||||
nokogiri (1.13.7)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.6-arm64-darwin)
|
||||
nokogiri (1.13.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.6-x86_64-darwin)
|
||||
nokogiri (1.13.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.6-x86_64-linux)
|
||||
nokogiri (1.13.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.8)
|
||||
oauth (0.5.10)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
parallel (1.21.0)
|
||||
parser (3.1.1.0)
|
||||
parallel (1.22.1)
|
||||
parser (3.1.2.0)
|
||||
ast (~> 2.4.1)
|
||||
path_expander (1.1.0)
|
||||
pg (1.3.2)
|
||||
pg (1.4.1)
|
||||
pg_search (2.3.6)
|
||||
activerecord (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
procore-sift (0.16.0)
|
||||
rails (> 4.2.0)
|
||||
pry (0.14.1)
|
||||
|
@ -402,46 +438,46 @@ GEM
|
|||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.6)
|
||||
public_suffix (4.0.7)
|
||||
puma (5.6.4)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.0)
|
||||
rack (2.2.3.1)
|
||||
rack-attack (6.6.0)
|
||||
rack (2.2.4)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.1.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-proxy (0.7.2)
|
||||
rack
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-timeout (0.6.0)
|
||||
rails (6.1.5.1)
|
||||
actioncable (= 6.1.5.1)
|
||||
actionmailbox (= 6.1.5.1)
|
||||
actionmailer (= 6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
actiontext (= 6.1.5.1)
|
||||
actionview (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activemodel (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activestorage (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
rack-test (2.0.2)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (6.1.6.1)
|
||||
actioncable (= 6.1.6.1)
|
||||
actionmailbox (= 6.1.6.1)
|
||||
actionmailer (= 6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
actiontext (= 6.1.6.1)
|
||||
actionview (= 6.1.6.1)
|
||||
activejob (= 6.1.6.1)
|
||||
activemodel (= 6.1.6.1)
|
||||
activerecord (= 6.1.6.1)
|
||||
activestorage (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.5.1)
|
||||
railties (= 6.1.6.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.2)
|
||||
rails-html-sanitizer (1.4.3)
|
||||
loofah (~> 2.3)
|
||||
railties (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
railties (6.1.6.1)
|
||||
actionpack (= 6.1.6.1)
|
||||
activesupport (= 6.1.6.1)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
|
@ -450,11 +486,11 @@ GEM
|
|||
rb-fsevent (0.11.1)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
redis (4.6.0)
|
||||
redis-namespace (1.8.1)
|
||||
redis (4.7.1)
|
||||
redis-namespace (1.8.2)
|
||||
redis (>= 3.0.4)
|
||||
regexp_parser (2.2.1)
|
||||
representable (3.1.1)
|
||||
regexp_parser (2.5.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
|
@ -468,16 +504,12 @@ GEM
|
|||
netrc (~> 0.8)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rspec (3.11.0)
|
||||
rspec-core (~> 3.11.0)
|
||||
rspec-expectations (~> 3.11.0)
|
||||
rspec-mocks (~> 3.11.0)
|
||||
rspec-core (3.11.0)
|
||||
rspec-support (~> 3.11.0)
|
||||
rspec-expectations (3.11.0)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.11.0)
|
||||
rspec-mocks (3.11.0)
|
||||
rspec-mocks (3.11.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.11.0)
|
||||
rspec-rails (5.0.3)
|
||||
|
@ -489,26 +521,27 @@ GEM
|
|||
rspec-mocks (~> 3.10)
|
||||
rspec-support (~> 3.10)
|
||||
rspec-support (3.11.0)
|
||||
rubocop (1.25.1)
|
||||
rubocop (1.31.2)
|
||||
json (~> 2.3)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.1.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml
|
||||
rubocop-ast (>= 1.15.1, < 2.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.18.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.16.0)
|
||||
rubocop-ast (1.19.1)
|
||||
parser (>= 3.1.1.0)
|
||||
rubocop-performance (1.13.2)
|
||||
rubocop-performance (1.14.2)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
rubocop-rails (2.13.2)
|
||||
rubocop-rails (2.15.2)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-rspec (2.8.0)
|
||||
rubocop (~> 1.19)
|
||||
rubocop-rspec (2.12.1)
|
||||
rubocop (~> 1.31)
|
||||
ruby-progressbar (1.11.0)
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
|
@ -516,7 +549,7 @@ GEM
|
|||
ruby2ruby (2.4.4)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_parser (3.18.1)
|
||||
ruby_parser (3.19.1)
|
||||
sexp_processor (~> 4.16)
|
||||
sassc (2.4.0)
|
||||
ffi (~> 1.9)
|
||||
|
@ -526,37 +559,37 @@ GEM
|
|||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
scout_apm (5.1.1)
|
||||
scout_apm (5.2.0)
|
||||
parser
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.3.0)
|
||||
sentry-rails (5.3.1)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby-core (~> 5.3.0)
|
||||
sentry-ruby (5.3.0)
|
||||
sentry-ruby-core (~> 5.3.1)
|
||||
sentry-ruby (5.3.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-ruby-core (= 5.3.0)
|
||||
sentry-ruby-core (5.3.0)
|
||||
sentry-ruby-core (= 5.3.1)
|
||||
sentry-ruby-core (5.3.1)
|
||||
concurrent-ruby
|
||||
sentry-sidekiq (5.3.0)
|
||||
sentry-ruby-core (~> 5.3.0)
|
||||
sentry-sidekiq (5.3.1)
|
||||
sentry-ruby-core (~> 5.3.1)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.16.0)
|
||||
sexp_processor (4.16.1)
|
||||
shoulda-matchers (5.1.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (6.4.1)
|
||||
sidekiq (6.4.2)
|
||||
connection_pool (>= 2.2.2)
|
||||
rack (~> 2.0)
|
||||
redis (>= 4.2.0)
|
||||
sidekiq-cron (1.4.0)
|
||||
sidekiq-cron (1.6.0)
|
||||
fugit (~> 1)
|
||||
sidekiq (>= 4.2.1)
|
||||
signet (0.16.0)
|
||||
signet (0.17.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simplecov (0.17.1)
|
||||
|
@ -574,7 +607,7 @@ GEM
|
|||
spring-watcher-listen (2.0.1)
|
||||
listen (>= 2.7, < 4.0)
|
||||
spring (>= 1.2, < 3.0)
|
||||
sprockets (4.0.3)
|
||||
sprockets (4.1.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.4.2)
|
||||
|
@ -583,31 +616,32 @@ GEM
|
|||
sprockets (>= 3.0.0)
|
||||
squasher (0.6.2)
|
||||
statsd-ruby (1.5.0)
|
||||
telephone_number (1.4.13)
|
||||
stripe (6.5.0)
|
||||
telephone_number (1.4.16)
|
||||
thor (1.2.1)
|
||||
tilt (2.0.10)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.32.0)
|
||||
faraday (~> 1.0.0)
|
||||
twilio-ruby (5.68.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, <= 2.5)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
twitty (0.1.4)
|
||||
oauth
|
||||
tzinfo (2.0.4)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2021.5)
|
||||
tzinfo-data (1.2022.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
uglifier (4.2.0)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8)
|
||||
unicode-display_width (2.1.0)
|
||||
uniform_notifier (1.14.2)
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.2.0)
|
||||
uniform_notifier (1.16.0)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (4.0.3)
|
||||
activemodel (>= 3.2)
|
||||
|
@ -639,7 +673,7 @@ GEM
|
|||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.5.4)
|
||||
zeitwerk (2.6.0)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
|
@ -681,7 +715,6 @@ DEPENDENCIES
|
|||
faker
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
flay
|
||||
foreman
|
||||
geocoder
|
||||
google-cloud-dialogflow
|
||||
|
@ -706,6 +739,7 @@ DEPENDENCIES
|
|||
mock_redis
|
||||
newrelic_rpm
|
||||
pg
|
||||
pg_search
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma
|
||||
|
@ -713,12 +747,11 @@ DEPENDENCIES
|
|||
rack-attack
|
||||
rack-cors
|
||||
rack-timeout
|
||||
rails
|
||||
rails (~> 6.1)
|
||||
redis
|
||||
redis-namespace
|
||||
responders
|
||||
rest-client
|
||||
rspec
|
||||
rspec-rails (~> 5.0.0)
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
@ -737,9 +770,10 @@ DEPENDENCIES
|
|||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stripe
|
||||
telephone_number
|
||||
time_diff
|
||||
twilio-ruby (~> 5.32.0)
|
||||
twilio-ruby (~> 5.66)
|
||||
twitty
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
@ -755,4 +789,4 @@ RUBY VERSION
|
|||
ruby 3.0.4p208
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.10
|
||||
2.3.14
|
||||
|
|
|
@ -26,6 +26,7 @@ ___
|
|||
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
|
||||
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
|
||||
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
|
||||
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
|
||||
</p>
|
||||
|
||||
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
|
||||
|
|
1
VERSION_CW
Normal file
1
VERSION_CW
Normal file
|
@ -0,0 +1 @@
|
|||
2.6.0
|
1
VERSION_CWCTL
Normal file
1
VERSION_CWCTL
Normal file
|
@ -0,0 +1 @@
|
|||
2.1.0
|
|
@ -1,7 +1,16 @@
|
|||
# retain_original_contact_name: false / true
|
||||
# In case of setUser we want to update the name of the identified contact,
|
||||
# which is the default behaviour
|
||||
#
|
||||
# But, In case of contact merge during prechat form contact update.
|
||||
# We don't want to update the name of the identified original contact.
|
||||
|
||||
class ContactIdentifyAction
|
||||
pattr_initialize [:contact!, :params!]
|
||||
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
||||
|
||||
def perform
|
||||
@attributes_to_update = [:identifier, :name, :email, :phone_number]
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
merge_if_existing_identified_contact
|
||||
merge_if_existing_email_contact
|
||||
|
@ -18,49 +27,89 @@ class ContactIdentifyAction
|
|||
end
|
||||
|
||||
def merge_if_existing_identified_contact
|
||||
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
|
||||
return unless merge_contacts?(existing_identified_contact, :identifier)
|
||||
|
||||
process_contact_merge(existing_identified_contact)
|
||||
end
|
||||
|
||||
def merge_if_existing_email_contact
|
||||
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
|
||||
return unless merge_contacts?(existing_email_contact, :email)
|
||||
|
||||
process_contact_merge(existing_email_contact)
|
||||
end
|
||||
|
||||
def merge_if_existing_phone_number_contact
|
||||
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact)
|
||||
return unless merge_contacts?(existing_phone_number_contact, :phone_number)
|
||||
return unless mergable_phone_contact?
|
||||
|
||||
process_contact_merge(existing_phone_number_contact)
|
||||
end
|
||||
|
||||
def process_contact_merge(mergee_contact)
|
||||
@contact = merge_contact(mergee_contact, @contact)
|
||||
@attributes_to_update.delete(:name) if retain_original_contact_name
|
||||
end
|
||||
|
||||
def existing_identified_contact
|
||||
return if params[:identifier].blank?
|
||||
|
||||
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier])
|
||||
@existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier])
|
||||
end
|
||||
|
||||
def existing_email_contact
|
||||
return if params[:email].blank?
|
||||
|
||||
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
|
||||
@existing_email_contact ||= account.contacts.find_by(email: params[:email])
|
||||
end
|
||||
|
||||
def existing_phone_number_contact
|
||||
return if params[:phone_number].blank?
|
||||
|
||||
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number])
|
||||
@existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
|
||||
end
|
||||
|
||||
def merge_contacts?(existing_contact, _contact)
|
||||
existing_contact && existing_contact.id != @contact.id
|
||||
def merge_contacts?(existing_contact, key)
|
||||
return if existing_contact.blank?
|
||||
|
||||
return true if params[:identifier].blank?
|
||||
|
||||
# we want to prevent merging contacts with different identifiers
|
||||
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
|
||||
# we will remove attribute from update list
|
||||
@attributes_to_update.delete(key)
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# case: contact 1: email: 1@test.com, phone: 123456789
|
||||
# params: email: 2@test.com, phone: 123456789
|
||||
# we don't want to overwrite 1@test.com since email parameter takes higer priority
|
||||
def mergable_phone_contact?
|
||||
return true if params[:email].blank?
|
||||
|
||||
if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email]
|
||||
@attributes_to_update.delete(:phone_number)
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def update_contact
|
||||
@contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v|
|
||||
v.blank?
|
||||
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
|
||||
v.blank?
|
||||
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }))
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||
end
|
||||
|
||||
def merge_contact(base_contact, merge_contact)
|
||||
return base_contact if base_contact.id == merge_contact.id
|
||||
|
||||
ContactMergeAction.new(
|
||||
account: account,
|
||||
base_contact: base_contact,
|
||||
|
@ -69,14 +118,14 @@ class ContactIdentifyAction
|
|||
end
|
||||
|
||||
def custom_attributes
|
||||
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes
|
||||
return @contact.custom_attributes if params[:custom_attributes].blank?
|
||||
|
||||
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
if params[:additional_attributes]
|
||||
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
|
||||
else
|
||||
@contact.additional_attributes
|
||||
end
|
||||
return @contact.additional_attributes if params[:additional_attributes].blank?
|
||||
|
||||
(@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,12 +9,15 @@ class Campaigns::CampaignConversationBuilder
|
|||
@contact_inbox.lock!
|
||||
|
||||
# We won't send campaigns if a conversation is already present
|
||||
return if @contact_inbox.reload.conversations.present?
|
||||
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||
end
|
||||
@conversation
|
||||
rescue StandardError => e
|
||||
Rails.logger.info(e.message)
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||
end
|
||||
ensure_contact_avatar
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
|
||||
@inbox.channel.authorization_error!
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
|
|
|
@ -73,6 +73,10 @@ class Messages::MessageBuilder
|
|||
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
|
||||
end
|
||||
|
||||
def template_params
|
||||
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
|
||||
end
|
||||
|
||||
def message_sender
|
||||
return if @params[:sender_type] != 'AgentBot'
|
||||
|
||||
|
@ -91,6 +95,6 @@ class Messages::MessageBuilder
|
|||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -54,6 +54,9 @@ class Messages::Messenger::MessageBuilder
|
|||
def fetch_story_link(attachment)
|
||||
message = attachment.message
|
||||
result = get_story_object_from_source_id(message.source_id)
|
||||
|
||||
return if result.blank?
|
||||
|
||||
story_id = result['story']['mention']['id']
|
||||
story_sender = result['from']['username']
|
||||
message.content_attributes[:story_sender] = story_sender
|
||||
|
@ -68,6 +71,11 @@ class Messages::Messenger::MessageBuilder
|
|||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
{}
|
||||
|
|
|
@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :description, :outgoing_url)
|
||||
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
|
||||
end
|
||||
end
|
||||
|
|
52
app/controllers/api/v1/accounts/articles_controller.rb
Normal file
52
app/controllers/api/v1/accounts/articles_controller.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_article, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@articles = @portal.articles
|
||||
@articles = @articles.search(list_params) if params[:payload].present?
|
||||
end
|
||||
|
||||
def create
|
||||
@article = @portal.articles.create!(article_params)
|
||||
@article.associate_root_article(article_params[:associated_article_id])
|
||||
@article.draft!
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@article.update!(article_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@article.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_article
|
||||
@article = @portal.articles.find(params[:id])
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status
|
||||
)
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:payload).permit(
|
||||
:category_slug, :locale, :query, :page
|
||||
)
|
||||
end
|
||||
end
|
|
@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
def create
|
||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions]
|
||||
@automation_rule.conditions = params[:conditions]
|
||||
|
||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||
|
||||
|
@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.save!
|
||||
automation_rule_update
|
||||
process_attachments
|
||||
|
||||
rescue StandardError => e
|
||||
|
@ -67,10 +66,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||
|
||||
private
|
||||
|
||||
def automation_rule_update
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||
@automation_rule.save!
|
||||
end
|
||||
|
||||
def automation_rules_permit
|
||||
params.permit(
|
||||
:name, :description, :event_name, :account_id, :active,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
|
|
@ -1,17 +1,28 @@
|
|||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@categories = @portal.categories
|
||||
@categories = @portal.categories.search(params)
|
||||
end
|
||||
|
||||
def create
|
||||
@category = @portal.categories.create!(category_params)
|
||||
@category.related_categories << related_categories_records
|
||||
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
|
||||
|
||||
@category.save!
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@category.update!(category_params)
|
||||
@category.related_categories = related_categories_records if related_categories_records.any?
|
||||
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
|
||||
|
||||
@category.save!
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -29,9 +40,13 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
|||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def related_categories_records
|
||||
@portal.categories.where(id: params[:category][:related_category_ids])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(
|
||||
:name, :description, :position
|
||||
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -27,6 +27,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||
end
|
||||
|
||||
def phone_number
|
||||
return if permitted_params[:phone_number].blank?
|
||||
|
||||
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
|
||||
end
|
||||
|
||||
|
@ -38,6 +40,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||
@twilio_channel = Current.account.twilio_sms.create!(
|
||||
account_sid: permitted_params[:account_sid],
|
||||
auth_token: permitted_params[:auth_token],
|
||||
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
|
||||
phone_number: phone_number,
|
||||
medium: medium
|
||||
)
|
||||
|
@ -49,7 +52,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||
|
||||
def permitted_params
|
||||
params.require(:twilio_channel).permit(
|
||||
:account_id, :phone_number, :account_sid, :auth_token, :name, :medium
|
||||
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
||||
|
||||
def index
|
||||
|
@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(contact_params)
|
||||
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
||||
@contact.save!
|
||||
@contact_inbox = build_contact_inbox
|
||||
process_avatar
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@contact.assign_attributes(contact_update_params)
|
||||
@contact.save!
|
||||
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
head :ok
|
||||
end
|
||||
|
||||
def avatar
|
||||
@contact.avatar.purge if @contact.avatar.attached?
|
||||
@contact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# TODO: Move this to a finder class
|
||||
|
@ -131,19 +138,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
|
||||
end
|
||||
|
||||
def contact_params
|
||||
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
|
||||
def permitted_params
|
||||
params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
|
||||
end
|
||||
|
||||
def contact_custom_attributes
|
||||
return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes]
|
||||
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
|
||||
|
||||
@contact.custom_attributes
|
||||
end
|
||||
|
||||
def contact_update_params
|
||||
# we want the merged custom attributes not the original one
|
||||
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
|
||||
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
|
||||
end
|
||||
|
||||
def set_include_contact_inboxes
|
||||
|
@ -158,6 +165,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||
end
|
||||
|
||||
def process_avatar
|
||||
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present?
|
||||
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
|
||||
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
|
||||
hash = Digest::MD5.hexdigest(params[:email])
|
||||
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
|
||||
::ContactAvatarJob.perform_later(@contact, gravatar_url)
|
||||
end
|
||||
end
|
||||
|
||||
def render_error(error, error_status)
|
||||
render json: error, status: error_status
|
||||
end
|
||||
|
|
|
@ -42,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def update
|
||||
@inbox.update(permitted_params.except(:channel))
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
update_inbox_working_hours
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
if @inbox.inbox_type == 'Email'
|
||||
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!
|
||||
end
|
||||
|
||||
|
@ -58,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@agent_bot = @inbox.agent_bot
|
||||
end
|
||||
|
@ -89,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def inbox_name(channel)
|
||||
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
|
||||
|
||||
permitted_params[:name]
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
||||
|
||||
|
@ -109,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
@inbox.channel.save!
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
params.permit(
|
||||
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
*inbox_attributes,
|
||||
channel: [:type, *channel_attributes]
|
||||
)
|
||||
end
|
||||
|
@ -129,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
def account_channels_method
|
||||
{
|
||||
'web_widget' => Current.account.web_widgets,
|
||||
'api' => Current.account.api_channels,
|
||||
'email' => Current.account.email_channels,
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||
|
@ -148,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
||||
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
|
51
app/controllers/api/v1/accounts/macros_controller.rb
Normal file
51
app/controllers/api/v1/accounts/macros_controller.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_macro, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@macros = Macro.with_visibility(current_user, params)
|
||||
end
|
||||
|
||||
def create
|
||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.actions = params[:actions]
|
||||
|
||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||
|
||||
@macro.save!
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def destroy
|
||||
@macro.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.save!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(
|
||||
:name, :account_id, :visibility,
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
def macros_with_user
|
||||
permitted_params.merge(updated_by_id: current_user.id)
|
||||
end
|
||||
|
||||
def fetch_macro
|
||||
@macro = Current.account.macros.find_by(id: params[:id])
|
||||
end
|
||||
end
|
|
@ -1,18 +1,36 @@
|
|||
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
include ::FileTypeHelper
|
||||
|
||||
before_action :fetch_portal, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@portals = Current.account.portals
|
||||
end
|
||||
|
||||
def add_members
|
||||
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
|
||||
@portal.members << agents
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@portal = Current.account.portals.create!(portal_params)
|
||||
@portal = Current.account.portals.build(portal_params)
|
||||
render json: { error: @portal.errors.messages }, status: :unprocessable_entity and return unless @portal.valid?
|
||||
|
||||
@portal.save!
|
||||
process_attached_logo
|
||||
end
|
||||
|
||||
def update
|
||||
@portal.update!(portal_params)
|
||||
ActiveRecord::Base.transaction do
|
||||
@portal.update!(portal_params) if params[:portal].present?
|
||||
process_attached_logo
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -20,6 +38,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||
head :ok
|
||||
end
|
||||
|
||||
def archive
|
||||
@portal.update(archive: true)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def process_attached_logo
|
||||
@portal.logo.attach(params[:logo])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_portal
|
||||
|
@ -32,7 +59,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, config: { allowed_locales: [] }
|
||||
)
|
||||
end
|
||||
|
||||
def portal_member_params
|
||||
params.require(:portal).permit(:account_id, member_ids: [])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -44,38 +44,6 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||
}
|
||||
end
|
||||
|
||||
def update_contact(email)
|
||||
contact_with_email = @current_account.contacts.find_by(email: email)
|
||||
if contact_with_email
|
||||
@contact = ::ContactMergeAction.new(
|
||||
account: @current_account,
|
||||
base_contact: contact_with_email,
|
||||
mergee_contact: @contact
|
||||
).perform
|
||||
else
|
||||
@contact.update!(email: email)
|
||||
update_contact_name
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact_phone_number(phone_number)
|
||||
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
|
||||
if contact_with_phone_number
|
||||
@contact = ::ContactMergeAction.new(
|
||||
account: @current_account,
|
||||
base_contact: contact_with_phone_number,
|
||||
mergee_contact: @contact
|
||||
).perform
|
||||
else
|
||||
@contact.update!(phone_number: phone_number)
|
||||
update_contact_name
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact_name
|
||||
@contact.update!(name: contact_name) if contact_name.present?
|
||||
end
|
||||
|
||||
def contact_email
|
||||
permitted_params.dig(:contact, :email)&.downcase
|
||||
end
|
||||
|
|
|
@ -1,14 +1,27 @@
|
|||
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
before_action :process_hmac, only: [:update]
|
||||
include WidgetHelper
|
||||
|
||||
before_action :validate_hmac, only: [:set_user]
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
contact_identify_action = ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: permitted_params.to_h.deep_symbolize_keys
|
||||
)
|
||||
@contact = contact_identify_action.perform
|
||||
identify_contact(@contact)
|
||||
end
|
||||
|
||||
def set_user
|
||||
contact = nil
|
||||
|
||||
if a_different_contact?
|
||||
@contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget)
|
||||
contact = @contact_inbox.contact
|
||||
else
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
# TODO : clean up this with proper routes delete contacts/custom_attributes
|
||||
|
@ -20,12 +33,23 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||
|
||||
private
|
||||
|
||||
def process_hmac
|
||||
def identify_contact(contact)
|
||||
contact_identify_action = ContactIdentifyAction.new(
|
||||
contact: contact,
|
||||
params: permitted_params.to_h.deep_symbolize_keys,
|
||||
discard_invalid_attrs: true
|
||||
)
|
||||
@contact = contact_identify_action.perform
|
||||
end
|
||||
|
||||
def a_different_contact?
|
||||
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
|
||||
end
|
||||
|
||||
def validate_hmac
|
||||
return unless should_verify_hmac?
|
||||
|
||||
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
|
||||
|
||||
@contact_inbox.update(hmac_verified: true)
|
||||
end
|
||||
|
||||
def should_verify_hmac?
|
||||
|
|
|
@ -14,8 +14,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||
end
|
||||
|
||||
def process_update_contact
|
||||
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
|
||||
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
|
||||
@contact = ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
||||
retain_original_contact_name: true
|
||||
).perform
|
||||
end
|
||||
|
||||
def update_last_seen
|
||||
|
|
|
@ -15,7 +15,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||
def update
|
||||
if @message.content_type == 'input_email'
|
||||
@message.update!(submitted_email: contact_email)
|
||||
update_contact(contact_email)
|
||||
ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email }
|
||||
).perform
|
||||
else
|
||||
@message.update!(message_update_params[:message])
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
class ApplicationController < ActionController::Base
|
||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||
include RequestExceptionHandler
|
||||
include Pundit
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
|
|||
Current.user = @user
|
||||
end
|
||||
|
||||
def current_subscription
|
||||
@subscription ||= Current.account.subscription
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
{
|
||||
user: Current.user,
|
||||
|
|
20
app/controllers/concerns/meta_token_verify_concern.rb
Normal file
20
app/controllers/concerns/meta_token_verify_concern.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
|
||||
# This concern handles the token verification step.
|
||||
|
||||
module MetaTokenVerifyConcern
|
||||
def verify
|
||||
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
|
||||
if valid_token?(params['hub.verify_token'])
|
||||
Rails.logger.info("#{service.capitalize} webhook verified")
|
||||
render json: params['hub.challenge']
|
||||
else
|
||||
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_token?(_token)
|
||||
raise 'Overwrite this method your controller'
|
||||
end
|
||||
end
|
|
@ -14,8 +14,7 @@ class DashboardController < ActionController::Base
|
|||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get(
|
||||
'LOGO',
|
||||
'LOGO_THUMBNAIL',
|
||||
'LOGO', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL',
|
||||
'TERMS_URL',
|
||||
|
@ -30,7 +29,8 @@ class DashboardController < ActionController::Base
|
|||
'DIRECT_UPLOADS_ENABLED',
|
||||
'HCAPTCHA_SITE_KEY',
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE'
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
|
@ -48,7 +48,8 @@ class DashboardController < ActionController::Base
|
|||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: 'v13.0'
|
||||
FACEBOOK_API_VERSION: 'v14.0',
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,7 +14,7 @@ class Platform::Api::V1::UsersController < PlatformController
|
|||
|
||||
def login
|
||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
|
||||
render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
|
25
app/controllers/public/api/v1/portals/articles_controller.rb
Normal file
25
app/controllers/public/api/v1/portals/articles_controller.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
class Public::Api::V1::Portals::ArticlesController < ApplicationController
|
||||
before_action :set_portal
|
||||
before_action :set_article, only: [:show]
|
||||
|
||||
def index
|
||||
@articles = @portal.articles
|
||||
@articles = @articles.search(list_params) if params[:payload].present?
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find(params[:id])
|
||||
end
|
||||
|
||||
def set_portal
|
||||
@portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false)
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:payload).permit(:query)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,20 @@
|
|||
class Public::Api::V1::Portals::CategoriesController < PublicController
|
||||
before_action :set_portal
|
||||
before_action :set_category, only: [:show]
|
||||
|
||||
def index
|
||||
@categories = @portal.categories
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def set_category
|
||||
@category = @portal.categories.find_by!(slug: params[:slug])
|
||||
end
|
||||
|
||||
def set_portal
|
||||
@portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false)
|
||||
end
|
||||
end
|
11
app/controllers/public/api/v1/portals_controller.rb
Normal file
11
app/controllers/public/api/v1/portals_controller.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class Public::Api::V1::PortalsController < PublicController
|
||||
before_action :set_portal
|
||||
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def set_portal
|
||||
@portal = ::Portal.find_by!(slug: params[:slug], archived: false)
|
||||
end
|
||||
end
|
|
@ -7,7 +7,7 @@ class Twilio::CallbackController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
def permitted_params # rubocop:disable Metrics/MethodLength
|
||||
params.permit(
|
||||
:ApiVersion,
|
||||
:SmsSid,
|
||||
|
@ -25,7 +25,8 @@ class Twilio::CallbackController < ApplicationController
|
|||
:ToCountry,
|
||||
:FromState,
|
||||
:MediaUrl0,
|
||||
:MediaContentType0
|
||||
:MediaContentType0,
|
||||
:MessagingServiceSid
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,15 +1,5 @@
|
|||
class Webhooks::InstagramController < ApplicationController
|
||||
skip_before_action :authenticate_user!, raise: false
|
||||
skip_before_action :set_current_user
|
||||
|
||||
def verify
|
||||
if valid_instagram_token?(params['hub.verify_token'])
|
||||
Rails.logger.info('Instagram webhook verified')
|
||||
render json: params['hub.challenge']
|
||||
else
|
||||
render json: { error: 'Error; wrong verify token', status: 403 }
|
||||
end
|
||||
end
|
||||
class Webhooks::InstagramController < ActionController::API
|
||||
include MetaTokenVerifyConcern
|
||||
|
||||
def events
|
||||
Rails.logger.info('Instagram webhook received events')
|
||||
|
@ -24,7 +14,7 @@ class Webhooks::InstagramController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def valid_instagram_token?(token)
|
||||
def valid_token?(token)
|
||||
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
class Webhooks::WhatsappController < ActionController::API
|
||||
include MetaTokenVerifyConcern
|
||||
|
||||
def process_payload
|
||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_token?(token)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
||||
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
|
||||
class WidgetsController < ActionController::Base
|
||||
include WidgetHelper
|
||||
|
||||
before_action :set_global_config
|
||||
before_action :set_web_widget
|
||||
before_action :set_token
|
||||
|
@ -40,11 +42,8 @@ class WidgetsController < ActionController::Base
|
|||
def build_contact
|
||||
return if @contact.present?
|
||||
|
||||
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
|
||||
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
|
||||
@contact = @contact_inbox.contact
|
||||
|
||||
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
||||
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
|
|
|
@ -2,6 +2,11 @@ class ConversationFinder
|
|||
attr_reader :current_user, :current_account, :params
|
||||
|
||||
DEFAULT_STATUS = 'open'.freeze
|
||||
SORT_OPTIONS = {
|
||||
latest: 'latest',
|
||||
sort_on_created_at: 'sort_on_created_at',
|
||||
last_user_message_at: 'last_user_message_at'
|
||||
}.with_indifferent_access
|
||||
|
||||
# assumptions
|
||||
# inbox_id if not given, take from all conversations, else specific to inbox
|
||||
|
@ -133,10 +138,7 @@ class ConversationFinder
|
|||
@conversations = @conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||
)
|
||||
if params[:conversation_type] == 'mention'
|
||||
@conversations.page(current_page)
|
||||
else
|
||||
@conversations.latest.page(current_page)
|
||||
end
|
||||
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
|
||||
@conversations.send(sort_by).page(current_page)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
module Api::V1::InboxesHelper
|
||||
def inbox_name(channel)
|
||||
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
|
||||
|
||||
permitted_params[:name]
|
||||
end
|
||||
|
||||
def validate_email_channel(attributes)
|
||||
channel_data = permitted_params(attributes)[:channel]
|
||||
|
||||
|
@ -19,8 +25,7 @@ module Api::V1::InboxesHelper
|
|||
enable_ssl: channel_data[:imap_enable_ssl] }
|
||||
end
|
||||
|
||||
Mail.connection do # rubocop:disable:block
|
||||
end
|
||||
check_imap_connection(channel_data)
|
||||
end
|
||||
|
||||
def validate_smtp(channel_data)
|
||||
|
@ -32,10 +37,29 @@ module Api::V1::InboxesHelper
|
|||
check_smtp_connection(channel_data, smtp)
|
||||
end
|
||||
|
||||
def check_imap_connection(channel_data)
|
||||
Mail.connection {} # rubocop:disable:block
|
||||
rescue SocketError => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
|
||||
rescue Net::IMAP::NoResponseError => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.no_response_error')
|
||||
rescue Errno::EHOSTUNREACH => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error')
|
||||
rescue Net::OpenTimeout => e
|
||||
raise StandardError,
|
||||
I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port])
|
||||
rescue Net::IMAP::Error => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error')
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
ensure
|
||||
Rails.logger.error e if e.present?
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||
smtp.finish unless smtp&.nil?
|
||||
smtp.finish
|
||||
end
|
||||
|
||||
def set_smtp_encryption(channel_data, smtp)
|
||||
|
@ -74,4 +98,22 @@ module Api::V1::InboxesHelper
|
|||
context.verify_mode = openssl_verify_mode
|
||||
context
|
||||
end
|
||||
|
||||
def account_channels_method
|
||||
{
|
||||
'web_widget' => Current.account.web_widgets,
|
||||
'api' => Current.account.api_channels,
|
||||
'email' => Current.account.email_channels,
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
||||
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
||||
end
|
||||
end
|
||||
|
|
9
app/helpers/widget_helper.rb
Normal file
9
app/helpers/widget_helper.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
module WidgetHelper
|
||||
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
|
||||
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
|
||||
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
|
||||
token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||
|
||||
[contact_inbox, token]
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@
|
|||
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view></router-view>
|
||||
<router-view />
|
||||
</transition>
|
||||
<add-account-modal
|
||||
:show="showAddAccountModal"
|
||||
|
|
|
@ -15,6 +15,11 @@ class ApiClient {
|
|||
|
||||
baseUrl() {
|
||||
let url = this.apiVersion;
|
||||
|
||||
if (this.options.enterprise) {
|
||||
url = `/enterprise${url}`;
|
||||
}
|
||||
|
||||
if (this.options.accountScoped) {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
|
|
9
app/javascript/dashboard/api/bulkActions.js
Normal file
9
app/javascript/dashboard/api/bulkActions.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import ApiClient from './ApiClient';
|
||||
|
||||
class BulkActionsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('bulk_actions', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new BulkActionsAPI();
|
|
@ -71,6 +71,10 @@ class ContactAPI extends ApiClient {
|
|||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
destroyAvatar(contactId) {
|
||||
return axios.delete(`${this.url}/${contactId}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
|
18
app/javascript/dashboard/api/enterprise/account.js
Normal file
18
app/javascript/dashboard/api/enterprise/account.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class EnterpriseAccountAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('', { accountScoped: true, enterprise: true });
|
||||
}
|
||||
|
||||
checkout() {
|
||||
return axios.post(`${this.url}checkout`);
|
||||
}
|
||||
|
||||
subscription() {
|
||||
return axios.post(`${this.url}subscription`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
|
@ -0,0 +1,31 @@
|
|||
import accountAPI from '../account';
|
||||
import ApiClient from '../../ApiClient';
|
||||
import describeWithAPIMock from '../../specs/apiSpecHelper';
|
||||
|
||||
describe('#enterpriseAccountAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(accountAPI).toBeInstanceOf(ApiClient);
|
||||
expect(accountAPI).toHaveProperty('get');
|
||||
expect(accountAPI).toHaveProperty('show');
|
||||
expect(accountAPI).toHaveProperty('create');
|
||||
expect(accountAPI).toHaveProperty('update');
|
||||
expect(accountAPI).toHaveProperty('delete');
|
||||
expect(accountAPI).toHaveProperty('checkout');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#checkout', () => {
|
||||
accountAPI.checkout();
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/checkout'
|
||||
);
|
||||
});
|
||||
|
||||
it('#subscription', () => {
|
||||
accountAPI.subscription();
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/subscription'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -10,6 +10,7 @@ export const buildCreatePayload = ({
|
|||
files,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
templateParams,
|
||||
}) => {
|
||||
let payload;
|
||||
if (files && files.length !== 0) {
|
||||
|
@ -32,6 +33,7 @@ export const buildCreatePayload = ({
|
|||
content_attributes: contentAttributes,
|
||||
cc_emails: ccEmails,
|
||||
bcc_emails: bccEmails,
|
||||
template_params: templateParams,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
|
@ -51,6 +53,7 @@ class MessageApi extends ApiClient {
|
|||
files,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
templateParams,
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
|
@ -63,6 +66,7 @@ class MessageApi extends ApiClient {
|
|||
files,
|
||||
ccEmails,
|
||||
bccEmails,
|
||||
templateParams,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
9
app/javascript/dashboard/api/specs/bulkAction.spec.js
Normal file
9
app/javascript/dashboard/api/specs/bulkAction.spec.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -12,6 +12,7 @@ describe('#ContactsAPI', () => {
|
|||
expect(contactAPI).toHaveProperty('delete');
|
||||
expect(contactAPI).toHaveProperty('getConversations');
|
||||
expect(contactAPI).toHaveProperty('filter');
|
||||
expect(contactAPI).toHaveProperty('destroyAvatar');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
|
@ -100,6 +101,13 @@ describe('#ContactsAPI', () => {
|
|||
queryPayload
|
||||
);
|
||||
});
|
||||
|
||||
it('#destroyAvatar', () => {
|
||||
contactAPI.destroyAvatar(1);
|
||||
expect(context.axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/avatar'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -383,8 +383,8 @@ $form-button-radius: var(--border-radius-normal);
|
|||
// 20. Label
|
||||
// ---------
|
||||
|
||||
$label-background: $primary-color;
|
||||
$label-color: $white;
|
||||
$label-background: $white;
|
||||
$label-color: $black;
|
||||
$label-color-alt: $black;
|
||||
$label-palette: $foundation-palette;
|
||||
$label-font-size: $font-size-mini;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
.page-sub-title {
|
||||
font-size: $font-size-large;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
|
|
|
@ -60,3 +60,9 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
|
@ -99,3 +99,7 @@ $ionicons-font-path: '~ionicons/fonts';
|
|||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
|
||||
:root {
|
||||
--dashboard-app-tabs-height: 3.9rem;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,34 @@
|
|||
.tabs--container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tabs--container--with-border {
|
||||
@include border-normal-bottom;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@include padding($zero $space-normal);
|
||||
@include border-normal-bottom;
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
border-top-width: 0;
|
||||
display: flex;
|
||||
min-width: var(--space-mega);
|
||||
}
|
||||
|
||||
.tabs--with-scroll {
|
||||
max-width: calc(100% - 64px);
|
||||
overflow: hidden;
|
||||
padding: 0 var(--space-smaller);
|
||||
}
|
||||
|
||||
.tabs--scroll-button {
|
||||
align-items: center;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
min-width: var(--space-large);
|
||||
}
|
||||
|
||||
// Tab chat type
|
||||
|
@ -22,6 +47,7 @@
|
|||
|
||||
.tabs-title {
|
||||
@include margin($zero $space-slab);
|
||||
flex-shrink: 0;
|
||||
|
||||
.badge {
|
||||
background: $color-background;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="conversations-list-wrap">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
<div
|
||||
class="chat-list__top"
|
||||
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
|
||||
|
@ -53,8 +53,7 @@
|
|||
size="small"
|
||||
class="btn-filter"
|
||||
@click="onToggleAdvanceFiltersModal"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -85,7 +84,19 @@
|
|||
<p v-if="!chatListLoading && !conversationList.length" class="content-box">
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
|
||||
<conversation-bulk-actions
|
||||
v-if="selectedConversations.length"
|
||||
:conversations="selectedConversations"
|
||||
:all-conversations-selected="allConversationsSelected"
|
||||
:selected-inboxes="uniqueInboxes"
|
||||
:show-open-action="allSelectedConversationsStatus('open')"
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
@select-all-conversations="selectAllConversations"
|
||||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
/>
|
||||
<div ref="activeConversation" class="conversations-list">
|
||||
<conversation-card
|
||||
v-for="chat in conversationList"
|
||||
|
@ -96,10 +107,13 @@
|
|||
:chat="chat"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:selected="isConversationSelected(chat.id)"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<woot-button
|
||||
|
@ -112,11 +126,7 @@
|
|||
</woot-button>
|
||||
|
||||
<p
|
||||
v-if="
|
||||
conversationList.length &&
|
||||
hasCurrentPageEndReached &&
|
||||
!chatListLoading
|
||||
"
|
||||
v-if="showEndOfListMessage"
|
||||
class="text-center text-muted end-of-list-text"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
|
@ -152,6 +162,8 @@ import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
|||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import {
|
||||
hasPressedAltAndJKey,
|
||||
|
@ -166,8 +178,9 @@ export default {
|
|||
ChatFilter,
|
||||
ConversationAdvancedFilter,
|
||||
DeleteCustomViews,
|
||||
ConversationBulkActions,
|
||||
},
|
||||
mixins: [timeMixin, conversationMixin, eventListenerMixins],
|
||||
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
|
||||
props: {
|
||||
conversationInbox: {
|
||||
type: [String, Number],
|
||||
|
@ -202,6 +215,8 @@ export default {
|
|||
foldersQuery: {},
|
||||
showAddFoldersModal: false,
|
||||
showDeleteFoldersModal: false,
|
||||
selectedConversations: [],
|
||||
selectedInboxes: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -217,6 +232,7 @@ export default {
|
|||
conversationStats: 'conversationStats/getStats',
|
||||
appliedFilters: 'getAppliedConversationFilters',
|
||||
folders: 'customViews/getCustomViews',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
}),
|
||||
isIframe() {
|
||||
return window.self !== window.top;
|
||||
|
@ -237,6 +253,13 @@ export default {
|
|||
}
|
||||
return {};
|
||||
},
|
||||
showEndOfListMessage() {
|
||||
return (
|
||||
this.conversationList.length &&
|
||||
this.hasCurrentPageEndReached &&
|
||||
!this.chatListLoading
|
||||
);
|
||||
},
|
||||
assigneeTabItems() {
|
||||
const ASSIGNEE_TYPE_TAB_KEYS = {
|
||||
me: 'mineCount',
|
||||
|
@ -346,6 +369,17 @@ export default {
|
|||
}
|
||||
return {};
|
||||
},
|
||||
allConversationsSelected() {
|
||||
return (
|
||||
this.conversationList.length === this.selectedConversations.length &&
|
||||
this.conversationList.every(el =>
|
||||
this.selectedConversations.includes(el.id)
|
||||
)
|
||||
);
|
||||
},
|
||||
uniqueInboxes() {
|
||||
return [...new Set(this.selectedInboxes)];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeTeam() {
|
||||
|
@ -379,6 +413,7 @@ export default {
|
|||
if (this.$route.name !== 'home') {
|
||||
this.$router.push({ name: 'home' });
|
||||
}
|
||||
this.resetBulkActions();
|
||||
this.foldersQuery = filterQueryGenerator(payload);
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
|
@ -444,6 +479,7 @@ export default {
|
|||
}
|
||||
},
|
||||
resetAndFetchData() {
|
||||
this.resetBulkActions();
|
||||
this.$store.dispatch('conversationPage/reset');
|
||||
this.$store.dispatch('emptyAllConversations');
|
||||
this.$store.dispatch('clearConversationFilters');
|
||||
|
@ -494,6 +530,7 @@ export default {
|
|||
},
|
||||
updateAssigneeTab(selectedTab) {
|
||||
if (this.activeAssigneeTab !== selectedTab) {
|
||||
this.resetBulkActions();
|
||||
bus.$emit('clearSearchInput');
|
||||
this.activeAssigneeTab = selectedTab;
|
||||
if (!this.currentPage) {
|
||||
|
@ -501,6 +538,10 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
resetBulkActions() {
|
||||
this.selectedConversations = [];
|
||||
this.selectedInboxes = [];
|
||||
},
|
||||
updateStatusType(index) {
|
||||
if (this.activeStatus !== index) {
|
||||
this.activeStatus = index;
|
||||
|
@ -523,6 +564,80 @@ export default {
|
|||
this.fetchConversations();
|
||||
}
|
||||
},
|
||||
isConversationSelected(id) {
|
||||
return this.selectedConversations.includes(id);
|
||||
},
|
||||
selectConversation(conversationId, inboxId) {
|
||||
this.selectedConversations.push(conversationId);
|
||||
this.selectedInboxes.push(inboxId);
|
||||
},
|
||||
deSelectConversation(conversationId, inboxId) {
|
||||
this.selectedConversations = this.selectedConversations.filter(
|
||||
item => item !== conversationId
|
||||
);
|
||||
this.selectedInboxes = this.selectedInboxes.filter(
|
||||
item => item !== inboxId
|
||||
);
|
||||
},
|
||||
selectAllConversations(check) {
|
||||
if (check) {
|
||||
this.selectedConversations = this.conversationList.map(item => item.id);
|
||||
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
|
||||
} else {
|
||||
this.resetBulkActions();
|
||||
}
|
||||
},
|
||||
async onAssignAgent(agent) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
fields: {
|
||||
assignee_id: agent.id,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onAssignLabels(labels) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
labels: {
|
||||
add: labels,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onUpdateConversations(status) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
},
|
||||
allSelectedConversationsStatus(status) {
|
||||
if (!this.selectedConversations.length) return false;
|
||||
return this.selectedConversations.every(item => {
|
||||
return this.$store.getters.getConversationById(item).status === status;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -538,7 +653,7 @@ export default {
|
|||
.conversations-list-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 34rem;
|
||||
|
||||
overflow: hidden;
|
||||
@include breakpoint(large up) {
|
||||
width: 36rem;
|
||||
}
|
||||
|
|
|
@ -98,4 +98,7 @@ export default {
|
|||
width: 48rem;
|
||||
}
|
||||
}
|
||||
.modal-big {
|
||||
width: 60%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
<h2 class="page-sub-title">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p v-if="headerContent" class="small-12 column">
|
||||
<p v-if="headerContent" class="small-12 column wrap-content">
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -29,3 +29,8 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.wrap-content {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,8 +20,7 @@
|
|||
color-scheme="warning"
|
||||
icon="dismiss-circle"
|
||||
@click="closeNotification"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="medium-6 small-12">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:icon="icon"
|
||||
/>
|
||||
<spinner v-if="isLoading" />
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import ArticleItemComponent from './ArticleItem.vue';
|
||||
const STATUS_LIST = {
|
||||
published: 'published',
|
||||
draft: 'draft',
|
||||
archived: 'archived',
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center',
|
||||
component: ArticleItemComponent,
|
||||
argTypes: {
|
||||
title: {
|
||||
defaultValue: 'Setup your account',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
readCount: {
|
||||
defaultValue: 13,
|
||||
control: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
category: {
|
||||
defaultValue: 'Getting started',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultValue: 'Status',
|
||||
control: {
|
||||
type: 'select',
|
||||
options: STATUS_LIST,
|
||||
},
|
||||
},
|
||||
updatedAt: {
|
||||
defaultValue: '1657255863',
|
||||
control: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { ArticleItemComponent },
|
||||
template:
|
||||
'<article-item-component v-bind="$props" ></article-item-component>',
|
||||
});
|
||||
|
||||
export const ArticleItem = Template.bind({});
|
||||
ArticleItem.args = {
|
||||
title: 'Setup your account',
|
||||
author: {
|
||||
name: 'John Doe',
|
||||
},
|
||||
category: 'Getting started',
|
||||
readCount: 12,
|
||||
status: 'published',
|
||||
updatedAt: 1657255863,
|
||||
};
|
129
app/javascript/dashboard/components/helpCenter/ArticleItem.vue
Normal file
129
app/javascript/dashboard/components/helpCenter/ArticleItem.vue
Normal file
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="row--article-block">
|
||||
<div class="article-block">
|
||||
<h6 class="sub-block-title text-truncate">
|
||||
<router-link class="article-name" :to="articlePath">
|
||||
{{ title }}
|
||||
</router-link>
|
||||
</h6>
|
||||
<div class="author">
|
||||
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
|
||||
<span class="name">{{ articleAuthorName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ category }}</td>
|
||||
<td>{{ readCount }}</td>
|
||||
<td>
|
||||
<Label :title="status" :color-scheme="labelColor" />
|
||||
</td>
|
||||
<td>{{ lastUpdatedAt }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<script>
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import Label from 'dashboard/components/ui/Label';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
export default {
|
||||
components: {
|
||||
Label,
|
||||
},
|
||||
mixins: [timeMixin],
|
||||
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
readCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: 'draft',
|
||||
values: ['archived', 'draft', 'published'],
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
lastUpdatedAt() {
|
||||
return this.dynamicTime(this.updatedAt);
|
||||
},
|
||||
articleAuthorName() {
|
||||
return this.author.name;
|
||||
},
|
||||
labelColor() {
|
||||
switch (this.status) {
|
||||
case 'archived':
|
||||
return 'secondary';
|
||||
case 'draft':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
},
|
||||
articlePath() {
|
||||
return frontendURL(`accounts/${this.accountId}/hc/articles/${this.id}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
td {
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-mini);
|
||||
padding-left: 0;
|
||||
}
|
||||
.row--article-block {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
text-align: left;
|
||||
|
||||
.article-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub-block-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-name {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-default);
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
color: var(--s-900);
|
||||
}
|
||||
.author {
|
||||
.by {
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--s-500);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
.name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,72 @@
|
|||
import ArticleTableComponent from './ArticleTable.vue';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
export default {
|
||||
title: 'Components/Help Center',
|
||||
component: ArticleTableComponent,
|
||||
argTypes: {
|
||||
articles: {
|
||||
defaultValue: [],
|
||||
control: {
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
articleCount: {
|
||||
defaultValue: 10,
|
||||
control: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
currentPage: {
|
||||
defaultValue: 1,
|
||||
control: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { ArticleTableComponent },
|
||||
template:
|
||||
'<article-table-component @onPageChange="onPageChange" v-bind="$props" ></article-table-component>',
|
||||
});
|
||||
|
||||
export const ArticleTable = Template.bind({});
|
||||
ArticleTable.args = {
|
||||
articles: [
|
||||
{
|
||||
title: 'Setup your account',
|
||||
author: {
|
||||
name: 'John Doe',
|
||||
},
|
||||
readCount: 13,
|
||||
category: 'Getting started',
|
||||
status: 'published',
|
||||
updatedAt: 1657255863,
|
||||
},
|
||||
{
|
||||
title: 'Docker Configuration',
|
||||
author: {
|
||||
name: 'Sam Manuel',
|
||||
},
|
||||
readCount: 13,
|
||||
category: 'Engineering',
|
||||
status: 'draft',
|
||||
updatedAt: 1656658046,
|
||||
},
|
||||
{
|
||||
title: 'Campaigns',
|
||||
author: {
|
||||
name: 'Sam Manuel',
|
||||
},
|
||||
readCount: 28,
|
||||
category: 'Engineering',
|
||||
status: 'archived',
|
||||
updatedAt: 1657590446,
|
||||
},
|
||||
],
|
||||
articleCount: 10,
|
||||
currentPage: 1,
|
||||
onPageChange: action('onPageChange'),
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div class="article-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}</th>
|
||||
<th scope="col">{{ $t('HELP_CENTER.TABLE.HEADERS.LAST_EDITED') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colspan="100%" class="horizontal-line" />
|
||||
</tr>
|
||||
<tbody>
|
||||
<ArticleItem
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
:title="article.title"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:read-count="article.readCount"
|
||||
:status="article.status"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<table-footer
|
||||
:on-page-change="onPageChange"
|
||||
:current-page="Number(currentPage)"
|
||||
:total-count="articleCount"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ArticleItem from './ArticleItem.vue';
|
||||
import TableFooter from 'dashboard/components/widgets/TableFooter';
|
||||
export default {
|
||||
components: {
|
||||
ArticleItem,
|
||||
TableFooter,
|
||||
},
|
||||
props: {
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
articleCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onPageChange() {
|
||||
this.$emit('onPageChange');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.article-container {
|
||||
width: 100%;
|
||||
|
||||
table thead th {
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: capitalize;
|
||||
color: var(--s-700);
|
||||
font-size: var(--font-size-small);
|
||||
padding-left: 0;
|
||||
}
|
||||
.horizontal-line {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.footer {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,34 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import EditArticle from './EditArticle.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center',
|
||||
component: EditArticle,
|
||||
argTypes: {
|
||||
article: {
|
||||
defaultValue: {},
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { EditArticle },
|
||||
template:
|
||||
'<edit-article v-bind="$props" @focus="onFocus" @blur="onBlur"></edit-article>',
|
||||
});
|
||||
|
||||
export const EditArticleView = Template.bind({});
|
||||
EditArticleView.args = {
|
||||
article: {
|
||||
id: '1',
|
||||
title: 'Lorem ipsum',
|
||||
content:
|
||||
'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.',
|
||||
},
|
||||
onFocus: action('focus'),
|
||||
onBlur: action('blur'),
|
||||
};
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<div class="edit-article--container">
|
||||
<input
|
||||
v-model="articleTitle"
|
||||
type="text"
|
||||
class="article-heading"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<woot-message-editor
|
||||
v-model="articleContent"
|
||||
class="article-content"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
|
||||
:is-format-mode="true"
|
||||
:min-height="24"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
export default {
|
||||
components: {
|
||||
WootMessageEditor,
|
||||
},
|
||||
props: {
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
articleTitle: '',
|
||||
articleContent: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.articleTitle = this.article.title;
|
||||
this.articleContent = this.article.content;
|
||||
},
|
||||
methods: {
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
onBlur() {
|
||||
this.$emit('blur');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-article--container {
|
||||
margin: var(--space-medium) var(--space-giga);
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.article-heading {
|
||||
font-size: var(--font-size-giga);
|
||||
font-weight: var(--font-weight-bold);
|
||||
min-height: var(--space-jumbo);
|
||||
max-height: var(--space-jumbo);
|
||||
border: 0px solid transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-menubar .ProseMirror-menuitem {
|
||||
.ProseMirror-icon {
|
||||
margin-right: var(--space-normal);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: var(--space-giga);
|
||||
max-height: 100%;
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
font-size: var(--font-size-default);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,44 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import ArticleHeader from './ArticleHeader';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center/Header',
|
||||
component: ArticleHeader,
|
||||
argTypes: {
|
||||
headerTitle: {
|
||||
defaultValue: 'All articles',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
count: {
|
||||
defaultValue: 112,
|
||||
control: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
selectedValue: {
|
||||
defaultValue: 'Status',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { ArticleHeader },
|
||||
template:
|
||||
'<article-header v-bind="$props" @openModal="openFilterModal" @open="openDropdown" @close="closeDropdown" ></article-header>',
|
||||
});
|
||||
|
||||
export const ArticleHeaderView = Template.bind({});
|
||||
ArticleHeaderView.args = {
|
||||
headerTitle: 'All articles',
|
||||
count: 112,
|
||||
selectedValue: 'Status',
|
||||
openFilterModal: action('openedFilterModal'),
|
||||
openDropdown: action('opened'),
|
||||
closeDropdown: action('closed'),
|
||||
};
|
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<div class="header--wrap">
|
||||
<div class="header-left--wrap">
|
||||
<h3 class="page-title">{{ headerTitle }}</h3>
|
||||
<span class="text-block-title count-view">{{ `(${count})` }}</span>
|
||||
</div>
|
||||
<div class="header-right--wrap">
|
||||
<woot-button
|
||||
class-names="article--buttons"
|
||||
icon="filter"
|
||||
color-scheme="secondary"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
@click="openFilterModal"
|
||||
>
|
||||
{{ $t('HELP_CENTER.HEADER.FILTER') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
class-names="article--buttons"
|
||||
icon="arrow-sort"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
variant="hollow"
|
||||
@click="openDropdown"
|
||||
>
|
||||
{{ $t('HELP_CENTER.HEADER.SORT') }}
|
||||
<span class="selected-value">
|
||||
{{ selectedValue }}
|
||||
<Fluent-icon class="dropdown-arrow" icon="chevron-down" size="14" />
|
||||
</span>
|
||||
</woot-button>
|
||||
<div
|
||||
v-if="showSortByDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open"
|
||||
>
|
||||
<woot-dropdown-menu>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="send-clock"
|
||||
>
|
||||
{{ 'Status' }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="dual-screen-clock"
|
||||
>
|
||||
{{ 'Created' }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="calendar-clock"
|
||||
>
|
||||
{{ 'Last edited' }}
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</woot-dropdown-menu>
|
||||
</div>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('HELP_CENTER.HEADER.SETTINGS_BUTTON')"
|
||||
icon="settings"
|
||||
class-names="article--buttons"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
/>
|
||||
<woot-button
|
||||
class-names="article--buttons"
|
||||
size="small"
|
||||
color-scheme="primary"
|
||||
>
|
||||
{{ $t('HELP_CENTER.HEADER.NEW_BUTTON') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
headerTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
selectedValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSortByDropdown: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
openFilterModal() {
|
||||
this.$emit('openModal');
|
||||
},
|
||||
openDropdown() {
|
||||
this.$emit('open');
|
||||
this.showSortByDropdown = true;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.$emit('close');
|
||||
this.showSortByDropdown = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header--wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
width: 100%;
|
||||
height: var(--space-larger);
|
||||
}
|
||||
.header-left--wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-right--wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.count-view {
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
.selected-value {
|
||||
display: inline-flex;
|
||||
margin-left: var(--space-smaller);
|
||||
color: var(--b-900);
|
||||
align-items: center;
|
||||
}
|
||||
.dropdown-arrow {
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
.article--buttons {
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,39 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import EditArticleHeader from './EditArticleHeader';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center/Header',
|
||||
component: EditArticleHeader,
|
||||
argTypes: {
|
||||
backButtonLabel: {
|
||||
defaultValue: 'Articles',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
draftState: {
|
||||
defaultValue: 'saving',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { EditArticleHeader },
|
||||
template:
|
||||
'<edit-article-header v-bind="$props" @back="onClickGoBack" @show="showPreview" @add="onClickAdd" @open="openSidebar" @close="closeSidebar" ></edit-article-header>',
|
||||
});
|
||||
|
||||
export const EditArticleHeaderView = Template.bind({});
|
||||
EditArticleHeaderView.args = {
|
||||
backButtonLabel: 'Articles',
|
||||
draftState: 'saving',
|
||||
onClickGoBack: action('goBack'),
|
||||
showPreview: action('previewOpened'),
|
||||
onClickAdd: action('added'),
|
||||
openSidebar: action('openedSidebar'),
|
||||
closeSidebar: action('closedSidebar'),
|
||||
};
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<div class="header--wrap">
|
||||
<div class="header-left--wrap">
|
||||
<woot-button
|
||||
icon="chevron-left"
|
||||
class-names="article--buttons"
|
||||
variant="clear"
|
||||
color-scheme="primary"
|
||||
@click="onClickGoBack"
|
||||
>
|
||||
{{ backButtonLabel }}
|
||||
</woot-button>
|
||||
</div>
|
||||
<div class="header-right--wrap">
|
||||
<span v-if="showDraftStatus" class="draft-status">
|
||||
{{ draftStatusText }}
|
||||
</span>
|
||||
<woot-button
|
||||
class-names="article--buttons"
|
||||
icon="globe"
|
||||
color-scheme="secondary"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
@click="showPreview"
|
||||
>
|
||||
{{ $t('HELP_CENTER.EDIT_HEADER.PREVIEW') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
class-names="article--buttons"
|
||||
icon="add"
|
||||
color-scheme="secondary"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
@click="onClickAdd"
|
||||
>
|
||||
{{ $t('HELP_CENTER.EDIT_HEADER.ADD_TRANSLATION') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-if="isSidebarOpen"
|
||||
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.OPEN_SIDEBAR')"
|
||||
icon="pane-open"
|
||||
class-names="article--buttons"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
@click="openSidebar"
|
||||
/>
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.top-end="$t('HELP_CENTER.EDIT_HEADER.CLOSE_SIDEBAR')"
|
||||
icon="pane-close"
|
||||
class-names="article--buttons"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
@click="closeSidebar"
|
||||
/>
|
||||
<woot-button
|
||||
class-names="article--buttons"
|
||||
size="small"
|
||||
color-scheme="primary"
|
||||
>
|
||||
{{ $t('HELP_CENTER.EDIT_HEADER.PUBLISH_BUTTON') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
backButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
draftState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isSidebarOpen: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isDraftStatusSavingOrSaved() {
|
||||
return this.draftState === 'saving' || 'saved';
|
||||
},
|
||||
draftStatusText() {
|
||||
if (this.draftState === 'saving') {
|
||||
return this.$t('HELP_CENTER.EDIT_HEADER.SAVING');
|
||||
}
|
||||
if (this.draftState === 'saved') {
|
||||
return this.$t('HELP_CENTER.EDIT_HEADER.SAVED');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
showDraftStatus() {
|
||||
return this.isDraftStatusSavingOrSaved;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClickGoBack() {
|
||||
this.$emit('back');
|
||||
},
|
||||
showPreview() {
|
||||
this.$emit('show');
|
||||
},
|
||||
onClickAdd() {
|
||||
this.$emit('add');
|
||||
},
|
||||
openSidebar() {
|
||||
this.$emit('open');
|
||||
this.isSidebarOpen = true;
|
||||
},
|
||||
closeSidebar() {
|
||||
this.$emit('close');
|
||||
this.isSidebarOpen = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header--wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
width: 100%;
|
||||
height: var(--space-larger);
|
||||
}
|
||||
.header-left--wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-right--wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.article--buttons {
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
.draft-status {
|
||||
margin-right: var(--space-smaller);
|
||||
margin-left: var(--space-normal);
|
||||
color: var(--s-400);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,134 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import Sidebar from './Sidebar';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center/Sidebar',
|
||||
component: { Sidebar, Thumbnail },
|
||||
argTypes: {
|
||||
thumbnailSrc: {
|
||||
defaultValue: '',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
headerTitle: {
|
||||
defaultValue: '',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
subTitle: {
|
||||
defaultValue: '',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
accessibleMenuItems: [],
|
||||
additionalSecondaryMenuItems: [],
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { Sidebar },
|
||||
template: '<sidebar v-bind="$props" @input="onSearch"></sidebar>',
|
||||
});
|
||||
|
||||
export const HelpCenterSidebarView = Template.bind({});
|
||||
HelpCenterSidebarView.args = {
|
||||
onSearch: action('search'),
|
||||
thumbnailSrc: '',
|
||||
headerTitle: 'Help Center',
|
||||
subTitle: 'English',
|
||||
accessibleMenuItems: [
|
||||
{
|
||||
icon: 'book',
|
||||
label: 'HELP_CENTER.ALL_ARTICLES',
|
||||
key: 'helpcenter_all',
|
||||
count: 199,
|
||||
toState: 'accounts/1/articles/all',
|
||||
toolTip: 'All Articles',
|
||||
toStateName: 'helpcenter_all',
|
||||
},
|
||||
{
|
||||
icon: 'pen',
|
||||
label: 'HELP_CENTER.MY_ARTICLES',
|
||||
key: 'helpcenter_mine',
|
||||
count: 112,
|
||||
toState: 'accounts/1/articles/mine',
|
||||
toolTip: 'My articles',
|
||||
toStateName: 'helpcenter_mine',
|
||||
},
|
||||
{
|
||||
icon: 'draft',
|
||||
label: 'HELP_CENTER.DRAFT',
|
||||
key: 'helpcenter_draft',
|
||||
count: 32,
|
||||
toState: 'accounts/1/articles/draft',
|
||||
toolTip: 'Draft',
|
||||
toStateName: 'helpcenter_draft',
|
||||
},
|
||||
{
|
||||
icon: 'archive',
|
||||
label: 'HELP_CENTER.ARCHIVED',
|
||||
key: 'helpcenter_archive',
|
||||
count: 10,
|
||||
toState: 'accounts/1/articles/archived',
|
||||
toolTip: 'Archived',
|
||||
toStateName: 'helpcenter_archive',
|
||||
},
|
||||
],
|
||||
additionalSecondaryMenuItems: [
|
||||
{
|
||||
icon: 'folder',
|
||||
label: 'HELP_CENTER.CATEGORY',
|
||||
hasSubMenu: true,
|
||||
key: 'category',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'Getting started',
|
||||
count: 12,
|
||||
truncateLabel: true,
|
||||
toState: 'accounts/1/articles/categories/new',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Channel',
|
||||
count: 19,
|
||||
truncateLabel: true,
|
||||
toState: 'accounts/1/articles/categories/channel',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'Feature',
|
||||
count: 24,
|
||||
truncateLabel: true,
|
||||
toState: 'accounts/1/articles/categories/feature',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Advanced',
|
||||
count: 8,
|
||||
truncateLabel: true,
|
||||
toState: 'accounts/1/articles/categories/advanced',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: 'Mobile app',
|
||||
count: 3,
|
||||
truncateLabel: true,
|
||||
toState: 'accounts/1/articles/categories/mobile-app',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label: 'Others',
|
||||
count: 39,
|
||||
truncateLabel: true,
|
||||
toState: 'accounts/1/articles/categories/others',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<div class="main-nav secondary-menu">
|
||||
<sidebar-header
|
||||
:thumbnail-src="thumbnailSrc"
|
||||
:header-title="headerTitle"
|
||||
:sub-title="subTitle"
|
||||
/>
|
||||
<sidebar-search @input="onSearch" />
|
||||
<!-- <transition-group name="menu-list" tag="ul" class="menu vertical"> -->
|
||||
<div name="menu-list" tag="ul" class="menu vertical">
|
||||
<secondary-nav-item
|
||||
v-for="menuItem in accessibleMenuItems"
|
||||
:key="menuItem.toState"
|
||||
:menu-item="menuItem"
|
||||
:is-help-center-sidebar="true"
|
||||
/>
|
||||
</div>
|
||||
<div name="menu-list" tag="ul" class="menu vertical">
|
||||
<secondary-nav-item
|
||||
v-for="menuItem in additionalSecondaryMenuItems"
|
||||
:key="menuItem.key"
|
||||
:menu-item="menuItem"
|
||||
:is-help-center-sidebar="true"
|
||||
/>
|
||||
</div>
|
||||
<!-- </transition-group> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SecondaryNavItem from 'dashboard/components/layout/sidebarComponents/SecondaryNavItem';
|
||||
import SidebarSearch from './SidebarSearch';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SecondaryNavItem,
|
||||
SidebarSearch,
|
||||
SidebarHeader,
|
||||
},
|
||||
props: {
|
||||
thumbnailSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
accessibleMenuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
additionalSecondaryMenuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
methods: {
|
||||
onSearch(value) {
|
||||
this.$emit('input', value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.secondary-menu {
|
||||
background: var(--white);
|
||||
border-right: 1px solid var(--s-50);
|
||||
height: 100%;
|
||||
width: var(--space-giga);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--space-small);
|
||||
|
||||
&:hover {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div class="sidebar-header--wrap">
|
||||
<div class="header-left--side">
|
||||
<thumbnail
|
||||
size="40px"
|
||||
:src="thumbnailSrc"
|
||||
:username="headerTitle"
|
||||
variant="square"
|
||||
/>
|
||||
<div class="header-title--wrap">
|
||||
<h4 class="sub-block-title title-view">{{ headerTitle }}</h4>
|
||||
<span class="sub-title--view">{{ subTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right--side">
|
||||
<fluent-icon
|
||||
icon="arrow-up-right"
|
||||
size="28px"
|
||||
class="pop-out--icon"
|
||||
@click="popOutHelpCenter"
|
||||
/>
|
||||
<fluent-icon
|
||||
icon="arrow-swap"
|
||||
size="28px"
|
||||
class="portal-switch--icon"
|
||||
@click="openSwitchPortalModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
props: {
|
||||
thumbnailSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
popOutHelpCenter() {
|
||||
this.$emit('pop-out');
|
||||
},
|
||||
openSwitchPortalModal() {
|
||||
this.$emit('open');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-header--wrap {
|
||||
display: flex;
|
||||
height: var(--space-jumbo);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-normal) 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.header-title--wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
||||
.title-view {
|
||||
margin-bottom: var(--space-zero);
|
||||
}
|
||||
|
||||
.sub-title--view {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--b-600);
|
||||
}
|
||||
|
||||
.header-left--side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right--side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pop-out--icon {
|
||||
padding: var(--space-smaller);
|
||||
}
|
||||
|
||||
.portal-switch--icon {
|
||||
padding: var(--space-smaller);
|
||||
margin-left: var(--space-small);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: var(--s-50);
|
||||
border-radius: var(--border-radius-normal);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div class="search-input--wrap">
|
||||
<div class="search-icon--wrap">
|
||||
<fluent-icon icon="search" size="18" class="search-icon" />
|
||||
</div>
|
||||
<input
|
||||
v-model="searchValue"
|
||||
class="search-input"
|
||||
:placeholder="$t('HELP_CENTER.SIDEBAR.SEARCH.PLACEHOLDER')"
|
||||
@input="onSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
searchValue: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSearch(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-input--wrap {
|
||||
display: flex;
|
||||
padding: var(--space-small) var(--space-zero);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: var(--space-large);
|
||||
border-radius: var(--border-radius-normal);
|
||||
background: var(--s-25);
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-small) var(--space-small) var(--space-small)
|
||||
var(--space-large);
|
||||
border: 1px solid var(--s-50);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--w-500);
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon--wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
color: var(--s-500);
|
||||
top: var(--space-small);
|
||||
left: var(--space-small);
|
||||
}
|
||||
</style>
|
|
@ -11,6 +11,7 @@
|
|||
@open-notification-panel="openNotificationPanel"
|
||||
/>
|
||||
<secondary-sidebar
|
||||
v-if="showSecondarySidebar"
|
||||
:account-id="accountId"
|
||||
:inboxes="inboxes"
|
||||
:labels="labels"
|
||||
|
@ -18,6 +19,7 @@
|
|||
:custom-views="customViews"
|
||||
:menu-config="activeSecondaryMenu"
|
||||
:current-role="currentRole"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@add-label="showAddLabelPopup"
|
||||
@toggle-accounts="toggleAccountModal"
|
||||
/>
|
||||
|
@ -50,6 +52,12 @@ export default {
|
|||
SecondarySidebar,
|
||||
},
|
||||
mixins: [adminMixin, alertMixin, eventListenerMixins],
|
||||
props: {
|
||||
showSecondarySidebar: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showOptionsMenu: false,
|
||||
|
@ -63,6 +71,7 @@ export default {
|
|||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
accountId: 'getCurrentAccountId',
|
||||
currentRole: 'getCurrentRole',
|
||||
|
@ -194,6 +203,7 @@ export default {
|
|||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ const settings = accountId => ({
|
|||
'settings_integrations_webhook',
|
||||
'settings_integrations_integration',
|
||||
'settings_applications',
|
||||
'settings_integrations_dashboard_apps',
|
||||
'settings_applications_webhook',
|
||||
'settings_applications_integration',
|
||||
'general_settings',
|
||||
|
@ -29,6 +30,7 @@ const settings = accountId => ({
|
|||
'settings_teams_edit',
|
||||
'settings_teams_edit_members',
|
||||
'settings_teams_edit_finish',
|
||||
'billing_settings_index',
|
||||
'automation_list',
|
||||
],
|
||||
menuItems: [
|
||||
|
@ -99,6 +101,14 @@ const settings = accountId => ({
|
|||
toState: frontendURL(`accounts/${accountId}/settings/applications`),
|
||||
toStateName: 'settings_applications',
|
||||
},
|
||||
{
|
||||
icon: 'credit-card-person',
|
||||
label: 'BILLING',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/billing`),
|
||||
toStateName: 'billing_settings_index',
|
||||
showOnlyOnCloud: true,
|
||||
},
|
||||
{
|
||||
icon: 'settings',
|
||||
label: 'ACCOUNT_SETTINGS',
|
||||
|
|
|
@ -83,7 +83,7 @@ export default {
|
|||
border-bottom-right-radius: var(--border-radius-normal);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: end;
|
||||
justify-content: flex-end;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
|
|
@ -55,6 +55,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnChatwootCloud: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasSecondaryMenu() {
|
||||
|
@ -67,12 +71,18 @@ export default {
|
|||
if (!this.currentRole) {
|
||||
return [];
|
||||
}
|
||||
return this.menuConfig.menuItems.filter(
|
||||
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
|
||||
menuItem =>
|
||||
window.roleWiseRoutes[this.currentRole].indexOf(
|
||||
menuItem.toStateName
|
||||
) > -1
|
||||
);
|
||||
return menuItemsFilteredByRole.filter(item => {
|
||||
if (item.showOnlyOnCloud) {
|
||||
return this.isOnChatwootCloud;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
inboxSection() {
|
||||
return {
|
||||
|
@ -95,6 +105,7 @@ export default {
|
|||
),
|
||||
type: inbox.channel_type,
|
||||
phoneNumber: inbox.phone_number,
|
||||
reauthorizationRequired: inbox.reauthorization_required,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
|
||||
|
|
|
@ -26,10 +26,21 @@
|
|||
:class="{ 'text-truncate': shouldTruncate }"
|
||||
>
|
||||
{{ label }}
|
||||
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
|
||||
{{ childItemCount }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="count" class="badge" :class="{ secondary: !isActive }">
|
||||
{{ count }}
|
||||
</span>
|
||||
<span v-if="warningIcon" class="badge--icon">
|
||||
<fluent-icon
|
||||
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
|
||||
class="inbox-icon"
|
||||
:icon="warningIcon"
|
||||
size="12"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
|
@ -57,10 +68,22 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
warningIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
count: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isHelpCenterSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
childItemCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
showIcon() {
|
||||
|
@ -134,6 +157,7 @@ $label-badge-size: var(--space-slab);
|
|||
height: $label-badge-size;
|
||||
min-width: $label-badge-size;
|
||||
margin-left: var(--space-smaller);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.badge.secondary {
|
||||
|
@ -142,4 +166,19 @@ $label-badge-size: var(--space-slab);
|
|||
color: var(--s-600);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.count-view {
|
||||
background: var(--s-50);
|
||||
border-radius: var(--border-radius-normal);
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-left: var(--space-smaller);
|
||||
padding: var(--space-zero) var(--space-smaller);
|
||||
|
||||
&.is-active {
|
||||
background: var(--w-50);
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
<template>
|
||||
<li class="sidebar-item">
|
||||
<span v-if="hasSubMenu" class="secondary-menu--title fs-small">
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
</span>
|
||||
<div v-if="hasSubMenu" class="secondary-menu--wrap">
|
||||
<span class="secondary-menu--title fs-small">
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
</span>
|
||||
<div v-if="isHelpCenterSidebar" class="submenu-icons">
|
||||
<fluent-icon icon="search" class="submenu-icon" size="16" />
|
||||
<fluent-icon icon="add" class="submenu-icon" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
v-else
|
||||
class="secondary-menu--title secondary-menu--link fs-small"
|
||||
|
@ -15,6 +21,13 @@
|
|||
size="14"
|
||||
/>
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
<span
|
||||
v-if="isHelpCenterSidebar"
|
||||
class="count-view"
|
||||
:class="computedClass"
|
||||
>
|
||||
{{ `${menuItem.count}` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="menuItem.label === 'AUTOMATION'"
|
||||
data-view-component="true"
|
||||
|
@ -34,6 +47,9 @@
|
|||
:label-color="child.color"
|
||||
:should-truncate="child.truncateLabel"
|
||||
:icon="computedInboxClass(child)"
|
||||
:warning-icon="computedInboxErrorClass(child)"
|
||||
:is-help-center-sidebar="isHelpCenterSidebar"
|
||||
:child-item-count="child.count"
|
||||
/>
|
||||
<router-link
|
||||
v-if="showItem(menuItem)"
|
||||
|
@ -63,7 +79,10 @@
|
|||
import { mapGetters } from 'vuex';
|
||||
|
||||
import adminMixin from '../../../mixins/isAdmin';
|
||||
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
getInboxClassByType,
|
||||
getInboxWarningIconClass,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
import SecondaryChildNavItem from './SecondaryChildNavItem';
|
||||
|
||||
|
@ -75,6 +94,10 @@ export default {
|
|||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isHelpCenterSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ activeInbox: 'getSelectedInbox' }),
|
||||
|
@ -116,17 +139,19 @@ export default {
|
|||
// If active Inbox is present
|
||||
// donot highlight conversations
|
||||
if (this.activeInbox) return ' ';
|
||||
|
||||
if (
|
||||
this.isInboxConversation ||
|
||||
this.isTeamsSettings ||
|
||||
this.isInboxsSettings ||
|
||||
this.isIntegrationsSettings ||
|
||||
this.isApplicationsSettings
|
||||
) {
|
||||
return 'is-active';
|
||||
if (this.hasSubMenu) {
|
||||
if (
|
||||
this.isInboxConversation ||
|
||||
this.isTeamsSettings ||
|
||||
this.isInboxsSettings ||
|
||||
this.isIntegrationsSettings ||
|
||||
this.isApplicationsSettings
|
||||
) {
|
||||
return 'is-active';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
return ' ';
|
||||
return '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -136,6 +161,15 @@ export default {
|
|||
const classByType = getInboxClassByType(type, phoneNumber);
|
||||
return classByType;
|
||||
},
|
||||
computedInboxErrorClass(child) {
|
||||
const { type, reauthorizationRequired } = child;
|
||||
if (!type) return '';
|
||||
const warningClass = getInboxWarningIconClass(
|
||||
type,
|
||||
reauthorizationRequired
|
||||
);
|
||||
return warningClass;
|
||||
},
|
||||
newLinkClick(e, navigate) {
|
||||
if (this.menuItem.newLinkRouteName) {
|
||||
navigate(e);
|
||||
|
@ -157,6 +191,11 @@ export default {
|
|||
margin: var(--space-smaller) 0 0;
|
||||
}
|
||||
|
||||
.secondary-menu--wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.secondary-menu--title {
|
||||
color: var(--s-600);
|
||||
display: flex;
|
||||
|
@ -229,6 +268,7 @@ export default {
|
|||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
|
||||
.beta {
|
||||
padding-right: var(--space-smaller) !important;
|
||||
padding-left: var(--space-smaller) !important;
|
||||
|
@ -242,4 +282,34 @@ export default {
|
|||
color: var(--g-800);
|
||||
border-color: var(--g-700);
|
||||
}
|
||||
|
||||
.count-view {
|
||||
background: var(--s-50);
|
||||
border-radius: var(--border-radius-normal);
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-left: var(--space-smaller);
|
||||
padding: var(--space-zero) var(--space-smaller);
|
||||
|
||||
&.is-active {
|
||||
background: var(--w-50);
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
|
||||
.submenu-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.submenu-icon {
|
||||
margin-left: var(--space-small);
|
||||
color: var(--s-600);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -30,8 +30,7 @@
|
|||
icon="dismiss-circle"
|
||||
class-names="banner-action__button"
|
||||
@click="onClickClose"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
<template>
|
||||
<div :class="labelClass" :style="labelStyle" :title="description">
|
||||
<button v-if="icon" class="label-action--button" @click="onClick">
|
||||
<span v-if="icon" class="label-action--button">
|
||||
<fluent-icon :icon="icon" size="12" class="label--icon" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="variant === 'smooth'"
|
||||
:style="{ background: color }"
|
||||
class="label-color-dot"
|
||||
/>
|
||||
<span v-if="!href">{{ title }}</span>
|
||||
<a v-else :href="href" :style="anchorStyle">{{ title }}</a>
|
||||
<button
|
||||
v-if="showClose"
|
||||
class="label-action--button"
|
||||
class="label-close--button "
|
||||
:style="{ color: textColor }"
|
||||
@click="onClick"
|
||||
>
|
||||
|
@ -48,14 +53,23 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.bgColor);
|
||||
if (this.variant === 'smooth') return '';
|
||||
return this.color || getContrastingTextColor(this.bgColor);
|
||||
},
|
||||
labelClass() {
|
||||
return `label ${this.colorScheme} ${this.small ? 'small' : ''}`;
|
||||
|
@ -94,9 +108,17 @@ export default {
|
|||
font-weight: var(--font-weight-medium);
|
||||
margin-right: var(--space-smaller);
|
||||
margin-bottom: var(--space-smaller);
|
||||
padding: var(--space-smaller);
|
||||
background: var(--s-50);
|
||||
color: var(--s-800);
|
||||
border: 1px solid var(--s-75);
|
||||
height: var(--space-medium);
|
||||
|
||||
&.small {
|
||||
font-size: var(--font-size-micro);
|
||||
padding: var(--space-micro) var(--space-smaller);
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.15px;
|
||||
}
|
||||
|
||||
.label--icon {
|
||||
|
@ -104,11 +126,6 @@ export default {
|
|||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
.close--icon {
|
||||
cursor: pointer;
|
||||
margin-left: var(--space-smaller);
|
||||
}
|
||||
|
||||
&.small .label--icon,
|
||||
&.small .close--icon {
|
||||
font-size: var(--font-size-nano);
|
||||
|
@ -164,7 +181,31 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
.label-close--button {
|
||||
color: var(--s-800);
|
||||
margin-bottom: var(--space-minus-micro);
|
||||
margin-left: var(--space-smaller);
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--s-100);
|
||||
}
|
||||
}
|
||||
|
||||
.label-action--button {
|
||||
margin-bottom: var(--space-minus-micro);
|
||||
}
|
||||
|
||||
.label-color-dot {
|
||||
display: inline-block;
|
||||
width: var(--space-one);
|
||||
height: var(--space-one);
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:aria-checked="value.toString()"
|
||||
@click="onClick"
|
||||
>
|
||||
<span aria-hidden="true" :class="{ active: value }"></span>
|
||||
<span aria-hidden="true" :class="{ active: value }" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -5,8 +5,61 @@ export default {
|
|||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
render() {
|
||||
data() {
|
||||
return { hasScroll: false };
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('resize', this.computeScrollWidth);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.computeScrollWidth);
|
||||
},
|
||||
mounted() {
|
||||
this.computeScrollWidth();
|
||||
},
|
||||
methods: {
|
||||
computeScrollWidth() {
|
||||
const tabElement = this.$el.getElementsByClassName('tabs')[0];
|
||||
this.hasScroll = tabElement.scrollWidth > tabElement.clientWidth;
|
||||
},
|
||||
onScrollClick(direction) {
|
||||
const tabElement = this.$el.getElementsByClassName('tabs')[0];
|
||||
let scrollPosition = tabElement.scrollLeft;
|
||||
if (direction === 'left') {
|
||||
scrollPosition -= 100;
|
||||
} else {
|
||||
scrollPosition += 100;
|
||||
}
|
||||
tabElement.scrollTo({
|
||||
top: 0,
|
||||
left: scrollPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
},
|
||||
createScrollButton(createElement, direction) {
|
||||
if (!this.hasScroll) {
|
||||
return false;
|
||||
}
|
||||
return createElement(
|
||||
'button',
|
||||
{
|
||||
class: 'tabs--scroll-button button clear secondary button--only-icon',
|
||||
on: { click: () => this.onScrollClick(direction) },
|
||||
},
|
||||
[
|
||||
createElement('fluent-icon', {
|
||||
props: { icon: `chevron-${direction}`, size: 16 },
|
||||
}),
|
||||
]
|
||||
);
|
||||
},
|
||||
},
|
||||
render(createElement) {
|
||||
const Tabs = this.$slots.default
|
||||
.filter(
|
||||
node =>
|
||||
|
@ -18,14 +71,21 @@ export default {
|
|||
data.index = index;
|
||||
return node;
|
||||
});
|
||||
const leftButton = this.createScrollButton(createElement, 'left');
|
||||
const rightButton = this.createScrollButton(createElement, 'right');
|
||||
return (
|
||||
<ul
|
||||
<div
|
||||
class={{
|
||||
tabs: true,
|
||||
'tabs--container--with-border': this.border,
|
||||
'tabs--container': true,
|
||||
}}
|
||||
>
|
||||
{Tabs}
|
||||
</ul>
|
||||
{leftButton}
|
||||
<ul class={{ tabs: true, 'tabs--with-scroll': this.hasScroll }}>
|
||||
{Tabs}
|
||||
</ul>
|
||||
{rightButton}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<button
|
||||
class="button"
|
||||
:type="type"
|
||||
:class="buttonClasses"
|
||||
:disabled="isDisabled || isLoading"
|
||||
@click="handleClick"
|
||||
|
@ -13,7 +14,7 @@
|
|||
:icon="icon"
|
||||
:icon-size="iconSize"
|
||||
/>
|
||||
<span v-if="$slots.default" class="button__content"><slot></slot></span>
|
||||
<span v-if="$slots.default" class="button__content"><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -24,6 +25,10 @@ export default {
|
|||
name: 'WootButton',
|
||||
components: { EmojiOrIcon, Spinner },
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'submit',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="action-message"
|
||||
></textarea>
|
||||
/>
|
||||
<p
|
||||
v-if="v.action_params.$dirty && v.action_params.$error"
|
||||
class="filter-error"
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
@input="updateValue"
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -40,13 +40,18 @@ export default {
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'circle',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
let style = {
|
||||
width: `${this.size}px`,
|
||||
height: `${this.size}px`,
|
||||
borderRadius: this.rounded ? '50%' : 0,
|
||||
borderRadius:
|
||||
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
|
||||
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
|
||||
fontSize: `${Math.floor(this.size / 2.5)}px`,
|
||||
};
|
||||
|
|
|
@ -215,6 +215,32 @@ export default {
|
|||
this.$emit('input', { ...payload, query_operator: value });
|
||||
},
|
||||
},
|
||||
custom_attribute_type: {
|
||||
get() {
|
||||
if (!this.customAttributeType) return '';
|
||||
return this.customAttributeType;
|
||||
},
|
||||
set() {
|
||||
const payload = this.value || {};
|
||||
this.$emit('input', {
|
||||
...payload,
|
||||
custom_attribute_type: this.customAttributeType,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
customAttributeType: {
|
||||
handler(value) {
|
||||
if (
|
||||
value === 'conversation_attribute' ||
|
||||
value === 'contact_attribute'
|
||||
) {
|
||||
this.value.custom_attribute_type = this.customAttributeType;
|
||||
} else this.value.custom_attribute_type = '';
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
removeFilter() {
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
:title="label.title"
|
||||
:description="label.description"
|
||||
:show-close="true"
|
||||
:bg-color="label.color"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
@click="removeItem"
|
||||
/>
|
||||
<div class="dropdown-wrap">
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<p v-if="headerContent" class="small-12 column">
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import Thumbnail from './Thumbnail.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Thumbnail',
|
||||
component: Thumbnail,
|
||||
argTypes: {
|
||||
src: {
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
size: {
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
badge: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: ['fb', 'whatsapp', 'sms', 'twitter-tweet', 'twitter-dm'],
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: ['circle', 'square'],
|
||||
},
|
||||
},
|
||||
username: {
|
||||
defaultValue: 'John Doe',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultValue: 'circle',
|
||||
control: {
|
||||
type: 'select',
|
||||
options: ['online', 'busy'],
|
||||
},
|
||||
},
|
||||
hasBorder: {
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
shouldShowStatusAlways: {
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { Thumbnail },
|
||||
template: '<thumbnail v-bind="$props" @click="onClick">{{label}}</thumbnail>',
|
||||
});
|
||||
|
||||
export const Primary = Template.bind({});
|
|
@ -12,6 +12,7 @@
|
|||
:username="username"
|
||||
:class="thumbnailClass"
|
||||
:size="avatarSize"
|
||||
:variant="variant"
|
||||
/>
|
||||
<img
|
||||
v-if="badge === 'instagram_direct_message'"
|
||||
|
@ -119,6 +120,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'circle',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -145,7 +150,9 @@ export default {
|
|||
},
|
||||
thumbnailClass() {
|
||||
const classname = this.hasBorder ? 'border' : '';
|
||||
return `user-thumbnail ${classname}`;
|
||||
const variant =
|
||||
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
|
||||
return `user-thumbnail ${classname} ${variant}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
@ -173,6 +180,9 @@ export default {
|
|||
|
||||
.user-thumbnail {
|
||||
border-radius: 50%;
|
||||
&.thumbnail-square {
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="audio-wave-wrapper">
|
||||
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin"></audio>
|
||||
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue