Compare commits

..
1 Commits
Author SHA1 Message Date
Shivam Mishra ce276f888a fix: remove error loader 2024-10-04 18:09:28 +05:30
974 changed files with 7429 additions and 11578 deletions
+129 -109
View File
@@ -1,87 +1,84 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
# Ruby CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
defaults: &defaults
working_directory: ~/build
machine:
image: ubuntu-2204:2024.05.1
resource_class: large
docker:
# specify the version you desire here
- image: cimg/ruby:3.3.3-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment:
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
jobs:
build:
<<: *defaults
steps:
- checkout
- node/install:
node-version: '20.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Install System Dependencies
name: Configure Bundler
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
redis-server \
postgresql \
build-essential \
git \
curl \
libssl-dev \
zlib1g-dev \
libreadline-dev \
libyaml-dev \
openjdk-11-jdk \
jq \
software-properties-common \
ca-certificates \
imagemagick \
libxml2-dev \
libxslt1-dev \
file \
g++ \
gcc \
autoconf \
gnupg2 \
patch \
ruby-dev \
liblzma-dev \
libgmp-dev \
libncurses5-dev \
libffi-dev \
libgdbm6 \
libgdbm-dev \
libvips
- run:
name: Install RVM and Ruby 3.3.3
command: |
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
source $BASH_ENV
gem install bundler
- run:
name: Install Application Dependencies
name: Which bundler?
command: bundle -v
- run:
name: Swap node versions
command: |
source ~/.rvm/scripts/rvm
bundle install
# pnpm install
set +e
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler
# Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit
- restore_cache:
keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- run: bundle install --frozen --path ~/.bundle
- save_cache:
paths:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- chatwoot-yarn-
- run:
name: yarn
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
# Store yarn / webpacker cache
- save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
- run:
name: Download cc-test-reporter
@@ -89,8 +86,12 @@ jobs:
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
# Swagger verification
# verify swagger specification
- run:
name: Verify swagger API specification
command: |
@@ -103,62 +104,45 @@ jobs:
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
- run:
name: Database Setup and Configure Environment Variables
command: |
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql
chmod 644 ${PWD}/.circleci/setup_chatwoot.sql
mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/
sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql
cp .env.example .env
sed -i '/^FRONTEND_URL/d' .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
# Database setup
- run:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
- run: bundle exec rake db:create
- run: bundle exec rake db:schema:load
# Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# ESLint linting
# - run:
# name: Brakeman
# command: bundle exec brakeman
- run:
name: eslint
command: pnpm run eslint
command: yarn run eslint
# Run frontend tests
- run:
name: Run frontend tests
command: |
mkdir -p ~/build/coverage/frontend
mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
yarn test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
# Run rails tests
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
mkdir -p coverage
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
@@ -166,18 +150,54 @@ jobs:
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage (Backend)
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
- persist_to_workspace:
root: ~/build
root: coverage
paths:
- coverage
- codeclimate.*.json
# collect reports
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/tmp/test-artifacts
- store_artifacts:
path: log
upload-coverage:
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:3.0.2-node-browsers
environment:
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
steps:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
- run:
name: Upload coverage results to Code Climate
command: |
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build
-11
View File
@@ -1,11 +0,0 @@
CREATE USER chatwoot CREATEDB;
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
ALTER ROLE chatwoot SUPERUSER;
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
DROP DATABASE template1;
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
\c template1;
VACUUM FREEZE;
+1 -2
View File
@@ -27,8 +27,7 @@ checks:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/**/**'
- '**/spec/**/**'
- '**/specs/'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
+1 -32
View File
@@ -1,37 +1,6 @@
module.exports = {
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
],
overrides: [
{
files: ['**/*.spec.{j,t}s?(x)'],
env: {
'vitest-globals/env': true,
},
},
{
files: ['**/*.story.vue'],
rules: {
'vue/no-undef-components': [
'error',
{
ignorePatterns: ['Variant', 'Story'],
},
],
// Story files can have static strings, it doesn't need to handle i18n always.
'vue/no-bare-strings-in-template': 'off',
'no-console': 'off',
},
},
],
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/vue3-recommended'],
plugins: ['html', 'prettier'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'prettier/prettier': ['error'],
camelcase: 'off',
-43
View File
@@ -1,43 +0,0 @@
name: Frontend Lint & Test
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile
- name: Run eslint
run: pnpm run eslint
- name: Run frontend tests with coverage
run: |
mkdir -p coverage
pnpm run test:coverage
+36 -39
View File
@@ -21,7 +21,7 @@ jobs:
image: postgres:15.3
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
@@ -41,49 +41,46 @@ jobs:
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Install pnpm dependencies
run: pnpm i
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+56 -56
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailbox (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.5)
actionpack (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailer (7.0.8.4)
actionpack (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionpack (7.0.8.4)
actionview (= 7.0.8.4)
activesupport (= 7.0.8.4)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.5)
actionpack (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actiontext (7.0.8.4)
actionpack (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
actionview (7.0.8.4)
activesupport (= 7.0.8.4)
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 (7.0.8.5)
activesupport (= 7.0.8.5)
activejob (7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.3.6)
activemodel (7.0.8.5)
activesupport (= 7.0.8.5)
activerecord (7.0.8.5)
activemodel (= 7.0.8.5)
activesupport (= 7.0.8.5)
activemodel (7.0.8.4)
activesupport (= 7.0.8.4)
activerecord (7.0.8.4)
activemodel (= 7.0.8.4)
activesupport (= 7.0.8.4)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activesupport (= 7.0.8.5)
activestorage (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activesupport (= 7.0.8.4)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.5)
activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -373,7 +373,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.6)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -479,7 +479,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.17)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@@ -557,7 +557,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.10)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -574,20 +574,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.5)
actioncable (= 7.0.8.5)
actionmailbox (= 7.0.8.5)
actionmailer (= 7.0.8.5)
actionpack (= 7.0.8.5)
actiontext (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activemodel (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
rails (7.0.8.4)
actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.4)
actiontext (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activemodel (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
bundler (>= 1.15.0)
railties (= 7.0.8.5)
railties (= 7.0.8.4)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -595,9 +595,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
railties (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
method_source
rake (>= 12.2)
thor (~> 1.0)
+2 -2
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup:
gem install bundler
bundle install
pnpm install
yarn install
db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
@@ -30,7 +30,7 @@ server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn:
bundle && pnpm install
bundle && yarn
run:
@if [ -f ./.overmind.sock ]; then \
@@ -1,15 +1,6 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
def find_channel_by_email
# find by imap_login first, and then by email
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
return imap_channel if imap_channel
Channel::Email.find_by(email: users_data['email'], account: account)
end
private
def provider_name
+1 -5
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
def find_or_create_inbox
channel_email = find_channel_by_email
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
@@ -39,10 +39,6 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists]
end
def find_channel_by_email
Channel::Email.find_by(email: users_data['email'], account: account)
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
+6 -10
View File
@@ -13,7 +13,6 @@ import { useStore } from 'dashboard/composables/store';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -36,9 +35,8 @@ export default {
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store, currentAccountId: accountId };
return { router, store };
},
data() {
return {
@@ -54,6 +52,7 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -70,13 +69,10 @@ export default {
this.showAddAccountModal = true;
}
},
currentAccountId: {
immediate: true,
handler() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
mounted() {
+1 -151
View File
@@ -46,158 +46,8 @@
@apply hidden;
}
// scss-lint:disable PropertySortOrder
@layer base {
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--background-color: 248 248 248;
--solid-1: 255 255 255;
--solid-2: 252 252 252;
--solid-3: 255 255 255;
--solid-active: 250 251 251;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--white-alpha: 255 255 255 0.1;
--border-weak: 231 231 231;
--border-strong: 235 235 235;
--blue: 39 129 246;
/* alpha is added by default */
--alpha-1: 36, 38, 48, 0.06;
--alpha-2: 130, 134, 150, 0.12;
--alpha-3: 255, 255, 255, 0.9;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
}
body.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--background-color: 18 18 19;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 36 38 48;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-active: 51 53 64;
--border-weak: 34 34 37;
--border-strong: 46 47 49;
--blue: 126 182 255;
/* alpha is added by default */
--alpha-1: 35, 37, 45, 0.8;
--alpha-2: 130, 134, 150, 0.15;
--alpha-3: 32, 33, 37, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
@@ -1,16 +0,0 @@
<script setup>
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div
class="relative flex flex-col w-full gap-3 px-6 py-5 group/cardLayout rounded-2xl bg-slate-25 dark:bg-slate-800/50"
@click="handleClick"
>
<slot name="header" />
<slot name="footer" />
</div>
</template>
@@ -1,47 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
});
</script>
<template>
<section
class="relative flex flex-col items-center justify-center w-full h-full min-h-screen p-4 overflow-hidden"
>
<div
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-9 bg-gradient-to-t from-white dark:from-slate-900 to-transparent font-interDisplay"
>
<div class="flex flex-col items-center justify-center gap-6">
<div class="flex flex-col items-center justify-center gap-2">
<h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white"
>
{{ title }}
</h2>
<p
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300"
>
{{ subtitle }}
</p>
</div>
<slot name="actions" />
</div>
</div>
</div>
</section>
</template>
@@ -1,56 +0,0 @@
<script setup>
import ArticleCard from './ArticleCard.vue';
const articles = [
{
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 1400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 4300,
},
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/HelpCenter/ArticleCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Article Card">
<div
v-for="(article, index) in articles"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<ArticleCard
:title="article.title"
:status="article.status"
:author="article.author"
:category="article.category"
:views="article.views"
:updated-at="article.updatedAt"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,142 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
title: {
type: String,
required: true,
},
status: {
type: String,
required: true,
},
author: {
type: String,
required: true,
},
category: {
type: String,
required: true,
},
views: {
type: Number,
required: true,
},
updatedAt: {
type: String,
required: true,
},
});
const isOpen = ref(false);
const menuItems = computed(() => {
const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }];
const menuOptions = {
archived: [
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Draft', action: 'draft', icon: 'draft' },
],
draft: [
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
],
'': [
// Empty string represents published status
{ label: 'Draft', action: 'draft', icon: 'draft' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
],
};
return [...(menuOptions[props.status] || menuOptions['']), ...baseItems];
});
const statusTextColor = computed(() => {
switch (props.status) {
case 'archived':
return '!text-slate-600 dark:!text-slate-200';
case 'draft':
return '!text-amber-700 dark:!text-amber-400';
default:
return '!text-teal-700 dark:!text-teal-400';
}
});
const statusText = computed(() => {
switch (props.status) {
case 'archived':
return 'Archived';
case 'draft':
return 'Draft';
default:
return 'Published';
}
});
const handleAction = () => {
isOpen.value = false;
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-1">
<span class="text-base text-slate-900 dark:text-slate-50 line-clamp-1">
{{ title }}
</span>
<div class="relative group">
<Button
variant="ghost"
size="sm"
class="text-xs bg-slate-50 !font-normal group-hover:bg-slate-100/50 dark:group-hover:bg-slate-700/50 !h-6 dark:bg-slate-800 rounded-md border-0 !px-2 !py-0.5"
:label="statusText"
:class="statusTextColor"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false">
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="right-0 mt-2 xl:left-0 top-full"
@action="handleAction"
/>
</OnClickOutside>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
<span class="text-sm text-slate-500 dark:text-slate-400">
{{ author }}
</span>
</div>
<span
class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400"
>
{{ category }}
</span>
<div
class="inline-flex items-center gap-1 text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
<FluentIcon icon="eye-show" size="18" />
<span class="text-sm"> {{ views }} views </span>
</div>
</div>
<span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1">
{{ updatedAt }}
</span>
</div>
</template>
</CardLayout>
</template>
@@ -1,41 +0,0 @@
<script setup>
import CategoryCard from './CategoryCard.vue';
const categories = [
{
id: 'getting-started',
title: '🚀 Getting started',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '5',
},
{
id: 'marketing',
title: '📈 Marketing',
description: '',
articlesCount: '4',
},
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/HelpCenter/CategoryCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Category Card">
<div
v-for="(category, index) in categories"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<CategoryCard
:title="category.title"
:description="category.description"
:articles-count="category.articlesCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,115 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
articlesCount: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
const emit = defineEmits(['click']);
const isOpen = ref(false);
const menuItems = [
{
label: 'Edit',
action: 'edit',
icon: 'edit',
},
{
label: 'Delete',
action: 'delete',
icon: 'delete',
},
];
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
const hasDescription = computed(() => {
return props.description.length > 0;
});
const handleClick = id => {
emit('click', id);
};
// eslint-disable-next-line no-unused-vars
const handleAction = action => {
// TODO: Implement action
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<CardLayout @click="handleClick(id)">
<template #header>
<div class="flex gap-2">
<div class="flex justify-between w-full">
<div class="flex items-center justify-start gap-2">
<span
class="text-base cursor-pointer group-hover/cardLayout:underline text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ title }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
>
{{ articlesCount }} articles
</span>
</div>
<div class="relative group" @click.stop>
<Button
variant="ghost"
size="icon"
icon="more-vertical"
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false">
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="right-0 mt-1 xl:left-0 top-full z-60"
@action="handleAction"
/>
</OnClickOutside>
</div>
</div>
</div>
</template>
<template #footer>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
>
{{ description }}
</span>
</template>
</CardLayout>
</template>
@@ -1,16 +0,0 @@
<script setup>
import ArticleEmptyState from './ArticleEmptyState.vue';
</script>
<template>
<Story
title="Components/HelpCenter/EmptyState/ArticleEmptyState"
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Article Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<ArticleEmptyState />
</div>
</Variant>
</Story>
</template>
@@ -1,87 +0,0 @@
<script setup>
// import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
// import AddLocaleDialog from 'dashboard/playground/HelpCenter/components/AddLocaleDialog.vue';
const articles = [
{
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
];
// const addLocaleDialogRef = ref(null);
// const openDialog = () => {
// addLocaleDialogRef.value.dialogRef.open();
// };
// const handleDialogConfirm = () => {
// // Add logic to create a new portal
// };
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<EmptyStateLayout
title="Write an article"
subtitle="Write a rich article, let's get started!"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4">
<ArticleCard
v-for="(article, index) in articles"
:key="`article-${index}`"
:title="article.title"
:status="article.status"
:updated-at="article.updatedAt"
:author="article.author"
:category="article.category"
:views="article.views"
/>
</div>
</template>
<template #actions>
<Button
variant="default"
label="New article"
icon="add"
@click="openDialog"
/>
<!-- <AddLocaleDialog
ref="addLocaleDialogRef"
@confirm="handleDialogConfirm"
/> -->
</template>
</EmptyStateLayout>
</template>
@@ -1,16 +0,0 @@
<script setup>
import PortalEmptyState from './PortalEmptyState.vue';
</script>
<template>
<Story
title="Components/HelpCenter/EmptyState/PortalEmptyState"
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Portal Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<PortalEmptyState />
</div>
</Variant>
</Story>
</template>
@@ -1,123 +0,0 @@
<script setup>
// import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
// import CreatePortalDialog from 'dashboard/playground/HelpCenter/components/CreatePortalDialog.vue';
const articles = [
{
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
];
const categories = [
{
title: 'Getting Started',
description: 'Essential guides for new users',
articlesCount: '5',
},
{
title: 'Advanced Features',
description: 'In-depth tutorials for power users',
articlesCount: '8',
},
];
const locales = [
{ name: 'English', isDefault: true },
{ name: 'Spanish', isDefault: false },
{ name: 'Malayalam', isDefault: false },
];
// const createPortalDialogRef = ref(null);
// const openDialog = () => {
// createPortalDialogRef.value.dialogRef.open();
// };
// const handleDialogConfirm = () => {
// // Add logic to create a new portal
// };
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<EmptyStateLayout
title="Help Center"
subtitle="Create self-service portals to access articles and information. Streamline queries, enhance agent efficiency, and elevate customer support."
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-4">
<ArticleCard
v-for="(article, index) in articles"
:key="`article-${index}`"
:title="article.title"
:status="article.status"
:updated-at="article.updatedAt"
:author="article.author"
:category="article.category"
:views="article.views"
/>
</div>
<div class="space-y-4">
<CategoryCard
v-for="(category, index) in categories"
:key="`category-${index}`"
:title="category.title"
:description="category.description"
:articles-count="category.articlesCount"
/>
<LocaleCard
v-for="(locale, index) in locales"
:key="`locale-${index}`"
:locale="locale.name"
:is-default="locale.isDefault"
/>
</div>
</div>
</template>
<template #actions>
<Button
variant="default"
label="Create Portal"
icon="add"
@click="openDialog"
/>
<!-- <CreatePortalDialog
ref="createPortalDialogRef"
@confirm="handleDialogConfirm"
/> -->
</template>
</EmptyStateLayout>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
defineProps({
header: {
type: String,
default: 'Chatwoot Help Center',
},
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showHeaderTitle: {
type: Boolean,
default: true,
},
showPaginationFooter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update:currentPage']);
const showPortalSwitcher = ref(false);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
const togglePortalSwitcher = () => {
showPortalSwitcher.value = !showPortalSwitcher.value;
};
</script>
<template>
<section
class="flex flex-col w-full h-full overflow-hidden bg-white dark:bg-slate-900"
>
<header
class="sticky top-0 z-10 px-6 pb-3 bg-white lg:px-0 dark:bg-slate-900"
>
<div class="w-full max-w-[900px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span class="text-xl font-medium text-slate-900 dark:text-white">
{{ header }}
</span>
<div class="relative group">
<Button
icon="more-vertical"
variant="ghost"
size="sm"
class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="togglePortalSwitcher"
/>
<OnClickOutside @trigger="showPortalSwitcher = false">
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute left-0 top-9"
/>
</OnClickOutside>
</div>
</div>
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-3">
<slot name="content" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pt-3 pb-4 bg-white dark:bg-slate-900"
>
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</section>
</template>
@@ -1,29 +0,0 @@
<script setup>
import LocaleCard from './LocaleCard.vue';
const locales = [
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/HelpCenter/LocaleCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Locale Card">
<div class="px-10 py-4 bg-white dark:bg-slate-900">
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
<LocaleCard
:locale="locale.name"
:is-default="locale.isDefault"
:article-count="locale.articleCount"
:category-count="locale.categoryCount"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,103 +0,0 @@
<script setup>
import { ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
defineProps({
locale: {
type: String,
required: true,
},
isDefault: {
type: Boolean,
required: true,
},
articleCount: {
type: Number,
required: true,
},
categoryCount: {
type: Number,
required: true,
},
});
const isOpen = ref(false);
const menuItems = [
{
label: 'Make default',
action: 'default',
icon: 'star-emphasis',
},
{
label: 'Delete',
action: 'delete',
icon: 'delete',
},
];
// eslint-disable-next-line no-unused-vars
const handleAction = action => {
// TODO: Implement action
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<CardLayout class="ltr:pr-2 rtl:pl-2">
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ locale }}
</span>
<span
v-if="isDefault"
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
>
Default
</span>
</div>
<div class="flex items-center justify-end gap-1">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ articleCount }} articles
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ categoryCount }} categories
</span>
</div>
<div class="relative group">
<Button
variant="ghost"
size="icon"
icon="more-vertical"
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false">
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]"
@action="handleAction"
/>
</OnClickOutside>
</div>
</div>
</div>
</template>
</CardLayout>
</template>
@@ -1,25 +0,0 @@
<script setup>
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
defineProps({
articles: {
type: Array,
required: true,
},
});
</script>
<template>
<ul role="list" class="w-full h-full space-y-4">
<ArticleCard
v-for="article in articles"
:key="article.title"
:title="article.title"
:status="article.status"
:author="article.author"
:category="article.category"
:views="article.views"
:updated-at="article.updatedAt"
/>
</ul>
</template>
@@ -1,72 +0,0 @@
<script setup>
import ArticlesPage from './ArticlesPage.vue';
const articles = [
{
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
];
</script>
<template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<ArticlesPage :articles="articles" />
</div>
</Variant>
</Story>
</template>
@@ -1,74 +0,0 @@
<script setup>
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
defineProps({
articles: {
type: Array,
required: true,
},
});
const tabs = [
{ label: 'All articles', count: 24 },
{ label: 'Mine', count: 13 },
{ label: 'Draft', count: 5 },
{ label: 'Archived', count: 11 },
];
// TODO: remove comments
// eslint-disable-next-line no-unused-vars
const handleTabChange = tab => {
// TODO: Implement tab change logic
};
// eslint-disable-next-line no-unused-vars
const handlePageChange = page => {
// TODO: Implement page change logic
};
</script>
<template>
<HelpCenterLayout
:current-page="1"
:total-items="100"
:items-per-page="10"
@update:current-page="handlePageChange"
>
<template #header-actions>
<div class="flex items-end justify-between">
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
<TabBar
:tabs="tabs"
:initial-active-tab="1"
@tab-changed="handleTabChange"
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<Button
label="English"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
<Button
label="All categories"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
</div>
<Button label="New article" icon="add" size="sm" />
</div>
</div>
</div>
</template>
<template #content>
<ArticleList :articles="articles" />
</template>
</HelpCenterLayout>
</template>
@@ -1,209 +0,0 @@
<script setup>
import CategoriesPage from './CategoriesPage.vue';
const categories = [
{
id: 'getting-started',
title: '🚀 Getting started',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
},
{
id: 'marketing',
title: 'Marketing',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
articlesCount: '4',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Published article',
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
],
},
{
id: 'development',
title: 'Development',
description: '',
articlesCount: '5',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Published article',
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
],
},
{
id: 'roadmap',
title: '🛣️ Roadmap',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '3',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
},
{
id: 'finance',
title: '💰 Finance',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
},
];
</script>
<template>
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
<Variant title="All Categories">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<CategoriesPage :categories="categories" />
</div>
</Variant>
</Story>
</template>
@@ -1,101 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
// import { OnClickOutside } from '@vueuse/components';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
// import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue';
const props = defineProps({
categories: {
type: Array,
required: true,
},
});
const selectedCategory = ref(null);
// const showEditCategory = ref(false);
// const openEditCategory = () => {
// showEditCategory.value = true;
// };
// const closeEditCategory = () => {
// showEditCategory.value = false;
// };
const breadcrumbItems = computed(() => {
const items = [{ label: 'Categories (en-US)', link: '#' }];
if (selectedCategory.value) {
items.push({
label: selectedCategory.value.title,
count: selectedCategory.value.articles.length,
});
}
return items;
});
const openCategoryArticles = id => {
selectedCategory.value = props.categories.find(
category => category.id === id
);
};
const resetCategory = () => {
selectedCategory.value = null;
};
const displayedArticles = computed(() => {
return selectedCategory.value ? selectedCategory.value.articles : [];
});
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div v-if="!selectedCategory" class="flex items-center gap-4">
<Button
label="English"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
<div
class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ categories.length }} categories
</span>
</div>
<Breadcrumb v-else :items="breadcrumbItems" @click="resetCategory" />
<Button
v-if="!selectedCategory"
label="New category"
icon="add"
size="sm"
/>
<div v-else class="relative">
<Button
label="Edit category"
variant="secondary"
size="sm"
@click="openEditCategory"
/>
<!-- <OnClickOutside @trigger="closeEditCategory">
<EditCategory v-if="showEditCategory" @close="closeEditCategory" />
</OnClickOutside> -->
</div>
</div>
</template>
<template #content>
<CategoryList
v-if="!selectedCategory"
:categories="categories"
@click="openCategoryArticles"
/>
<ArticleList v-else :articles="displayedArticles" />
</template>
</HelpCenterLayout>
</template>
@@ -1,30 +0,0 @@
<script setup>
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['click']);
const handleClick = id => {
emit('click', id);
};
</script>
<template>
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
<CategoryCard
v-for="category in categories"
:id="category.id"
:key="category.title"
:title="category.title"
:description="category.description"
:articles-count="category.articlesCount"
@click="handleClick(category.id)"
/>
</ul>
</template>
@@ -1,146 +0,0 @@
<script setup>
import { computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
const { article } = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle']);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => article.title,
set: title => {
saveArticle({ title });
},
});
const articleContent = computed({
get: () => article.content,
set: content => {
saveArticle({ content });
},
});
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between h-20">
<Button
label="Back to articles"
icon="chevron-lucide-left"
icon-lib="lucide"
variant="link"
text-variant="info"
size="sm"
/>
<div class="flex items-center gap-4">
<span class="text-xs font-medium text-slate-500 dark:text-slate-400">
Saved
</span>
<div class="flex items-center gap-2">
<Button label="Preview" variant="secondary" size="sm" />
<Button
label="Publish"
icon="chevron-lucide-down"
icon-position="right"
icon-lib="lucide"
size="sm"
/>
</div>
</div>
</div>
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
<TextArea
v-model="articleTitle"
class="h-12"
custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]"
placeholder="Title"
/>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" />
<span class="text-sm text-slate-500 dark:text-slate-400">
John Doe
</span>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<Button
label="Uncategorized"
icon="play-shape"
variant="ghost"
class="!px-2 font-normal"
text-variant="info"
/>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<Button
label="More properties"
icon="add"
variant="ghost"
class="!px-2 font-normal"
/>
</div>
</div>
<FullEditor
v-model="articleContent"
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
placeholder="Write something"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
/>
</template>
</HelpCenterLayout>
</template>
<style lang="scss" scoped>
::v-deep {
.ProseMirror .empty-node::before {
@apply text-slate-200 dark:text-slate-500 text-base;
}
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
display: none; // Hide by default
}
.editor-root .has-selection {
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-1 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
}
}
}
}
}
</style>
@@ -1,23 +0,0 @@
<script setup>
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
defineProps({
locales: {
type: Array,
required: true,
},
});
</script>
<template>
<ul role="list" class="w-full h-full space-y-4">
<LocaleCard
v-for="(locale, index) in locales"
:key="index"
:locale="locale.name"
:is-default="locale.isDefault"
:article-count="locale.articleCount"
:category-count="locale.categoryCount"
/>
</ul>
</template>
@@ -1,52 +0,0 @@
<script setup>
import LocalesPage from './LocalesPage.vue';
const locales = [
{
name: 'English (en-US)',
isDefault: true,
articleCount: 5,
categoryCount: 5,
},
{
name: 'Spanish (es-ES)',
isDefault: false,
articleCount: 20,
categoryCount: 10,
},
{
name: 'English (en-UK)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malay (ms-MY)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malayalam (ml-IN)',
isDefault: false,
articleCount: 10,
categoryCount: 5,
},
{
name: 'Hindi (hi-IN)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
];
</script>
<template>
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
<Variant title="All Locales">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<LocalesPage :locales="locales" />
</div>
</Variant>
</Story>
</template>
@@ -1,47 +0,0 @@
<script setup>
import { computed } from 'vue';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
const props = defineProps({
locales: {
type: Array,
required: true,
},
});
const localeCount = computed(() => props.locales?.length);
// TODO: remove comments
// eslint-disable-next-line no-unused-vars
const handleTabChange = tab => {
// TODO: Implement tab change logic
};
// eslint-disable-next-line no-unused-vars
const handlePageChange = page => {
// TODO: Implement page change logic
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="add"
size="sm"
/>
</div>
</template>
<template #content>
<LocaleList :locales="locales" />
</template>
</HelpCenterLayout>
</template>
@@ -1,13 +0,0 @@
<script setup>
import PortalSettings from './PortalSettings.vue';
</script>
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
<PortalSettings />
</div>
</Variant>
</Story>
</template>
@@ -1,113 +0,0 @@
<script setup>
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const handleUploadAvatar = () => {};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #content>
<div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8">
<div class="flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-2">
<label
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
Avatar
</label>
<Avatar
label="Avatar"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-400"
@upload="handleUploadAvatar"
/>
</div>
<div class="flex flex-col w-full gap-2">
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Name
</label>
<Input placeholder="Name" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Header text
</label>
<Input placeholder="Header text" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Page title
</label>
<Input placeholder="Page title" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Widget color
</label>
</div>
<div class="flex justify-end w-full gap-2 py-2">
<Button label="Save changes" size="sm" />
</div>
</div>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
</div>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col w-full gap-6">
<h6 class="text-base font-medium text-slate-900 dark:text-slate-50">
Configuration
</h6>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Slug"
label="Slug:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Custom domain"
label="Custom domain:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Home page link"
label="Home page link:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
</div>
</div>
<div class="flex justify-end w-full gap-3 py-4">
<Button label="Edit configuration" size="sm" variant="secondary" />
<Button
label="Delete Test-Help Center"
size="sm"
variant="destructive"
/>
</div>
</div>
</div>
</template>
</HelpCenterLayout>
</template>
@@ -1,44 +0,0 @@
<script setup>
import PortalSwitcher from './PortalSwitcher.vue';
const portals = [
{
id: 1,
name: 'Chatwoot Help Center',
articles: 67,
domain: 'chatwoot.help',
slug: 'help-center',
},
{
id: 2,
name: 'Chatwoot Handbook',
articles: 42,
domain: 'chatwoot.help',
slug: 'handbook',
},
{
id: 3,
name: 'Developer Documentation',
articles: 89,
domain: 'dev.chatwoot.com',
slug: 'docs',
},
];
</script>
<template>
<Story
title="Components/HelpCenter/PortalSwitcher"
:layout="{ type: 'grid', width: '510px' }"
>
<Variant title="Portal Switcher">
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
<PortalSwitcher
:portals="portals"
header="Choose a Portal"
description="Select from available help center portals"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,111 +0,0 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
portals: {
type: Array,
default: () => [
{
id: 1,
name: 'Chatwoot Help Center',
articles: 67,
domain: 'chatwoot.help',
slug: 'help-center',
},
{
id: 2,
name: 'Chatwoot Handbook',
articles: 42,
domain: 'chatwoot.help',
slug: 'handbook',
},
],
},
header: {
type: String,
default: 'Portals',
},
description: {
type: String,
default: 'Create and manage multiple portals',
},
});
const selectedPortal = ref(1);
const handlePortalChange = id => {
selectedPortal.value = id;
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<div
class="pt-5 pb-3 bg-white z-50 dark:bg-slate-800 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
>
<div class="flex items-center justify-between gap-4 px-6 pb-2">
<div class="flex flex-col gap-1">
<h2 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ header }}
</h2>
<p class="text-sm text-slate-600 dark:text-slate-300">
{{ description }}
</p>
</div>
<Button label="New portal" variant="secondary" icon="add" size="sm" />
</div>
<div v-if="portals.length > 0" class="flex flex-col gap-3">
<template v-for="(portal, index) in portals" :key="portal.id">
<div class="flex flex-col gap-2 px-6 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
:id="portal.id"
v-model="selectedPortal"
type="radio"
:value="portal.id"
class="mr-3"
@change="handlePortalChange(portal.id)"
/>
<label
:for="portal.id"
class="text-sm font-medium text-slate-900 dark:text-slate-100"
>
{{ portal.name }}
</label>
</div>
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
</div>
<div class="inline-flex items-center gap-2 py-1 text-sm">
<span class="text-slate-600 dark:text-slate-400">
articles:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.articles }}
</span>
</span>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
<span class="text-slate-600 dark:text-slate-400">
domain:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.domain }}
</span>
</span>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
<span class="text-slate-600 dark:text-slate-400">
slug:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.slug }}
</span>
</span>
</div>
</div>
<div
v-if="index < portals.length - 1 && portals.length > 1"
class="w-full h-px bg-slate-50 dark:bg-slate-700/50"
/>
</template>
</div>
</div>
</template>
@@ -1,36 +0,0 @@
<script setup>
import Avatar from './Avatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
/>
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,51 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from '../icon/Icon.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
});
const emit = defineEmits(['upload']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const handleUploadAvatar = () => {
emit('upload');
};
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
:style="{
width: avatarSize,
height: avatarSize,
}"
>
<img
v-if="src"
:src="props.src"
alt="avatar"
class="w-full h-full shadow-sm rounded-xl"
/>
<div
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
:size="iconSize"
class="text-white dark:text-white"
/>
</div>
</div>
</template>
@@ -1,63 +0,0 @@
<script setup>
import { ref } from 'vue';
import Breadcrumb from './Breadcrumb.vue';
const singleItem = ref([{ label: 'Home', link: '#' }]);
const twoItems = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
]);
const threeItems = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
{ label: 'Marketing', count: 6 },
]);
const longBreadcrumb = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
{ label: 'Marketing', link: '#' },
{ label: 'Digital', link: '#' },
{ label: 'Social Media', count: 12 },
]);
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Breadcrumb"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Single Item">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="singleItem" />
</div>
</Variant>
<Variant title="Two Items">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="twoItems" />
</div>
</Variant>
<Variant title="Three Items with Count">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</Variant>
<Variant title="Long Breadcrumb">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
</div>
</Variant>
<Variant title="RTL Support">
<div dir="rtl">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,60 +0,0 @@
<script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
defineProps({
items: {
type: Array,
required: true,
validator: value => {
return value.every(
item =>
typeof item.label === 'string' &&
(item.link === undefined || typeof item.link === 'string') &&
(item.count === undefined || typeof item.count === 'number')
);
},
},
countLabel: {
type: String,
default: '',
},
});
const { t } = useI18n();
</script>
<template>
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li
v-for="(item, index) in items"
:key="index"
class="flex items-center gap-3"
>
<template v-if="index === items.length - 1">
<span class="text-sm text-slate-900 dark:text-slate-50">
{{
`${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}`
}}
</span>
</template>
<a
v-else
:href="item.link"
class="text-sm transition-colors duration-200 text-slate-300 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-100"
>
{{ item.label }}
</a>
<FluentIcon
v-if="index < items.length - 1"
icon="chevron-lucide-right"
size="18"
icon-lib="lucide"
class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3"
/>
</li>
</ol>
</nav>
</template>
@@ -1,126 +0,0 @@
<script setup>
import Button from './Button.vue';
</script>
<template>
<Story title="Components/Button" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<Button label="Default Button" />
</div>
</Variant>
<Variant title="Disabled">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Disabled" disabled />
<Button label="Disabled" variant="outline" disabled />
<Button label="Disabled" disabled icon="delete" variant="outline" />
<Button
label="Disabled"
disabled
icon="delete"
variant="destructive"
size="sm"
/>
<Button
label="Disabled"
disabled
icon="delete"
variant="ghost"
size="sm"
/>
<Button
label="Disabled"
disabled
icon="delete"
variant="link"
size="sm"
/>
</div>
</Variant>
<Variant title="Disabled with icon">
<div class="p-4 bg-white dark:bg-slate-900">
<Button label="Disabled Button" icon="emoji-add" disabled />
</div>
</Variant>
<Variant title="Different variant">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" variant="default" />
<Button label="Destructive" variant="destructive" />
<Button label="Outline" variant="outline" />
<Button label="Secondary" variant="secondary" />
<Button label="Ghost" variant="ghost" />
<Button label="Link" variant="link" />
</div>
</Variant>
<Variant title="Different variant with icon only">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button icon="emoji-add" variant="default" />
<Button icon="emoji-add" variant="destructive" />
<Button icon="emoji-add" variant="outline" />
<Button icon="emoji-add" variant="secondary" />
<Button icon="emoji-add" variant="ghost" />
<Button icon="emoji-add" variant="link" />
</div>
</Variant>
<Variant title="Different size">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" />
<Button label="Large" size="lg" />
<Button label="Small" size="sm" />
</div>
</Variant>
<Variant title="Different text variant">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" text-variant="default" variant="outline" />
<Button label="Success" text-variant="success" variant="outline" />
<Button label="Warning" text-variant="warning" variant="outline" />
<Button label="Danger" text-variant="danger" variant="outline" />
<Button label="Info" text-variant="info" variant="outline" />
</div>
</Variant>
<Variant title="Button with left icon with different sizes and icon only">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" icon="emoji-add" icon-position="left" />
<Button
label="Default LG"
icon="emoji-add"
icon-position="left"
size="lg"
/>
<Button
label="Default SM"
icon="emoji-add"
icon-position="left"
size="sm"
/>
<Button icon="emoji-add" size="icon" />
</div>
</Variant>
<Variant title="Button with right icon with different sizes and icon only">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" icon="emoji-add" icon-position="right" />
<Button
label="Default LG"
icon="emoji-add"
icon-position="right"
size="lg"
/>
<Button
label="Default SM"
icon="emoji-add"
icon-position="right"
size="sm"
/>
<Button icon="emoji-add" size="icon" />
</div>
</Variant>
</Story>
</template>
@@ -1,130 +0,0 @@
<script setup>
import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'default',
validator: value =>
[
'default',
'destructive',
'outline',
'secondary',
'ghost',
'link',
].includes(value),
},
textVariant: {
type: String,
default: '',
validator: value =>
['', 'default', 'success', 'warning', 'danger', 'info'].includes(value),
},
size: {
type: String,
default: 'default',
validator: value => ['default', 'sm', 'lg', 'icon'].includes(value),
},
icon: {
type: String,
default: '',
},
iconPosition: {
type: String,
default: 'left',
validator: value => ['left', 'right'].includes(value),
},
iconLib: {
type: String,
default: 'fluent',
},
});
const emit = defineEmits(['click']);
const buttonVariants = {
variant: {
default:
'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600',
destructive:
'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800',
outline:
'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600',
secondary:
'bg-slate-50 text-slate-900 dark:bg-slate-700/50 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-600',
ghost:
'text-slate-900 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800',
link: 'text-woot-500 underline-offset-4 hover:underline dark:hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-8 px-3',
lg: 'h-11 px-4',
icon: 'h-auto w-auto px-2',
},
text: {
default:
'!text-woot-500 dark:!text-woot-500 hover:!text-woot-600 dark:hover:!text-woot-600',
success:
'!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600',
warning:
'!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600',
danger:
'!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800',
info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500',
},
};
const buttonClasses = computed(() => {
const classes = [
buttonVariants.variant[props.variant],
buttonVariants.size[props.size],
];
if (props.textVariant && buttonVariants.text[props.textVariant]) {
classes.push(buttonVariants.text[props.textVariant]);
}
return classes.join(' ');
});
const iconSize = computed(() => {
if (props.size === 'sm') return 16;
if (props.size === 'lg') return 20;
return 18;
});
const handleClick = () => {
emit('click');
};
</script>
<template>
<button
:class="buttonClasses"
class="inline-flex items-center justify-center h-10 min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
@click="handleClick"
>
<FluentIcon
v-if="icon && iconPosition === 'left'"
:icon="icon"
:size="iconSize"
:icon-lib="iconLib"
class="flex-shrink-0"
/>
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
<FluentIcon
v-if="icon && iconPosition === 'right'"
:icon="icon"
:size="iconSize"
:icon-lib="iconLib"
class="flex-shrink-0"
/>
</button>
</template>
@@ -1,30 +0,0 @@
<script setup>
import { ref } from 'vue';
import ComboBox from './ComboBox.vue';
const options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
{ value: 5, label: 'Option 5' },
];
const selectedValue = ref('');
</script>
<template>
<Story title="Components/ComboBox" :layout="{ type: 'grid', width: '300px' }">
<Variant title="Default">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<ComboBox v-model="selectedValue" :options="options" />
<p class="mt-2">Selected value: {{ selectedValue }}</p>
</div>
</Variant>
<Variant title="Disabled">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<ComboBox :options="options" disabled />
</div>
</Variant>
</Story>
</template>
@@ -1,163 +0,0 @@
<script setup>
import { nextTick, ref, computed, watch } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
options: {
type: Array,
required: true,
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: '',
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValue = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const searchInput = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label.toLowerCase().includes(searchTerm)
);
});
const selectPlaceholder = computed(() => {
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
});
const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? selectPlaceholder.value;
});
const selectOption = option => {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
open.value = false;
search.value = '';
};
const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => searchInput.value.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValue.value = newValue;
}
);
onClickOutside(comboboxRef, () => {
open.value = false;
});
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
>
<Button
variant="outline"
:label="selectedLabel"
icon-position="right"
size="sm"
:disabled="disabled"
class="justify-between w-full text-slate-900 dark:text-slate-100 group-hover/combobox:border-slate-300 dark:group-hover/combobox:border-slate-600"
:icon="open ? 'chevron-up' : 'chevron-down'"
@click="toggleDropdown"
/>
<div
v-show="open"
class="absolute z-50 w-full mt-1 transition-opacity duration-200 bg-white border rounded-md shadow-lg border-slate-200 dark:bg-slate-900 dark:border-slate-700/50"
>
<div class="relative border-b border-slate-100 dark:border-slate-700/50">
<FluentIcon
icon="search"
:size="14"
class="absolute text-gray-400 dark:text-slate-500 top-3 left-3"
aria-hidden="true"
/>
<input
ref="searchInput"
v-model="search"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm bg-white border-none rounded-t-md dark:bg-slate-900 text-slate-900 dark:text-slate-50"
/>
</div>
<ul
class="py-1 overflow-auto max-h-60"
role="listbox"
:aria-activedescendant="selectedValue"
>
<li
v-for="option in filteredOptions"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
:class="{
'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue,
}"
role="option"
:aria-selected="option.value === selectedValue"
@click="selectOption(option)"
>
<span :class="{ 'font-medium': option.value === selectedValue }">
{{ option.label }}
</span>
<FluentIcon
v-if="option.value === selectedValue"
icon="checkmark"
:size="16"
class="flex-shrink-0"
aria-hidden="true"
/>
</li>
<li
v-if="filteredOptions.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
</div>
</template>
@@ -1,81 +0,0 @@
<script setup>
import { ref } from 'vue';
import Dialog from './Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const alertDialog = ref(null);
const editDialog = ref(null);
const confirmDialog = ref(null);
const openAlertDialog = () => {
alertDialog.value.open();
};
const openEditDialog = () => {
editDialog.value.open();
};
const openConfirmDialog = () => {
confirmDialog.value.open();
};
// eslint-disable-next-line no-unused-vars
const onConfirm = dialog => {};
</script>
<template>
<Story title="Components/Dialog" :layout="{ type: 'grid', width: '100%' }">
<Variant title="Alert Dialog">
<Button label="Open Alert Dialog" @click="openAlertDialog" />
<Dialog
ref="alertDialog"
type="alert"
title="Alert"
description="This is an alert message."
/>
</Variant>
<Variant title="Edit Dialog">
<Button label="Open Edit Dialog" @click="openEditDialog" />
<Dialog
ref="editDialog"
type="edit"
description="You can create a new portal here, by providing a name and a slug."
title="Create Portal"
confirm-button-label="Save"
@confirm="onConfirm()"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
type="text"
placeholder="User Guide | Chatwoot"
label="Name"
message="This will be the name of your public facing portal"
/>
<Input
id="portal-slug"
type="text"
placeholder="user-guide"
label="Slug"
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
/>
</div>
</template>
</Dialog>
</Variant>
<Variant title="Confirm Dialog">
<Button label="Open Confirm Dialog" @click="openConfirmDialog" />
<Dialog
ref="confirmDialog"
type="confirm"
title="Confirm Action"
description="Are you sure you want to perform this action?"
confirm-button-label="Yes, I'm sure"
cancel-button-label="No, cancel"
@confirm="onConfirm()"
/>
</Variant>
</Story>
</template>
@@ -1,109 +0,0 @@
<script setup>
import { ref } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
type: {
type: String,
default: 'edit',
validator: value => ['alert', 'edit'].includes(value),
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
cancelButtonLabel: {
type: String,
default: '',
},
confirmButtonLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const open = () => {
dialogRef.value?.showModal();
};
const close = () => {
dialogRef.value?.close();
};
const confirm = () => {
emit('confirm');
};
defineExpose({ open });
onClickOutside(dialogContentRef, event => {
if (
dialogRef.value &&
dialogRef.value.open &&
event.target === dialogRef.value
) {
close();
}
});
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full max-w-lg overflow-visible shadow-xl bg-modal-backdrop-light dark:bg-modal-backdrop-dark rounded-xl"
@close="close"
>
<div
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-white shadow-xl dark:bg-slate-800 rounded-xl"
@click.stop
>
<div class="flex flex-col gap-2">
<h3
class="text-base font-medium leading-6 text-gray-900 dark:text-white"
>
{{ title }}
</h3>
<p
v-if="description"
class="mb-0 text-sm text-slate-500 dark:text-slate-400"
>
{{ description }}
</p>
</div>
<slot name="form">
<!-- Form content will be injected here -->
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="secondary"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
size="sm"
@click="close"
/>
<Button
v-if="type !== 'alert'"
:variant="type === 'edit' ? 'default' : 'destructive'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
size="sm"
@click="confirm"
/>
</div>
</div>
</dialog>
</Teleport>
</template>
@@ -1,55 +0,0 @@
<script setup>
import { ref } from 'vue';
import DropdownMenu from './DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const menuItems = [
{ label: 'Edit', action: 'edit', icon: 'edit' },
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
{ label: 'Delete', action: 'delete', icon: 'delete' },
];
const isOpen = ref(false);
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const handleAction = () => {
isOpen.value = false;
};
</script>
<template>
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
<Variant title="Default">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<div class="relative">
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="absolute left-0 top-10"
@action="handleAction"
/>
</div>
</div>
</Variant>
<Variant title="Always Open">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
</div>
</Variant>
<Variant title="Custom Items">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu
:menu-items="[
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
{ label: 'Danger', action: 'delete', icon: 'delete' },
]"
@action="handleAction"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,35 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
menuItems: {
type: Array,
required: true,
},
});
const emit = defineEmits(['action']);
const handleAction = action => {
emit('action', action);
};
</script>
<template>
<div
class="bg-white dark:bg-slate-800 absolute rounded-xl z-50 py-3 px-1 gap-2 flex flex-col min-w-[136px] shadow-lg"
>
<Button
v-for="item in menuItems"
:key="item.action"
:label="item.label"
:icon="item.icon"
variant="ghost"
size="sm"
class="!justify-start w-full hover:bg-white dark:hover:bg-slate-800 z-60 font-normal"
:text-variant="item.action === 'delete' ? 'danger' : ''"
@click="handleAction(item.action)"
/>
</div>
</template>
@@ -1,17 +0,0 @@
<script setup>
import { h, isVNode } from 'vue';
const props = defineProps({
icon: { type: [String, Object, Function], required: true },
});
const renderIcon = () => {
if (!props.icon) return null;
if (typeof props.icon === 'function' || isVNode(props.icon)) {
return props.icon;
}
return h('span', { class: props.icon });
};
</script>
<template>
<component :is="renderIcon" />
</template>
@@ -1,82 +0,0 @@
<script setup>
import InlineInput from './InlineInput.vue';
</script>
<template>
<Story
title="Components/InlineInput"
:layout="{ type: 'grid', width: '400' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput id="inline-input-1" placeholder="Default InlineInput" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-2"
label="Username"
placeholder="Enter your username"
/>
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-3"
label="Disabled InlineInput"
placeholder="Can't type here"
disabled
/>
</div>
</Variant>
<Variant title="With Custom Classes">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-4"
label="Custom Input Class"
placeholder="Custom input style"
custom-input-class="placeholder:text-green-200 dark:placeholder:text-green-800"
/>
<InlineInput
id="inline-input-5"
label="Custom Label Class"
placeholder="Custom label style"
custom-label-class="text-green-600 dark:text-green-400"
/>
</div>
</Variant>
<Variant title="Different Types">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-6"
label="Text"
placeholder="Text input"
/>
<InlineInput
id="inline-input-7"
label="Number"
placeholder="Number input"
type="number"
/>
<InlineInput
id="inline-input-8"
label="Password"
placeholder="Password input"
type="password"
/>
<InlineInput
id="inline-input-9"
label="Email"
placeholder="Email input"
type="email"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,65 +0,0 @@
<script setup>
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
customLabelClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<div
class="relative flex items-center justify-between w-full gap-2 whitespace-nowrap"
>
<label
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding custom labels to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
/>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup>
import Input from './Input.vue';
</script>
<template>
<Story title="Components/Input" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<Input placeholder="Default Input" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<Input label="Username" placeholder="Enter your username" />
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<Input label="Disabled Input" placeholder="Can't type here" disabled />
</div>
</Variant>
<Variant title="With Message">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<Input
label="Email"
placeholder="Enter your email"
message="We'll never share your email."
/>
<Input
label="Password"
type="password"
placeholder="Enter your password"
message="Password is incorrect"
message-type="error"
/>
<Input
label="Verification Code"
placeholder="Enter the code"
message="Code verified successfully"
message-type="success"
/>
</div>
</Variant>
<Variant title="Different Types">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<Input label="Text" type="text" placeholder="Text input" />
<Input label="Number" type="number" placeholder="Number input" />
<Input label="Password" type="password" placeholder="Password input" />
<Input label="Email" type="email" placeholder="Email input" />
<Input label="Date" type="date" />
</div>
</Variant>
<Variant title="Custom Input Class">
<div class="p-4 bg-white dark:bg-slate-800">
<Input
label="Custom Style"
placeholder="Custom input class"
custom-input-class="border-yellow-500 dark:border-yellow-700"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,92 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
});
defineEmits(['update:modelValue']);
const messageClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'text-red-500 dark:text-red-400';
case 'success':
return 'text-green-500 dark:text-green-400';
default:
return 'text-slate-500 dark:text-slate-400';
}
});
const inputBorderClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'border-red-500 dark:border-red-400';
default:
return 'border-slate-100 dark:border-slate-700/50';
}
});
</script>
<template>
<div class="relative flex flex-col gap-1">
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:class="[customInputClass, inputBorderClass]"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
class="flex w-full reset-base text-sm h-8 pl-3 pr-2 rtl:pr-3 rtl:pl-2 py-1.5 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
/>
<p
v-if="message"
class="mt-1 mb-0 text-xs transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>
@@ -1,80 +0,0 @@
<script setup>
import { ref } from 'vue';
import PaginationFooter from './PaginationFooter.vue';
const createPaginationState = (initialPage, totalItems, itemsPerPage) => {
const currentPage = ref(initialPage);
const handlePageChange = newPage => {
currentPage.value = newPage;
};
return { currentPage, totalItems, itemsPerPage, handlePageChange };
};
const defaultState = createPaginationState(1, 100, 16);
const middlePageState = createPaginationState(3, 100, 16);
const lastPageState = createPaginationState(7, 100, 16);
const customItemsState = createPaginationState(2, 100, 10);
const singlePageState = createPaginationState(1, 10, 16);
</script>
<template>
<Story
title="Components/PaginationFooter"
:layout="{ type: 'grid', width: '957' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="defaultState.currentPage.value"
:total-items="defaultState.totalItems"
:items-per-page="defaultState.itemsPerPage"
@update:current-page="defaultState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Middle Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="middlePageState.currentPage.value"
:total-items="middlePageState.totalItems"
:items-per-page="middlePageState.itemsPerPage"
@update:current-page="middlePageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Last Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="lastPageState.currentPage.value"
:total-items="lastPageState.totalItems"
:items-per-page="lastPageState.itemsPerPage"
@update:current-page="lastPageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Custom Items Per Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="customItemsState.currentPage.value"
:total-items="customItemsState.totalItems"
:items-per-page="customItemsState.itemsPerPage"
@update:current-page="customItemsState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Single Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="singlePageState.currentPage.value"
:total-items="singlePageState.totalItems"
:items-per-page="singlePageState.itemsPerPage"
@update:current-page="singlePageState.handlePageChange"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,113 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
currentPage: {
type: Number,
required: true,
},
totalItems: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
default: 16,
},
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
);
const startItem = computed(
() => (props.currentPage - 1) * props.itemsPerPage + 1
);
const endItem = computed(() =>
Math.min(startItem.value + props.itemsPerPage - 1, props.totalItems)
);
const isFirstPage = computed(() => props.currentPage === 1);
const isLastPage = computed(() => props.currentPage === totalPages.value);
const changePage = newPage => {
if (newPage >= 1 && newPage <= totalPages.value) {
emit('update:currentPage', newPage);
}
};
const currentPageInformation = computed(() => {
return t('PAGINATION_FOOTER.SHOWING', {
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
});
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
});
</script>
<template>
<div
class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-slate-25 dark:bg-slate-800/50 rounded-xl py-2 px-3 items-center"
>
<div class="flex items-center gap-3">
<span
class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300"
>
{{ currentPageInformation }}
</span>
</div>
<div class="flex items-center gap-2">
<Button
icon="chevrons-lucide-left"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isFirstPage"
@click="changePage(1)"
/>
<Button
icon="chevron-lucide-left"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isFirstPage"
@click="changePage(currentPage - 1)"
/>
<div
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"
>
<span
class="px-3 tabular-nums py-0.5 bg-white dark:bg-slate-900 rounded-md"
>
{{ currentPage }}
</span>
<span>{{ pageInfo }}</span>
</div>
<Button
icon="chevron-lucide-right"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isLastPage"
@click="changePage(currentPage + 1)"
/>
<Button
icon="chevrons-lucide-right"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isLastPage"
@click="changePage(totalPages)"
/>
</div>
</div>
</template>
@@ -1,15 +0,0 @@
<script setup>
import Spinner from './Spinner.vue';
</script>
<template>
<Story title="Components/Spinner" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<Spinner size="24" class="text-n-slate-10" />
<Spinner size="32" class="text-n-ruby-10" />
<Spinner size="40" class="text-n-amber-10" />
<Spinner size="48" class="text-n-teal-10" />
<Spinner size="64" class="text-n-brand" />
</Variant>
</Story>
</template>
@@ -1,25 +0,0 @@
<script setup>
defineProps({
size: {
type: Number,
default: 24,
},
});
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeLinejoin="round"
class="animate-spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</template>
@@ -1,61 +0,0 @@
<script setup>
import TabBar from './TabBar.vue';
const defaultTabs = [
{ label: 'All articles', count: 24 },
{ label: 'Mine', count: 13 },
{ label: 'Draft', count: 5 },
{ label: 'Archived', count: 11 },
{ label: 'Unpublished', count: 23 },
{ label: 'Published', count: 100 },
{ label: 'Scheduled', count: 49 },
];
const customTabs = [
{ label: 'Active', count: 10 },
{ label: 'Pending', count: 5 },
{ label: 'Completed', count: 15 },
];
const handleTabChanged = tab => {
console.log('Tab changed:', tab);
};
</script>
<template>
<Story title="Components/TabBar" :layout="{ type: 'grid', width: '920px' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar :tabs="defaultTabs" @tab-changed="handleTabChanged" />
</div>
</Variant>
<Variant title="Custom Tabs with Initial Tab">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar
:tabs="customTabs"
:initial-active-tab="1"
@tab-changed="handleTabChanged"
/>
</div>
</Variant>
<Variant title="No Counts">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar
:tabs="[{ label: 'Tab 1' }, { label: 'Tab 2' }, { label: 'Tab 3' }]"
@tab-changed="handleTabChanged"
/>
</div>
</Variant>
<Variant title="Single Tab">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar
:tabs="[{ label: 'Single Tab', count: 42 }]"
@tab-changed="handleTabChanged"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,58 +0,0 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
initialActiveTab: {
type: Number,
default: 0,
},
tabs: {
type: Array,
required: true,
validator: value => {
return value.every(
tab => typeof tab.label === 'string' && typeof tab.count === 'number'
);
},
},
});
const emit = defineEmits(['tabChanged']);
const activeTab = ref(props.initialActiveTab);
const selectTab = index => {
activeTab.value = index;
emit('tabChanged', props.tabs[index]);
};
const showDivider = index => {
return (
// Show dividers after the active tab, but not after the last tab
(index > activeTab.value && index < props.tabs.length - 1) ||
// Show dividers before the active tab, but not immediately before it and not before the first tab
(index < activeTab.value - 1 && index > -1)
);
};
</script>
<template>
<div class="flex h-8 rounded-lg bg-slate-25 dark:bg-slate-800/50 w-fit">
<template v-for="(tab, index) in tabs" :key="index">
<button
class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out"
:class="[
activeTab === index
? 'text-woot-500 bg-woot-500/10 dark:bg-woot-500/10'
: 'text-slate-500 dark:text-slate-400 hover:text-woot-500 dark:hover:text-woot-400',
]"
@click="selectTab(index)"
>
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button>
<div
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class="
showDivider(index)
? 'bg-slate-75 dark:bg-slate-800'
: 'bg-transparent dark:bg-transparent'
"
/>
</template>
</div>
</template>
@@ -1,67 +0,0 @@
<script setup>
import { ref } from 'vue';
import TextArea from './TextArea.vue';
const bio = ref('');
const description = ref('');
</script>
<template>
<Story title="Components/TextArea" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<TextArea placeholder="Default TextArea" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<TextArea label="Description" placeholder="Enter your description" />
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<TextArea
label="Disabled TextArea"
placeholder="Can't type here"
disabled
/>
</div>
</Variant>
<Variant title="With Character Count">
<div class="p-4 bg-white dark:bg-slate-800">
<TextArea
v-model="bio"
label="Bio"
placeholder="Tell us about yourself"
:max-length="100"
show-character-count
/>
</div>
</Variant>
<Variant title="Custom Max Length">
<div class="p-4 bg-white dark:bg-slate-800">
<TextArea
v-model="description"
label="Long Description"
placeholder="Enter a longer description"
:max-length="500"
show-character-count
/>
</div>
</Variant>
<Variant title="Custom TextArea Class">
<div class="p-4 bg-white dark:bg-slate-800">
<TextArea
label="Custom Style"
placeholder="Custom textarea class"
custom-text-area-class="border-yellow-500 dark:border-yellow-700"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,69 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
maxLength: {
type: Number,
default: 200,
},
id: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
customTextAreaClass: {
type: String,
default: '',
},
showCharacterCount: {
type: Boolean,
default: false,
},
});
defineEmits(['update:modelValue']);
const characterCount = computed(() => props.modelValue.length);
</script>
<template>
<div class="relative flex flex-col gap-1">
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
{{ label }}
</label>
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
:maxlength="maxLength"
class="flex w-full reset-base text-sm h-24 px-3 pt-3 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white transition-all duration-500 ease-in-out resize-none disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
:class="[customTextAreaClass, showCharacterCount ? 'pb-9' : 'pb-3']"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
<div
v-if="showCharacterCount"
class="absolute flex items-center justify-between mt-1 bottom-3 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-slate-300 dark:text-slate-600">
{{ characterCount }} / {{ maxLength }}
</span>
</div>
</div>
</template>
@@ -27,14 +27,14 @@ export default {
<div class="flex flex-col items-start px-8 pt-8 pb-0">
<img v-if="headerImage" :src="headerImage" alt="No image" />
<h2
data-test-id="modal-header-title"
ref="modalHeaderTitle"
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
>
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
data-test-id="modal-header-content"
ref="modalHeaderContent"
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
>
{{ headerContent }}
@@ -105,7 +105,7 @@ export default {
size="small"
:color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'smooth' : 'clear'"
class="status-change--dropdown-button"
class-names="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)"
>
<AvailabilityStatusBadge :status="status.value" />
@@ -2,7 +2,6 @@
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAccount } from 'dashboard/composables/useAccount';
import { useRoute, useRouter } from 'vue-router';
import PrimarySidebar from './sidebarComponents/Primary.vue';
@@ -34,7 +33,6 @@ export default {
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const { accountId } = useAccount();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
@@ -74,7 +72,6 @@ export default {
return {
toggleKeyShortcutModal,
accountId,
};
},
data() {
@@ -85,6 +82,7 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
@@ -90,17 +90,13 @@ export default {
/>
</div>
<div class="flex flex-col items-center justify-end pb-6">
<a
<PrimaryNavItem
v-if="!isACustomBrandedInstance"
v-tooltip.right="$t(`SIDEBAR.DOCS`)"
:href="helpDocsURL"
class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative"
rel="noopener noreferrer nofollow"
target="_blank"
>
<fluent-icon icon="book-open-globe" />
<span class="sr-only">{{ $t(`SIDEBAR.DOCS`) }}</span>
</a>
icon="book-open-globe"
name="DOCS"
open-in-new-page
:to="helpDocsURL"
/>
<NotificationBell @open-notification-panel="openNotificationPanel" />
<AgentDetails @toggle-menu="toggleOptions" />
<OptionsMenu
@@ -1,62 +1,79 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AccountSelector from '../AccountSelector.vue';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import i18n from 'dashboard/i18n';
import WootModal from 'dashboard/components/Modal.vue';
import WootModalHeader from 'dashboard/components/ModalHeader.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => ({
accounts: [
{ id: 1, name: 'Chatwoot', role: 'administrator' },
{ id: 2, name: 'GitX', role: 'agent' },
],
}),
},
},
globalConfig: {
namespaced: true,
getters: {
get: () => ({ createNewAccountFromDashboard: false }),
},
},
},
const localVue = createLocalVue();
localVue.component('woot-modal', WootModal);
localVue.component('woot-modal-header', WootModalHeader);
localVue.component('fluent-icon', FluentIcon);
localVue.use(Vuex);
localVue.use(VueI18n);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
describe('AccountSelector', () => {
describe('accountSelctor', () => {
let accountSelector = null;
const currentUser = {
accounts: [
{
id: 1,
name: 'Chatwoot',
role: 'administrator',
},
{
id: 2,
name: 'GitX',
role: 'agent',
},
],
};
let actions = null;
let modules = null;
beforeEach(() => {
accountSelector = mount(AccountSelector, {
global: {
plugins: [store],
components: {
'woot-modal': WootModal,
'woot-modal-header': WootModalHeader,
'fluent-icon': FluentIcon,
},
stubs: {
WootButton: { template: '<button />' },
// override global stub
WootModalHeader: false,
actions = {};
modules = {
auth: {
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => currentUser,
},
},
props: { showAccountModal: true },
globalConfig: {
getters: {
'globalConfig/get': () => ({ createNewAccountFromDashboard: false }),
},
},
};
let store = new Vuex.Store({ actions, modules });
accountSelector = mount(AccountSelector, {
store,
localVue,
i18n: i18nConfig,
propsData: { showAccountModal: true },
stubs: { WootButton: { template: '<button />' } },
});
});
it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader);
const title = headerComponent.find('[data-test-id="modal-header-title"]');
const title = headerComponent.findComponent({ ref: 'modalHeaderTitle' });
expect(title.text()).toBe('Switch Account');
const content = headerComponent.find(
'[data-test-id="modal-header-content"]'
);
const content = headerComponent.findComponent({
ref: 'modalHeaderContent',
});
expect(content.text()).toBe('Select an account from the following list');
});
@@ -1,10 +1,27 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AgentDetails from '../AgentDetails.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import i18n from 'dashboard/i18n';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('thumbnail', Thumbnail);
localVue.component('woot-button', WootButton);
localVue.component('woot-button', WootButton);
localVue.use(VTooltip, {
defaultHtml: false,
});
describe('AgentDetails', () => {
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
describe('agentDetails', () => {
const currentUser = {
name: 'Neymar Junior',
avatar_url: '',
@@ -12,46 +29,37 @@ describe('AgentDetails', () => {
};
const currentRole = 'agent';
let store = null;
let actions = null;
let modules = null;
let agentDetails = null;
const mockTooltipDirective = {
mounted: (el, binding) => {
// You can mock the behavior here if necessary
el.setAttribute('data-tooltip', binding.value || '');
},
};
beforeEach(() => {
store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole,
getCurrentUserAvailability: () => currentUser.availability_status,
},
actions = {};
modules = {
auth: {
getters: {
getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole,
getCurrentUserAvailability: () => currentUser.availability_status,
},
},
};
store = new Vuex.Store({
actions,
modules,
});
agentDetails = shallowMount(AgentDetails, {
global: {
plugins: [store],
components: {
Thumbnail,
WootButton,
},
directives: {
tooltip: mockTooltipDirective, // Mocking the tooltip directive
},
stubs: { WootButton: { template: '<button><slot /></button>' } },
},
store,
localVue,
i18n: i18nConfig,
});
});
it('shows the correct agent status', () => {
expect(agentDetails.findComponent(Thumbnail).vm.status).toBe('online');
it(' the agent status', () => {
expect(agentDetails.find('thumbnail-stub').vm.status).toBe('online');
});
it('agent thumbnail exists', () => {
@@ -1,7 +1,20 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import NotificationBell from '../NotificationBell.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import i18n from 'dashboard/i18n';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('fluent-icon', FluentIcon);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
const $route = {
name: 'notifications_index',
@@ -20,51 +33,43 @@ describe('notificationBell', () => {
};
modules = {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => accountId,
},
},
notifications: {
namespaced: false,
getters: {
'notifications/getMeta': () => notificationMetadata,
},
},
};
store = createStore({
store = new Vuex.Store({
actions,
modules,
});
});
it('it should return unread count 19', () => {
it('it should return unread count 19 ', () => {
const wrapper = shallowMount(NotificationBell, {
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
expect(wrapper.vm.unreadCount).toBe('19');
});
it('it should return unread count 99+', async () => {
it('it should return unread count 99+ ', async () => {
notificationMetadata.unreadCount = 100;
const wrapper = shallowMount(NotificationBell, {
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
expect(wrapper.vm.unreadCount).toBe('99+');
@@ -72,14 +77,11 @@ describe('notificationBell', () => {
it('isNotificationPanelActive', async () => {
const notificationBell = shallowMount(NotificationBell, {
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
store,
localVue,
i18n: i18nConfig,
mocks: {
$route,
},
});
@@ -1,6 +1,9 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AvailabilityStatus from '../AvailabilityStatus.vue';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import FloatingVue from 'floating-vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -8,64 +11,70 @@ import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import i18n from 'dashboard/i18n';
const localVue = createLocalVue();
localVue.use(FloatingVue, {
html: false,
});
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('woot-button', WootButton);
localVue.component('woot-dropdown-header', WootDropdownHeader);
localVue.component('woot-dropdown-menu', WootDropdownMenu);
localVue.component('woot-dropdown-divider', WootDropdownDivider);
localVue.component('woot-dropdown-item', WootDropdownItem);
localVue.component('fluent-icon', FluentIcon);
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('AvailabilityStatus', () => {
const currentAvailability = 'online';
const currentAccountId = '1';
const currentUserAutoOffline = false;
let store = null;
let actions = null;
let modules = null;
let availabilityStatus = null;
beforeEach(() => {
actions = {
updateAvailability: vi.fn(() => Promise.resolve()),
updateAvailability: vi.fn(() => {
return Promise.resolve();
}),
};
store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentUserAvailability: () => currentAvailability,
getCurrentAccountId: () => currentAccountId,
getCurrentUserAutoOffline: () => currentUserAutoOffline,
},
modules = {
auth: {
getters: {
getCurrentUserAvailability: () => currentAvailability,
getCurrentAccountId: () => currentAccountId,
getCurrentUserAutoOffline: () => currentUserAutoOffline,
},
},
actions,
};
store = new Vuex.Store({ actions, modules });
availabilityStatus = mount(AvailabilityStatus, {
store,
localVue,
i18n: i18nConfig,
stubs: { WootSwitch: { template: '<button />' } },
});
});
it('dispatches an action when user changes status', async () => {
const wrapper = mount(AvailabilityStatus, {
global: {
plugins: [store],
components: {
WootButton,
WootDropdownItem,
WootDropdownMenu,
WootDropdownHeader,
WootDropdownDivider,
FluentIcon,
},
stubs: {
WootSwitch: { template: '<button />' },
},
},
});
await availabilityStatus;
availabilityStatus
.findAll('.status-change--dropdown-button')
.at(2)
.trigger('click');
// Ensure that the dropdown menu is opened
await wrapper.vm.openStatusMenu();
// Simulate the user clicking the 3rd button (offline status)
const buttons = wrapper.findAll('.status-change--dropdown-button');
expect(buttons.length).toBeGreaterThan(0); // Ensure buttons exist
await buttons[2].trigger('click');
expect(actions.updateAvailability).toHaveBeenCalledTimes(1);
expect(actions.updateAvailability.mock.calls[0][1]).toEqual({
availability: 'offline',
account_id: currentAccountId,
});
expect(actions.updateAvailability).toBeCalledWith(
expect.any(Object),
{ availability: 'offline', account_id: currentAccountId },
undefined
);
});
});
@@ -7,8 +7,5 @@ exports[`SidemenuIcon > matches snapshot 1`] = `
icon="list"
size="small"
variant="clear"
>
</button>
/>
`;
@@ -48,7 +48,7 @@ const debouncedEmit = debounce(value => {
const onSearch = value => {
searchTerm.value = value;
debouncedEmit(value);
debouncedEmit();
};
const filteredListItems = computed(() => {
@@ -84,10 +84,10 @@ const shouldShowEmptyState = computed(() => {
<slot name="search">
<DropdownSearch
v-if="enableSearch"
v-model="searchTerm"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@update:model-value="onSearch"
@input="onSearch"
@remove="$emit('removeFilter')"
/>
</slot>
@@ -1,6 +1,10 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
import { defineEmits } from 'vue';
defineProps({
inputValue: {
type: String,
default: '',
},
inputPlaceholder: {
type: String,
default: '',
@@ -11,12 +15,7 @@ defineProps({
},
});
const emit = defineEmits(['remove']);
const value = defineModel({
type: String,
default: '',
});
const emit = defineEmits(['input', 'remove']);
</script>
<template>
@@ -30,15 +29,16 @@ const value = defineModel({
class="text-slate-400 dark:text-slate-400 flex-shrink-0"
/>
<input
v-model="value"
:placeholder="inputPlaceholder"
type="text"
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
:placeholder="inputPlaceholder"
:value="inputValue"
@input="emit('input', $event.target.value)"
/>
</div>
<!-- Clear filter button -->
<woot-button
v-if="!modelValue && showClearFilter"
v-if="!inputValue && showClearFilter"
size="small"
variant="clear"
color-scheme="primary"
@@ -1,10 +1,9 @@
<script>
export default {
props: {
teams: { type: Array, required: true },
modelValue: { type: Object, required: true },
},
emits: ['update:modelValue'],
// The value types are dynamic, hence prop validation removed to work with our action schema
// eslint-disable-next-line vue/require-prop-types
props: ['teams', 'value'],
emits: ['input'],
data() {
return {
selectedTeams: [],
@@ -12,13 +11,13 @@ export default {
};
},
mounted() {
const { team_ids: teamIds } = this.modelValue;
const { team_ids: teamIds } = this.value;
this.selectedTeams = teamIds;
this.message = this.modelValue.message;
this.message = this.value.message;
},
methods: {
updateValue() {
this.$emit('update:modelValue', {
this.$emit('input', {
team_ids: this.selectedTeams.map(team => team.id),
message: this.message,
});
@@ -13,12 +13,7 @@ const props = defineProps({
},
});
const emit = defineEmits([
'recorderProgressChanged',
'finishRecord',
'pause',
'play',
]);
const emit = defineEmits(['recorderProgressChanged', 'finishRecord']);
const waveformContainer = ref(null);
const wavesurfer = ref(null);
@@ -52,9 +47,6 @@ const initWaveSurfer = () => {
],
});
wavesurfer.value.on('pause', () => emit('pause'));
wavesurfer.value.on('play', () => emit('play'));
record.value = wavesurfer.value.plugins[0];
wavesurfer.value.on('finish', () => {
@@ -114,9 +106,11 @@ onUnmounted(() => {
}
});
defineExpose({ playPause, stopRecording, record });
defineExpose({ playPause, stopRecording });
</script>
<template>
<div ref="waveformContainer" class="w-full p-1" />
<div class="w-full">
<div ref="waveformContainer" />
</div>
</template>
@@ -59,7 +59,6 @@ export default {
data() {
return {
plugins: [imagePastePlugin(this.handleImageUpload)],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
};
},
watch: {
@@ -182,7 +181,6 @@ export default {
if (tx.docChanged) {
this.emitOnChange();
}
this.checkSelection(state);
},
handleDOMEvents: {
keyup: this.onKeyup,
@@ -228,56 +226,6 @@ export default {
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
this.isTextSelected = hasSelection;
const { editor } = this.$refs;
// Toggle the 'has-selection' class based on whether there's a selection
editor.classList.toggle('has-selection', hasSelection);
// If there's a selection, update the menubar position
if (hasSelection) this.setMenubarPosition(editorState);
},
setMenubarPosition(editorState) {
if (!editorState.selection) return;
// Get the start and end positions of the selection
const { from, to } = editorState.selection;
const { editor } = this.$refs;
// Get the editor's position relative to the viewport
const { left: editorLeft, top: editorTop } =
editor.getBoundingClientRect();
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
// Get the start position of the selection
const { left: startLeft } = editorView.coordsAtPos(from);
// Calculate the top position for the menubar (10px below the selection)
const top = endBottom - editorTop + 10;
// Calculate the left position for the menubar
// This centers the menubar on the selection while keeping it within the editor's bounds
const left = Math.max(
0,
Math.min(
(startLeft + endRight) / 2 - editorLeft,
editorWidth - menubarWidth
)
);
// Set the CSS custom properties for positioning the menubar
editor.style.setProperty('--selection-top', `${top}px`);
editor.style.setProperty('--selection-left', `${left}px`);
},
},
};
</script>
@@ -311,7 +259,6 @@ export default {
}
.editor-root {
position: relative;
width: 100%;
}
@@ -1,5 +1,4 @@
<script>
import { ref } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -35,7 +34,7 @@ export default {
},
recordingAudioDurationText: {
type: String,
default: '00:00',
default: '',
},
// inbox prop is used in /mixins/inboxMixin,
// remove this props when refactoring to composable if not needed
@@ -123,8 +122,6 @@ export default {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const uploadRef = ref(false);
const keyboardEvents = {
'Alt+KeyA': {
action: () => {
@@ -146,7 +143,6 @@ export default {
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
},
computed: {
@@ -259,13 +255,13 @@ export default {
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="emoji"
emoji="😊"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleEmojiPicker"
/>
<FileUpload
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
@@ -284,6 +280,7 @@ export default {
class-names="button--upload"
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
icon="attach"
emoji="📎"
color-scheme="secondary"
variant="smooth"
size="small"
@@ -293,6 +290,7 @@ export default {
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
emoji="🎤"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
@@ -302,6 +300,7 @@ export default {
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="quote"
emoji="🖊️"
color-scheme="secondary"
variant="smooth"
size="small"
@@ -310,6 +309,7 @@ export default {
<woot-button
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
emoji="🎤"
color-scheme="secondary"
variant="smooth"
size="small"
@@ -350,7 +350,7 @@ export default {
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
v-show="$refs.uploadRef && $refs.uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
@@ -1,7 +1,6 @@
import lamejs from '@breezystack/lamejs';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
@@ -29,9 +28,7 @@ const bufferToWav = async (buffer, numChannels, sampleRate) => {
// WAV Data
const offset = 44;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < buffer.length; i++) {
// eslint-disable-next-line no-plusplus
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(
-1,
@@ -478,7 +478,6 @@ export default {
</div>
<ul class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
@@ -19,7 +19,7 @@ import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -50,20 +50,20 @@ const EmojiInput = defineAsyncComponent(
export default {
components: {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
Banner,
CannedResponse,
EmojiInput,
MessageSignatureMissingAlert,
ReplyBottomPanel,
ReplyEmailHead,
CannedResponse,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
WhatsappTemplates,
AttachmentPreview,
ReplyTopPanel,
ReplyEmailHead,
ReplyBottomPanel,
WootMessageEditor,
WootAudioRecorder,
Banner,
WhatsappTemplates,
MessageSignatureMissingAlert,
ArticleSearchPopover,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
emits: ['update:popoutReplyBox', 'togglePopout'],
@@ -115,7 +115,6 @@ export default {
showVariablesMenu: false,
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
};
},
computed: {
@@ -280,6 +279,12 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
hasRecordedAudio() {
return (
this.$refs.audioRecorderInput &&
this.$refs.audioRecorderInput.hasAudio()
);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
@@ -359,7 +364,7 @@ export default {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
return AUDIO_FORMATS.MP3;
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
},
@@ -798,14 +803,19 @@ export default {
this.attachedFiles = [];
this.isRecordingAudio = false;
this.resetReplyToMessage();
this.resetAudioRecorderInput();
},
clearEmailField() {
this.ccEmails = '';
this.bccEmails = '';
this.toEmails = '';
},
clearRecorder() {
this.isRecordingAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio
);
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
@@ -813,18 +823,17 @@ export default {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
this.clearRecorder();
}
},
toggleAudioRecorderPlayPause() {
if (!this.isRecordingAudio) {
return;
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopRecording();
} else if (this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause();
if (this.isRecordingAudio) {
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopRecording();
} else if (this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause();
}
}
},
hideEmojiPicker() {
@@ -851,9 +860,13 @@ export default {
onRecordProgressChanged(duration) {
this.recordingAudioDurationText = duration;
},
onStateRecorderChanged(state) {
this.recordingAudioState = state;
if (state && 'notallowederror'.includes(state)) {
this.toggleAudioRecorder();
}
},
onFinishRecorder(file) {
this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true;
// Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = {
@@ -1054,16 +1067,6 @@ export default {
toggleInsertArticle() {
this.showArticleSearchPopover = !this.showArticleSearchPopover;
},
resetAudioRecorderInput() {
this.recordingAudioDurationText = '00:00';
this.isRecordingAudio = false;
this.recordingAudioState = '';
this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio
);
},
},
};
</script>
@@ -1119,14 +1122,13 @@ export default {
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
<WootAudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@state-recorder-changed="onStateRecorderChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
@@ -1167,11 +1169,7 @@ export default {
@clear-selection="clearEditorSelection"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
>
<div v-if="hasAttachments" class="attachment-preview-box" @paste="onPaste">
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@@ -1,5 +1,5 @@
<script>
import { Letter } from 'vue-letter';
import Letter from 'vue-letter';
import GalleryView from '../components/GalleryView.vue';
export default {
@@ -1,7 +1,11 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import MoreActions from '../MoreActions.vue';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import FloatingVue from 'floating-vue';
import Button from 'dashboard/components/buttons/Button.vue';
import i18n from 'dashboard/i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import MoreActions from '../MoreActions.vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -11,67 +15,75 @@ vi.mock('shared/helpers/mitt', () => ({
},
}));
const mockDirective = {
mounted: () => {},
import { emitter } from 'shared/helpers/mitt';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.use(FloatingVue);
localVue.component('fluent-icon', FluentIcon);
localVue.component('woot-button', Button);
localVue.prototype.$emitter = {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
};
import { emitter } from 'shared/helpers/mitt';
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('MoveActions', () => {
let currentChat = { id: 8, muted: false };
let store = null;
let state = null;
let muteConversation = null;
let unmuteConversation = null;
let modules = null;
let getters = null;
let store = null;
let moreActions = null;
beforeEach(() => {
state = {
authenticated: true,
currentChat,
};
muteConversation = vi.fn(() => Promise.resolve());
unmuteConversation = vi.fn(() => Promise.resolve());
store = createStore({
state: {
authenticated: true,
currentChat,
},
getters: {
getSelectedChat: () => currentChat,
},
modules: {
conversations: {
namespaced: false,
actions: { muteConversation, unmuteConversation },
},
modules = {
conversations: { actions: { muteConversation, unmuteConversation } },
};
getters = { getSelectedChat: () => currentChat };
store = new Vuex.Store({ state, modules, getters });
moreActions = mount(MoreActions, {
store,
localVue,
i18n: i18nConfig,
stubs: {
WootModal: { template: '<div><slot/> </div>' },
WootModalHeader: { template: '<div><slot/> </div>' },
},
});
});
const createWrapper = () =>
mount(MoreActions, {
global: {
plugins: [store],
components: {
'fluent-icon': FluentIcon,
},
directives: {
'on-clickaway': mockDirective,
},
},
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(muteConversation).toHaveBeenCalledTimes(1);
expect(muteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
expect(muteConversation).toBeCalledWith(
expect.any(Object),
currentChat.id,
undefined
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message:
@@ -87,19 +99,17 @@ describe('MoveActions', () => {
});
it('triggers "unmuteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(unmuteConversation).toHaveBeenCalledTimes(1);
expect(unmuteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
expect(unmuteConversation).toBeCalledWith(
expect.any(Object),
currentChat.id,
undefined
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message: 'This contact is unblocked successfully.',
@@ -1,5 +1,5 @@
import { emitter } from 'shared/helpers/mitt';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
import analyticsHelper from '/dashboard/helper/AnalyticsHelper/index';
/**
* Custom hook to track events
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { emitter } from 'shared/helpers/mitt';
import { useEmitter } from '../emitter';
import { defineComponent } from 'vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -11,34 +10,31 @@ vi.mock('shared/helpers/mitt', () => ({
}));
describe('useEmitter', () => {
let wrapper;
const eventName = 'my-event';
const callback = vi.fn();
let wrapper;
const TestComponent = defineComponent({
setup() {
return {
cleanup: useEmitter(eventName, callback),
};
},
template: '<div>Hello world</div>',
});
beforeEach(() => {
wrapper = shallowMount(TestComponent);
});
afterEach(() => {
vi.clearAllMocks();
wrapper = shallowMount({
template: `
<div>
Hello world
</div>
`,
setup() {
return {
cleanup: useEmitter(eventName, callback),
};
},
});
});
it('should add an event listener on mount', () => {
expect(emitter.on).toHaveBeenCalledWith(eventName, callback);
});
it('should remove the event listener when the component is unmounted', async () => {
await wrapper.unmount();
it('should remove the event listener when the component is unmounted', () => {
wrapper.destroy();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
@@ -1,26 +1,20 @@
import { getCurrentInstance } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { useTrack, useAlert } from '../index';
vi.mock('vue', () => ({
getCurrentInstance: vi.fn(),
}));
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
},
}));
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
describe('useTrack', () => {
it('should call analyticsHelper.track and return a function', () => {
const eventArgs = ['event-name', { some: 'data' }];
useTrack(...eventArgs);
expect(analyticsHelper.track).toHaveBeenCalledWith(...eventArgs);
it('should return a function', () => {
const track = useTrack();
expect(typeof track).toBe('function');
});
});
@@ -4,20 +4,14 @@ import {
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('vue-i18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
@@ -46,7 +40,9 @@ describe('useAI', () => {
};
return { value: mockValues[getter] };
});
useTrack.mockReturnValue(vi.fn());
useI18n.mockReturnValue({ t: vi.fn() });
useAlert.mockReturnValue(vi.fn());
});
it('initializes computed properties correctly', async () => {
@@ -82,12 +78,13 @@ describe('useAI', () => {
});
it('records analytics correctly', async () => {
// const mockTrack = analyticsHelper.track;
const mockTrack = vi.fn();
useTrack.mockReturnValue(mockTrack);
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
@@ -1,15 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { describe, it, expect, vi } from 'vitest';
import { useAccount } from '../useAccount';
import { useRoute } from 'vue-router';
import { useStoreGetters } from 'dashboard/composables/store';
vi.mock('vue-router');
vi.mock('dashboard/composables/store');
describe('useAccount', () => {
beforeEach(() => {
useRoute.mockReturnValue({
params: {
accountId: 123,
},
useStoreGetters.mockReturnValue({
getCurrentAccountId: ref(123),
});
});
@@ -1,7 +1,7 @@
import { useAutomation } from '../useAutomation';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useI18n } from '../useI18n';
import * as automationHelper from 'dashboard/helper/automationHelper';
import {
customAttributes,
@@ -20,7 +20,7 @@ import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('vue-i18n');
vi.mock('../useI18n');
vi.mock('dashboard/helper/automationHelper');
describe('useAutomation', () => {
@@ -120,8 +120,8 @@ describe('useAutomation', () => {
});
it('appends new condition and action correctly', () => {
const { appendNewCondition, appendNewAction, automation } = useAutomation();
automation.value = {
const { appendNewCondition, appendNewAction } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [],
actions: [],
@@ -130,37 +130,36 @@ describe('useAutomation', () => {
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
appendNewCondition();
appendNewAction();
appendNewCondition(mockAutomation);
appendNewAction(mockAutomation);
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
});
it('removes filter and action correctly', () => {
const { removeFilter, removeAction, automation } = useAutomation();
automation.value = {
const { removeFilter, removeAction } = useAutomation();
const mockAutomation = {
conditions: [{ id: 1 }, { id: 2 }],
actions: [{ id: 1 }, { id: 2 }],
};
removeFilter(0);
removeAction(0);
removeFilter(mockAutomation, 0);
removeAction(mockAutomation, 0);
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
expect(automation.value.conditions[0].id).toBe(2);
expect(automation.value.actions[0].id).toBe(2);
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
expect(mockAutomation.conditions[0].id).toBe(2);
expect(mockAutomation.actions[0].id).toBe(2);
});
it('resets filter and action correctly', () => {
const { resetFilter, resetAction, automation, automationTypes } =
useAutomation();
automation.value = {
const { resetFilter, resetAction } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [
{
@@ -171,37 +170,77 @@ describe('useAutomation', () => {
],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
automationTypes.message_created = {
conditions: [
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
],
const mockAutomationTypes = {
message_created: {
conditions: [
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
],
},
};
resetFilter(0, automation.value.conditions[0]);
resetAction(0);
resetFilter(
mockAutomation,
mockAutomationTypes,
0,
mockAutomation.conditions[0]
);
resetAction(mockAutomation, 0);
expect(automation.value.conditions[0].filter_operator).toBe('not_equal_to');
expect(automation.value.conditions[0].values).toBe('');
expect(automation.value.actions[0].action_params).toEqual([]);
expect(mockAutomation.conditions[0].filter_operator).toBe('not_equal_to');
expect(mockAutomation.conditions[0].values).toBe('');
expect(mockAutomation.actions[0].action_params).toEqual([]);
});
it('formats automation correctly', () => {
const { formatAutomation } = useAutomation();
const mockAutomation = {
conditions: [{ attribute_key: 'status', values: ['open'] }],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
const mockAutomationTypes = {};
const mockAutomationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
automationHelper.getConditionOptions.mockReturnValue([
{ id: 'open', name: 'open' },
]);
automationHelper.getActionOptions.mockReturnValue([
{ id: 1, name: 'Agent 1' },
]);
const result = formatAutomation(
mockAutomation,
customAttributes,
mockAutomationTypes,
mockAutomationActionTypes
);
expect(result.conditions[0].values).toEqual([{ id: 'open', name: 'open' }]);
expect(result.actions[0].action_params).toEqual([
{ id: 1, name: 'Agent 1' },
]);
});
it('manifests custom attributes correctly', () => {
const { manifestCustomAttributes, automationTypes } = useAutomation();
automationTypes.message_created = { conditions: [] };
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
const { manifestCustomAttributes } = useAutomation();
const mockAutomationTypes = {
message_created: { conditions: [] },
conversation_created: { conditions: [] },
conversation_updated: { conditions: [] },
conversation_opened: { conditions: [] },
};
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
manifestCustomAttributes();
manifestCustomAttributes(mockAutomationTypes);
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
2
);
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
Object.values(automationTypes).forEach(type => {
Object.values(mockAutomationTypes).forEach(type => {
expect(type.conditions).toHaveLength(0);
});
});
@@ -234,8 +273,8 @@ describe('useAutomation', () => {
});
it('handles event change correctly', () => {
const { onEventChange, automation } = useAutomation();
automation.value = {
const { onEventChange } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [],
actions: [],
@@ -244,13 +283,13 @@ describe('useAutomation', () => {
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
onEventChange();
onEventChange(mockAutomation);
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
});
});
@@ -1,20 +1,18 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for account-related operations.
* @returns {Object} An object containing account-related properties and methods.
*/
export function useAccount() {
const getters = useStoreGetters();
/**
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const route = useRoute();
const accountId = computed(() => {
return Number(route.params.accountId);
});
const accountId = computed(() => getters.getCurrentAccountId.value);
/**
* Generates an account-scoped URL.
@@ -1,143 +1,313 @@
import { ref, computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import {
generateCustomAttributeTypes,
getActionOptions,
getConditionOptions,
getCustomAttributeInputType,
getDefaultConditions,
getDefaultActions,
filterCustomAttributes,
getStandardAttributeInputType,
isCustomAttribute,
generateCustomAttributes,
} from 'dashboard/helper/automationHelper';
import useAutomationValues from './useAutomationValues';
import {
// AUTOMATION_RULE_EVENTS,
// AUTOMATION_ACTION_TYPES,
AUTOMATIONS,
} from 'dashboard/routes/dashboard/settings/automation/constants.js';
/**
* Composable for handling automation-related functionality.
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export function useAutomation(startValue = null) {
export function useAutomation() {
const getters = useStoreGetters();
const { t } = useI18n();
const {
booleanFilterOptions,
statusFilterOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
} = useAutomationValues();
const agents = useMapGetter('agents/getAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabels');
const teams = useMapGetter('teams/getTeams');
const slaPolicies = useMapGetter('sla/getSLA');
const automation = ref(startValue);
const automationTypes = structuredClone(AUTOMATIONS);
const eventName = computed(() => automation.value?.event_name);
const booleanFilterOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
/**
* Handles the event change for an automation.value.
*/
const onEventChange = () => {
automation.value.conditions = getDefaultConditions(eventName.value);
automation.value.actions = getDefaultActions();
};
const statusFilterItems = computed(() => {
return {
open: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
},
resolved: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
},
pending: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
},
snoozed: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
},
all: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
},
};
});
/**
* Appends a new condition to the automation.value.
*/
const appendNewCondition = () => {
const defaultCondition = getDefaultConditions(eventName.value);
automation.value.conditions = [
...automation.value.conditions,
...defaultCondition,
const statusFilterOptions = computed(() => {
const statusFilters = statusFilterItems.value;
return [
...Object.keys(statusFilters).map(status => ({
id: status,
name: statusFilters[status].TEXT,
})),
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
];
};
});
/**
* Appends a new action to the automation.value.
* Handles the event change for an automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewAction = () => {
const defaultAction = getDefaultActions();
automation.value.actions = [...automation.value.actions, ...defaultAction];
const onEventChange = automation => {
automation.conditions = getDefaultConditions(automation.event_name);
automation.actions = getDefaultActions();
};
/**
* Removes a filter from the automation.value.
* Gets the condition dropdown values for a given type.
* @param {string} type - The type of condition.
* @returns {Array} An array of condition dropdown values.
*/
const getConditionDropdownValues = type => {
return getConditionOptions({
agents: agents.value,
booleanFilterOptions: booleanFilterOptions.value,
campaigns: campaigns.value,
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
teams: teams.value,
languages,
countries,
type,
});
};
/**
* Appends a new condition to the automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewCondition = automation => {
automation.conditions.push(...getDefaultConditions(automation.event_name));
};
/**
* Appends a new action to the automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewAction = automation => {
automation.actions.push(...getDefaultActions());
};
/**
* Removes a filter from the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the filter to remove.
*/
const removeFilter = index => {
if (automation.value.conditions.length <= 1) {
const removeFilter = (automation, index) => {
if (automation.conditions.length <= 1) {
useAlert(t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
} else {
automation.value.conditions = automation.value.conditions.filter(
(_, i) => i !== index
);
automation.conditions.splice(index, 1);
}
};
/**
* Removes an action from the automation.value.
* Removes an action from the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the action to remove.
*/
const removeAction = index => {
if (automation.value.actions.length <= 1) {
const removeAction = (automation, index) => {
if (automation.actions.length <= 1) {
useAlert(t('AUTOMATION.ACTION.DELETE_MESSAGE'));
} else {
automation.value.actions = automation.value.actions.filter(
(_, i) => i !== index
);
automation.actions.splice(index, 1);
}
};
/**
* Resets a filter in the automation.value.
* Resets a filter in the automation.
* @param {Object} automation - The automation object to update.
* @param {Object} automationTypes - The automation types object.
* @param {number} index - The index of the filter to reset.
* @param {Object} currentCondition - The current condition object.
*/
const resetFilter = (index, currentCondition) => {
const newConditions = [...automation.value.conditions];
newConditions[index] = {
...newConditions[index],
filter_operator: automationTypes[eventName.value].conditions.find(
condition => condition.key === currentCondition.attribute_key
).filterOperators[0].value,
values: '',
};
automation.value.conditions = newConditions;
const resetFilter = (
automation,
automationTypes,
index,
currentCondition
) => {
automation.conditions[index].filter_operator = automationTypes[
automation.event_name
].conditions.find(
condition => condition.key === currentCondition.attribute_key
).filterOperators[0].value;
automation.conditions[index].values = '';
};
/**
* Resets an action in the automation.value.
* Resets an action in the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the action to reset.
*/
const resetAction = index => {
const newActions = [...automation.value.actions];
newActions[index] = {
...newActions[index],
action_params: [],
};
const resetAction = (automation, index) => {
automation.actions[index].action_params = [];
};
automation.value.actions = newActions;
/**
* This function sets the conditions for automation.
* It help to format the conditions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing conditions to manifest.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @returns {Array} An array of manifested conditions.
*/
const manifestConditions = (
automation,
allCustomAttributes,
automationTypes
) => {
const customAttributes = filterCustomAttributes(allCustomAttributes);
return automation.conditions.map(condition => {
const customAttr = isCustomAttribute(
customAttributes,
condition.attribute_key
);
let inputType = 'plain_text';
if (customAttr) {
inputType = getCustomAttributeInputType(customAttr.type);
} else {
inputType = getStandardAttributeInputType(
automationTypes,
automation.event_name,
condition.attribute_key
);
}
if (inputType === 'plain_text' || inputType === 'date') {
return { ...condition, values: condition.values[0] };
}
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
),
};
});
};
/**
* Gets the action dropdown values for a given type.
* @param {string} type - The type of action.
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
return getActionOptions({
agents: agents.value,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
languages,
type,
});
};
/**
* Generates an array of actions for the automation.
* @param {Object} action - The action object.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array|Object} Generated actions array or object based on input type.
*/
const generateActionsArray = (action, automationActionTypes) => {
const params = action.action_params;
const inputType = automationActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
return [...getActionDropdownValues(action.action_name)].filter(item =>
[...params].includes(item.id)
);
}
if (inputType === 'team_message') {
return {
team_ids: [...getActionDropdownValues(action.action_name)].filter(
item => [...params[0].team_ids].includes(item.id)
),
message: params[0].message,
};
}
return [...params];
};
/**
* This function sets the actions for automation.
* It help to format the actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing actions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array} An array of manifested actions.
*/
const manifestActions = (automation, automationActionTypes) => {
return automation.actions.map(action => ({
...action,
action_params: action.action_params.length
? generateActionsArray(action, automationActionTypes)
: [],
}));
};
/**
* Formats the automation object for use when we edit the automation.
* It help to format the conditions and actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object to format.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
*/
const formatAutomation = (
automation,
allCustomAttributes,
automationTypes,
automationActionTypes
) => {
return {
...automation,
conditions: manifestConditions(
automation,
allCustomAttributes,
automationTypes
),
actions: manifestActions(automation, automationActionTypes),
};
};
/**
* This function formats the custom attributes for automation types.
* It retrieves custom attributes for conversations and contacts,
* generates custom attribute types, and adds them to the relevant automation types.
* @param {Object} automationTypes - The automation types object to update with custom attributes.
*/
const manifestCustomAttributes = () => {
const manifestCustomAttributes = automationTypes => {
const conversationCustomAttributesRaw = getters[
'attributes/getAttributesByModel'
].value('conversation_attribute');
@@ -160,22 +330,21 @@ export function useAutomation(startValue = null) {
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
);
[
'message_created',
'conversation_created',
'conversation_updated',
'conversation_opened',
].forEach(eventToUpdate => {
automationTypes[eventToUpdate].conditions = [
...automationTypes[eventToUpdate].conditions,
...manifestedCustomAttributes,
];
});
automationTypes.message_created.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_created.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_updated.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_opened.conditions.push(
...manifestedCustomAttributes
);
};
return {
automation,
automationTypes,
agents,
campaigns,
contacts,
@@ -193,6 +362,7 @@ export function useAutomation(startValue = null) {
removeAction,
resetFilter,
resetAction,
formatAutomation,
getActionDropdownValues,
manifestCustomAttributes,
};
@@ -1,115 +0,0 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import {
getActionOptions,
getConditionOptions,
} from 'dashboard/helper/automationHelper';
/**
* This is a shared composables that holds utilites used to build dropdown and file options
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabels');
const teams = useMapGetter('teams/getTeams');
const slaPolicies = useMapGetter('sla/getSLA');
const booleanFilterOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const statusFilterItems = computed(() => {
return {
open: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
},
resolved: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
},
pending: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
},
snoozed: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
},
all: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
},
};
});
const statusFilterOptions = computed(() => {
const statusFilters = statusFilterItems.value;
return [
...Object.keys(statusFilters).map(status => ({
id: status,
name: statusFilters[status].TEXT,
})),
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
];
});
/**
* Gets the condition dropdown values for a given type.
* @param {string} type - The type of condition.
* @returns {Array} An array of condition dropdown values.
*/
const getConditionDropdownValues = type => {
return getConditionOptions({
agents: agents.value,
booleanFilterOptions: booleanFilterOptions.value,
campaigns: campaigns.value,
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
teams: teams.value,
languages,
countries,
type,
});
};
/**
* Gets the action dropdown values for a given type.
* @param {string} type - The type of action.
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
return getActionOptions({
agents: agents.value,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
languages,
type,
});
};
return {
booleanFilterOptions,
statusFilterItems,
statusFilterOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
};
}
@@ -1,129 +0,0 @@
import useAutomationValues from './useAutomationValues';
import {
getCustomAttributeInputType,
filterCustomAttributes,
getStandardAttributeInputType,
isCustomAttribute,
} from 'dashboard/helper/automationHelper';
export function useEditableAutomation() {
const { getConditionDropdownValues, getActionDropdownValues } =
useAutomationValues();
/**
* This function sets the conditions for automation.
* It help to format the conditions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing conditions to manifest.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @returns {Array} An array of manifested conditions.
*/
const manifestConditions = (
automation,
allCustomAttributes,
automationTypes
) => {
const customAttributes = filterCustomAttributes(allCustomAttributes);
return automation.conditions.map(condition => {
const customAttr = isCustomAttribute(
customAttributes,
condition.attribute_key
);
let inputType = 'plain_text';
if (customAttr) {
inputType = getCustomAttributeInputType(customAttr.type);
} else {
inputType = getStandardAttributeInputType(
automationTypes,
automation.event_name,
condition.attribute_key
);
}
if (inputType === 'plain_text' || inputType === 'date') {
return { ...condition, values: condition.values[0] };
}
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
),
};
});
};
/**
* Generates an array of actions for the automation.
* @param {Object} action - The action object.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array|Object} Generated actions array or object based on input type.
*/
const generateActionsArray = (action, automationActionTypes) => {
const params = action.action_params;
const inputType = automationActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
return [...getActionDropdownValues(action.action_name)].filter(item =>
[...params].includes(item.id)
);
}
if (inputType === 'team_message') {
return {
team_ids: [...getActionDropdownValues(action.action_name)].filter(
item => [...params[0].team_ids].includes(item.id)
),
message: params[0].message,
};
}
return [...params];
};
/**
* This function sets the actions for automation.
* It help to format the actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing actions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array} An array of manifested actions.
*/
const manifestActions = (automation, automationActionTypes) => {
return automation.actions.map(action => ({
...action,
action_params: action.action_params.length
? generateActionsArray(action, automationActionTypes)
: [],
}));
};
/**
* Formats the automation object for use when we edit the automation.
* It help to format the conditions and actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object to format.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
*/
const formatAutomation = (
automation,
allCustomAttributes,
automationTypes,
automationActionTypes
) => {
return {
...automation,
conditions: manifestConditions(
automation,
allCustomAttributes,
automationTypes
),
actions: manifestActions(automation, automationActionTypes),
};
};
return { formatAutomation };
}
@@ -0,0 +1,36 @@
import Vue from 'vue';
import plugin from '../plugin';
import analyticsHelper from '../index';
vi.spyOn(analyticsHelper, 'init');
vi.spyOn(analyticsHelper, 'track');
describe('Vue Analytics Plugin', () => {
beforeEach(() => {
Vue.use(plugin);
});
it('should call the init method on analyticsHelper once during plugin installation', () => {
expect(analyticsHelper.init).toHaveBeenCalledTimes(1);
});
it('should add the analyticsHelper to the Vue prototype as $analytics', () => {
expect(Vue.prototype.$analytics).toBe(analyticsHelper);
});
it('should add a track method to the Vue prototype as $track', () => {
expect(typeof Vue.prototype.$track).toBe('function');
Vue.prototype.$track('eventName');
expect(analyticsHelper.track)
.toHaveBeenCalledTimes(1)
.toHaveBeenCalledWith('eventName');
});
it('should call the track method on analyticsHelper with the correct event name when $track is called', () => {
const eventName = 'testEvent';
Vue.prototype.$track(eventName);
expect(analyticsHelper.track)
.toHaveBeenCalledTimes(1)
.toHaveBeenCalledWith(eventName);
});
});
@@ -39,13 +39,13 @@ class DashboardAudioNotificationHelper {
};
}
setInstanceValues = ({
setInstanceValues({
currentUser,
alwaysPlayAudioAlert,
alertIfUnreadConversationExist,
audioAlertType,
audioAlertTone,
}) => {
}) {
this.audioAlertType = audioAlertType;
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
@@ -58,9 +58,9 @@ class DashboardAudioNotificationHelper {
});
});
initFaviconSwitcher();
};
}
executeRecurringNotification = () => {
executeRecurringNotification() {
if (!window.WOOT_STORE) {
this.clearSetTimeout();
return;
@@ -81,9 +81,9 @@ class DashboardAudioNotificationHelper {
showBadgeOnFavicon();
}
this.clearSetTimeout();
};
}
clearSetTimeout = () => {
clearSetTimeout() {
if (this.recurringNotificationTimer) {
clearTimeout(this.recurringNotificationTimer);
}
@@ -91,9 +91,9 @@ class DashboardAudioNotificationHelper {
this.executeRecurringNotification,
NOTIFICATION_TIME
);
};
}
playAudioEvery30Seconds = () => {
playAudioEvery30Seconds() {
// Audio alert is disabled dismiss the timer
if (this.audioAlertType === 'none') {
return;
@@ -104,25 +104,25 @@ class DashboardAudioNotificationHelper {
}
this.clearSetTimeout();
};
}
isConversationAssignedToCurrentUser = message => {
isConversationAssignedToCurrentUser(message) {
const conversationAssigneeId = message?.conversation?.assignee_id;
return conversationAssigneeId === this.currentUserId;
};
}
// eslint-disable-next-line class-methods-use-this
isMessageFromCurrentConversation = message => {
isMessageFromCurrentConversation(message) {
return (
window.WOOT_STORE.getters.getSelectedChat?.id === message.conversation_id
);
};
}
isMessageFromCurrentUser = message => {
isMessageFromCurrentUser(message) {
return message?.sender_id === this.currentUserId;
};
}
isUserHasConversationPermission = () => {
isUserHasConversationPermission() {
const currentAccountId = window.WOOT_STORE.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(
@@ -134,16 +134,16 @@ class DashboardAudioNotificationHelper {
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
};
}
shouldNotifyOnMessage = message => {
shouldNotifyOnMessage(message) {
if (this.audioAlertType === 'mine') {
return this.isConversationAssignedToCurrentUser(message);
}
return this.audioAlertType === 'all';
};
}
onNewMessage = message => {
onNewMessage(message) {
// If the user does not have the permission to view the conversation, then dismiss the alert
if (!this.isUserHasConversationPermission()) {
return;
@@ -176,7 +176,7 @@ class DashboardAudioNotificationHelper {
window.playAudioAlert();
showBadgeOnFavicon();
this.playAudioEvery30Seconds();
};
}
}
const notifHelper = new DashboardAudioNotificationHelper();
@@ -37,10 +37,8 @@ const storeMock = {
const routerMock = {
currentRoute: {
value: {
name: '',
params: { conversation_id: null },
},
name: '',
params: { conversation_id: null },
},
};
@@ -224,7 +222,7 @@ describe('ReconnectService', () => {
describe('fetchConversationMessagesOnReconnect', () => {
it('should dispatch syncActiveConversationMessages if conversationId exists', async () => {
routerMock.currentRoute.value.params.conversation_id = 1;
routerMock.currentRoute.params.conversation_id = 1;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'syncActiveConversationMessages',
@@ -233,7 +231,7 @@ describe('ReconnectService', () => {
});
it('should not dispatch syncActiveConversationMessages if conversationId does not exist', async () => {
routerMock.currentRoute.value.params.conversation_id = null;
routerMock.currentRoute.params.conversation_id = null;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'syncActiveConversationMessages',
@@ -307,7 +305,7 @@ describe('ReconnectService', () => {
describe('setConversationLastMessageId', () => {
it('should dispatch setConversationLastMessageId if conversationId exists', async () => {
routerMock.currentRoute.value.params.conversation_id = 1;
routerMock.currentRoute.params.conversation_id = 1;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'setConversationLastMessageId',
@@ -316,7 +314,7 @@ describe('ReconnectService', () => {
});
it('should not dispatch setConversationLastMessageId if conversationId does not exist', async () => {
routerMock.currentRoute.value.params.conversation_id = null;
routerMock.currentRoute.params.conversation_id = null;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'setConversationLastMessageId',
@@ -0,0 +1,78 @@
import resize from '../../directives/resize';
class ResizeObserverMock {
// eslint-disable-next-line class-methods-use-this
observe() {}
// eslint-disable-next-line class-methods-use-this
unobserve() {}
// eslint-disable-next-line class-methods-use-this
disconnect() {}
}
describe('resize directive', () => {
let el;
let binding;
let observer;
beforeEach(() => {
el = document.createElement('div');
binding = {
value: vi.fn(),
};
observer = {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
window.ResizeObserver = ResizeObserverMock;
vi.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should create ResizeObserver on bind', () => {
resize.bind(el, binding);
expect(ResizeObserver).toHaveBeenCalled();
expect(observer.observe).toHaveBeenCalledWith(el);
});
it('should call callback on observer callback', () => {
el = document.createElement('div');
binding = {
value: vi.fn(),
};
resize.bind(el, binding);
const entries = [{ contentRect: { width: 100, height: 100 } }];
const callback = binding.value;
callback(entries[0]);
expect(binding.value).toHaveBeenCalledWith(entries[0]);
});
it('should destroy and recreate observer on update', () => {
resize.bind(el, binding);
resize.update(el, { ...binding, oldValue: 'old' });
expect(observer.unobserve).toHaveBeenCalledWith(el);
expect(observer.disconnect).toHaveBeenCalled();
expect(ResizeObserver).toHaveBeenCalledTimes(2);
expect(observer.observe).toHaveBeenCalledTimes(2);
});
it('should destroy observer on unbind', () => {
resize.bind(el, binding);
resize.unbind(el);
expect(observer.unobserve).toHaveBeenCalledWith(el);
expect(observer.disconnect).toHaveBeenCalled();
});
});
@@ -9,8 +9,8 @@ import {
findNodeToInsertImage,
setURLWithQueryAndSize,
} from '../editorHelper';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Schema } from 'prosemirror-model';
// Define a basic ProseMirror schema
@@ -65,7 +65,7 @@
"ERROR_MESSAGE": "Couldn't delete the custom attribute. Try again."
},
"CONFIRM": {
"TITLE": "Are you sure want to delete - {attributeName}",
"TITLE": "Are you sure want to delete - %{attributeName}",
"PLACE_HOLDER": "Please type {attributeName} to confirm",
"MESSAGE": "Deleting will remove the custom attribute",
"YES": "Delete ",

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