Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub e55d089f01 Merge branch 'develop' into feat/github-integration 2025-10-28 19:27:09 +05:30
Muhsin KelothandGitHub d0e84373ee Merge branch 'develop' into feat/github-integration 2025-10-06 15:29:09 +05:30
Muhsin KelothandGitHub 62152321bc Merge branch 'develop' into feat/github-integration 2025-09-25 16:25:58 +05:30
Muhsin KelothandGitHub c6714b20c2 Merge branch 'develop' into feat/github-integration 2025-09-22 11:19:35 +05:30
Muhsin KelothandGitHub 0f2263ec94 Merge branch 'develop' into feat/github-integration 2025-09-12 17:10:29 +05:30
Muhsin KelothandGitHub 784e0468c3 Merge branch 'develop' into feat/github-integration 2025-08-27 11:44:46 +05:30
Muhsin KelothandGitHub 75c9b38099 Merge branch 'develop' into feat/github-integration 2025-08-22 00:44:13 +05:30
Muhsin KelothandGitHub f8c155dd9e Merge branch 'develop' into feat/github-integration 2025-08-12 14:48:17 +05:30
Muhsin KelothandGitHub b9cf410b1f Merge branch 'develop' into feat/github-integration 2025-08-11 23:31:49 +05:30
Muhsin KelothandGitHub e6d96b14ac Merge branch 'develop' into feat/github-integration 2025-08-11 16:22:11 +05:30
Muhsin KelothandGitHub f0b3f025ff Merge branch 'develop' into feat/github-integration 2025-08-07 18:29:25 +05:30
Muhsin KelothandGitHub 5b32761d72 Merge branch 'develop' into feat/github-integration 2025-08-07 09:47:27 +05:30
Muhsin KelothandGitHub 7146471270 Merge branch 'develop' into feat/github-integration 2025-08-05 16:22:18 +05:30
Muhsin KelothandGitHub 8d6ea64848 Merge branch 'develop' into feat/github-integration 2025-08-04 12:36:38 +04:00
Muhsin KelothandGitHub 27b0a22019 Merge branch 'develop' into feat/github-integration 2025-07-29 12:50:05 +04:00
Muhsin KelothandGitHub 611635d3d7 Merge branch 'develop' into feat/github-integration 2025-07-23 11:42:04 +04:00
Muhsin KelothandGitHub 02178eb930 Merge branch 'develop' into feat/github-integration 2025-07-22 15:28:13 +04:00
Muhsin KelothandGitHub a1413d1656 Merge branch 'develop' into feat/github-integration 2025-07-21 21:48:28 +04:00
Muhsin KelothandGitHub a1747eeb62 Merge branch 'develop' into feat/github-integration 2025-07-17 09:26:59 +05:30
Muhsin Keloth f90a324ddf chore: update allowed config mappings for integrations 2025-07-17 09:26:15 +05:30
Muhsin Keloth 3f5d4f46f8 chore: add whatsApp and google oauth config options 2025-07-17 09:25:28 +05:30
Muhsin Keloth 7ae916e2fc chore: delete feature.yml 2025-07-17 09:23:40 +05:30
Muhsin KelothandGitHub c623d6079b Merge branch 'develop' into feat/github-integration 2025-07-14 19:23:13 +05:30
Muhsin KelothandGitHub f0876e9877 Merge branch 'develop' into feat/github-integration 2025-07-14 11:32:05 +05:30
Muhsin KelothandGitHub 92b0c2d4f5 Merge branch 'develop' into feat/github-integration 2025-07-11 11:48:12 +05:30
Muhsin KelothandGitHub 0a80076387 Merge branch 'develop' into feat/github-integration 2025-07-10 20:14:01 +05:30
Muhsin Keloth a59f53d00d chore: add more specs 2025-07-04 23:32:12 +05:30
Muhsin Keloth 143f394a18 chore: add specs 2025-07-04 17:10:11 +05:30
Muhsin Keloth 8e98fd26b5 chore: fix account id 2025-07-04 16:52:02 +05:30
Muhsin Keloth 4c85a1c6c0 chore: add github installation steps 2025-07-04 16:43:59 +05:30
Muhsin KelothandGitHub 6e28c4fcf8 Merge branch 'develop' into feat/github-integration 2025-07-04 14:38:18 +05:30
Muhsin KelothandGitHub 7f9d711845 Merge branch 'develop' into feat/github-integration 2025-07-02 22:42:41 +05:30
Muhsin KelothandGitHub 31436412fb Merge branch 'develop' into feat/github-integration 2025-07-01 16:52:46 +05:30
Muhsin KelothandGitHub 84d2135a04 Merge branch 'develop' into feat/github-integration 2025-07-01 11:34:35 +05:30
Muhsin KelothandGitHub 5bd739739e Merge branch 'develop' into feat/github-integration 2025-07-01 08:54:23 +05:30
Muhsin KelothandGitHub 3b42b6c3bb Merge branch 'develop' into feat/github-integration 2025-06-30 20:55:13 +05:30
Muhsin Keloth d3b8edd522 chore: add github basic apis 2025-06-30 12:54:50 +05:30
Muhsin Keloth ac7cac33a8 chore: update oauth url 2025-06-30 12:47:33 +05:30
Muhsin Keloth 4d98b1871a chore: fix route 2025-06-30 12:44:26 +05:30
Muhsin Keloth 4f214dd77a chore: fix route 2025-06-30 12:41:58 +05:30
Muhsin KelothandGitHub 94640628eb Merge branch 'develop' into feat/github-integration 2025-06-30 12:35:17 +05:30
Muhsin Keloth c9c9a011fe chore: enable GitHub Integration feature 2025-06-24 20:52:20 +05:30
Muhsin KelothandGitHub 747a856181 Merge branch 'develop' into feat/github-integration 2025-06-24 15:59:16 +05:30
Muhsin KelothandGitHub be5248ad68 Merge branch 'develop' into feat/github-integration 2025-06-23 11:54:12 +05:30
Muhsin KelothandGitHub a9ead61a89 Merge branch 'develop' into feat/github-integration 2025-06-19 11:52:06 +05:30
Muhsin KelothandGitHub c5b72239c6 Merge branch 'develop' into feat/github-integration 2025-06-18 11:31:31 +05:30
Muhsin KelothandGitHub 824b5a8113 Merge branch 'develop' into feat/github-integration 2025-06-17 09:24:16 +05:30
Muhsin KelothandGitHub 913ff2821a Merge branch 'develop' into feat/github-integration 2025-06-13 10:54:32 +05:30
Muhsin KelothandGitHub 05c004a3cb Merge branch 'develop' into feat/github-integration 2025-06-12 17:20:13 +05:30
Muhsin Keloth bd4c572aea chore: add octokit 2025-06-12 14:36:22 +05:30
Muhsin Keloth 7e672e5c71 Update app.rb 2025-06-12 14:18:26 +05:30
Muhsin Keloth 1622ba1b7f chore: fix configs 2025-06-12 14:00:47 +05:30
Muhsin Keloth d59f2d7bd1 chore: fix cyclomatic complexity 2025-06-12 13:34:39 +05:30
Muhsin Keloth 959f7447c3 Update app.rb 2025-06-12 12:56:29 +05:30
Muhsin Keloth 39a6485c2a Update app.rb 2025-06-12 12:55:04 +05:30
Muhsin Keloth bcaca9d3d3 Update app.rb 2025-06-12 12:54:08 +05:30
Muhsin Keloth 4de56468b7 chore: add state 2025-06-12 12:52:59 +05:30
Muhsin KelothandGitHub 6f84899beb Merge branch 'develop' into feat/github-integration 2025-06-12 12:19:04 +05:30
Muhsin Keloth bf78dde488 chore: delete github hook 2025-04-21 20:04:39 +05:30
Muhsin Keloth 1df0159f65 chore: add oauth flow 2025-04-21 19:50:08 +05:30
Muhsin Keloth b2a1e3282c chore: add basic config 2025-04-21 19:43:11 +05:30
713 changed files with 7157 additions and 17219 deletions
+49 -196
View File
@@ -3,7 +3,6 @@ orbs:
node: circleci/node@6.1.0 node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0 qlty-orb: qltysh/qlty-orb@0.0
# Shared defaults for setup steps
defaults: &defaults defaults: &defaults
working_directory: ~/build working_directory: ~/build
machine: machine:
@@ -13,106 +12,10 @@ defaults: &defaults
RAILS_LOG_TO_STDOUT: false RAILS_LOG_TO_STDOUT: false
COVERAGE: true COVERAGE: true
LOG_LEVEL: warn LOG_LEVEL: warn
parallelism: 4
jobs: jobs:
# Separate job for linting (no parallelism needed) build:
lint:
<<: *defaults
steps:
- checkout
# Install minimal system dependencies for linting
- run:
name: Install System Dependencies
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
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.4.4
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.4.4"
rvm use 3.4.4 --default
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
command: |
source ~/.rvm/scripts/rvm
bundle install
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
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
# 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 --parallel
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
# Separate job for frontend tests
frontend-tests:
<<: *defaults <<: *defaults
steps: steps:
- checkout - checkout
@@ -122,38 +25,8 @@ jobs:
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
override-ci-command: pnpm i override-ci-command: pnpm i
- run: node --version
- run: - run: pnpm --version
name: Run frontend tests (with coverage)
command: pnpm run test:coverage
- run:
name: Move coverage files if they exist
command: |
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/frontend || true
fi
when: always
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
steps:
- checkout
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: - run:
name: Add PostgreSQL repository and update name: Add PostgreSQL repository and update
command: | command: |
@@ -218,6 +91,20 @@ jobs:
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
bundle install bundle install
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
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
# Configure environment and database # Configure environment and database
- run: - run:
name: Database Setup and Configure Environment Variables name: Database Setup and Configure Environment Variables
@@ -240,91 +127,57 @@ jobs:
name: Run DB migrations name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare command: bundle exec rails db:chatwoot_prepare
# Run backend tests (parallelized) # Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
- run:
name: Run frontend tests (with coverage)
command: |
mkdir -p ~/build/coverage/frontend
pnpm run test:coverage
# Run backend tests
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend mkdir -p ~/build/coverage/backend
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
# Use round-robin distribution (same as GitHub Actions) for better test isolation
# This prevents tests with similar timing from being grouped on the same runner
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \ --format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \ --out ~/tmp/test-results/rspec.xml \
-- $TESTS -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
# Store test results for better splitting in future runs # Qlty coverage publish
- store_test_results: - qlty-orb/coverage_publish:
path: ~/tmp/test-results files: |
coverage/coverage.json
coverage/lcov.info
- run: - run:
name: Move coverage files if they exist name: List coverage directory contents
command: | command: |
if [ -d "coverage" ]; then ls -R ~/build/coverage
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/backend || true
fi
when: always
- persist_to_workspace: - persist_to_workspace:
root: ~/build root: ~/build
paths: paths:
- coverage - coverage
# Collect coverage from all jobs
coverage:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/build
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/frontend/lcov.info
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage || echo "No coverage directory"
- store_artifacts: - store_artifacts:
path: coverage path: coverage
destination: coverage destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
build:
jobs:
- lint
- frontend-tests
- backend-tests
- coverage:
requires:
- frontend-tests
- backend-tests
- build:
requires:
- lint
- coverage
-7
View File
@@ -105,7 +105,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
# mandrill for Mandrill # mandrill for Mandrill
# postmark for Postmark # postmark for Postmark
# sendgrid for Sendgrid # sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE= RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service # Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
@@ -115,10 +114,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY= MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY= MANDRILL_INGRESS_API_KEY=
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
# Configure only if the rails_inbound_email_service = ses
ACTION_MAILBOX_SES_SNS_TOPIC=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format: # Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
@@ -261,8 +256,6 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance ## Change these values to fine tune performance
# control the concurrency setting of sidekiq # control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10 # SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features # AI powered features
+15 -79
View File
@@ -1,6 +1,4 @@
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -10,58 +8,11 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# Separate linting jobs for faster feedback test:
lint-backend: runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run ESLint
run: pnpm run eslint
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run frontend tests
run: pnpm run test:coverage
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ci_node_total: [16]
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
services: services:
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg15
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: '' POSTGRES_PASSWORD: ''
@@ -69,6 +20,8 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ports: ports:
- 5432:5432 - 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >- options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data --mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready --health-cmd pg_isready
@@ -76,7 +29,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
redis: redis:
image: redis:alpine image: redis
ports: ports:
- 6379:6379 - 6379:6379
options: --entrypoint redis-server options: --entrypoint redis-server
@@ -90,7 +43,7 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -111,36 +64,19 @@ jobs:
- name: Seed database - name: Seed database
run: bundle exec rake db:schema:load run: bundle exec rake db:schema:load
- name: Run backend tests (parallelized) - name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
run: | run: |
# Get all spec files and split them using round-robin distribution bundle exec rspec --profile=10 --format documentation
# This ensures slow tests are distributed evenly across all nodes
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
# Assign spec to this node if: index % total == node_index
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
if [ -n "$TESTS" ]; then
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
fi
env: env:
NODE_OPTIONS: --openssl-legacy-provider NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test results - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: rspec-results-${{ matrix.ci_node_index }} name: rails-log-folder
path: tmp/rspec_results.json
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: failure()
with:
name: rails-log-folder-${{ matrix.ci_node_index }}
path: log path: log
+2 -2
View File
@@ -36,5 +36,5 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false
load: false load: false
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max
+1 -4
View File
@@ -21,7 +21,7 @@ gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info' gem 'octokit'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -55,9 +55,6 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing' gem 'image_processing'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'pg' gem 'pg'
+12 -15
View File
@@ -136,13 +136,9 @@ GEM
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.760.0) aws-partitions (1.760.0)
aws-sdk-core (3.188.0) aws-sdk-core (3.171.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
@@ -150,13 +146,10 @@ GEM
aws-sdk-kms (1.64.0) aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0) aws-sdk-s3 (1.122.0)
aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2) aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9) barnes (0.0.9)
@@ -277,7 +270,6 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
http (>= 3.0) http (>= 3.0)
ruby2_keywords ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
@@ -599,10 +591,13 @@ GEM
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0) snaky_hash (~> 2.0)
version_gem (~> 1.1) version_gem (~> 1.1)
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.4) omniauth (2.1.3)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger logger
rack (>= 2.2.3) rack (>= 2.2.3)
@@ -661,7 +656,7 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (4.2.1) rack-protection (4.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
@@ -825,6 +820,9 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
scout_apm (5.3.3) scout_apm (5.3.3)
parser parser
scss_lint (0.60.0) scss_lint (0.60.0)
@@ -1002,7 +1000,6 @@ DEPENDENCIES
annotate annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3 aws-sdk-s3
azure-storage-blob! azure-storage-blob!
barnes barnes
@@ -1025,7 +1022,6 @@ DEPENDENCIES
dotenv-rails (>= 3.0.0) dotenv-rails (>= 3.0.0)
down down
elastic-apm elastic-apm
email-provider-info
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails (>= 6.4.3) factory_bot_rails (>= 6.4.3)
@@ -1067,6 +1063,7 @@ DEPENDENCIES
net-smtp (~> 0.3.4) net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2) newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm newrelic_rpm
octokit
omniauth (>= 2.1.2) omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3) omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2 omniauth-oauth2
+1 -1
View File
@@ -1 +1 @@
4.8.0 4.4.0
-4
View File
@@ -36,10 +36,6 @@
"REDIS_OPENSSL_VERIFY_MODE":{ "REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none" "value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
} }
}, },
"formation": { "formation": {
+44 -33
View File
@@ -1,8 +1,5 @@
class Messages::MessageBuilder class Messages::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params)
@@ -26,7 +23,7 @@ class Messages::MessageBuilder
process_emails process_emails
# When the message has no quoted content, it will just be rendered as a regular message # When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case # The frontend is equipped to handle this case
process_email_content process_email_content if @account.feature_enabled?(:quoted_email_reply)
@message.save! @message.save!
@message @message
end end
@@ -41,12 +38,30 @@ class Messages::MessageBuilder
params = convert_to_hash(@params) params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {}) content_attributes = params.fetch(:content_attributes, {})
return safe_parse_json(content_attributes) if content_attributes.is_a?(String) return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash) return content_attributes if content_attributes.is_a?(Hash)
{} {}
end end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments def process_attachments
return if @attachments.blank? return if @attachments.blank?
@@ -95,6 +110,12 @@ class Messages::MessageBuilder
email_string.gsub(/\s+/, '').split(',') email_string.gsub(/\s+/, '').split(',')
end end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -157,17 +178,14 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content) normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content # Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided? email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content build_custom_html_content
else else
build_html_content(processed_content) build_html_content(normalized_content)
end end
email_attributes[:text_content] = build_text_content(processed_content) email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes email_attributes
end end
@@ -186,6 +204,22 @@ class Messages::MessageBuilder
text_content text_content
end end
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
def custom_email_content_provided? def custom_email_content_provided?
@params[:email_html_content].present? @params[:email_html_content].present?
end end
@@ -198,27 +232,4 @@ class Messages::MessageBuilder
html_content html_content
end end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end end
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :agent_bot, except: [:index, :create] before_action :agent_bot, except: [:index, :create]
def index def index
@agent_bots = AgentBot.accessible_to(Current.account) @agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
end end
def show; end def show; end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
private private
def agent_bot def agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show' @agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id]) @agent_bot ||= Current.account.agent_bots.find(params[:id])
end end
@@ -22,10 +22,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end def edit; end
def create def create
params_with_defaults = article_params @article = @portal.articles.create!(article_params)
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article.associate_root_article(article_params[:associated_article_id]) @article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end
@@ -1,12 +1,13 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create def create
case normalized_type if type_matches?
when 'Conversation' ::BulkActionsJob.perform_later(
enqueue_conversation_job account: @current_account,
head :ok user: current_user,
when 'Contact' params: permitted_params
check_authorization_for_contact_action )
enqueue_contact_job
head :ok head :ok
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
@@ -15,54 +16,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private private
def normalized_type def type_matches?
params[:type].to_s.camelize ['Conversation'].include?(params[:type])
end end
def enqueue_conversation_job def permitted_params
::BulkActionsJob.perform_later( params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def delete_contact_action?
params[:action_name] == 'delete'
end
def check_authorization_for_contact_action
authorize(Contact, :destroy?) if delete_contact_action?
end
def conversation_params
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
# and then remove this method in favor of a common params method.
base = params.permit(
:snoozed_until,
fields: [:status, :assignee_id, :team_id]
)
append_common_bulk_attributes(base)
end
def contact_params
# TODO: remove this method in favor of a common params method.
# once legacy conversation payloads are migrated.
append_common_bulk_attributes({})
end
def append_common_bulk_attributes(base_params)
# NOTE: Conversation payloads historically diverged per action. Going forward we
# want all objects to share a common contract: `{ action_name, action_attributes }`
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
base_params.merge(common)
end end
end end
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation # assigns agent/team to a conversation
def create def create
if params.key?(:assignee_id) || agent_bot_assignment? if params.key?(:assignee_id)
set_agent set_agent
elsif params.key?(:team_id) elsif params.key?(:team_id)
set_team set_team
@@ -13,23 +13,17 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private private
def set_agent def set_agent
resource = Conversations::AssignmentService.new( @agent = Current.account.users.find_by(id: params[:assignee_id])
conversation: @conversation, @conversation.assignee = @agent
assignee_id: params[:assignee_id], @conversation.save!
assignee_type: params[:assignee_type] render_agent
).perform
render_agent(resource)
end end
def render_agent(resource) def render_agent
case resource if @agent.nil?
when User
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
else
render json: nil render json: nil
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
end end
end end
@@ -38,8 +32,4 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team) @conversation.update!(team: @team)
render json: @team render json: @team
end end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
end
end end
@@ -0,0 +1,155 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :fetch_hook
before_action :ensure_hook_exists, except: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def repositories
repositories = github_service.repositories
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def search_repositories
repositories = github_service.search_repositories(params[:q])
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def assignees
assignees = github_service.assignees(repo_full_name)
filtered_assignees = assignees.map do |assignee|
{
login: assignee.login,
avatar_url: assignee.avatar_url
}
end
render json: filtered_assignees
end
def labels
labels = github_service.labels(repo_full_name)
filtered_labels = labels.map do |label|
{
name: label.name,
color: label.color
}
end
render json: filtered_labels
end
def search_issues
issues = github_service.search_issues(repo_full_name, params[:q])
filtered_issues = issues.map do |issue|
{
number: issue.number,
title: issue.title,
html_url: issue.html_url
}
end
render json: filtered_issues
end
def create_issue
issue_data = github_service.create_issue(
repo_full_name,
params[:title],
params[:body],
issue_options
)
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def link_issue
issue_data = github_service.issue(repo_full_name, params[:issue_number])
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def linked_issues
issues = GithubIssue.where(conversation_id: params[:conversation_id])
render json: issues.map do |issue|
{
id: issue.id,
issue_number: issue.issue_number,
title: issue.issue_title,
html_url: issue.html_url,
repo_full_name: issue.repo_full_name,
linked_by: {
id: issue.linked_by.id,
name: issue.linked_by.name
},
created_at: issue.created_at
}
end
end
def unlink_issue
github_issue = GithubIssue.find(params[:id])
github_issue.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
end
def ensure_hook_exists
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
end
def github_service
@github_service ||= Github::GithubService.new(hook: @hook)
end
def repo_full_name
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
end
def issue_options
options = {}
options[:assignees] = params[:assignees] if params[:assignees].present?
options[:labels] = params[:labels] if params[:labels].present?
options
end
def create_linked_issue(issue_data)
GithubIssue.create!(
conversation_id: params[:conversation_id],
account: Current.account,
repo_full_name: repo_full_name,
issue_number: issue_data.number,
issue_title: issue_data.title,
html_url: issue_data.html_url,
linked_by: Current.user
)
end
def issue_response(github_issue)
{
id: github_issue.id,
issue_number: github_issue.issue_number,
title: github_issue.issue_title,
html_url: github_issue.html_url,
repo_full_name: github_issue.repo_full_name
}
end
end
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private private
def webhook_params def webhook_params
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end end
def fetch_webhook def fetch_webhook
@@ -9,13 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private private
def set_global_config def set_global_config
@global_config = GlobalConfig.get( @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
'LOGO_THUMBNAIL',
'BRAND_NAME',
'WIDGET_BRAND_URL',
'MAXIMUM_FILE_UPLOAD_SIZE',
'INSTALLATION_NAME'
)
end end
def set_contact def set_contact
@@ -25,9 +25,6 @@ module EnsureCurrentAccountHelper
end end
def account_accessible_for_bot?(account) def account_accessible_for_bot?(account)
return if @resource.account_id == account.id render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
render_unauthorized('Bot is not authorized to access this account')
end end
end end
+20 -35
View File
@@ -1,31 +1,6 @@
class DashboardController < ActionController::Base class DashboardController < ActionController::Base
include SwitchLocale include SwitchLocale
GLOBAL_CONFIG_KEYS = %w[
LOGO
LOGO_DARK
LOGO_THUMBNAIL
INSTALLATION_NAME
WIDGET_BRAND_URL
TERMS_URL
BRAND_URL
BRAND_NAME
PRIVACY_URL
DISPLAY_MANIFEST
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
LOGOUT_REDIRECT_LINK
DISABLE_USER_PROFILE_UPDATE
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
].freeze
before_action :set_application_pack before_action :set_application_pack
before_action :set_global_config before_action :set_global_config
before_action :set_dashboard_scripts before_action :set_dashboard_scripts
@@ -44,7 +19,25 @@ class DashboardController < ActionController::Base
end end
def set_global_config def set_global_config
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config) @global_config = GlobalConfig.get(
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'TERMS_URL',
'BRAND_URL', 'BRAND_NAME',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
'CHATWOOT_INBOX_TOKEN',
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN'
).merge(app_config)
end end
def set_dashboard_scripts def set_dashboard_scripts
@@ -78,18 +71,10 @@ class DashboardController < ActionController::Base
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH, GIT_SHA: GIT_HASH
ALLOWED_LOGIN_METHODS: allowed_login_methods
} }
end end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
methods
end
def set_application_pack def set_application_pack
@application_pack = if request.path.include?('/auth') || request.path.include?('/login') @application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app' 'v3app'
@@ -0,0 +1,160 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
def show
# Validate account context early for all flows that require it
account if params[:code].present?
if params[:installation_id].present? && params[:code].present?
# Both installation and OAuth code present - handle both
handle_installation_with_oauth
elsif params[:installation_id].present?
# Only installation_id present - redirect to OAuth
handle_installation
else
# Only OAuth code present - handle authorization
handle_authorization
end
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
private
def handle_installation_with_oauth
# Handle both installation and OAuth in one go
installation_id = params[:installation_id]
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response(installation_id)
end
def handle_installation
if params[:setup_action] == 'install'
installation_id = params[:installation_id]
redirect_to build_oauth_url(installation_id)
else
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
redirect_to github_integration_settings_url
end
end
def handle_authorization
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response
end
def build_oauth_url(installation_id)
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# Store installation_id in session for later use
session[:github_installation_id] = installation_id
# For now, redirect to a page that will initiate OAuth with proper account context
# This is a temporary solution until we have a proper account-agnostic setup
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
end
def oauth_client
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://github.com',
token_url: '/login/oauth/access_token',
authorize_url: '/login/oauth/authorize'
}
)
end
def handle_response(installation_id = nil)
settings = build_hook_settings(installation_id)
hook = create_integration_hook(settings)
hook.save!
cleanup_session_data
redirect_to github_redirect_uri
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
def build_hook_settings(installation_id)
settings = {
token_type: parsed_body['token_type'],
scope: parsed_body['scope']
}
settings[:installation_id] = installation_id || session[:github_installation_id]
settings.compact
end
def create_integration_hook(settings)
account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'github',
settings: settings
)
end
def cleanup_session_data
session.delete(:github_installation_id)
end
def account
@account ||= account_from_state
end
def account_from_state
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
# Try signed GlobalID first (installation flow)
account = GlobalID::Locator.locate_signed(params[:state])
return account if account
# Fallback to JWT token (direct OAuth flow)
account_id = verify_github_token(params[:state])
return Account.find(account_id) if account_id
raise 'Invalid or expired state'
rescue StandardError
raise ActionController::BadRequest, 'Invalid account context'
end
def github_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
end
def github_integration_settings_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
end
def fallback_redirect_uri
github_redirect_uri
rescue StandardError
# Fallback if no account context available
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -15,20 +15,14 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end end
def create def create
errors = []
params['app_config'].each do |key, value| params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key) next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value i.value = value
errors.concat(i.errors.full_messages) unless i.save i.save!
end
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end end
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end end
private private
@@ -48,13 +42,11 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
} }
@allowed_configs = mapping.fetch( @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
)
end end
end end
+1 -8
View File
@@ -14,14 +14,7 @@ class WidgetsController < ActionController::Base
private private
def set_global_config def set_global_config
@global_config = GlobalConfig.get( @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
'LOGO_THUMBNAIL',
'BRAND_NAME',
'WIDGET_BRAND_URL',
'DIRECT_UPLOADS_ENABLED',
'MAXIMUM_FILE_UPLOAD_SIZE',
'INSTALLATION_NAME'
)
end end
def set_web_widget def set_web_widget
-24
View File
@@ -1,24 +0,0 @@
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
# This module contains helper methods for converting between different data types,
# normalizing hashes, and safely handling JSON operations.
module DataHelper
# Ensures a hash supports indifferent access (string or symbol keys).
# Returns an empty hash if the input is blank.
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
def safe_parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
end
+1 -21
View File
@@ -4,19 +4,6 @@ module EmailHelper
domain.split('.').first domain.split('.').first
end end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
def validate_email_addresses(emails_to_test)
emails_to_test&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
# ref: https://www.rfc-editor.org/rfc/rfc5233.html # ref: https://www.rfc-editor.org/rfc/rfc5233.html
# This is not a mandatory requirement for email addresses, but it is a common practice. # This is not a mandatory requirement for email addresses, but it is a common practice.
# john+test@xyc.com is the same as john@xyc.com # john+test@xyc.com is the same as john@xyc.com
@@ -34,10 +21,6 @@ module EmailHelper
end end
end end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def modified_liquid_content(email) def modified_liquid_content(email)
# This regex is used to match the code blocks in the content # This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks # We don't want to process liquid in code blocks
@@ -46,10 +29,7 @@ module EmailHelper
def message_drops(conversation) def message_drops(conversation)
{ {
'contact' => ContactDrop.new(conversation.contact), 'contact' => ContactDrop.new(conversation.contact)
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(conversation.inbox),
'account' => AccountDrop.new(conversation.account)
} }
end end
end end
+47
View File
@@ -0,0 +1,47 @@
module Github::IntegrationHelper
# Generates a signed JWT token for Github integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_github_token(account_id)
return if github_client_secret.blank?
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Github token: #{e.message}")
nil
end
def github_token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Github JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_github_token(token)
return if token.blank? || github_client_secret.blank?
github_decode_token(token, github_client_secret)
end
private
def github_client_secret
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
end
def github_decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
nil
end
end
-7
View File
@@ -9,13 +9,6 @@ captain:
icon: 'icon-captain' icon: 'icon-captain'
config_key: 'captain' config_key: 'captain'
enterprise: true enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
enabled: <%= ChatwootApp.enterprise? %>
icon: 'icon-lock-line'
config_key: 'saml'
enterprise: true
custom_branding: custom_branding:
name: 'Custom Branding' name: 'Custom Branding'
description: 'Apply your own branding to this installation.' description: 'Apply your own branding to this installation.'
+13
View File
@@ -1,5 +1,6 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
@@ -24,6 +25,7 @@ export default {
name: 'App', name: 'App',
components: { components: {
AddAccountModal,
LoadingState, LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
@@ -49,6 +51,7 @@ export default {
}, },
data() { data() {
return { return {
showAddAccountModal: false,
latestChatwootVersion: null, latestChatwootVersion: null,
reconnectService: null, reconnectService: null,
}; };
@@ -61,12 +64,21 @@ export default {
authUIFlags: 'getAuthUIFlags', authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags', accountUIFlags: 'accounts/getUIFlags',
}), }),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
hideOnOnboardingView() { hideOnOnboardingView() {
return !isOnOnboardingView(this.$route); return !isOnOnboardingView(this.$route);
}, },
}, },
watch: { watch: {
currentUser() {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
},
currentAccountId: { currentAccountId: {
immediate: true, immediate: true,
handler() { handler() {
@@ -144,6 +156,7 @@ export default {
<component :is="Component" /> <component :is="Component" />
</transition> </transition>
</router-view> </router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox /> <WootSnackbarBox />
<NetworkNotification /> <NetworkNotification />
</div> </div>
-37
View File
@@ -1,37 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
class CompanyAPI extends ApiClient {
constructor() {
super('companies', { accountScoped: true });
}
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
return axios.get(requestURL);
}
}
export default new CompanyAPI();
@@ -63,9 +63,10 @@ class ConversationApi extends ApiClient {
} }
assignAgent({ conversationId, agentId }) { assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, { return axios.post(
assignee_id: agentId, `${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
}); {}
);
} }
assignTeam({ conversationId, teamId }) { assignTeam({ conversationId, teamId }) {
@@ -1,142 +0,0 @@
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
it('creates correct instance', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get with default params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=2&sort=domain'
);
});
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
});
});
@@ -92,10 +92,8 @@ describe('#ConversationAPI', () => {
it('#assignAgent', () => { it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`, `/api/v1/conversations/12/assignments?assignee_id=34`,
{ {}
assignee_id: 34,
}
); );
}); });
@@ -1,95 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow } from 'date-fns';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
updatedAt: { type: [String, Number], default: null },
});
const emit = defineEmits(['showCompany']);
const { t } = useI18n();
const onClickViewDetails = () => emit('showCompany', props.id);
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
});
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:username="displayName"
:src="avatarSource"
class="shrink-0"
:name="name"
:size="48"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<span class="text-base font-medium truncate text-n-slate-12">
{{ displayName }}
</span>
<span
v-if="domain && description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<span
v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div>
<span
v-if="updatedAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedUpdatedAt }}
</span>
</div>
</div>
</div>
</CardLayout>
</template>
@@ -1,55 +0,0 @@
<script setup>
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
</div>
</div>
</header>
</template>
@@ -1,116 +0,0 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'name',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
value: 'domain',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('COMPANIES.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('COMPANIES.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -1,51 +0,0 @@
<script setup>
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
currentPage: { type: Number, default: 1 },
totalItems: { type: Number, default: 100 },
activeSort: { type: String, default: 'name' },
activeOrdering: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
:search-value="searchValue"
:header-title="headerTitle"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
</section>
</template>
@@ -5,7 +5,6 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({ defineProps({
selectedContact: { selectedContact: {
@@ -25,44 +24,42 @@ const openConfirmDeleteContactDialog = () => {
</script> </script>
<template> <template>
<Policy :permissions="['administrator']"> <div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5"> <Button
<Button :label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')" sm
sm link
link slate
slate class="hover:!no-underline text-n-slate-12"
class="hover:!no-underline text-n-slate-12" icon="i-lucide-chevron-down"
icon="i-lucide-chevron-down" trailing-icon
trailing-icon @click="toggleDeleteSection()"
@click="toggleDeleteSection()" />
/>
<div <div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden" class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class=" :class="
showDeleteSection showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2' ? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0' : 'grid-rows-[0fr] opacity-0 mt-0'
" "
> >
<div class="overflow-hidden min-h-0"> <div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1"> <span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }} {{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button <Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')" :label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm sm
ruby ruby
link link
@click="openConfirmDeleteContactDialog()" @click="openConfirmDeleteContactDialog()"
/> />
</span> </span>
</div>
</div> </div>
</div> </div>
<ConfirmContactDeleteDialog </div>
ref="confirmDeleteContactDialogRef" <ConfirmContactDeleteDialog
:selected-contact="selectedContact" ref="confirmDeleteContactDialogRef"
/> :selected-contact="selectedContact"
</Policy> />
</template> </template>
@@ -8,7 +8,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries'; import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
@@ -21,17 +20,9 @@ const props = defineProps({
availabilityStatus: { type: String, default: null }, availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
]);
const { t } = useI18n(); const { t } = useI18n();
@@ -97,148 +88,111 @@ const onClickExpand = () => {
}; };
const onClickViewDetails = () => emit('showContact', props.id); const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script> </script>
<template> <template>
<div class="relative"> <CardLayout :key="id" layout="row">
<CardLayout <div class="flex items-center justify-start flex-1 gap-4">
:key="id" <Avatar
layout="row" :name="name"
:class="{ :src="thumbnail"
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected, :size="48"
}" :status="availabilityStatus"
> hide-offline-status
<div class="flex items-center justify-start flex-1 gap-4"> rounded-full
<div />
class="relative" <div class="flex flex-col gap-0.5 flex-1">
@mouseenter="handleAvatarHover(true)" <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
@mouseleave="handleAvatarHover(false)" <span class="text-base font-medium truncate text-n-slate-12">
> {{ name }}
<Avatar </span>
:name="name" <span class="inline-flex items-center gap-1">
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span <span
v-if="countryDetails" v-if="additionalAttributes?.companyName"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11" class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/> />
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div> </div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div> </div>
</div> </div>
</div>
<Button <Button
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
variant="ghost" variant="ghost"
color="slate" color="slate"
size="xs" size="xs"
:class="{ 'rotate-180': isExpanded }" :class="{ 'rotate-180': isExpanded }"
@click="onClickExpand" @click="onClickExpand"
/> />
<template #after> <template #after>
<div <div
class="transition-all duration-500 ease-in-out grid overflow-hidden" class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class=" :class="
isExpanded isExpanded
? 'grid-rows-[1fr] opacity-100' ? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0' : 'grid-rows-[0fr] opacity-0'
" "
> >
<div class="overflow-hidden"> <div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong"> <div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm <ContactsForm
ref="contactsFormRef" ref="contactsFormRef"
:contact-data="contactData" :contact-data="contactData"
@update="handleFormUpdate" @update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/> />
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div> </div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div> </div>
</template> </div>
</CardLayout> </template>
</div> </CardLayout>
</template> </template>
@@ -10,7 +10,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({ const props = defineProps({
selectedContact: { selectedContact: {
@@ -175,29 +174,27 @@ const handleAvatarDelete = async () => {
@click="updateContact" @click="updateContact"
/> />
</div> </div>
<Policy :permissions="['administrator']"> <div
<div class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong" >
> <div class="flex flex-col gap-2">
<div class="flex flex-col gap-2"> <h6 class="text-base font-medium text-n-slate-12">
<h6 class="text-base font-medium text-n-slate-12"> {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} </h6>
</h6> <span class="text-sm text-n-slate-11">
<span class="text-sm text-n-slate-11"> {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} </span>
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div> </div>
<ConfirmContactDeleteDialog <Button
ref="confirmDeleteContactDialogRef" :label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
:selected-contact="selectedContact" color="ruby"
@go-to-contacts-list="emit('goToContactsList')" @click="openConfirmDeleteContactDialog"
/> />
</Policy> </div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div> </div>
</template> </template>
@@ -10,15 +10,7 @@ import {
} from 'shared/helpers/CustomErrors'; } from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
const props = defineProps({ defineProps({ contacts: { type: Array, required: true } });
contacts: { type: Array, required: true },
selectedContactIds: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['toggleContact']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -28,9 +20,6 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags'); const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating); const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null); const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => { const updateContact = async updatedData => {
try { try {
@@ -69,43 +58,25 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => { const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id; expandedCardId.value = expandedCardId.value === id ? null : id;
}; };
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script> </script>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div v-for="contact in contacts" :key="contact.id" class="relative"> <ContactsCard
<ContactsCard v-for="contact in contacts"
:id="contact.id" :id="contact.id"
:name="contact.name" :key="contact.id"
:email="contact.email" :name="contact.name"
:thumbnail="contact.thumbnail" :email="contact.email"
:phone-number="contact.phoneNumber" :thumbnail="contact.thumbnail"
:additional-attributes="contact.additionalAttributes" :phone-number="contact.phoneNumber"
:availability-status="contact.availabilityStatus" :additional-attributes="contact.additionalAttributes"
:is-expanded="expandedCardId === contact.id" :availability-status="contact.availabilityStatus"
:is-updating="isUpdating" :is-expanded="expandedCardId === contact.id"
:selectable="shouldShowSelection(contact.id)" :is-updating="isUpdating"
:is-selected="isSelected(contact.id)" @toggle="toggleExpanded(contact.id)"
@toggle="toggleExpanded(contact.id)" @update-contact="updateContact"
@update-contact="updateContact" @show-contact="onClickViewDetails"
@show-contact="onClickViewDetails" />
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div> </div>
</template> </template>
@@ -1,6 +1,5 @@
<script setup> <script setup>
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue'; import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -56,17 +55,17 @@ useKeyboardEvents(keyboardEvents);
</script> </script>
<template> <template>
<ButtonGroup <div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow" class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
> >
<Button <Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')" v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost ghost
slate slate
sm sm
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75" class="!rounded-full"
:class="{ :class="{
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen, 'bg-n-alpha-2': isContactSidebarOpen,
}" }"
icon="i-ph-user-bold" icon="i-ph-user-bold"
@click="handleConversationSidebarToggle" @click="handleConversationSidebarToggle"
@@ -76,14 +75,13 @@ useKeyboardEvents(keyboardEvents);
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')" v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost ghost
slate slate
sm class="!rounded-full"
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
:class="{ :class="{
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm': 'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
isCopilotPanelOpen,
}" }"
sm
icon="i-woot-captain" icon="i-woot-captain"
@click="handleCopilotSidebarToggle" @click="handleCopilotSidebarToggle"
/> />
</ButtonGroup> </div>
</template> </template>
@@ -21,10 +21,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] }, enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false }, enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -104,10 +100,6 @@ watch(
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions" :enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools" :enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -14,10 +14,6 @@ defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
showBackdrop: {
type: Boolean,
default: true,
},
}); });
</script> </script>
@@ -29,24 +25,14 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
> >
<div <div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none" class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
> >
<slot name="empty-state-item" /> <slot name="empty-state-item" />
</div> </div>
<div <div
class="flex flex-col items-center justify-end w-full h-full pb-20" class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
> >
<div <div class="flex flex-col items-center justify-center gap-6">
class="flex flex-col items-center justify-center gap-6"
:class="{
'mt-48': !showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-3"> <div class="flex flex-col items-center justify-center gap-3">
<h2 <h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay" class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@@ -54,7 +40,6 @@ defineProps({
{{ title }} {{ title }}
</h2> </h2>
<p <p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]" class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
> >
{{ subtitle }} {{ subtitle }}
@@ -14,7 +14,6 @@ import {
} from 'dashboard/helper/portalHelper'; } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -141,12 +140,11 @@ const updateArticleStatus = async ({ value }) => {
:disabled="!articleId" :disabled="!articleId"
@click="previewArticle" @click="previewArticle"
/> />
<ButtonGroup class="flex items-center"> <div class="flex items-center">
<Button <Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')" :label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm" size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none" class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing" :is-loading="isArticlePublishing"
:disabled=" :disabled="
status === ARTICLE_STATUSES.PUBLISHED || status === ARTICLE_STATUSES.PUBLISHED ||
@@ -161,7 +159,6 @@ const updateArticleStatus = async ({ value }) => {
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
size="sm" size="sm"
:disabled="!articleId" :disabled="!articleId"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none" class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu" @click.stop="showArticleActionMenu = !showArticleActionMenu"
/> />
@@ -173,7 +170,7 @@ const updateArticleStatus = async ({ value }) => {
/> />
</OnClickOutside> </OnClickOutside>
</div> </div>
</ButtonGroup> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +92,7 @@ const setSignature = () => {
const toggleMessageSignature = () => { const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
}; };
// Added this watch to dynamically set signature on target inbox change. // Added this watch to dynamically set signature on target inbox change.
@@ -199,20 +199,16 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = []; state.attachedFiles = [];
}; };
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage(); // Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
state.message = removeSignature(state.message, signatureToRemove);
}
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
@@ -220,7 +216,6 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => { const clearSelectedContact = () => {
emit('clearSelectedContact'); emit('clearSelectedContact');
state.attachedFiles = []; state.attachedFiles = [];
removeSignatureFromMessage();
}; };
const onClickInsertEmoji = emoji => { const onClickInsertEmoji = emoji => {
@@ -359,7 +354,6 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0" :has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/> />
<AttachmentPreviews <AttachmentPreviews
@@ -17,7 +17,6 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false }, hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -91,10 +90,6 @@ const replaceText = async message => {
" "
enable-variables enable-variables
:show-character-count="false" :show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/> />
</template> </template>
<template v-else> <template v-else>
@@ -36,7 +36,6 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' }, icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false }, trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false }, isLoading: { type: Boolean, default: false },
noAnimation: { type: Boolean, default: false },
}); });
const slots = useSlots(); const slots = useSlots();
@@ -180,18 +179,12 @@ const STYLE_CONFIG = {
md: 'text-sm font-medium', md: 'text-sm font-medium',
lg: 'text-base', lg: 'text-base',
}, },
clickAnimation: {
xs: 'active:enabled:scale-[0.97]',
sm: 'active:enabled:scale-[0.97]',
md: 'active:enabled:scale-[0.98]',
lg: 'active:enabled:scale-[0.98]',
},
justify: { justify: {
start: 'justify-start', start: 'justify-start',
center: 'justify-center', center: 'justify-center',
end: 'justify-end', end: 'justify-end',
}, },
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50', base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
}; };
const variantClasses = computed(() => { const variantClasses = computed(() => {
@@ -228,12 +221,6 @@ const linkButtonClasses = computed(() => {
return classes.join(' '); return classes.join(' ');
}); });
const animationClasses = computed(() => {
return props.noAnimation
? ''
: STYLE_CONFIG.clickAnimation[computedSize.value];
});
</script> </script>
<template> <template>
@@ -243,7 +230,6 @@ const animationClasses = computed(() => {
[STYLE_CONFIG.base]: true, [STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true, [isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true, [STYLE_CONFIG.fontSize[computedSize]]: true,
[animationClasses]: true,
[STYLE_CONFIG.justify[computedJustify]]: true, [STYLE_CONFIG.justify[computedJustify]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly, 'flex-row-reverse': trailingIcon && !isIconOnly,
}" }"
@@ -1,20 +0,0 @@
<script setup>
defineProps({
noAnimation: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
:class="
noAnimation
? ''
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
"
>
<slot />
</div>
</template>
@@ -1,17 +1,11 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { usePolicy } from 'dashboard/composables/usePolicy'; import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue'; import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
const props = defineProps({ const props = defineProps({
currentPage: { currentPage: {
@@ -62,36 +56,11 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showAssistantSwitcher: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['click', 'close', 'update:currentPage']); const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { t } = useI18n();
const route = useRoute();
const { shouldShowPaywall } = usePolicy(); const { shouldShowPaywall } = usePolicy();
const showAssistantSwitcherDropdown = ref(false);
const createAssistantDialogRef = ref(null);
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const currentAssistantId = computed(() => route.params.assistantId);
const isFetchingAssistants = computed(() => uiFlags.value?.fetchingList);
const activeAssistantName = computed(() => {
return (
assistants.value?.find(
assistant => assistant.id === Number(currentAssistantId.value)
)?.name || t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')
);
});
const showPaywall = computed(() => { const showPaywall = computed(() => {
return shouldShowPaywall(props.featureFlag); return shouldShowPaywall(props.featureFlag);
}); });
@@ -103,15 +72,6 @@ const handleButtonClick = () => {
const handlePageChange = event => { const handlePageChange = event => {
emit('update:currentPage', event); emit('update:currentPage', event);
}; };
const toggleAssistantSwitcher = () => {
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
};
const handleCreateAssistant = () => {
showAssistantSwitcherDropdown.value = false;
createAssistantDialogRef.value.dialogRef.open();
};
</script> </script>
<template> <template>
@@ -121,86 +81,39 @@ const handleCreateAssistant = () => {
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
<div class="flex gap-3 items-center"> <div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :back-url="backUrl" /> <BackButton v-if="backUrl" :to="backUrl" />
<div <slot name="headerTitle">
v-if="showAssistantSwitcher && !showPaywall" <span class="text-xl font-medium text-n-slate-12">
class="flex items-center gap-2"
>
<div class="flex items-center gap-2">
<span
v-if="!isFetchingAssistants"
class="text-xl font-medium truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
<div class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:disabled="isFetchingAssistants"
:is-loading="isFetchingAssistants"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
v-if="showAssistantSwitcher && !showPaywall && headerTitle"
class="w-0.5 h-4 rounded-2xl bg-n-weak"
/>
<span
v-if="headerTitle"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div </slot>
v-if="!isEmpty && showKnowMore" <div
class="flex items-center gap-2" v-if="!isEmpty && showKnowMore"
> class="flex items-center gap-2"
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" /> >
<slot name="knowMore" /> <div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
</div> <slot name="knowMore" />
</div> </div>
</div> </div>
<div class="flex gap-2"> <div
<slot name="search" /> v-if="!showPaywall && buttonLabel"
<div v-on-clickaway="() => emit('close')"
v-if="!showPaywall && buttonLabel" class="relative group/campaign-button"
v-on-clickaway="() => emit('close')" >
class="relative group/captain-button" <Policy :permissions="buttonPolicy">
> <Button
<Policy :permissions="buttonPolicy"> :label="buttonLabel"
<Button icon="i-lucide-plus"
:label="buttonLabel" size="sm"
icon="i-lucide-plus" class="group-hover/campaign-button:brightness-110"
size="sm" @click="handleButtonClick"
class="group-hover/captain-button:brightness-110" />
@click="handleButtonClick" </Policy>
/> <slot name="action" />
</Policy>
<slot name="action" />
</div>
</div> </div>
</div> </div>
<slot name="subHeader" />
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
@@ -230,6 +143,5 @@ const handleCreateAssistant = () => {
@update:current-page="handlePageChange" @update:current-page="handlePageChange"
/> />
</footer> </footer>
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
</section> </section>
</template> </template>
@@ -0,0 +1,91 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -76,11 +76,12 @@ const handleAction = ({ action, value }) => {
<template> <template>
<CardLayout> <CardLayout>
<div class="flex justify-between w-full gap-1"> <div class="flex justify-between w-full gap-1">
<h6 <router-link
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors" :to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
> >
{{ name }} {{ name }}
</h6> </router-link>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue'; import MessageList from './MessageList.vue';
@@ -29,16 +29,6 @@ const resetConversation = () => {
newMessage.value = ''; newMessage.value = '';
}; };
// Watch for assistant ID changes and reset conversation
watch(
() => assistantId,
(newId, oldId) => {
if (oldId && newId !== oldId) {
resetConversation();
}
}
);
const sendMessage = async () => { const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return; if (!newMessage.value.trim() || isLoading.value) return;
@@ -75,17 +65,16 @@ const sendMessage = async () => {
<template> <template>
<div <div
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11" class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
> >
<div class="mb-8 px-6"> <div class="mb-4">
<div class="flex justify-between items-center mb-1"> <div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium"> <h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }} {{ t('CAPTAIN.PLAYGROUND.HEADER') }}
</h3> </h3>
<NextButton <NextButton
ghost ghost
sm size="small"
slate
icon="i-lucide-rotate-ccw" icon="i-lucide-rotate-ccw"
@click="resetConversation" @click="resetConversation"
/> />
@@ -98,17 +87,17 @@ const sendMessage = async () => {
<MessageList :messages="messages" :is-loading="isLoading" /> <MessageList :messages="messages" :is-loading="isLoading" />
<div <div
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3" class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
> >
<input <input
v-model="newMessage" v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10" class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')" :placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage" @keyup.enter="sendMessage"
/> />
<NextButton <NextButton
ghost ghost
sm size="small"
:disabled="!newMessage.trim()" :disabled="!newMessage.trim()"
icon="i-lucide-send" icon="i-lucide-send"
@click="sendMessage" @click="sendMessage"
@@ -64,23 +64,20 @@ const bulkCheckboxState = computed({
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow" class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5">
<Checkbox <Checkbox
v-model="bulkCheckboxState" v-model="bulkCheckboxState"
:indeterminate="isIndeterminate" :indeterminate="isIndeterminate"
/> />
<span <span class="text-sm font-medium text-n-slate-12 tabular-nums">
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
>
{{ selectAllLabel }} {{ selectAllLabel }}
</span> </span>
</div> </div>
<span class="text-sm text-n-slate-10 truncate tabular-nums"> <span class="text-sm text-n-slate-10 tabular-nums">
{{ selectedCountLabel }} {{ selectedCountLabel }}
</span> </span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div> </div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount"> <slot name="actions" :selected-count="selectedCount">
<Button <Button
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
const getMessageStyle = sender => const getMessageStyle = sender =>
isUserMessage(sender) isUserMessage(sender)
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl' ? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl'; : 'bg-n-solid-iris text-n-slate-12';
const scrollToBottom = async () => { const scrollToBottom = async () => {
await nextTick(); await nextTick();
@@ -49,10 +49,7 @@ watch(() => props.messages.length, scrollToBottom);
</script> </script>
<template> <template>
<div <div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
ref="messageContainer"
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
>
<div <div
v-for="(message, index) in messages" v-for="(message, index) in messages"
:key="index" :key="index"
@@ -60,20 +57,15 @@ watch(() => props.messages.length, scrollToBottom);
:class="getMessageAlignment(message.sender)" :class="getMessageAlignment(message.sender)"
> >
<div <div
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]" class="flex items-start gap-1.5"
:class="getMessageDirection(message.sender)" :class="getMessageDirection(message.sender)"
> >
<Avatar <Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
:name="getAvatarName(message.sender)"
rounded-full
:size="24"
class="shrink-0"
/>
<div <div
class="px-4 py-3 text-sm [overflow-wrap:break-word]" class="max-w-[80%] rounded-lg p-3 text-sm"
:class="getMessageStyle(message.sender)" :class="getMessageStyle(message.sender)"
> >
<div v-html="formatMessage(message.content)" /> <div class="break-words" v-html="formatMessage(message.content)" />
</div> </div>
</div> </div>
</div> </div>
@@ -9,7 +9,6 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -60,10 +59,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showActions: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['action', 'navigate', 'select', 'hover']); const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -164,116 +159,73 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5"> <span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }} {{ answer }}
</span> </span>
<div <div v-if="!compact" class="items-center justify-between hidden lg:flex">
v-if="!compact" <div class="inline-flex items-center">
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3" <span
> class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
<Policy v-if="showActions" :permissions="['administrator']"> >
<div class="flex items-center gap-2 sm:gap-5 w-full"> <i class="i-woot-captain" />
<Button {{ assistant?.name || '' }}
v-if="status === 'pending'" </span>
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')" <div
icon="i-lucide-circle-check-big" v-if="documentable"
sm class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
link >
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'approve', value: 'approve' })
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
icon="i-lucide-pencil-line"
sm
slate
link
class="hover:!no-underline"
@click="
handleAssistantAction({
action: 'edit',
value: 'edit',
})
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
icon="i-lucide-trash"
sm
ruby
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'delete', value: 'delete' })
"
/>
</div>
</Policy>
<div
class="flex items-center gap-3"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-3 min-w-0">
<span <span
v-if="status === 'approved'" v-if="documentable.type === 'Captain::Document'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1" class="inline-flex items-center gap-1 truncate over"
> >
<Icon icon="i-woot-captain" class="size-3.5" /> <i class="i-ph-files-light text-base" />
{{ assistant?.name || '' }} <span class="max-w-96 truncate" :title="documentable.name">
</span>
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-ph-files-light"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-ph-user-circle-plus"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-ph-chat-circle-dots"
class="size-3.5"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate"
:title="documentable.name"
>
{{ documentable.name }} {{ documentable.name }}
</span> </span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span <span
v-else-if="documentable.type === 'User'" class="max-w-96 truncate"
class="truncate"
:title="documentable.available_name" :title="documentable.available_name"
> >
{{ documentable.available_name }} {{ documentable.available_name }}
</span> </span>
<span </span>
v-else-if="documentable.type === 'Conversation'" <span
class="hover:underline truncate cursor-pointer" v-else-if="documentable.type === 'Conversation'"
role="button" class="inline-flex items-center gap-1 group cursor-pointer"
@click="handleDocumentableClick" role="button"
> @click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
{{ {{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, { t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id, id: documentable.display_id,
}) })
}} }}
</span> </span>
</div> </span>
<span v-else />
</div> </div>
<div <div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1" v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
> >
<Icon icon="i-ph-calendar-dot" class="size-3.5" /> <i
{{ timestamp }} class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
</div> </div>
</div> </div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</div> </div>
</CardLayout> </CardLayout>
</template> </template>
@@ -0,0 +1,68 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
assistantId: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const isFilterOpen = ref(false);
const assistants = useMapGetter('captainAssistants/getRecords');
const assistantOptions = computed(() => [
{
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
value: 'all',
action: 'filter',
},
...assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
action: 'filter',
})),
]);
const selectedAssistantLabel = computed(() => {
const assistant = assistantOptions.value.find(
option => option.value === props.assistantId
);
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
selected: assistant ? assistant.label : '',
});
});
const handleAssistantFilterChange = ({ value }) => {
isFilterOpen.value = false;
emit('update', value);
};
</script>
<template>
<OnClickOutside @trigger="isFilterOpen = false">
<Button
:label="selectedAssistantLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isFilterOpen = !isFilterOpen"
/>
<DropdownMenu
v-if="isFilterOpen"
:menu-items="assistantOptions"
class="mt-2"
@action="handleAssistantFilterChange"
/>
</OnClickOutside>
</template>
@@ -18,7 +18,7 @@ const props = defineProps({
validator: value => ['create', 'edit'].includes(value), validator: value => ['create', 'edit'].includes(value),
}, },
}); });
const emit = defineEmits(['close', 'created']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -35,18 +35,8 @@ const i18nKey = computed(
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}` () => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
); );
const createAssistant = async assistantDetails => { const createAssistant = assistantDetails =>
try { store.dispatch('captainAssistants/create', assistantDetails);
const newAssistant = await store.dispatch(
'captainAssistants/create',
assistantDetails
);
emit('created', newAssistant);
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleSubmit = async updatedAssistant => { const handleSubmit = async updatedAssistant => {
try { try {
@@ -0,0 +1,333 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
welcomeMessage: '',
handoffMessage: '',
resolutionMessage: '',
instructions: '',
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
welcomeMessage: { minLength: minLength(1) },
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
welcomeMessage: getErrorMessage('welcomeMessage'),
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.welcomeMessage = config.welcome_message;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
},
};
emit('submit', payload);
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.welcomeMessage.$validate(),
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
welcome_message: state.welcomeMessage,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
},
};
emit('submit', payload);
};
const handleInstructionsUpdate = async () => {
const result = await v$.value.instructions.$validate();
if (!result) return;
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
emit('submit', payload);
};
const handleFeaturesUpdate = () => {
const payload = {
config: {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<Accordion
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
is-open
>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicInfoUpdate"
>
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleInstructionsUpdate"
/>
</div>
</div>
</Accordion>
<!-- Greeting Messages Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</Accordion>
<!-- Features Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleFeaturesUpdate"
/>
</div>
</div>
</Accordion>
</form>
</template>
@@ -3,8 +3,6 @@ import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators'; import { minLength } from '@vuelidate/validators';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -19,16 +17,10 @@ const props = defineProps({
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
const { t } = useI18n(); const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const initialState = { const initialState = {
handoffMessage: '', handoffMessage: '',
resolutionMessage: '', resolutionMessage: '',
instructions: '',
temperature: 1, temperature: 1,
}; };
@@ -37,7 +29,6 @@ const state = reactive({ ...initialState });
const validationRules = { const validationRules = {
handoffMessage: { minLength: minLength(1) }, handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) }, resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
}; };
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
@@ -49,30 +40,20 @@ const getErrorMessage = field => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'), handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'), resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
})); }));
const updateStateFromAssistant = assistant => { const updateStateFromAssistant = assistant => {
const { config = {} } = assistant; const { config = {} } = assistant;
state.handoffMessage = config.handoff_message; state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message; state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1; state.temperature = config.temperature || 1;
}; };
const handleSystemMessagesUpdate = async () => { const handleSystemMessagesUpdate = async () => {
const validations = [ const result = await Promise.all([
v$.value.handoffMessage.$validate(), v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(), v$.value.resolutionMessage.$validate(),
]; ]).then(results => results.every(Boolean));
if (!isCaptainV2Enabled.value) {
validations.push(v$.value.instructions.$validate());
}
const result = await Promise.all(validations).then(results =>
results.every(Boolean)
);
if (!result) return; if (!result) return;
const payload = { const payload = {
@@ -84,10 +65,6 @@ const handleSystemMessagesUpdate = async () => {
}, },
}; };
if (!isCaptainV2Enabled.value) {
payload.config.instructions = state.instructions;
}
emit('submit', payload); emit('submit', payload);
}; };
@@ -118,16 +95,6 @@ watch(
:message-type="formErrors.resolutionMessage ? 'error' : 'info'" :message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/> />
<Editor
v-if="!isCaptainV2Enabled"
v-model="state.instructions"
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12"> <label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }} {{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
@@ -8,13 +8,6 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue'; import DocumentForm from './DocumentForm.vue';
defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -55,11 +48,7 @@ defineExpose({ dialogRef });
:show-confirm-button="false" :show-confirm-button="false"
@close="handleClose" @close="handleClose"
> >
<DocumentForm <DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
:assistant-id="assistantId"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer /> <template #footer />
</Dialog> </Dialog>
</template> </template>
@@ -2,7 +2,7 @@
import { reactive, computed, ref, nextTick } from 'vue'; import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minLength, requiredIf, url } from '@vuelidate/validators'; import { required, minLength, requiredIf, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
@@ -10,13 +10,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['submit', 'cancel']); const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -25,11 +18,13 @@ const { t } = useI18n();
const formState = { const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'), uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
}; };
const initialState = { const initialState = {
name: '', name: '',
url: '', url: '',
assistantId: null,
documentType: 'url', documentType: 'url',
pdfFile: null, pdfFile: null,
}; };
@@ -43,11 +38,19 @@ const validationRules = {
url: requiredIf(() => state.documentType === 'url' && url), url: requiredIf(() => state.documentType === 'url' && url),
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)), minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
}, },
assistantId: { required },
pdfFile: { pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'), required: requiredIf(() => state.documentType === 'pdf'),
}, },
}; };
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const documentTypeOptions = [ const documentTypeOptions = [
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') }, { value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') }, { value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
@@ -67,6 +70,7 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'), url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'), pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
})); }));
@@ -102,7 +106,7 @@ const openFileDialog = () => {
const prepareDocumentDetails = () => { const prepareDocumentDetails = () => {
const formData = new FormData(); const formData = new FormData();
formData.append('document[assistant_id]', props.assistantId); formData.append('document[assistant_id]', state.assistantId);
if (state.documentType === 'url') { if (state.documentType === 'url') {
formData.append('document[external_link]', state.url); formData.append('document[external_link]', state.url);
@@ -214,6 +218,21 @@ const handleSubmit = async () => {
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')" :placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/> />
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
:message="formErrors.assistantId"
/>
</div>
<div class="flex gap-3 justify-between items-center w-full"> <div class="flex gap-3 justify-between items-center w-full">
<Button <Button
type="button" type="button"
@@ -6,39 +6,16 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue'; import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js'; import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { computed } from 'vue'; const emit = defineEmits(['click']);
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount(); const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
}; };
const onClearFilters = () => {
emit('clearFilters');
};
</script> </script>
<template> <template>
<FeatureSpotlight <FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')" :title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')" :note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg" fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
@@ -48,16 +25,11 @@ const onClearFilters = () => {
class="mb-8" class="mb-8"
/> />
<EmptyStateLayout <EmptyStateLayout
:title=" :title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
isPending :subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']" :action-perms="['administrator']"
:show-backdrop="isApproved"
> >
<template v-if="isApproved" #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard <ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)" v-for="(response, index) in responsesList.slice(0, 5)"
@@ -73,21 +45,11 @@ const onClearFilters = () => {
</div> </div>
</template> </template>
<template #actions> <template #actions>
<div class="flex flex-col items-center gap-3"> <Button
<Button :label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
v-if="isApproved" icon="i-lucide-plus"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')" @click="onClick"
icon="i-lucide-plus" />
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template> </template>
</EmptyStateLayout> </EmptyStateLayout>
</template> </template>
@@ -3,7 +3,6 @@ import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseForm from './ResponseForm.vue'; import ResponseForm from './ResponseForm.vue';
@@ -22,7 +21,6 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const route = useRoute();
const dialogRef = ref(null); const dialogRef = ref(null);
const responseForm = ref(null); const responseForm = ref(null);
@@ -41,15 +39,9 @@ const createResponse = responseDetails =>
const handleSubmit = async updatedResponse => { const handleSubmit = async updatedResponse => {
try { try {
if (props.type === 'edit') { if (props.type === 'edit') {
await updateResponse({ await updateResponse(updatedResponse);
...updatedResponse,
assistant_id: route.params.assistantId,
});
} else { } else {
await createResponse({ await createResponse(updatedResponse);
...updatedResponse,
assistant_id: route.params.assistantId,
});
} }
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`)); useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close(); dialogRef.value.close();
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -20,17 +21,18 @@ const props = defineProps({
default: () => ({}), default: () => ({}),
}, },
}); });
const emit = defineEmits(['submit', 'cancel']); const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n(); const { t } = useI18n();
const formState = { const formState = {
uiFlags: useMapGetter('captainResponses/getUIFlags'), uiFlags: useMapGetter('captainResponses/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
}; };
const initialState = { const initialState = {
question: '', question: '',
answer: '', answer: '',
assistantId: null,
}; };
const state = reactive({ ...initialState }); const state = reactive({ ...initialState });
@@ -38,8 +40,16 @@ const state = reactive({ ...initialState });
const validationRules = { const validationRules = {
question: { required, minLength: minLength(1) }, question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) }, answer: { required, minLength: minLength(1) },
assistantId: { required },
}; };
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem); const isLoading = computed(() => formState.uiFlags.value.creatingItem);
@@ -53,6 +63,7 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'), question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'), answer: getErrorMessage('answer', 'ANSWER'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
})); }));
const handleCancel = () => emit('cancel'); const handleCancel = () => emit('cancel');
@@ -60,6 +71,7 @@ const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({ const prepareDocumentDetails = () => ({
question: state.question, question: state.question,
answer: state.answer, answer: state.answer,
assistant_id: state.assistantId,
}); });
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -74,11 +86,12 @@ const handleSubmit = async () => {
const updateStateFromResponse = response => { const updateStateFromResponse = response => {
if (!response) return; if (!response) return;
const { question, answer } = response; const { question, answer, assistant } = response;
Object.assign(state, { Object.assign(state, {
question, question,
answer, answer,
assistantId: assistant.id,
}); });
}; };
@@ -102,6 +115,7 @@ watch(
:message="formErrors.question" :message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'" :message-type="formErrors.question ? 'error' : 'info'"
/> />
<Editor <Editor
v-model="state.answer" v-model="state.answer"
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')" :label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
@@ -110,6 +124,22 @@ watch(
:max-length="10000" :max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'" :message-type="formErrors.answer ? 'error' : 'info'"
/> />
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3"> <div class="flex items-center justify-between w-full gap-3">
<Button <Button
type="button" type="button"
@@ -1,148 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createAssistant']);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const assistants = useMapGetter('captainAssistants/getRecords');
const currentAssistantId = computed(() => route.params.assistantId);
const isAssistantActive = assistant => {
return assistant.id === Number(currentAssistantId.value);
};
const fetchDataForRoute = async (routeName, assistantId) => {
const dataFetchMap = {
captain_assistants_responses_index: async () => {
await store.dispatch('captainResponses/get', { assistantId });
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
},
captain_assistants_responses_pending: async () => {
await store.dispatch('captainResponses/get', {
assistantId,
status: 'pending',
});
},
captain_assistants_documents_index: async () => {
await store.dispatch('captainDocuments/get', { assistantId });
},
captain_assistants_scenarios_index: async () => {
await store.dispatch('captainScenarios/get', { assistantId });
},
captain_assistants_playground_index: () => {
// Playground doesn't need pre-fetching, it loads on interaction
},
captain_assistants_inboxes_index: async () => {
await store.dispatch('captainInboxes/get', { assistantId });
},
captain_tools_index: async () => {
await store.dispatch('captainCustomTools/get', { page: 1 });
},
captain_assistants_settings_index: async () => {
await store.dispatch('captainAssistants/show', assistantId);
},
};
const fetchFn = dataFetchMap[routeName];
if (fetchFn) {
await fetchFn();
}
};
const handleAssistantChange = async assistant => {
if (isAssistantActive(assistant)) return;
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
await fetchDataForRoute(targetRouteName, assistant.id);
await router.push({
name: targetRouteName,
params: {
accountId: route.params.accountId,
assistantId: assistant.id,
},
});
emit('close');
};
const openCreateAssistantDialog = () => {
emit('createAssistant');
emit('close');
};
</script>
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
</h2>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="assistant in assistants"
:key="assistant.id"
:label="assistant.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
size="sm"
@click="handleAssistantChange(assistant)"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"
:size="20"
icon-name="i-lucide-bot"
rounded-full
/>
</Button>
</div>
<div v-else class="flex flex-col items-center gap-2 px-4 py-3">
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANT_SWITCHER.EMPTY_LIST') }}
</p>
</div>
</div>
</template>
@@ -96,7 +96,6 @@ watch(
:label="selectedLabel" :label="selectedLabel"
trailing-icon trailing-icon
:disabled="disabled" :disabled="disabled"
no-animation
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand" class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{ :class="{
focused: open, focused: open,
@@ -78,10 +78,8 @@ const handleSuggestion = opt => {
</p> </p>
<router-link <router-link
:to="{ :to="{
name: 'captain_assistants_create_index', name: 'captain_assistants_index',
params: { params: { accountId: route.params.accountId },
accountId: route.params.accountId,
},
}" }"
class="text-n-slate-11 underline hover:text-n-slate-12" class="text-n-slate-11 underline hover:text-n-slate-12"
> >
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -54,17 +53,14 @@ const toggleSidebar = () => {
v-if="showCopilotLauncher" v-if="showCopilotLauncher"
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50" class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
> >
<ButtonGroup <div class="rounded-full bg-n-alpha-2 p-1">
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button <Button
icon="i-woot-captain" icon="i-woot-captain"
no-animation class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg lg
@click="toggleSidebar" @click="toggleSidebar"
/> />
</ButtonGroup> </div>
</div> </div>
<template v-else /> <template v-else />
</template> </template>
@@ -8,15 +8,11 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
menuItems: { menuItems: {
type: Array, type: Array,
default: () => [], required: true,
validator: value => { validator: value => {
return value.every(item => item.action && item.value && item.label); return value.every(item => item.action && item.value && item.label);
}, },
}, },
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: { thumbnailSize: {
type: Number, type: Number,
default: 20, default: 20,
@@ -46,62 +42,19 @@ const { t } = useI18n();
const searchInput = ref(null); const searchInput = ref(null);
const searchQuery = ref(''); const searchQuery = ref('');
const hasSections = computed(() => props.menuSections.length > 0);
const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => { const filteredMenuItems = computed(() => {
if (!searchQuery.value) return flattenedMenuItems.value; if (!searchQuery.value) return props.menuItems;
return flattenedMenuItems.value.filter(item => return props.menuItems.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase()) item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
); );
}); });
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (!searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleAction = item => { const handleAction = item => {
const { action, value, ...rest } = item; const { action, value, ...rest } = item;
emit('action', { action, value, ...rest }); emit('action', { action, value, ...rest });
}; };
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => { onMounted(() => {
if (searchInput.value && props.showSearch) { if (searchInput.value && props.showSearch) {
searchInput.value.focus(); searchInput.value.focus();
@@ -111,122 +64,54 @@ onMounted(() => {
<template> <template>
<div <div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2" class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
:class="{
'pt-2': !showSearch,
}"
> >
<div <div v-if="showSearch" class="relative">
v-if="showSearch" <span class="absolute i-lucide-search size-3.5 top-2 left-3" />
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2" <input
> ref="searchInput"
<div class="relative"> v-model="searchQuery"
<span class="absolute i-lucide-search size-3.5 top-2 left-3" /> type="search"
<input :placeholder="
ref="searchInput" searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
v-model="searchQuery" "
type="search" class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
:placeholder=" />
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
</div> </div>
<template v-if="hasSections"> <button
<div v-for="(item, index) in filteredMenuItems"
v-for="(section, sectionIndex) in filteredMenuSections" :key="index"
:key="section.title || sectionIndex" type="button"
class="flex flex-col gap-1" class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
> :class="{
<p 'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
v-if="section.title" 'text-n-ruby-11': item.action === 'delete',
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide" 'text-n-slate-12': item.action !== 'delete',
> }"
{{ section.title }} :disabled="item.disabled"
</p> @click="handleAction(item)"
<button >
v-for="(item, itemIndex) in section.items" <slot name="thumbnail" :item="item">
:key="item.value || itemIndex" <Avatar
type="button" v-if="item.thumbnail"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" :name="item.thumbnail.name"
:class="{ :src="item.thumbnail.src"
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected, :size="thumbnailSize"
'text-n-ruby-11': item.action === 'delete', rounded-full
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/> />
</div> </slot>
</template> <Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<template v-else> <span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<button <span
v-for="(item, index) in filteredMenuItems" v-if="item.label"
:key="index" class="min-w-0 text-sm truncate"
type="button" :class="labelClass"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
> >
<slot name="thumbnail" :item="item"> {{ item.label }}
<Avatar </span>
v-if="item.thumbnail" </button>
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div <div
v-if="shouldShowEmptyState" v-if="filteredMenuItems.length === 0"
class="text-sm text-n-slate-11 px-2 py-1.5" class="text-sm text-n-slate-11 px-2 py-1.5"
> >
{{ {{
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue'; import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@@ -12,6 +11,7 @@ import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue'; import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const emit = defineEmits(['error']);
const { t } = useI18n(); const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext(); const { filteredCurrentChatAttachments, attachments } = useMessageContext();
@@ -20,16 +20,14 @@ const attachment = computed(() => {
return attachments.value[0]; return attachments.value[0];
}); });
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry(); const hasError = ref(false);
const showGallery = ref(false); const showGallery = ref(false);
const isDownloading = ref(false); const isDownloading = ref(false);
onMounted(() => { const handleError = () => {
if (attachment.value?.dataUrl) { hasError.value = true;
loadWithRetry(attachment.value.dataUrl); emit('error');
} };
});
const downloadAttachment = async () => { const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value; const { fileType, dataUrl, extension } = attachment.value;
@@ -42,10 +40,6 @@ const downloadAttachment = async () => {
isDownloading.value = false; isDownloading.value = false;
} }
}; };
const handleImageError = () => {
hasError.value = true;
};
</script> </script>
<template> <template>
@@ -60,12 +54,14 @@ const handleImageError = () => {
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }} {{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p> </p>
</div> </div>
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden"> <div v-else class="relative group rounded-lg overflow-hidden">
<img <img
class="skip-context-menu" class="skip-context-menu"
:src="attachment.dataUrl" :src="attachment.dataUrl"
:width="attachment.width" :width="attachment.width"
:height="attachment.height" :height="attachment.height"
@click="onClick"
@error="handleError"
/> />
<div <div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex" class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
@@ -90,7 +86,7 @@ const handleImageError = () => {
v-model:show="showGallery" v-model:show="showGallery"
:attachment="useSnakeCase(attachment)" :attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments" :all-attachments="filteredCurrentChatAttachments"
@error="handleImageError" @error="handleError"
@close="() => (showGallery = false)" @close="() => (showGallery = false)"
/> />
</template> </template>
@@ -1,29 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
</div>
</div>
</template>
@@ -1,32 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -1,25 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -1,20 +0,0 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -1,14 +0,0 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -47,7 +47,6 @@ const isReel = computed(() => {
'max-w-48': isReel, 'max-w-48': isReel,
'max-w-full': !isReel, 'max-w-full': !isReel,
}" }"
@click.stop
@error="handleError" @error="handleError"
/> />
</div> </div>
@@ -1,21 +0,0 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,23 +0,0 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,23 +0,0 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,20 +0,0 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
defineProps({ defineProps({
isMobileSidebarOpen: { isMobileSidebarOpen: {
@@ -46,17 +45,14 @@ const toggleSidebar = () => {
}, },
]" ]"
> >
<ButtonGroup <div class="rounded-full bg-n-alpha-2 p-1">
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button <Button
icon="i-lucide-menu" icon="i-lucide-menu"
no-animation class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg lg
@click="toggleSidebar" @click="toggleSidebar"
/> />
</ButtonGroup> </div>
</div> </div>
<template v-else /> <template v-else />
</template> </template>
@@ -221,70 +221,26 @@ const menuItems = computed(() => {
name: 'Captain', name: 'Captain',
icon: 'i-woot-captain', icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'), label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [ children: [
{ {
name: 'FAQs', name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_RESPONSES'), label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
activeOn: [ to: accountScopedRoute('captain_assistants_index'),
'captain_assistants_responses_index',
'captain_assistants_responses_pending',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_responses_index',
}),
}, },
{ {
name: 'Documents', name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'), label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
activeOn: ['captain_assistants_documents_index'], to: accountScopedRoute('captain_documents_index'),
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_documents_index',
}),
}, },
{ {
name: 'Scenarios', name: 'Responses',
label: t('SIDEBAR.CAPTAIN_SCENARIOS'), label: t('SIDEBAR.CAPTAIN_RESPONSES'),
activeOn: ['captain_assistants_scenarios_index'], to: accountScopedRoute('captain_responses_index'),
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_scenarios_index',
}),
},
{
name: 'Playground',
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
activeOn: ['captain_assistants_playground_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_playground_index',
}),
},
{
name: 'Inboxes',
label: t('SIDEBAR.CAPTAIN_INBOXES'),
activeOn: ['captain_assistants_inboxes_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_inboxes_index',
}),
}, },
{ {
name: 'Tools', name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'), label: t('SIDEBAR.CAPTAIN_TOOLS'),
activeOn: ['captain_tools_index'], to: accountScopedRoute('captain_tools_index'),
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
activeOn: [
'captain_assistants_settings_index',
'captain_assistants_guidelines_index',
'captain_assistants_guardrails_index',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_settings_index',
}),
}, },
], ],
}, },
@@ -351,23 +307,6 @@ const menuItems = computed(() => {
}, },
], ],
}, },
{
name: 'Companies',
label: t('SIDEBAR.COMPANIES'),
icon: 'i-lucide-building-2',
children: [
{
name: 'All Companies',
label: t('SIDEBAR.ALL_COMPANIES'),
to: accountScopedRoute(
'companies_dashboard_index',
{},
{ page: 1, search: undefined }
),
activeOn: ['companies_dashboard_index'],
},
],
},
{ {
name: 'Reports', name: 'Reports',
label: t('SIDEBAR.REPORTS'), label: t('SIDEBAR.REPORTS'),
@@ -19,46 +19,19 @@ export function useSidebarContext() {
return '/'; return '/';
}; };
// Helper to find route definition by name without resolving
const findRouteByName = name => {
const routes = router.getRoutes();
return routes.find(route => route.name === name);
};
const resolvePermissions = to => { const resolvePermissions = to => {
if (!to) return []; if (to) return router.resolve(to)?.meta?.permissions ?? [];
return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.permissions ?? [];
}
return router.resolve(to)?.meta?.permissions ?? [];
}; };
const resolveFeatureFlag = to => { const resolveFeatureFlag = to => {
if (!to) return ''; if (to) return router.resolve(to)?.meta?.featureFlag || '';
return '';
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.featureFlag || '';
}
return router.resolve(to)?.meta?.featureFlag || '';
}; };
const resolveInstallationType = to => { const resolveInstallationType = to => {
if (!to) return []; if (to) return router.resolve(to)?.meta?.installationTypes || [];
return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.installationTypes || [];
}
return router.resolve(to)?.meta?.installationTypes || [];
}; };
const isAllowed = to => { const isAllowed = to => {
@@ -1,7 +1,5 @@
<script setup> <script setup>
import { computed, ref, onMounted, nextTick } from 'vue'; import { computed } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({ const props = defineProps({
initialActiveTab: { initialActiveTab: {
type: Number, type: Number,
@@ -24,32 +22,6 @@ const emit = defineEmits(['tabChanged']);
const activeTab = computed(() => props.initialActiveTab); const activeTab = computed(() => props.initialActiveTab);
const tabRefs = ref([]);
const indicatorStyle = ref({});
const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
});
const selectTab = index => { const selectTab = index => {
emit('tabChanged', props.tabs[index]); emit('tabChanged', props.tabs[index]);
}; };
@@ -65,30 +37,20 @@ const showDivider = index => {
</script> </script>
<template> <template>
<div <div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
:style="indicatorStyle"
/>
<template v-for="(tab, index) in tabs" :key="index"> <template v-for="(tab, index) in tabs" :key="index">
<button <button
:ref="el => (tabRefs[index] = el)" class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[ :class="[
activeTab === index activeTab === index
? 'text-n-blue-text scale-100' ? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
: 'text-n-slate-10 scale-[0.98]', : 'text-n-slate-10 outline-transparent h-8',
]" ]"
@click="selectTab(index)" @click="selectTab(index)"
> >
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }} {{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button> </button>
<div <div
v-if="index < tabs.length - 1"
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out" class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class=" :class="
showDivider(index) showDivider(index)
@@ -15,7 +15,6 @@ import {
CMD_RESOLVE_CONVERSATION, CMD_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore(); const store = useStore();
@@ -134,7 +133,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div class="relative flex items-center justify-end resolve-actions">
<ButtonGroup <div
class="rounded-lg shadow outline-1 outline flex-shrink-0" class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
@@ -143,7 +142,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')" :label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0" class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdResolveConversation" @click="onCmdResolveConversation"
@@ -153,7 +151,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')" :label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0" class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
@@ -163,7 +160,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.OPEN_ACTION')" :label="t('CONVERSATION.HEADER.OPEN_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
/> />
@@ -173,13 +169,12 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
:disabled="isLoading" :disabled="isLoading"
size="sm" size="sm"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0" class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
color="slate" color="slate"
trailing-icon trailing-icon
@click="openDropdown" @click="openDropdown"
/> />
</ButtonGroup> </div>
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
@@ -23,7 +23,6 @@ defineProps({
slate slate
sm sm
class="relative" class="relative"
no-animation
:icon="icon" :icon="icon"
:trailing-icon="trailingIcon" :trailing-icon="trailingIcon"
> >
@@ -302,16 +302,7 @@ function isBodyEmpty(content) {
} }
function handleEmptyBodyWithSignature() { function handleEmptyBodyWithSignature() {
const { schema, tr, doc } = state; const { schema, tr } = state;
const isEmptyParagraph = node =>
node && node.type === schema.nodes.paragraph && node.content.size === 0;
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
if (isEmptyParagraph(doc.firstChild)) {
focusEditorInputField('start');
return;
}
// create a paragraph node and // create a paragraph node and
// start a transaction to append it at the end // start a transaction to append it at the end
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
<template> <template>
<button <button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75" class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
:disabled="disabled" :disabled="disabled"
:class="{ :class="{
'cursor-not-allowed': disabled, 'cursor-not-allowed': disabled,
@@ -30,7 +30,7 @@ const assignedAgent = computed({
return currentChat.value?.meta?.assignee; return currentChat.value?.meta?.assignee;
}, },
set(agent) { set(agent) {
const agentId = agent ? agent.id : null; const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent); store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', { store.dispatch('assignAgent', {
conversationId: currentChat.value?.id, conversationId: currentChat.value?.id,
@@ -1,129 +1,100 @@
<script setup> <script>
import { ref, computed } from 'vue'; import { mapGetters } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']); export default {
components: {
const { t } = useI18n(); NextButton,
},
const labels = useMapGetter('labels/getLabels'); emits: ['update', 'close', 'assign'],
data() {
const query = ref(''); return {
const selectedLabels = ref([]); query: '',
selectedLabels: [],
const filteredLabels = computed(() => { };
if (!query.value) return labels.value; },
return labels.value.filter(label => computed: {
label.title.toLowerCase().includes(query.value.toLowerCase()) ...mapGetters({ labels: 'labels/getLabels' }),
); filteredLabels() {
}); return this.labels.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase())
const hasLabels = computed(() => labels.value.length > 0); );
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0); },
},
const isLabelSelected = label => { methods: {
return selectedLabels.value.includes(label); isLabelSelected(label) {
}; return this.selectedLabels.includes(label);
},
const onClose = () => { assignLabels(key) {
emit('close'); this.$emit('update', key);
}; },
onClose() {
const handleAssign = () => { this.$emit('close');
if (selectedLabels.value.length > 0) { },
emit('assign', selectedLabels.value); },
}
}; };
</script> </script>
<template> <template>
<div <div v-on-clickaway="onClose" class="labels-container">
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle"> <div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24"> <svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" /> <path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg> </svg>
</div> </div>
<div class="flex items-center justify-between p-2.5"> <div class="flex items-center justify-between header">
<span class="text-sm font-medium">{{ <span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div> </div>
<div class="flex flex-col max-h-60 min-h-0"> <div class="labels-list">
<header class="py-2 px-2.5"> <header class="labels-list__header">
<Input <div
v-model="query" class="flex items-center justify-between h-8 gap-2 label-list-search"
type="search" >
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" <fluent-icon icon="search" class="search-icon" size="16" />
icon-left="i-lucide-search" <input
size="sm" v-model="query"
class="w-full" type="search"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/> class="reset-base !outline-0 !text-sm label--search_input"
/>
</div>
</header> </header>
<ul <ul class="labels-list__body">
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li <li
v-for="label in filteredLabels" v-for="label in filteredLabels"
:key="label.id" :key="label.id"
class="my-1 mx-0 py-0 px-2.5" class="label__list-item"
role="option"
:aria-selected="isLabelSelected(label.title)"
> >
<label <label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2" class="item"
:class="{ 'label-selected': isLabelSelected(label.title) }"
> >
<input <input
v-model="selectedLabels" v-model="selectedLabels"
type="checkbox" type="checkbox"
:value="label.title" :value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5" class="label-checkbox"
:aria-label="label.title"
/> />
<span <span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis" class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
> >
{{ label.title }} {{ label.title }}
</span> </span>
<span <span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak" class="label-pill"
:style="{ backgroundColor: label.color }" :style="{ backgroundColor: label.color }"
/> />
</label> </label>
</li> </li>
</ul> </ul>
<div v-else class="p-2 text-center"> <footer class="labels-list__footer">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton <NextButton
sm sm
type="submit" type="submit"
class="w-full" :label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length" :disabled="!selectedLabels.length"
@click="handleAssign" @click="$emit('assign', selectedLabels)"
/> />
</footer> </footer>
</div> </div>
@@ -131,11 +102,107 @@ const handleAssign = () => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.triangle { .labels-list {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position]; @apply flex flex-col max-h-[15rem] min-h-[auto];
svg path { .labels-list__header {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak; @apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
}
.labels-list__body {
@apply flex-1 overflow-y-auto py-2.5 mx-0;
}
.labels-list__footer {
@apply p-2;
button {
@apply w-full;
.button__content {
@apply text-center;
}
}
} }
} }
.label-list-search {
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.label--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
.labels-container {
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply max-h-[15rem] overflow-y-auto;
.label__list-container {
@apply h-full;
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
}
.labels-placeholder {
@apply p-2;
}
.label__list-item {
@apply my-1 mx-0 py-0 px-2.5;
.item {
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
&.label-selected {
@apply bg-n-slate-2;
}
span {
@apply text-sm;
}
.label-checkbox {
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
}
.label-title {
@apply flex-grow w-full;
}
.label-pill {
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
}
}
}
.search-container {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
}
</style> </style>
@@ -1,57 +0,0 @@
import { ref } from 'vue';
export const useLoadWithRetry = (config = {}) => {
const maxRetry = config.max_retry || 3;
const backoff = config.backoff || 1000;
const isLoaded = ref(false);
const hasError = ref(false);
const loadWithRetry = async url => {
const attemptLoad = () => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
isLoaded.value = true;
hasError.value = false;
resolve();
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = url;
});
};
const sleep = ms => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
const retry = async (attempt = 0) => {
try {
await attemptLoad();
} catch (error) {
if (attempt + 1 >= maxRetry) {
hasError.value = true;
isLoaded.value = false;
return;
}
await sleep(backoff * (attempt + 1));
await retry(attempt + 1);
}
};
await retry();
};
return {
isLoaded,
hasError,
loadWithRetry,
};
};
@@ -12,11 +12,7 @@ vi.mock('dashboard/composables', () => ({
})); }));
vi.mock('vue-i18n'); vi.mock('vue-i18n');
vi.mock('activestorage'); vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper', () => ({ vi.mock('shared/helpers/FileHelper');
checkFileSizeLimit: vi.fn(),
resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40),
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40,
}));
vi.mock('@chatwoot/utils'); vi.mock('@chatwoot/utils');
describe('useFileUpload', () => { describe('useFileUpload', () => {
@@ -40,9 +36,7 @@ describe('useFileUpload', () => {
getCurrentAccountId: { value: '123' }, getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } }, getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } }, getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { 'globalConfig/get': { value: { directUploadsEnabled: true } },
value: { directUploadsEnabled: true, maximumFileUploadSize: 40 },
},
}; };
return getterMap[getter]; return getterMap[getter];
}); });
@@ -92,9 +86,7 @@ describe('useFileUpload', () => {
getCurrentAccountId: { value: '123' }, getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } }, getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } }, getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { 'globalConfig/get': { value: { directUploadsEnabled: false } },
value: { directUploadsEnabled: false, maximumFileUploadSize: 40 },
},
}; };
return getterMap[getter]; return getterMap[getter];
}); });
@@ -14,7 +14,7 @@ import {
} from 'dashboard/constants/automation'; } from 'dashboard/constants/automation';
/** /**
* This is a shared composables that holds utilities used to build dropdown and file options * This is a shared composables that holds utilites used to build dropdown and file options
* @returns {Object} An object containing various automation-related functions and computed properties. * @returns {Object} An object containing various automation-related functions and computed properties.
*/ */
export default function useAutomationValues() { export default function useAutomationValues() {
@@ -4,11 +4,7 @@ import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage'; import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils'; import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
import { import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
resolveMaximumFileUploadSize,
} from 'shared/helpers/FileHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
/** /**
* Composable for handling file uploads in conversations * Composable for handling file uploads in conversations
@@ -25,34 +21,18 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
const currentChat = useMapGetter('getSelectedChat'); const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const installationLimit = resolveMaximumFileUploadSize(
globalConfig.value?.maximumFileUploadSize
);
// helper: compute max upload size for a given file's mime // helper: compute max upload size for a given file's mime
const maxSizeFor = mime => { const maxSizeFor = mime => {
// Use default/installation limit for private notes // Use default file size limit for private notes
if (isPrivateNote) { if (isPrivateNote) {
return installationLimit; return MAXIMUM_FILE_UPLOAD_SIZE;
} }
const channelType = inbox?.channel_type; return getMaxUploadSizeByChannel({
channelType: inbox?.channel_type,
if (!channelType || channelType === INBOX_TYPES.WEB) {
return installationLimit;
}
const channelLimit = getMaxUploadSizeByChannel({
channelType,
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc. medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
mime, // e.g. 'image/png' mime, // e.g. 'image/png'
}); });
if (channelLimit === DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE) {
return installationLimit;
}
return Math.min(channelLimit, installationLimit);
}; };
const alertOverLimit = maxSizeMB => const alertOverLimit = maxSizeMB =>
-1
View File
@@ -41,7 +41,6 @@ export const FEATURE_FLAGS = {
CAPTAIN_V2: 'captain_integration_v2', CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml', SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply', QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
}; };
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
enable_audio_alerts: audioAlertType, enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist, alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone, notification_tone: audioAlertTone,
// UI Settings can be undefined initially as we don't send the // UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process. // entire payload for the user during the signup process.
} = uiSettings || {}; } = uiSettings || {};
@@ -24,7 +24,7 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found", "NO_LABELS_FOUND": "No labels found for",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again." "ASSIGN_FAILED": "Failed to assign labels. Please try again."
@@ -571,27 +571,6 @@
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙" "ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
} }
}, },
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": { "COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": { "CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again." "ERROR_MESSAGE": "We couldn’t complete the search. Please try again."

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