Merge branch 'release/3.14.0'
This commit is contained in:
commit
604c592e89
@ -1,84 +1,87 @@
|
||||
# Ruby CircleCI 2.0 configuration file
|
||||
#
|
||||
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
|
||||
#
|
||||
version: 2
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@6.1.0
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
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
|
||||
parallelism: 4
|
||||
machine:
|
||||
image: ubuntu-2204:2024.05.1
|
||||
resource_class: large
|
||||
environment:
|
||||
RAILS_LOG_TO_STDOUT: false
|
||||
COVERAGE: true
|
||||
LOG_LEVEL: warn
|
||||
parallelism: 4
|
||||
|
||||
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: Configure Bundler
|
||||
name: Install System Dependencies
|
||||
command: |
|
||||
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
|
||||
source $BASH_ENV
|
||||
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
|
||||
gem install bundler
|
||||
|
||||
- run:
|
||||
name: Which bundler?
|
||||
command: bundle -v
|
||||
|
||||
- run:
|
||||
name: Swap node versions
|
||||
name: Install Application Dependencies
|
||||
command: |
|
||||
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
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
# pnpm install
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
@ -86,12 +89,8 @@ 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
|
||||
|
||||
# verify swagger specification
|
||||
# Swagger verification
|
||||
- run:
|
||||
name: Verify swagger API specification
|
||||
command: |
|
||||
@ -104,45 +103,62 @@ 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
|
||||
|
||||
# Database setup
|
||||
- run: bundle exec rake db:create
|
||||
- run: bundle exec rake db:schema:load
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# - run:
|
||||
# name: Brakeman
|
||||
# command: bundle exec brakeman
|
||||
|
||||
# ESLint linting
|
||||
- run:
|
||||
name: eslint
|
||||
command: yarn run eslint
|
||||
command: pnpm run eslint
|
||||
|
||||
# Run frontend tests
|
||||
- run:
|
||||
name: Run frontend tests
|
||||
command: |
|
||||
mkdir -p ~/tmp/test-results/frontend_specs
|
||||
mkdir -p ~/build/coverage/frontend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
yarn test:coverage
|
||||
- run:
|
||||
name: Code Climate Test Coverage
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
pnpm run test:coverage
|
||||
|
||||
# Run rails tests
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Frontend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
# Run backend tests
|
||||
- run:
|
||||
name: Run backend tests
|
||||
command: |
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
mkdir -p coverage
|
||||
mkdir -p ~/build/coverage/backend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
bundle exec rspec --format progress \
|
||||
@ -150,54 +166,18 @@ jobs:
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- ${TESTFILES}
|
||||
no_output_timeout: 30m
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage
|
||||
name: Code Climate Test Coverage (Backend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||
~/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
|
||||
|
||||
- persist_to_workspace:
|
||||
root: coverage
|
||||
root: ~/build
|
||||
paths:
|
||||
- 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
|
||||
- coverage
|
||||
|
||||
11
.circleci/setup_chatwoot.sql
Normal file
11
.circleci/setup_chatwoot.sql
Normal file
@ -0,0 +1,11 @@
|
||||
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;
|
||||
@ -27,7 +27,8 @@ checks:
|
||||
threshold: 50
|
||||
exclude_patterns:
|
||||
- 'spec/'
|
||||
- '**/specs/'
|
||||
- '**/specs/**/**'
|
||||
- '**/spec/**/**'
|
||||
- 'db/*'
|
||||
- 'bin/**/*'
|
||||
- 'db/**/*'
|
||||
|
||||
39
.eslintrc.js
39
.eslintrc.js
@ -2,16 +2,36 @@ module.exports = {
|
||||
extends: [
|
||||
'airbnb-base/legacy',
|
||||
'prettier',
|
||||
'plugin:vue/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
'plugin:cypress/recommended',
|
||||
'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',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
parser: '@babel/eslint-parser',
|
||||
ecmaVersion: 2020,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['html', 'prettier', 'babel'],
|
||||
rules: {
|
||||
'prettier/prettier': ['error'],
|
||||
camelcase: 'off',
|
||||
@ -210,13 +230,6 @@ module.exports = {
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: 'config/webpack/resolve.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
|
||||
43
.github/workflows/frontend-fe.yml
vendored
Normal file
43
.github/workflows/frontend-fe.yml
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
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
|
||||
74
.github/workflows/run_foss_spec.yml
vendored
74
.github/workflows/run_foss_spec.yml
vendored
@ -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,47 +41,49 @@ jobs:
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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: yarn
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
- 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: yarn check-files
|
||||
run: yarn install --check-files
|
||||
- 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
|
||||
|
||||
10
.github/workflows/run_response_bot_spec.yml
vendored
10
.github/workflows/run_response_bot_spec.yml
vendored
@ -49,13 +49,17 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.3.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
cache: pnpm
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
- name: pnpm
|
||||
run: pnpm install
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
20
.github/workflows/size-limit.yml
vendored
20
.github/workflows/size-limit.yml
vendored
@ -19,23 +19,33 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.3.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
- name: pnpm
|
||||
run: pnpm install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: setup env
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
RAILS_ENV: production
|
||||
|
||||
- name: Size Check
|
||||
run: yarn run size
|
||||
run: pnpm run size
|
||||
|
||||
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -32,6 +32,16 @@ master.key
|
||||
|
||||
public/uploads
|
||||
public/packs*
|
||||
public/assets/administrate*
|
||||
public/assets/action*.js
|
||||
public/assets/activestorage*.js
|
||||
public/assets/trix*
|
||||
public/assets/belongs_to*.js
|
||||
public/assets/manifest*.js
|
||||
public/assets/manifest*.js
|
||||
public/assets/*.js.gz
|
||||
public/assets/secretField*
|
||||
public/assets/.sprockets-manifest-*.json
|
||||
|
||||
# VIM files
|
||||
*.swp
|
||||
@ -75,4 +85,8 @@ yalc.lock
|
||||
yarn-debug.log*
|
||||
.yarn-integrity
|
||||
|
||||
/storybook-static
|
||||
# Vite Ruby
|
||||
/public/vite*
|
||||
# Vite uses dotenv and suggests to ignore local-only env files. See
|
||||
# https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||
*.local
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
# #!/bin/sh
|
||||
# . "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# lint js and vue files
|
||||
npx --no-install lint-staged
|
||||
# # lint js and vue files
|
||||
# npx --no-install lint-staged
|
||||
|
||||
# lint only staged ruby files
|
||||
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
|
||||
# # lint only staged ruby files
|
||||
# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
|
||||
|
||||
# stage rubocop changes to files
|
||||
git diff --name-only --cached | xargs git add
|
||||
# # stage rubocop changes to files
|
||||
# git diff --name-only --cached | xargs git add
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
const path = require('path');
|
||||
const resolve = require('../config/webpack/resolve');
|
||||
|
||||
// Chatwoot's webpack.config.js
|
||||
process.env.NODE_ENV = 'development';
|
||||
const custom = require('../config/webpack/environment');
|
||||
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../stories/**/*.stories.mdx',
|
||||
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
{
|
||||
name: '@storybook/addon-docs',
|
||||
options: {
|
||||
vueDocgenOptions: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '../'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
{
|
||||
/**
|
||||
* Fix Storybook issue with PostCSS@8
|
||||
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
|
||||
*/
|
||||
name: '@storybook/addon-postcss',
|
||||
options: {
|
||||
postcssLoaderOptions: {
|
||||
implementation: require('postcss'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
webpackFinal: config => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
modules: custom.resolvedModules.map(i => i.value),
|
||||
},
|
||||
};
|
||||
|
||||
newConfig.module.rules.push({
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
|
||||
include: path.resolve(__dirname, '../app/javascript'),
|
||||
});
|
||||
|
||||
return newConfig;
|
||||
},
|
||||
};
|
||||
@ -1,46 +0,0 @@
|
||||
import { addDecorator } from '@storybook/vue';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||
|
||||
import WootUiKit from '../app/javascript/dashboard/components';
|
||||
import i18n from '../app/javascript/dashboard/i18n';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
|
||||
|
||||
import '../app/javascript/dashboard/assets/scss/storybook.scss';
|
||||
|
||||
Vue.use(VueI18n);
|
||||
Vue.use(WootUiKit);
|
||||
Vue.use(Vuex);
|
||||
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
|
||||
Vue.component('multiselect', Multiselect);
|
||||
Vue.component('fluent-icon', FluentIcon);
|
||||
|
||||
const store = new Vuex.Store({});
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
addDecorator(() => ({
|
||||
template: '<story/>',
|
||||
i18n: i18nConfig,
|
||||
store,
|
||||
beforeCreate: function () {
|
||||
this.$root._i18n = this.$i18n;
|
||||
},
|
||||
}));
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
4
Gemfile
4
Gemfile
@ -64,7 +64,7 @@ gem 'activerecord-import'
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'webpacker'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
@ -204,8 +204,6 @@ group :development do
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Cypress in rails.
|
||||
gem 'cypress-on-rails'
|
||||
# fast cleaning of database
|
||||
gem 'database_cleaner'
|
||||
# mock http calls
|
||||
|
||||
157
Gemfile.lock
157
Gemfile.lock
@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actioncable (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
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)
|
||||
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)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
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)
|
||||
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)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionpack (7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
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.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
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)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionview (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
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.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activejob (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.3.6)
|
||||
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)
|
||||
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)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
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)
|
||||
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)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.4)
|
||||
activesupport (7.0.8.5)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@ -178,8 +178,6 @@ GEM
|
||||
csv (3.3.0)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
cypress-on-rails (1.16.0)
|
||||
rack
|
||||
database_cleaner (2.0.2)
|
||||
database_cleaner-active_record (>= 2, < 3)
|
||||
database_cleaner-active_record (2.1.0)
|
||||
@ -222,6 +220,7 @@ GEM
|
||||
railties (>= 6.1)
|
||||
down (5.4.0)
|
||||
addressable (~> 2.8)
|
||||
dry-cli (1.1.0)
|
||||
ecma-re-validator (0.4.0)
|
||||
regexp_parser (~> 2.2)
|
||||
elastic-apm (4.6.2)
|
||||
@ -322,10 +321,10 @@ GEM
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-protobuf (3.25.3)
|
||||
google-protobuf (3.25.3-arm64-darwin)
|
||||
google-protobuf (3.25.3-x86_64-darwin)
|
||||
google-protobuf (3.25.3-x86_64-linux)
|
||||
google-protobuf (3.25.5)
|
||||
google-protobuf (3.25.5-arm64-darwin)
|
||||
google-protobuf (3.25.5-x86_64-darwin)
|
||||
google-protobuf (3.25.5-x86_64-linux)
|
||||
googleapis-common-protos (1.6.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (~> 1.7)
|
||||
@ -374,7 +373,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.5)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@ -480,7 +479,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.14)
|
||||
net-imap (0.4.17)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@ -496,14 +495,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6)
|
||||
nokogiri (1.16.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@ -552,13 +551,13 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.0)
|
||||
puma (6.4.2)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
rack (2.2.9)
|
||||
racc (1.8.1)
|
||||
rack (2.2.10)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@ -570,25 +569,25 @@ GEM
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.6)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
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)
|
||||
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)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.4)
|
||||
railties (= 7.0.8.5)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@ -596,9 +595,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
railties (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@ -709,7 +708,6 @@ GEM
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.19.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
@ -800,6 +798,13 @@ GEM
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.0.17)
|
||||
railties (>= 5.1, < 8)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.8.0)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
rack-proxy (~> 0.6, >= 0.6.1)
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
@ -814,12 +819,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webpacker (5.4.4)
|
||||
activesupport (>= 5.2)
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webrick (1.8.1)
|
||||
webrick (1.8.2)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@ -827,7 +827,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.16)
|
||||
zeitwerk (2.6.17)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@ -862,7 +862,6 @@ DEPENDENCIES
|
||||
climate_control
|
||||
commonmarker
|
||||
csv-safe
|
||||
cypress-on-rails
|
||||
database_cleaner
|
||||
ddtrace
|
||||
debug (~> 1.8)
|
||||
@ -960,10 +959,10 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
uglifier
|
||||
valid_email2
|
||||
vite_rails
|
||||
web-console (>= 4.2.1)
|
||||
web-push (>= 3.0.1)
|
||||
webmock
|
||||
webpacker
|
||||
wisper (= 2.0.0)
|
||||
working_hours
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@ -6,7 +6,7 @@ RAILS_ENV ?= development
|
||||
setup:
|
||||
gem install bundler
|
||||
bundle install
|
||||
yarn install
|
||||
pnpm 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 && yarn
|
||||
bundle && pnpm install
|
||||
|
||||
run:
|
||||
@if [ -f ./.overmind.sock ]; then \
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
backend: bin/rails s -p 3000
|
||||
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
|
||||
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
vite: bin/vite dev
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
backend: RAILS_ENV=test bin/rails s -p 5050
|
||||
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
|
||||
vite: bin/vite dev
|
||||
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
|
||||
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
___
|
||||
|
||||
# Chatwoot
|
||||
# Chatwoot
|
||||
|
||||
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
<p>
|
||||
@ -98,7 +101,7 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
|
||||
|
||||
### Other deployment options
|
||||
|
||||
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
|
||||
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
3.9.0
|
||||
3.13.0
|
||||
|
||||
@ -1 +1 @@
|
||||
2.8.0
|
||||
3.0.0
|
||||
|
||||
@ -2,5 +2,4 @@
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link dashboardChart.js
|
||||
//= link secretField.js
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
// eslint-disable-next-line
|
||||
function prepareData(data) {
|
||||
var labels = [];
|
||||
var dataSet = [];
|
||||
data.forEach(item => {
|
||||
labels.push(item[0]);
|
||||
dataSet.push(item[1]);
|
||||
});
|
||||
return { labels, dataSet };
|
||||
}
|
||||
|
||||
function getChartOptions() {
|
||||
var fontFamily =
|
||||
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
return {
|
||||
responsive: true,
|
||||
legend: { labels: { fontFamily } },
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
barPercentage: 1.26,
|
||||
ticks: { fontFamily },
|
||||
gridLines: { display: false },
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
ticks: { fontFamily },
|
||||
gridLines: { display: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
function drawSuperAdminDashboard(data) {
|
||||
var ctx = document.getElementById('dashboard-chart').getContext('2d');
|
||||
var chartData = prepareData(data);
|
||||
// eslint-disable-next-line
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Conversations',
|
||||
data: chartData.dataSet,
|
||||
backgroundColor: '#1f93ff',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: getChartOptions(),
|
||||
});
|
||||
}
|
||||
@ -86,8 +86,5 @@ $swift-ease-out-duration: .4s !default;
|
||||
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
|
||||
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
|
||||
|
||||
// Ionicons
|
||||
$ionicons-font-path: '~ionicons/fonts';
|
||||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
|
||||
@ -72,7 +72,7 @@ class DashboardController < ActionController::Base
|
||||
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
||||
'v3app'
|
||||
else
|
||||
'application'
|
||||
'dashboard'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
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
|
||||
|
||||
@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
|
||||
end
|
||||
|
||||
def find_or_create_inbox
|
||||
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
channel_email = find_channel_by_email
|
||||
# we need this value to know where to redirect on sucessful processing of the callback
|
||||
channel_exists = channel_email.present?
|
||||
|
||||
@ -39,6 +39,10 @@ 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,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import router from '../dashboard/routes';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
@ -9,9 +8,12 @@ import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
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,
|
||||
@ -31,6 +33,13 @@ export default {
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
return { router, store, currentAccountId: accountId };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
@ -38,7 +47,6 @@ export default {
|
||||
reconnectService: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
@ -46,7 +54,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@ -63,10 +70,13 @@ export default {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@ -74,7 +84,7 @@ export default {
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
},
|
||||
beforeDestroy() {
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
this.reconnectService.disconnect();
|
||||
}
|
||||
@ -100,8 +110,9 @@ export default {
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
this.setLocale(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.$store, router);
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
window.reconnectService = this.reconnectService;
|
||||
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
@ -129,9 +140,11 @@ export default {
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
<UpgradeBanner />
|
||||
</template>
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
@ -141,6 +154,22 @@ export default {
|
||||
|
||||
<style lang="scss">
|
||||
@import './assets/scss/app';
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__inner {
|
||||
background: black !important;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px !important;
|
||||
border-radius: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
|
||||
91
app/javascript/dashboard/assets/images/auth/auth--bg.svg
Normal file
91
app/javascript/dashboard/assets/images/auth/auth--bg.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 76 KiB |
@ -0,0 +1,3 @@
|
||||
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M800 0H600V200H400V400H200V600H0V800H200H400H600H800V600V400V200V0Z" fill="#2773E4" fill-opacity="0.42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
3
app/javascript/dashboard/assets/images/auth/top-left.svg
Normal file
3
app/javascript/dashboard/assets/images/auth/top-left.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M200 0H0V200V400V600H200V400H400V200H600V0H400H200Z" fill="#2773E4" fill-opacity="0.42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@ -31,23 +31,6 @@
|
||||
transform: translateX($space-medium);
|
||||
}
|
||||
|
||||
.menu-list-enter-active,
|
||||
.menu-list-leave-active {
|
||||
transition: opacity 0.3s var(--ease-out-cubic),
|
||||
transform 0.2s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.menu-list-leave-to {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transform: translateX($space-small);
|
||||
}
|
||||
|
||||
.menu-list-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-$space-small);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
transition: all 0.3s var(--ease-in-cubic);
|
||||
}
|
||||
@ -65,7 +48,8 @@
|
||||
.menu-slide-enter-active,
|
||||
.menu-slide-leave-active {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.25s var(--ease-in-cubic),
|
||||
transition:
|
||||
transform 0.25s var(--ease-in-cubic),
|
||||
opacity 0.15s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@import '~vue2-datepicker/scss/index';
|
||||
@import 'vue-datepicker-next/scss/index';
|
||||
|
||||
.date-picker {
|
||||
// To be removed one SLA reports date picker is created
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
|
||||
.formulate-input {
|
||||
.formulate-input-errors {
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
// Inter,
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'PlusJakarta',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
@ -23,7 +24,7 @@ body {
|
||||
}
|
||||
|
||||
.app-wrapper {
|
||||
@apply h-full flex-grow-0 min-h-0 w-full;
|
||||
@apply h-screen flex-grow-0 min-h-0 w-full;
|
||||
|
||||
.button--fixed-top {
|
||||
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~widget/assets/scss/mixins';
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
@import 'widget/assets/scss/mixins';
|
||||
|
||||
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
|
||||
@ -89,9 +89,6 @@ $swift-ease-out-duration: .4s !default;
|
||||
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
|
||||
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
|
||||
|
||||
// Ionicons
|
||||
$ionicons-font-path: '~ionicons/fonts';
|
||||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
@ -34,19 +33,171 @@
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
.tooltip {
|
||||
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900;
|
||||
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
|
||||
}
|
||||
|
||||
#app {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
.hide {
|
||||
@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;
|
||||
--white-alpha: 255 255 255 0.1;
|
||||
--border-weak: 231 231 231;
|
||||
--border-strong: 235 235 235;
|
||||
--amber-solid: 252 232 193;
|
||||
--blue-solid: 218 236 255;
|
||||
--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-active: 51 53 64;
|
||||
--border-weak: 34 34 37;
|
||||
--border-strong: 46 47 49;
|
||||
--amber-solid: 42 37 30;
|
||||
--blue-solid: 16 49 91;
|
||||
--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;
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.875rem] min-h-[2.875rem];
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@import 'shared/assets/stylesheets/font-weights';
|
||||
@import 'shared/assets/stylesheets/shadows';
|
||||
@import 'shared/assets/stylesheets/border-radius';
|
||||
@import 'variables';
|
||||
|
||||
@import 'vue-multiselect/dist/vue-multiselect.min.css';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
@import 'mixins';
|
||||
@import 'helper-classes';
|
||||
|
||||
@import 'typography';
|
||||
@import 'layout';
|
||||
@import 'animations';
|
||||
|
||||
@import 'widgets/buttons';
|
||||
@import 'widgets/base';
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
|
||||
@import 'widget/assets/scss/reset';
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'widget/assets/scss/utilities';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'PlusJakarta', sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
}
|
||||
16
app/javascript/dashboard/components-next/CardLayout.vue
Normal file
16
app/javascript/dashboard/components-next/CardLayout.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@ -0,0 +1,56 @@
|
||||
<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>
|
||||
@ -0,0 +1,142 @@
|
||||
<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>
|
||||
@ -0,0 +1,41 @@
|
||||
<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>
|
||||
@ -0,0 +1,115 @@
|
||||
<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>
|
||||
@ -0,0 +1,99 @@
|
||||
<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>
|
||||
@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@ -0,0 +1,103 @@
|
||||
<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>
|
||||
@ -0,0 +1,25 @@
|
||||
<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>
|
||||
@ -0,0 +1,72 @@
|
||||
<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>
|
||||
@ -0,0 +1,74 @@
|
||||
<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>
|
||||
@ -0,0 +1,209 @@
|
||||
<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>
|
||||
@ -0,0 +1,101 @@
|
||||
<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>
|
||||
@ -0,0 +1,30 @@
|
||||
<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>
|
||||
@ -0,0 +1,146 @@
|
||||
<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>
|
||||
@ -0,0 +1,23 @@
|
||||
<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>
|
||||
@ -0,0 +1,52 @@
|
||||
<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>
|
||||
@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@ -0,0 +1,13 @@
|
||||
<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>
|
||||
@ -0,0 +1,113 @@
|
||||
<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>
|
||||
@ -0,0 +1,44 @@
|
||||
<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>
|
||||
@ -0,0 +1,111 @@
|
||||
<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>
|
||||
@ -0,0 +1,36 @@
|
||||
<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>
|
||||
52
app/javascript/dashboard/components-next/avatar/Avatar.vue
Normal file
52
app/javascript/dashboard/components-next/avatar/Avatar.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.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"
|
||||
>
|
||||
<FluentIcon
|
||||
icon="upload-lucide"
|
||||
icon-lib="lucide"
|
||||
:size="iconSize"
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,63 @@
|
||||
<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>
|
||||
@ -0,0 +1,60 @@
|
||||
<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>
|
||||
126
app/javascript/dashboard/components-next/button/Button.story.vue
Normal file
126
app/javascript/dashboard/components-next/button/Button.story.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<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>
|
||||
130
app/javascript/dashboard/components-next/button/Button.vue
Normal file
130
app/javascript/dashboard/components-next/button/Button.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<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>
|
||||
@ -0,0 +1,30 @@
|
||||
<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>
|
||||
163
app/javascript/dashboard/components-next/combobox/ComboBox.vue
Normal file
163
app/javascript/dashboard/components-next/combobox/ComboBox.vue
Normal file
@ -0,0 +1,163 @@
|
||||
<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>
|
||||
@ -0,0 +1,81 @@
|
||||
<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>
|
||||
109
app/javascript/dashboard/components-next/dialog/Dialog.vue
Normal file
109
app/javascript/dashboard/components-next/dialog/Dialog.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<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>
|
||||
@ -0,0 +1,55 @@
|
||||
<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>
|
||||
@ -0,0 +1,35 @@
|
||||
<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>
|
||||
@ -0,0 +1,82 @@
|
||||
<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>
|
||||
@ -0,0 +1,65 @@
|
||||
<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>
|
||||
@ -0,0 +1,68 @@
|
||||
<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>
|
||||
92
app/javascript/dashboard/components-next/input/Input.vue
Normal file
92
app/javascript/dashboard/components-next/input/Input.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<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>
|
||||
@ -0,0 +1,80 @@
|
||||
<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>
|
||||
@ -0,0 +1,113 @@
|
||||
<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>
|
||||
@ -0,0 +1,15 @@
|
||||
<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>
|
||||
25
app/javascript/dashboard/components-next/spinner/Spinner.vue
Normal file
25
app/javascript/dashboard/components-next/spinner/Spinner.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<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>
|
||||
@ -0,0 +1,61 @@
|
||||
<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>
|
||||
58
app/javascript/dashboard/components-next/tabbar/TabBar.vue
Normal file
58
app/javascript/dashboard/components-next/tabbar/TabBar.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<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>
|
||||
@ -0,0 +1,67 @@
|
||||
<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>
|
||||
@ -0,0 +1,69 @@
|
||||
<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>
|
||||
@ -1,30 +0,0 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import AccordionItemComponent from './AccordionItem';
|
||||
|
||||
export default {
|
||||
title: 'Components/Generic/Accordion',
|
||||
component: AccordionItemComponent,
|
||||
argTypes: {
|
||||
title: {
|
||||
control: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { AccordionItem: AccordionItemComponent },
|
||||
template: `
|
||||
<accordion-item v-bind="$props" @click="onClick">
|
||||
This is a sample content you can pass as a slot
|
||||
</accordion-item>
|
||||
`,
|
||||
});
|
||||
|
||||
export const AccordionItem = Template.bind({});
|
||||
AccordionItem.args = {
|
||||
onClick: action('Added'),
|
||||
title: 'Title of the accordion item',
|
||||
};
|
||||
@ -1,32 +1,34 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
||||
import { defineEmits } from 'vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmojiOrIcon,
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const onToggle = () => {
|
||||
emit('toggle');
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -34,7 +36,7 @@ export default {
|
||||
<div class="-mt-px text-sm">
|
||||
<button
|
||||
class="flex items-center select-none w-full rounded-none bg-slate-50 dark:bg-slate-800 border border-l-0 border-r-0 border-solid m-0 border-slate-100 dark:border-slate-700/50 cursor-grab justify-between py-2 px-4 drag-handle"
|
||||
@click="$emit('click')"
|
||||
@click.stop="onToggle"
|
||||
>
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<EmojiOrIcon class="inline-block w-5" :icon="icon" :emoji="emoji" />
|
||||
|
||||
@ -16,7 +16,6 @@ export default {
|
||||
<template>
|
||||
<button
|
||||
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
|
||||
<h3
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -61,7 +61,7 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<template v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
size="tiny"
|
||||
@ -78,8 +78,8 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
icon="dismiss-circle"
|
||||
@click="emit('resetFilters')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hasActiveFolders">
|
||||
</template>
|
||||
<template v-if="hasActiveFolders">
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||
size="tiny"
|
||||
@ -96,7 +96,7 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
icon="delete"
|
||||
@click="emit('deleteFolders')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
@ -108,7 +108,7 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
/>
|
||||
<ConversationBasicFilter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
@changeFilter="onBasicFilterChange"
|
||||
@change-filter="onBasicFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import 'highlight.js/styles/default.css';
|
||||
import 'highlight.js/lib/common';
|
||||
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
@ -28,14 +30,28 @@ export default {
|
||||
return JSON.stringify({
|
||||
title: this.codepenTitle,
|
||||
private: true,
|
||||
[lang]: this.script,
|
||||
[lang]: this.scrubbedScript,
|
||||
});
|
||||
},
|
||||
scrubbedScript() {
|
||||
// remove trailing and leading extra lines and not spaces
|
||||
const scrubbed = this.script.replace(/^\s*[\r\n]/gm, '');
|
||||
const lines = scrubbed.split('\n');
|
||||
|
||||
// remove extra indentations
|
||||
const minIndent = lines.reduce((min, line) => {
|
||||
if (line.trim().length === 0) return min;
|
||||
const indent = line.match(/^\s*/)[0].length;
|
||||
return Math.min(min, indent);
|
||||
}, Infinity);
|
||||
|
||||
return lines.map(line => line.slice(minIndent)).join('\n');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(this.script);
|
||||
await copyTextToClipboard(this.scrubbedScript);
|
||||
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
},
|
||||
@ -43,42 +59,24 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code--container">
|
||||
<div class="code--action-area">
|
||||
<div class="relative text-left">
|
||||
<div class="top-1.5 absolute right-1.5 flex items-center gap-1">
|
||||
<form
|
||||
v-if="enableCodePen"
|
||||
class="code--codeopen-form"
|
||||
class="flex items-center"
|
||||
action="https://codepen.io/pen/define"
|
||||
method="POST"
|
||||
target="_blank"
|
||||
>
|
||||
<input type="hidden" name="data" :value="codepenScriptValue" />
|
||||
|
||||
<button type="submit" class="button secondary tiny">
|
||||
{{ $t('COMPONENTS.CODE.CODEPEN') }}
|
||||
</button>
|
||||
</form>
|
||||
<button class="button secondary tiny" @click="onCopy">
|
||||
<button type="button" class="button secondary tiny" @click="onCopy">
|
||||
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
|
||||
</button>
|
||||
</div>
|
||||
<highlightjs v-if="script" :language="lang" :code="script" />
|
||||
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code--container {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
|
||||
.code--action-area {
|
||||
top: var(--space-small);
|
||||
position: absolute;
|
||||
right: var(--space-small);
|
||||
}
|
||||
|
||||
.code--codeopen-form {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -14,6 +14,7 @@ export default {
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
'assignPriority',
|
||||
'isConversationSelected',
|
||||
],
|
||||
props: {
|
||||
source: {
|
||||
@ -36,10 +37,6 @@ export default {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
isConversationSelected: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -59,14 +56,14 @@ export default {
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:show-assignee="showAssignee"
|
||||
enable-context-menu
|
||||
@selectConversation="selectConversation"
|
||||
@deSelectConversation="deSelectConversation"
|
||||
@assignAgent="assignAgent"
|
||||
@assignTeam="assignTeam"
|
||||
@assignLabel="assignLabels"
|
||||
@updateConversationStatus="updateConversationStatus"
|
||||
@contextMenuToggle="toggleContextMenu"
|
||||
@markAsUnread="markAsUnread"
|
||||
@assignPriority="assignPriority"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@assign-priority="assignPriority"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -7,6 +7,7 @@ import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
|
||||
import { isValidURL } from '../helper/URLHelper';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
@ -30,6 +31,7 @@ export default {
|
||||
attributeKey: { type: String, required: true },
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
emits: ['update', 'delete', 'copy'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
@ -135,10 +137,10 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.editedValue = this.formattedValue;
|
||||
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
},
|
||||
destroyed() {
|
||||
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
unmounted() {
|
||||
emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
},
|
||||
methods: {
|
||||
onFocusAttribute(focusAttributeKey) {
|
||||
@ -321,7 +323,7 @@ export default {
|
||||
'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
@click="onUpdateListValue"
|
||||
@select="onUpdateListValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ const shouldShowContent = computed(
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="shouldShowContent">
|
||||
<slot />
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<script>
|
||||
import DatePicker from 'vue2-datepicker';
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatePicker,
|
||||
},
|
||||
emits: ['close', 'chooseTime'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
@ -43,15 +44,18 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
|
||||
<form class="modal-content" @submit.prevent="chooseTime">
|
||||
<form
|
||||
class="modal-content w-full pt-2 px-5 pb-6"
|
||||
@submit.prevent="chooseTime"
|
||||
>
|
||||
<DatePicker
|
||||
v-model="snoozeTime"
|
||||
v-model:value="snoozeTime"
|
||||
type="datetime"
|
||||
inline
|
||||
input-class="mx-input reset-base"
|
||||
:lang="lang"
|
||||
:disabled-date="disabledDate"
|
||||
:disabled-time="disabledTime"
|
||||
:popup-style="{ width: '100%' }"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-button variant="clear" @click.prevent="onClose">
|
||||
@ -64,9 +68,3 @@ export default {
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-content {
|
||||
@apply pt-2 px-5 pb-6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,32 +1,26 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
|
||||
},
|
||||
<script setup>
|
||||
import { ref, defineEmits } from 'vue';
|
||||
import { useIntersectionObserver } from '@vueuse/core';
|
||||
|
||||
const { options } = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
|
||||
},
|
||||
mounted() {
|
||||
this.intersectionObserver = null;
|
||||
this.registerInfiniteLoader();
|
||||
});
|
||||
|
||||
const emit = defineEmits(['observed']);
|
||||
const observedElement = ref('');
|
||||
|
||||
useIntersectionObserver(
|
||||
observedElement,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
emit('observed');
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unobserveInfiniteLoadObserver();
|
||||
},
|
||||
methods: {
|
||||
registerInfiniteLoader() {
|
||||
this.intersectionObserver = new IntersectionObserver(entries => {
|
||||
if (entries && entries[0].isIntersecting) {
|
||||
this.$emit('observed');
|
||||
}
|
||||
}, this.options);
|
||||
this.intersectionObserver.observe(this.$refs.observedElement);
|
||||
},
|
||||
unobserveInfiniteLoadObserver() {
|
||||
this.intersectionObserver.unobserve(this.$refs.observedElement);
|
||||
},
|
||||
},
|
||||
};
|
||||
options
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -1,78 +1,69 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
closeOnBackdropClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
show: Boolean,
|
||||
showCloseButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modalType: {
|
||||
type: String,
|
||||
default: 'centered',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mousedDownOnBackdrop: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modalClassName() {
|
||||
const modalClassNameMap = {
|
||||
centered: '',
|
||||
'right-aligned': 'right-aligned',
|
||||
};
|
||||
<script setup>
|
||||
// [TODO] Use Teleport to move the modal to the end of the body
|
||||
import { ref, computed, defineEmits, onMounted } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
return `modal-mask skip-context-menu ${
|
||||
modalClassNameMap[this.modalType] || ''
|
||||
}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keydown', e => {
|
||||
if (this.show && e.code === 'Escape') {
|
||||
this.onClose();
|
||||
}
|
||||
});
|
||||
const { modalType, closeOnBackdropClick, onClose } = defineProps({
|
||||
closeOnBackdropClick: { type: Boolean, default: true },
|
||||
showCloseButton: { type: Boolean, default: true },
|
||||
onClose: { type: Function, required: true },
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
modalType: { type: String, default: 'centered' },
|
||||
size: { type: String, default: '' },
|
||||
});
|
||||
|
||||
document.body.addEventListener('mouseup', this.onMouseUp);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.body.removeEventListener('mouseup', this.onMouseUp);
|
||||
},
|
||||
methods: {
|
||||
handleMouseDown() {
|
||||
this.mousedDownOnBackdrop = true;
|
||||
},
|
||||
close() {
|
||||
this.onClose();
|
||||
},
|
||||
onMouseUp() {
|
||||
if (this.mousedDownOnBackdrop) {
|
||||
this.mousedDownOnBackdrop = false;
|
||||
if (this.closeOnBackdropClick) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['close']);
|
||||
const show = defineModel('show', { type: Boolean, default: false });
|
||||
|
||||
const modalClassName = computed(() => {
|
||||
const modalClassNameMap = {
|
||||
centered: '',
|
||||
'right-aligned': 'right-aligned',
|
||||
};
|
||||
|
||||
return `modal-mask skip-context-menu ${modalClassNameMap[modalType] || ''}`;
|
||||
});
|
||||
|
||||
// [TODO] Revisit this logic to use outside click directive
|
||||
const mousedDownOnBackdrop = ref(false);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
mousedDownOnBackdrop.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
emit('close');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (mousedDownOnBackdrop.value) {
|
||||
mousedDownOnBackdrop.value = false;
|
||||
if (closeOnBackdropClick) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeydown = e => {
|
||||
if (show.value && e.code === 'Escape') {
|
||||
close();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(document.body, 'mouseup', onMouseUp);
|
||||
useEventListener(document, 'keydown', onKeydown);
|
||||
|
||||
onMounted(() => {
|
||||
if (onClose && typeof onClose === 'function') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead."
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<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
|
||||
ref="modalHeaderTitle"
|
||||
data-test-id="modal-header-title"
|
||||
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="headerContent"
|
||||
ref="modalHeaderContent"
|
||||
data-test-id="modal-header-content"
|
||||
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ headerContent }}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useRoute } from 'dashboard/composables/route';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user