Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816294e609 | ||
|
|
9813400237 | ||
|
|
cece07d649 | ||
|
|
02c4863d95 | ||
|
|
bae958334d | ||
|
|
a5fda8e118 | ||
|
|
8fa039e1c5 | ||
|
|
4061f99114 | ||
|
|
f064b09776 | ||
|
|
07a39f4b42 | ||
|
|
8bbf6c75e3 | ||
|
|
aad6d655d5 | ||
|
|
70c29f699c | ||
|
|
4ab0b17e6e | ||
|
|
3548948c92 | ||
|
|
b1898e019b | ||
|
|
b3a76289cc | ||
|
|
873cfa08d8 | ||
|
|
a0cc27faaf | ||
|
|
fdd35ff549 | ||
|
|
2ea10ae065 | ||
|
|
23a804512a | ||
|
|
f6510e0d43 | ||
|
|
c3d98fc064 | ||
|
|
b5ebc47637 | ||
|
|
f916fb2924 | ||
|
|
dc335e88c9 | ||
|
|
b1120ae7fb | ||
|
|
443214e9a0 | ||
|
|
3ce026e2bc | ||
|
|
f42fddd38e | ||
|
|
22b5e12a53 | ||
|
|
03bde0a8aa | ||
|
|
3a0b5f387d | ||
|
|
9bd658137a | ||
|
|
f73c5ef0b8 | ||
|
|
f9fce5e2df | ||
|
|
c2d8e2ad77 | ||
|
|
03c0a7c62e | ||
|
|
d40a59f7fa | ||
|
|
72c5671e09 | ||
|
|
8c0885e1d2 | ||
|
|
e92f72b318 | ||
|
|
ea04a292ed | ||
|
|
deeb6d4e3f | ||
|
|
a3bf753f4c | ||
|
|
459f225559 | ||
|
|
99de5f4257 | ||
|
|
4b417ce9e7 | ||
|
|
8697a30dc5 | ||
|
|
8565341682 | ||
|
|
9bd7daeaf8 | ||
|
|
abe22c8649 | ||
|
|
743b73d0af | ||
|
|
dc7f1597e5 | ||
|
|
bc42aec68e | ||
|
|
3c8abd5b30 | ||
|
|
1602b071db | ||
|
|
4664402bf3 | ||
|
|
df7401f71c | ||
|
|
a07f2a7c1b | ||
|
|
af650af489 | ||
|
|
2ee63656e2 | ||
|
|
27ec791353 | ||
|
|
ccb3672ff8 | ||
|
|
54faefb876 |
@@ -73,15 +73,15 @@ jobs:
|
|||||||
libvips
|
libvips
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install RVM and Ruby 3.3.3
|
name: Install RVM and Ruby 3.4.4
|
||||||
command: |
|
command: |
|
||||||
sudo apt-get install -y gpg
|
sudo apt-get install -y gpg
|
||||||
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||||
\curl -sSL https://get.rvm.io | bash -s stable
|
\curl -sSL https://get.rvm.io | bash -s stable
|
||||||
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
rvm install "3.3.3"
|
rvm install "3.4.4"
|
||||||
rvm use 3.3.3 --default
|
rvm use 3.4.4 --default
|
||||||
gem install bundler -v 2.5.16
|
gem install bundler -v 2.5.16
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
|
|||||||
@@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
|
|||||||
|
|
||||||
# Do the set up required for chatwoot app
|
# Do the set up required for chatwoot app
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Copy dependency files first for better caching
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
COPY Gemfile Gemfile.lock ./
|
||||||
|
|
||||||
|
# Install dependencies (will be cached if files don't change)
|
||||||
|
RUN pnpm install --frozen-lockfile && \
|
||||||
|
gem install bundler && \
|
||||||
|
bundle install --jobs=$(nproc)
|
||||||
|
|
||||||
|
# Copy source code after dependencies are installed
|
||||||
COPY . /workspace
|
COPY . /workspace
|
||||||
RUN yarn && gem install bundler && bundle install
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
ARG VARIANT="ubuntu-22.04"
|
||||||
ARG VARIANT
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
|
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
ARG NODE_VERSION
|
ARG NODE_VERSION
|
||||||
ARG RUBY_VERSION
|
ARG RUBY_VERSION
|
||||||
ARG USER_UID
|
ARG USER_UID
|
||||||
ARG USER_GID
|
ARG USER_GID
|
||||||
|
ARG PNPM_VERSION="10.2.0"
|
||||||
|
ENV PNPM_VERSION ${PNPM_VERSION}
|
||||||
|
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
|
||||||
|
|
||||||
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
||||||
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
||||||
@@ -15,61 +19,80 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
|||||||
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
|
||||||
&& apt-get -y install --no-install-recommends \
|
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
build-essential \
|
&& apt-get update \
|
||||||
libssl-dev \
|
&& apt-get -y install --no-install-recommends \
|
||||||
zlib1g-dev \
|
build-essential \
|
||||||
gnupg2 \
|
libssl-dev \
|
||||||
tar \
|
zlib1g-dev \
|
||||||
tzdata \
|
gnupg \
|
||||||
postgresql-client \
|
tar \
|
||||||
libpq-dev \
|
tzdata \
|
||||||
yarn \
|
postgresql-client \
|
||||||
git \
|
libpq-dev \
|
||||||
imagemagick \
|
git \
|
||||||
tmux \
|
imagemagick \
|
||||||
zsh \
|
libyaml-dev \
|
||||||
git-flow \
|
curl \
|
||||||
npm \
|
ca-certificates \
|
||||||
libyaml-dev
|
tmux \
|
||||||
|
nodejs \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# Install rbenv and ruby
|
# Install rbenv and ruby for root user first
|
||||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||||
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
||||||
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
||||||
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
|
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
|
||||||
RUN git clone https://github.com/rbenv/ruby-build.git && \
|
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
|
||||||
PREFIX=/usr/local ./ruby-build/install.sh
|
PREFIX=/usr/local ./ruby-build/install.sh
|
||||||
|
|
||||||
RUN rbenv install $RUBY_VERSION && \
|
RUN rbenv install $RUBY_VERSION && \
|
||||||
rbenv global $RUBY_VERSION && \
|
rbenv global $RUBY_VERSION && \
|
||||||
rbenv versions
|
rbenv versions
|
||||||
|
|
||||||
# Install overmind
|
# Set up rbenv for vscode user
|
||||||
|
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
|
||||||
|
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
|
||||||
|
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
|
||||||
|
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
|
||||||
|
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
|
||||||
|
|
||||||
|
# Install overmind and gh in single layer
|
||||||
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
||||||
&& gunzip overmind.gz \
|
&& gunzip overmind.gz \
|
||||||
&& sudo mv overmind /usr/local/bin \
|
&& mv overmind /usr/local/bin \
|
||||||
&& chmod +x /usr/local/bin/overmind
|
&& chmod +x /usr/local/bin/overmind \
|
||||||
|
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||||
# Install gh
|
&& apt-get update \
|
||||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
&& apt-get install -y --no-install-recommends gh \
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
&& apt-get clean \
|
||||||
&& sudo apt update \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
&& sudo apt install gh
|
|
||||||
|
|
||||||
|
|
||||||
# Do the set up required for chatwoot app
|
# Do the set up required for chatwoot app
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY . /workspace
|
RUN chown vscode:vscode /workspace
|
||||||
|
|
||||||
# set up ruby
|
# set up node js, pnpm and claude code in single layer
|
||||||
COPY Gemfile Gemfile.lock ./
|
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
|
||||||
RUN gem install bundler && bundle install
|
&& npm cache clean --force
|
||||||
|
|
||||||
# set up node js
|
# Switch to vscode user
|
||||||
RUN npm install n -g && \
|
USER vscode
|
||||||
n $NODE_VERSION
|
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
|
||||||
RUN npm install --global yarn
|
|
||||||
RUN yarn
|
# Copy dependency files first for better caching
|
||||||
|
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies as vscode user
|
||||||
|
RUN eval "$(rbenv init -)" \
|
||||||
|
&& gem install bundler -N \
|
||||||
|
&& bundle install --jobs=$(nproc) \
|
||||||
|
&& pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code after dependencies are installed
|
||||||
|
COPY --chown=vscode:vscode . /workspace
|
||||||
|
|||||||
@@ -4,17 +4,26 @@
|
|||||||
"dockerComposeFile": "docker-compose.yml",
|
"dockerComposeFile": "docker-compose.yml",
|
||||||
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"terminal.integrated.shell.linux": "/bin/zsh"
|
"terminal.integrated.shell.linux": "/bin/zsh",
|
||||||
|
"extensions.showRecommendationsOnlyOnDemand": true,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"files.trimTrailingWhitespace": true,
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/tmp": true,
|
||||||
|
"**/log": true,
|
||||||
|
"**/coverage": true,
|
||||||
|
"**/public/packs": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// Add the IDs of extensions you want installed when the container is created.
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"rebornix.Ruby",
|
"Shopify.ruby-lsp",
|
||||||
"misogi.ruby-rubocop",
|
"misogi.ruby-rubocop",
|
||||||
"wingrunr21.vscode-ruby",
|
|
||||||
"davidpallinder.rails-test-runner",
|
"davidpallinder.rails-test-runner",
|
||||||
"eamodio.gitlens",
|
|
||||||
"github.copilot",
|
"github.copilot",
|
||||||
"mrmlnc.vscode-duplicate"
|
"mrmlnc.vscode-duplicate"
|
||||||
],
|
],
|
||||||
@@ -23,15 +32,15 @@
|
|||||||
// 5432 postgres
|
// 5432 postgres
|
||||||
// 6379 redis
|
// 6379 redis
|
||||||
// 1025,8025 mailhog
|
// 1025,8025 mailhog
|
||||||
"forwardPorts": [8025, 3000, 3035],
|
"forwardPorts": [8025, 3000, 3036],
|
||||||
|
|
||||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
|
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"3000": {
|
"3000": {
|
||||||
"label": "Rails Server"
|
"label": "Rails Server"
|
||||||
},
|
},
|
||||||
"3035": {
|
"3036": {
|
||||||
"label": "Webpack Dev Server"
|
"label": "Vite Dev Server"
|
||||||
},
|
},
|
||||||
"8025": {
|
"8025": {
|
||||||
"label": "Mailhog UI"
|
"label": "Mailhog UI"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Docker Compose file for building the base image in GitHub Actions
|
||||||
|
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
|
||||||
|
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
base:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: .devcontainer/Dockerfile.base
|
||||||
|
args:
|
||||||
|
VARIANT: 'ubuntu-22.04'
|
||||||
|
NODE_VERSION: '23.7.0'
|
||||||
|
RUBY_VERSION: '3.4.4'
|
||||||
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
|
USER_UID: '1000'
|
||||||
|
USER_GID: '1000'
|
||||||
|
image: ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||||
@@ -5,19 +5,6 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
base:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: .devcontainer/Dockerfile.base
|
|
||||||
args:
|
|
||||||
VARIANT: 'ubuntu-22.04'
|
|
||||||
NODE_VERSION: '23.7.0'
|
|
||||||
RUBY_VERSION: '3.3.3'
|
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
|
||||||
USER_UID: '1000'
|
|
||||||
USER_GID: '1000'
|
|
||||||
image: base:latest
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -25,7 +12,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '23.7.0'
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: '3.3.3'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
USER_GID: '1000'
|
USER_GID: '1000'
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ cp .env.example .env
|
|||||||
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
||||||
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
||||||
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
|
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
|
||||||
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
|
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
|
||||||
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
|
||||||
# uncomment the webpacker env variable
|
# Setup Claude Code API key if available
|
||||||
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
|
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
|
||||||
# fix the error with webpacker
|
mkdir -p ~/.claude
|
||||||
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
|
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
|
||||||
|
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
|
||||||
|
chmod +x ~/.claude/anthropic_key.sh
|
||||||
|
fi
|
||||||
|
|
||||||
# codespaces make the ports public
|
# codespaces make the ports public
|
||||||
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
|
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the Codespace Base Image
|
- name: Build the Codespace Base Image
|
||||||
run: |
|
run: |
|
||||||
docker-compose -f .devcontainer/docker-compose.yml build base
|
docker compose -f .devcontainer/docker-compose.base.yml build base
|
||||||
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
|
|
||||||
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||||
|
|||||||
+179
-8
@@ -1,7 +1,10 @@
|
|||||||
require:
|
plugins:
|
||||||
- rubocop-performance
|
- rubocop-performance
|
||||||
- rubocop-rails
|
- rubocop-rails
|
||||||
- rubocop-rspec
|
- rubocop-rspec
|
||||||
|
- rubocop-factory_bot
|
||||||
|
|
||||||
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
|
|
||||||
@@ -13,44 +16,61 @@ Metrics/ClassLength:
|
|||||||
Exclude:
|
Exclude:
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
- 'app/models/conversation.rb'
|
- 'app/models/conversation.rb'
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 19
|
Max: 19
|
||||||
|
Exclude:
|
||||||
|
- 'enterprise/lib/captain/agent.rb'
|
||||||
|
|
||||||
RSpec/ExampleLength:
|
RSpec/ExampleLength:
|
||||||
Max: 25
|
Max: 25
|
||||||
|
|
||||||
Style/Documentation:
|
Style/Documentation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/ExponentialNotation:
|
Style/ExponentialNotation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/FrozenStringLiteralComment:
|
Style/FrozenStringLiteralComment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/SymbolArray:
|
Style/SymbolArray:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
- 'app/dispatchers/dispatcher.rb'
|
- 'app/dispatchers/dispatcher.rb'
|
||||||
|
|
||||||
Style/GlobalVars:
|
Style/GlobalVars:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/initializers/01_redis.rb'
|
- 'config/initializers/01_redis.rb'
|
||||||
- 'config/initializers/rack_attack.rb'
|
- 'config/initializers/rack_attack.rb'
|
||||||
- 'lib/redis/alfred.rb'
|
- 'lib/redis/alfred.rb'
|
||||||
- 'lib/global_config.rb'
|
- 'lib/global_config.rb'
|
||||||
|
|
||||||
Style/ClassVars:
|
Style/ClassVars:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
|
|
||||||
Lint/MissingSuper:
|
Lint/MissingSuper:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/drops/base_drop.rb'
|
- 'app/drops/base_drop.rb'
|
||||||
|
|
||||||
Lint/SymbolConversion:
|
Lint/SymbolConversion:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Lint/EmptyBlock:
|
Lint/EmptyBlock:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
|
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
|
||||||
|
|
||||||
Lint/OrAssignmentToConstant:
|
Lint/OrAssignmentToConstant:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'lib/redis/config.rb'
|
- 'lib/redis/config.rb'
|
||||||
|
|
||||||
Metrics/BlockLength:
|
Metrics/BlockLength:
|
||||||
Max: 30
|
Max: 30
|
||||||
Exclude:
|
Exclude:
|
||||||
@@ -58,10 +78,16 @@ Metrics/BlockLength:
|
|||||||
- '**/routes.rb'
|
- '**/routes.rb'
|
||||||
- 'config/environments/*'
|
- 'config/environments/*'
|
||||||
- db/schema.rb
|
- db/schema.rb
|
||||||
|
|
||||||
Metrics/ModuleLength:
|
Metrics/ModuleLength:
|
||||||
Exclude:
|
Exclude:
|
||||||
- lib/seeders/message_seeder.rb
|
- lib/seeders/message_seeder.rb
|
||||||
- spec/support/slack_stubs.rb
|
- spec/support/slack_stubs.rb
|
||||||
|
|
||||||
|
Rails/HelperInstanceVariable:
|
||||||
|
Exclude:
|
||||||
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
|
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -71,74 +97,101 @@ Rails/ApplicationController:
|
|||||||
- 'app/controllers/platform_controller.rb'
|
- 'app/controllers/platform_controller.rb'
|
||||||
- 'app/controllers/public_controller.rb'
|
- 'app/controllers/public_controller.rb'
|
||||||
- 'app/controllers/survey/responses_controller.rb'
|
- 'app/controllers/survey/responses_controller.rb'
|
||||||
|
|
||||||
Rails/FindEach:
|
Rails/FindEach:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Include:
|
Include:
|
||||||
- 'app/**/*.rb'
|
- 'app/**/*.rb'
|
||||||
|
|
||||||
Rails/CompactBlank:
|
Rails/CompactBlank:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/EnvironmentVariableAccess:
|
Rails/EnvironmentVariableAccess:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/TimeZoneAssignment:
|
Rails/TimeZoneAssignment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/RedundantPresenceValidationOnBelongsTo:
|
Rails/RedundantPresenceValidationOnBelongsTo:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/InverseOf:
|
||||||
|
Exclude:
|
||||||
|
- enterprise/app/models/captain/assistant.rb
|
||||||
|
|
||||||
|
Rails/UniqueValidationWithoutIndex:
|
||||||
|
Exclude:
|
||||||
|
- app/models/canned_response.rb
|
||||||
|
- app/models/telegram_bot.rb
|
||||||
|
- enterprise/app/models/captain_inbox.rb
|
||||||
|
- 'app/models/channel/twitter_profile.rb'
|
||||||
|
- 'app/models/webhook.rb'
|
||||||
|
- 'app/models/contact.rb'
|
||||||
|
|
||||||
Style/ClassAndModuleChildren:
|
Style/ClassAndModuleChildren:
|
||||||
EnforcedStyle: compact
|
EnforcedStyle: compact
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/application.rb'
|
- 'config/application.rb'
|
||||||
- 'config/initializers/monkey_patches/*'
|
- 'config/initializers/monkey_patches/*'
|
||||||
|
|
||||||
Style/MapToHash:
|
Style/MapToHash:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/HashSyntax:
|
Style/HashSyntax:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
EnforcedStyle: no_mixed_keys
|
EnforcedStyle: no_mixed_keys
|
||||||
EnforcedShorthandSyntax: never
|
EnforcedShorthandSyntax: never
|
||||||
|
|
||||||
RSpec/NestedGroups:
|
RSpec/NestedGroups:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Max: 4
|
Max: 4
|
||||||
|
|
||||||
RSpec/MessageSpies:
|
RSpec/MessageSpies:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
RSpec/StubbedMock:
|
RSpec/StubbedMock:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
RSpec/FactoryBot/SyntaxMethods:
|
|
||||||
Enabled: false
|
|
||||||
Naming/VariableNumber:
|
Naming/VariableNumber:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Naming/MemoizedInstanceVariableName:
|
Naming/MemoizedInstanceVariableName:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
|
|
||||||
Style/GuardClause:
|
Style/GuardClause:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/builders/account_builder.rb'
|
- 'app/builders/account_builder.rb'
|
||||||
- 'app/models/attachment.rb'
|
- 'app/models/attachment.rb'
|
||||||
- 'app/models/message.rb'
|
- 'app/models/message.rb'
|
||||||
|
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 26
|
Max: 26
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/concerns/auth_helper.rb'
|
- 'app/controllers/concerns/auth_helper.rb'
|
||||||
Rails/UniqueValidationWithoutIndex:
|
|
||||||
Exclude:
|
|
||||||
- 'app/models/channel/twitter_profile.rb'
|
|
||||||
- 'app/models/webhook.rb'
|
|
||||||
- 'app/models/contact.rb'
|
|
||||||
- 'app/models/integrations/hook.rb'
|
- 'app/models/integrations/hook.rb'
|
||||||
- 'app/models/canned_response.rb'
|
- 'app/models/canned_response.rb'
|
||||||
- 'app/models/telegram_bot.rb'
|
- 'app/models/telegram_bot.rb'
|
||||||
|
|
||||||
Rails/RenderInline:
|
Rails/RenderInline:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/swagger_controller.rb'
|
- 'app/controllers/swagger_controller.rb'
|
||||||
|
|
||||||
Rails/ThreeStateBooleanColumn:
|
Rails/ThreeStateBooleanColumn:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
||||||
|
|
||||||
RSpec/IndexedLet:
|
RSpec/IndexedLet:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
RSpec/NamedSubject:
|
RSpec/NamedSubject:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
# we should bring this down
|
# we should bring this down
|
||||||
RSpec/MultipleExpectations:
|
RSpec/MultipleExpectations:
|
||||||
Max: 7
|
Max: 7
|
||||||
|
|
||||||
RSpec/MultipleMemoizedHelpers:
|
RSpec/MultipleMemoizedHelpers:
|
||||||
Max: 14
|
Max: 14
|
||||||
|
|
||||||
@@ -166,3 +219,121 @@ AllCops:
|
|||||||
- 'tmp/**/*'
|
- 'tmp/**/*'
|
||||||
- 'storage/**/*'
|
- 'storage/**/*'
|
||||||
- 'db/migrate/20230426130150_init_schema.rb'
|
- 'db/migrate/20230426130150_init_schema.rb'
|
||||||
|
|
||||||
|
FactoryBot/SyntaxMethods:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Disable new rules causing errors
|
||||||
|
Layout/LeadingCommentSpace:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/ReturnNilInPredicateMethodDefinition:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantParentheses:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Performance/StringIdentifierArgument:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/EmptyLinesAroundExceptionHandlingKeywords:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/LiteralAsCondition:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantReturn:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/SpaceAroundOperators:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/EnvLocal:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/WhereRange:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/UselessConstantScoping:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/MultipleComparison:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Bundler/OrderedGems:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
RSpec/ExampleWording:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
RSpec/ReceiveMessages:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
FactoryBot/AssociationStyle:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/EnumSyntax:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/RedundantTypeConversion:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Additional rules to disable
|
||||||
|
Rails/RedundantActiveRecordAllMethod:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/TrailingEmptyLines:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/SafeNavigationChainLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/SafeNavigationConsistency:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Lint/CopDirectiveSyntax:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Final set of rules to disable
|
||||||
|
FactoryBot/ExcessiveCreateList:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
RSpec/MissingExpectationTargetMethod:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Performance/InefficientHashSearch:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantSelfAssignmentBranch:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/YAMLFileRead:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Layout/ExtraSpacing:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantFilterChain:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Performance/MapMethodChain:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/RootPathnameMethods:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/SuperArguments:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Final remaining rules to disable
|
||||||
|
Rails/Delegate:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/CaseLikeIf:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
FactoryBot/RedundantFactoryOption:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
FactoryBot/FactoryAssociationWithStrategy:
|
||||||
|
Enabled: false
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.3.3
|
3.4.4
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
ruby '3.3.3'
|
ruby '3.4.4'
|
||||||
|
|
||||||
##-- base gems for rails --##
|
##-- base gems for rails --##
|
||||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||||
gem 'rails', '~> 7.0.8.4'
|
gem 'rails', '~> 7.1'
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
gem 'bootsnap', require: false
|
gem 'bootsnap', require: false
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ gem 'liquid'
|
|||||||
gem 'commonmarker'
|
gem 'commonmarker'
|
||||||
# Validate Data against JSON Schema
|
# Validate Data against JSON Schema
|
||||||
gem 'json_schemer'
|
gem 'json_schemer'
|
||||||
|
# used in swagger build
|
||||||
|
gem 'json_refs'
|
||||||
# Rack middleware for blocking & throttling abusive requests
|
# Rack middleware for blocking & throttling abusive requests
|
||||||
gem 'rack-attack', '>= 6.7.0'
|
gem 'rack-attack', '>= 6.7.0'
|
||||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||||
@@ -196,9 +198,6 @@ group :development do
|
|||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
gem 'web-console', '>= 4.2.1'
|
gem 'web-console', '>= 4.2.1'
|
||||||
|
|
||||||
# used in swagger build
|
|
||||||
gem 'json_refs'
|
|
||||||
|
|
||||||
# When we want to squash migrations
|
# When we want to squash migrations
|
||||||
gem 'squasher'
|
gem 'squasher'
|
||||||
|
|
||||||
@@ -237,6 +236,7 @@ group :development, :test do
|
|||||||
gem 'rubocop-performance', require: false
|
gem 'rubocop-performance', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'rubocop-rails', require: false
|
||||||
gem 'rubocop-rspec', require: false
|
gem 'rubocop-rspec', require: false
|
||||||
|
gem 'rubocop-factory_bot', require: false
|
||||||
gem 'seed_dump'
|
gem 'seed_dump'
|
||||||
gem 'shoulda-matchers'
|
gem 'shoulda-matchers'
|
||||||
gem 'simplecov', '0.17.1', require: false
|
gem 'simplecov', '0.17.1', require: false
|
||||||
|
|||||||
+160
-140
@@ -25,76 +25,89 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.0.8.7)
|
actioncable (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (7.0.8.7)
|
zeitwerk (~> 2.6)
|
||||||
actionpack (= 7.0.8.7)
|
actionmailbox (7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activestorage (= 7.0.8.7)
|
activerecord (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activestorage (= 7.1.5.1)
|
||||||
|
activesupport (= 7.1.5.1)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
actionmailer (7.0.8.7)
|
actionmailer (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
actionview (= 7.0.8.7)
|
actionview (= 7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (7.0.8.7)
|
actionpack (7.1.5.1)
|
||||||
actionview (= 7.0.8.7)
|
actionview (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
rack (~> 2.0, >= 2.2.4)
|
nokogiri (>= 1.8.5)
|
||||||
|
racc
|
||||||
|
rack (>= 2.2.4)
|
||||||
|
rack-session (>= 1.0.1)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.6)
|
||||||
actiontext (7.0.8.7)
|
actiontext (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
activerecord (= 7.1.5.1)
|
||||||
activestorage (= 7.0.8.7)
|
activestorage (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.0.8.7)
|
actionview (7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-html-sanitizer (~> 1.6)
|
||||||
active_record_query_trace (1.8)
|
active_record_query_trace (1.8)
|
||||||
activejob (7.0.8.7)
|
activejob (7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.0.8.7)
|
activemodel (7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
activerecord (7.0.8.7)
|
activerecord (7.1.5.1)
|
||||||
activemodel (= 7.0.8.7)
|
activemodel (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
activerecord-import (1.4.1)
|
timeout (>= 0.4.0)
|
||||||
|
activerecord-import (2.1.0)
|
||||||
activerecord (>= 4.2)
|
activerecord (>= 4.2)
|
||||||
activestorage (7.0.8.7)
|
activestorage (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
activerecord (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
activesupport (7.1.5.1)
|
||||||
activesupport (7.0.8.7)
|
base64
|
||||||
|
benchmark (>= 0.3)
|
||||||
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
connection_pool (>= 2.2.5)
|
||||||
|
drb
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
|
logger (>= 1.4.2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
|
mutex_m
|
||||||
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
acts-as-taggable-on (9.0.1)
|
acts-as-taggable-on (12.0.0)
|
||||||
activerecord (>= 6.0, < 7.1)
|
activerecord (>= 7.1, < 8.1)
|
||||||
|
zeitwerk (>= 2.4, < 3.0)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
administrate (0.20.1)
|
administrate (0.20.1)
|
||||||
@@ -116,7 +129,7 @@ GEM
|
|||||||
annotate (3.2.0)
|
annotate (3.2.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 3.2, < 8.0)
|
||||||
rake (>= 10.4, < 14.0)
|
rake (>= 10.4, < 14.0)
|
||||||
ast (2.4.2)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
@@ -142,14 +155,15 @@ GEM
|
|||||||
statsd-ruby (~> 1.1)
|
statsd-ruby (~> 1.1)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
bigdecimal (3.1.8)
|
benchmark (0.4.0)
|
||||||
|
bigdecimal (3.1.9)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.16.0)
|
bootsnap (1.16.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (5.4.1)
|
brakeman (5.4.1)
|
||||||
browser (5.3.1)
|
browser (5.3.1)
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bullet (7.0.7)
|
bullet (8.0.7)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundle-audit (0.1.0)
|
bundle-audit (0.1.0)
|
||||||
@@ -161,8 +175,8 @@ GEM
|
|||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
concurrent-ruby (1.3.4)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.4.1)
|
connection_pool (2.5.3)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
@@ -176,16 +190,10 @@ GEM
|
|||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
datadog-ci (0.8.3)
|
|
||||||
msgpack
|
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
ddtrace (1.23.2)
|
ddtrace (0.48.0)
|
||||||
datadog-ci (~> 0.8.1)
|
ffi (~> 1.0)
|
||||||
debase-ruby_core_source (= 3.3.1)
|
|
||||||
libdatadog (~> 7.0.0.1.0)
|
|
||||||
libddwaf (~> 1.14.0.0.0)
|
|
||||||
msgpack
|
msgpack
|
||||||
debase-ruby_core_source (3.3.1)
|
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@@ -196,10 +204,10 @@ GEM
|
|||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise_token_auth (1.2.3)
|
devise_token_auth (1.2.5)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
devise (> 3.5.2, < 5)
|
devise (> 3.5.2, < 5)
|
||||||
rails (>= 4.2.0, < 7.2)
|
rails (>= 4.2.0, < 8.1)
|
||||||
diff-lcs (1.5.1)
|
diff-lcs (1.5.1)
|
||||||
digest-crc (0.6.5)
|
digest-crc (0.6.5)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
@@ -212,6 +220,7 @@ GEM
|
|||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
down (5.4.0)
|
down (5.4.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
|
drb (2.2.3)
|
||||||
dry-cli (1.1.0)
|
dry-cli (1.1.0)
|
||||||
ecma-re-validator (0.4.0)
|
ecma-re-validator (0.4.0)
|
||||||
regexp_parser (~> 2.2)
|
regexp_parser (~> 2.2)
|
||||||
@@ -254,7 +263,10 @@ GEM
|
|||||||
fcm (1.0.8)
|
fcm (1.0.8)
|
||||||
faraday (>= 1.0.0, < 3.0)
|
faraday (>= 1.0.0, < 3.0)
|
||||||
googleauth (~> 1)
|
googleauth (~> 1)
|
||||||
ffi (1.16.3)
|
ffi (1.17.2)
|
||||||
|
ffi (1.17.2-arm64-darwin)
|
||||||
|
ffi (1.17.2-x86_64-darwin)
|
||||||
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi-compiler (1.0.1)
|
ffi-compiler (1.0.1)
|
||||||
ffi (>= 1.0.0)
|
ffi (>= 1.0.0)
|
||||||
rake
|
rake
|
||||||
@@ -315,16 +327,13 @@ GEM
|
|||||||
google-cloud-translate-v3 (0.10.0)
|
google-cloud-translate-v3 (0.10.0)
|
||||||
gapic-common (>= 0.20.0, < 2.a)
|
gapic-common (>= 0.20.0, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-protobuf (3.25.5)
|
google-protobuf (3.25.7)
|
||||||
google-protobuf (3.25.5-arm64-darwin)
|
|
||||||
google-protobuf (3.25.5-x86_64-darwin)
|
|
||||||
google-protobuf (3.25.5-x86_64-linux)
|
|
||||||
googleapis-common-protos (1.6.0)
|
googleapis-common-protos (1.6.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
googleapis-common-protos-types (~> 1.7)
|
googleapis-common-protos-types (~> 1.7)
|
||||||
grpc (~> 1.41)
|
grpc (~> 1.41)
|
||||||
googleapis-common-protos-types (1.14.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
google-protobuf (~> 3.18)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
googleauth (1.11.2)
|
googleauth (1.11.2)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-env (~> 2.1)
|
google-cloud-env (~> 2.1)
|
||||||
@@ -334,17 +343,17 @@ GEM
|
|||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
groupdate (6.2.1)
|
groupdate (6.2.1)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
grpc (1.62.0)
|
grpc (1.72.0)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.62.0-arm64-darwin)
|
grpc (1.72.0-arm64-darwin)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.62.0-x86_64-darwin)
|
grpc (1.72.0-x86_64-darwin)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.62.0-x86_64-linux)
|
grpc (1.72.0-x86_64-linux)
|
||||||
google-protobuf (~> 3.25)
|
google-protobuf (>= 3.25, < 5.0)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
haikunator (1.1.1)
|
haikunator (1.1.1)
|
||||||
hairtrigger (1.0.0)
|
hairtrigger (1.0.0)
|
||||||
@@ -370,7 +379,7 @@ GEM
|
|||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
i18n (1.14.6)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.12.2)
|
image_processing (1.12.2)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
@@ -388,7 +397,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.6.3)
|
json (2.12.0)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -423,21 +432,13 @@ GEM
|
|||||||
faraday-multipart
|
faraday-multipart
|
||||||
json (>= 1.8)
|
json (>= 1.8)
|
||||||
rexml
|
rexml
|
||||||
|
language_server-protocol (3.17.0.5)
|
||||||
launchy (2.5.2)
|
launchy (2.5.2)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
letter_opener (1.8.1)
|
letter_opener (1.8.1)
|
||||||
launchy (>= 2.2, < 3)
|
launchy (>= 2.2, < 3)
|
||||||
libdatadog (7.0.0.1.0)
|
|
||||||
libdatadog (7.0.0.1.0-x86_64-linux)
|
|
||||||
libddwaf (1.14.0.0.0)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.14.0.0.0-arm64-darwin)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.14.0.0.0-x86_64-darwin)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
libddwaf (1.14.0.0.0-x86_64-linux)
|
|
||||||
ffi (~> 1.0)
|
|
||||||
line-bot-api (1.28.0)
|
line-bot-api (1.28.0)
|
||||||
|
lint_roller (1.1.0)
|
||||||
liquid (5.4.0)
|
liquid (5.4.0)
|
||||||
listen (3.8.0)
|
listen (3.8.0)
|
||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
@@ -445,7 +446,7 @@ GEM
|
|||||||
llhttp-ffi (0.4.0)
|
llhttp-ffi (0.4.0)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
logger (1.6.0)
|
logger (1.7.0)
|
||||||
lograge (0.14.0)
|
lograge (0.14.0)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
@@ -471,10 +472,10 @@ GEM
|
|||||||
mini_magick (4.12.0)
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.8)
|
mini_portile2 (2.8.8)
|
||||||
minitest (5.25.4)
|
minitest (5.25.5)
|
||||||
mock_redis (0.36.0)
|
mock_redis (0.36.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
msgpack (1.7.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
multipart-post (2.3.0)
|
multipart-post (2.3.0)
|
||||||
@@ -545,14 +546,16 @@ GEM
|
|||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
parallel (1.23.0)
|
parallel (1.27.0)
|
||||||
parser (3.2.2.1)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
|
racc
|
||||||
pg (1.5.3)
|
pg (1.5.3)
|
||||||
pg_search (2.3.6)
|
pg_search (2.3.6)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
pgvector (0.1.1)
|
pgvector (0.1.1)
|
||||||
|
prism (1.4.0)
|
||||||
procore-sift (1.0.0)
|
procore-sift (1.0.0)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
pry (0.14.2)
|
pry (0.14.2)
|
||||||
@@ -567,7 +570,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.14)
|
rack (2.2.15)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -581,23 +584,28 @@ GEM
|
|||||||
rack (~> 2.2, >= 2.2.4)
|
rack (~> 2.2, >= 2.2.4)
|
||||||
rack-proxy (0.7.7)
|
rack-proxy (0.7.7)
|
||||||
rack
|
rack
|
||||||
|
rack-session (1.0.2)
|
||||||
|
rack (< 3)
|
||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rack-timeout (0.6.3)
|
rack-timeout (0.6.3)
|
||||||
rails (7.0.8.7)
|
rackup (1.0.1)
|
||||||
actioncable (= 7.0.8.7)
|
rack (< 3)
|
||||||
actionmailbox (= 7.0.8.7)
|
webrick
|
||||||
actionmailer (= 7.0.8.7)
|
rails (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actioncable (= 7.1.5.1)
|
||||||
actiontext (= 7.0.8.7)
|
actionmailbox (= 7.1.5.1)
|
||||||
actionview (= 7.0.8.7)
|
actionmailer (= 7.1.5.1)
|
||||||
activejob (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activemodel (= 7.0.8.7)
|
actiontext (= 7.1.5.1)
|
||||||
activerecord (= 7.0.8.7)
|
actionview (= 7.1.5.1)
|
||||||
activestorage (= 7.0.8.7)
|
activejob (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activemodel (= 7.1.5.1)
|
||||||
|
activerecord (= 7.1.5.1)
|
||||||
|
activestorage (= 7.1.5.1)
|
||||||
|
activesupport (= 7.1.5.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.0.8.7)
|
railties (= 7.1.5.1)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -605,13 +613,14 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.1)
|
rails-html-sanitizer (1.6.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (7.0.8.7)
|
railties (7.1.5.1)
|
||||||
actionpack (= 7.0.8.7)
|
actionpack (= 7.1.5.1)
|
||||||
activesupport (= 7.0.8.7)
|
activesupport (= 7.1.5.1)
|
||||||
method_source
|
irb
|
||||||
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
zeitwerk (~> 2.5)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.2.1)
|
rake (13.2.1)
|
||||||
rb-fsevent (0.11.2)
|
rb-fsevent (0.11.2)
|
||||||
@@ -623,7 +632,7 @@ GEM
|
|||||||
connection_pool
|
connection_pool
|
||||||
redis-namespace (1.10.0)
|
redis-namespace (1.10.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
regexp_parser (2.8.0)
|
regexp_parser (2.10.0)
|
||||||
reline (0.3.6)
|
reline (0.3.6)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
@@ -643,7 +652,7 @@ GEM
|
|||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
reverse_markdown (2.1.1)
|
reverse_markdown (2.1.1)
|
||||||
nokogiri
|
nokogiri
|
||||||
rexml (3.3.9)
|
rexml (3.4.1)
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.2)
|
rspec-expectations (3.13.2)
|
||||||
@@ -663,30 +672,36 @@ GEM
|
|||||||
rspec-support (3.13.1)
|
rspec-support (3.13.1)
|
||||||
rspec_junit_formatter (0.6.0)
|
rspec_junit_formatter (0.6.0)
|
||||||
rspec-core (>= 2, < 4, != 2.12.0)
|
rspec-core (>= 2, < 4, != 2.12.0)
|
||||||
rubocop (1.50.2)
|
rubocop (1.75.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
lint_roller (~> 1.1.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.2.0.0)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rexml (>= 3.2.5, < 4.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-ast (>= 1.28.0, < 2.0)
|
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.28.1)
|
rubocop-ast (1.44.1)
|
||||||
parser (>= 3.2.1.0)
|
parser (>= 3.3.7.2)
|
||||||
rubocop-capybara (2.18.0)
|
prism (~> 1.4)
|
||||||
rubocop (~> 1.41)
|
rubocop-factory_bot (2.27.1)
|
||||||
rubocop-performance (1.17.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop-ast (>= 0.4.0)
|
rubocop-performance (1.25.0)
|
||||||
rubocop-rails (2.19.1)
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
|
rubocop-ast (>= 1.38.0, < 2.0)
|
||||||
|
rubocop-rails (2.32.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-rspec (2.21.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop (~> 1.33)
|
rubocop-rspec (3.6.0)
|
||||||
rubocop-capybara (~> 2.17)
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
ruby-openai (7.3.1)
|
ruby-openai (7.3.1)
|
||||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||||
faraday (>= 1)
|
faraday (>= 1)
|
||||||
@@ -816,8 +831,10 @@ GEM
|
|||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.8.2)
|
unf_ext (0.0.8.2)
|
||||||
unicode-display_width (2.4.2)
|
unicode-display_width (3.1.4)
|
||||||
uniform_notifier (1.16.0)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
|
unicode-emoji (4.0.4)
|
||||||
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.3)
|
uri (1.0.3)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
@@ -845,7 +862,9 @@ GEM
|
|||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket-driver (0.7.6)
|
webrick (1.9.1)
|
||||||
|
websocket-driver (0.7.7)
|
||||||
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
wisper (2.0.0)
|
wisper (2.0.0)
|
||||||
@@ -951,7 +970,7 @@ DEPENDENCIES
|
|||||||
rack-cors (= 2.0.0)
|
rack-cors (= 2.0.0)
|
||||||
rack-mini-profiler (>= 3.2.0)
|
rack-mini-profiler (>= 3.2.0)
|
||||||
rack-timeout
|
rack-timeout
|
||||||
rails (~> 7.0.8.4)
|
rails (~> 7.1)
|
||||||
redis
|
redis
|
||||||
redis-namespace
|
redis-namespace
|
||||||
responders (>= 3.1.1)
|
responders (>= 3.1.1)
|
||||||
@@ -960,6 +979,7 @@ DEPENDENCIES
|
|||||||
rspec-rails (>= 6.1.5)
|
rspec-rails (>= 6.1.5)
|
||||||
rspec_junit_formatter
|
rspec_junit_formatter
|
||||||
rubocop
|
rubocop
|
||||||
|
rubocop-factory_bot
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
@@ -997,7 +1017,7 @@ DEPENDENCIES
|
|||||||
working_hours
|
working_hours
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.3p89
|
ruby 3.4.4p34
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.16
|
2.5.16
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ run:
|
|||||||
|
|
||||||
force_run:
|
force_run:
|
||||||
rm -f ./.overmind.sock
|
rm -f ./.overmind.sock
|
||||||
|
rm -f tmp/pids/*.pid
|
||||||
overmind start -f Procfile.dev
|
overmind start -f Procfile.dev
|
||||||
|
|
||||||
debug:
|
debug:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
|
||||||
|
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||||
|
vite: BUILD_MODE=dev bin/vite build --watch
|
||||||
@@ -32,14 +32,7 @@ class AccountBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_email
|
def validate_email
|
||||||
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
|
Account::SignUpEmailValidationService.new(@email).perform
|
||||||
|
|
||||||
address = ValidEmail2::Address.new(@email)
|
|
||||||
if address.valid? && !address.disposable?
|
|
||||||
true
|
|
||||||
else
|
|
||||||
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_user
|
def validate_user
|
||||||
@@ -81,21 +74,4 @@ class AccountBuilder
|
|||||||
@user.confirm if @confirmed
|
@user.confirm if @confirmed
|
||||||
@user.save!
|
@user.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def domain_blocked?
|
|
||||||
domain = @email.split('@').last
|
|
||||||
|
|
||||||
blocked_domains.each do |blocked_domain|
|
|
||||||
return true if domain.match?(blocked_domain)
|
|
||||||
end
|
|
||||||
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def blocked_domains
|
|
||||||
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
|
|
||||||
return [] if domains.blank?
|
|
||||||
|
|
||||||
domains.split("\n").map(&:strip)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ class ContactInboxBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_contact_inbox
|
def create_contact_inbox
|
||||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
attrs = {
|
||||||
contact_id: @contact.id,
|
contact_id: @contact.id,
|
||||||
inbox_id: @inbox.id,
|
inbox_id: @inbox.id,
|
||||||
source_id: @source_id
|
source_id: @source_id
|
||||||
)
|
}
|
||||||
|
|
||||||
|
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
|
||||||
rescue ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotUnique
|
||||||
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
||||||
update_old_contact_inbox
|
update_old_contact_inbox
|
||||||
|
|||||||
@@ -152,11 +152,13 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
end
|
end
|
||||||
|
|
||||||
def message_already_exists?
|
def message_already_exists?
|
||||||
cw_message = conversation.messages.where(
|
find_message_by_source_id(@messaging[:message][:mid]).present?
|
||||||
source_id: @messaging[:message][:mid]
|
end
|
||||||
).first
|
|
||||||
|
|
||||||
cw_message.present?
|
def find_message_by_source_id(source_id)
|
||||||
|
return unless source_id
|
||||||
|
|
||||||
|
@message = Message.find_by(source_id: source_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def all_unsupported_files?
|
def all_unsupported_files?
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_access_token
|
||||||
|
@agent_bot.access_token.regenerate_token
|
||||||
|
@agent_bot.reload
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def article_params
|
def article_params
|
||||||
params.require(:article).permit(
|
params.require(:article).permit(
|
||||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||||
:locale, meta: [:title,
|
:locale, meta: [:title,
|
||||||
:description,
|
:description,
|
||||||
{ tags: [] }]
|
{ tags: [] }]
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
|||||||
Current.account
|
Current.account
|
||||||
).perform
|
).perform
|
||||||
|
|
||||||
# Only allow conversations from inboxes the user has access to
|
|
||||||
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
|
|
||||||
conversations = conversations.where(inbox_id: inbox_ids)
|
|
||||||
|
|
||||||
@conversations = conversations.order(last_activity_at: :desc).limit(20)
|
@conversations = conversations.order(last_activity_at: :desc).limit(20)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id,
|
||||||
|
label_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_hook
|
def fetch_hook
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
|||||||
@result = search('Message')
|
@result = search('Message')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def articles
|
||||||
|
@result = search('Article')
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def search(search_type)
|
def search(search_type)
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_access_token
|
||||||
|
@user.access_token.regenerate_token
|
||||||
|
@user.reload
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
|
|
||||||
def sign_up_user
|
def sign_up_user
|
||||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
|
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||||
|
|
||||||
create_account_for_user
|
create_account_for_user
|
||||||
token = @resource.send(:set_reset_password_token)
|
token = @resource.send(:set_reset_password_token)
|
||||||
@@ -53,9 +53,11 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
).first
|
).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_business_account?
|
def validate_signup_email_is_business_domain?
|
||||||
# return true if the user is a business account, false if it is a gmail account
|
# return true if the user is a business account, false if it is a blocked domain account
|
||||||
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
|
Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform
|
||||||
|
rescue CustomExceptions::Account::InvalidEmail
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_account_for_user
|
def create_account_for_user
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class Platform::Api::V1::UsersController < PlatformController
|
class Platform::Api::V1::UsersController < PlatformController
|
||||||
# ref: https://stackoverflow.com/a/45190318/939299
|
# ref: https://stackoverflow.com/a/45190318/939299
|
||||||
# set resource is called for other actions already in platform controller
|
# set resource is called for other actions already in platform controller
|
||||||
# we want to add login to that chain as well
|
# we want to add login and token to that chain as well
|
||||||
before_action(only: [:login]) { set_resource }
|
before_action(only: [:login, :token]) { set_resource }
|
||||||
before_action(only: [:login]) { validate_platform_app_permissible }
|
before_action(only: [:login, :token]) { validate_platform_app_permissible }
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ class Platform::Api::V1::UsersController < PlatformController
|
|||||||
render json: { url: @resource.generate_sso_link }
|
render json: { url: @resource.generate_sso_link }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def token; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@resource.assign_attributes(user_update_params)
|
@resource.assign_attributes(user_update_params)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :set_category, except: [:index, :show]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
@@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
|
def tracking_pixel
|
||||||
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
|
return head :not_found unless @article
|
||||||
|
|
||||||
|
@article.increment_view_count if @article.published?
|
||||||
|
|
||||||
|
# Serve the 1x1 tracking pixel with 24-hour private cache
|
||||||
|
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
|
||||||
|
expires_in 24.hours, public: false
|
||||||
|
response.headers['Content-Type'] = 'image/png'
|
||||||
|
|
||||||
|
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
|
||||||
|
send_file pixel_path, type: 'image/png', disposition: 'inline'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def limit_results
|
def limit_results
|
||||||
@@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def set_article
|
def set_article
|
||||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
@article.increment_view_count if @article.published?
|
|
||||||
@parsed_content = render_article_content(@article.content)
|
@parsed_content = render_article_content(@article.content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def allowed_configs
|
def allowed_configs
|
||||||
@allowed_configs = case @config
|
mapping = {
|
||||||
when 'facebook'
|
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||||
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
|
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||||
when 'shopify'
|
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||||
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
|
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||||
when 'microsoft'
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
when 'email'
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
}
|
||||||
when 'linear'
|
|
||||||
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
|
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||||
when 'instagram'
|
|
||||||
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
|
||||||
else
|
|
||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class ConversationFinder
|
|||||||
|
|
||||||
def find_conversation_by_inbox
|
def find_conversation_by_inbox
|
||||||
@conversations = current_account.conversations
|
@conversations = current_account.conversations
|
||||||
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
|
|
||||||
|
return unless params[:inbox_id]
|
||||||
|
|
||||||
|
@conversations = @conversations.where(inbox_id: @inbox_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_all_conversations
|
def find_all_conversations
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class AgentBotsAPI extends ApiClient {
|
|||||||
deleteAgentBotAvatar(botId) {
|
deleteAgentBotAvatar(botId) {
|
||||||
return axios.delete(`${this.url}/${botId}/avatar`);
|
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetAccessToken(botId) {
|
||||||
|
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AgentBotsAPI();
|
export default new AgentBotsAPI();
|
||||||
|
|||||||
@@ -102,4 +102,8 @@ export default {
|
|||||||
const urlData = endPoints('resendConfirmation');
|
const urlData = endPoints('resendConfirmation');
|
||||||
return axios.post(urlData.url);
|
return axios.post(urlData.url);
|
||||||
},
|
},
|
||||||
|
resetAccessToken() {
|
||||||
|
const urlData = endPoints('resetAccessToken');
|
||||||
|
return axios.post(urlData.url);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CopilotMessages extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/copilot_threads', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(threadId) {
|
||||||
|
return axios.get(`${this.url}/${threadId}/copilot_messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create({ threadId, ...rest }) {
|
||||||
|
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CopilotMessages();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CopilotThreads extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/copilot_threads', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CopilotThreads();
|
||||||
@@ -51,6 +51,9 @@ const endPoints = {
|
|||||||
resendConfirmation: {
|
resendConfirmation: {
|
||||||
url: '/api/v1/profile/resend_confirmation',
|
url: '/api/v1/profile/resend_confirmation',
|
||||||
},
|
},
|
||||||
|
resetAccessToken: {
|
||||||
|
url: '/api/v1/profile/reset_access_token',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default page => {
|
export default page => {
|
||||||
|
|||||||
@@ -134,10 +134,6 @@ class ConversationApi extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${conversationId}/attachments`);
|
return axios.get(`${this.url}/${conversationId}/attachments`);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestCopilot(conversationId, body) {
|
|
||||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
getInboxAssistant(conversationId) {
|
getInboxAssistant(conversationId) {
|
||||||
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
articles({ q, page = 1 }) {
|
||||||
|
return axios.get(`${this.url}/articles`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
page: page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SearchAPI();
|
export default new SearchAPI();
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => {
|
|||||||
expect(AgentBotsAPI).toHaveProperty('create');
|
expect(AgentBotsAPI).toHaveProperty('create');
|
||||||
expect(AgentBotsAPI).toHaveProperty('update');
|
expect(AgentBotsAPI).toHaveProperty('update');
|
||||||
expect(AgentBotsAPI).toHaveProperty('delete');
|
expect(AgentBotsAPI).toHaveProperty('delete');
|
||||||
|
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ select {
|
|||||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||||
background-size: 9px 6px;
|
background-size: 9px 6px;
|
||||||
|
|
||||||
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
|
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
|
||||||
|
|
||||||
&[disabled] {
|
&[disabled] {
|
||||||
@apply field-disabled;
|
@apply field-disabled;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs--container--with-border {
|
.tabs--container--with-border {
|
||||||
@apply border-b border-n-weak;
|
@apply border-b border-b-n-weak;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs--container--compact.tab--chat-type {
|
.tabs--container--compact.tab--chat-type {
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
selectedContact: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [showDeleteSection, toggleDeleteSection] = useToggle();
|
||||||
|
const confirmDeleteContactDialogRef = ref(null);
|
||||||
|
|
||||||
|
const openConfirmDeleteContactDialog = () => {
|
||||||
|
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
|
sm
|
||||||
|
link
|
||||||
|
slate
|
||||||
|
class="hover:!no-underline text-n-slate-12"
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
trailing-icon
|
||||||
|
@click="toggleDeleteSection()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||||
|
:class="
|
||||||
|
showDeleteSection
|
||||||
|
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||||
|
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden min-h-0">
|
||||||
|
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||||
|
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
link
|
||||||
|
@click="openConfirmDeleteContactDialog()"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ConfirmContactDeleteDialog
|
||||||
|
ref="confirmDeleteContactDialogRef"
|
||||||
|
:selected-contact="selectedContact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
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 countries from 'shared/constants/countries';
|
import countries from 'shared/constants/countries';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template #after>
|
<template #after>
|
||||||
<transition
|
<div
|
||||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
:class="
|
||||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
isExpanded
|
||||||
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
"
|
||||||
>
|
>
|
||||||
<div v-show="isExpanded" class="w-full">
|
<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"
|
||||||
@@ -176,8 +177,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ContactDeleteSection
|
||||||
|
:selected-contact="{
|
||||||
|
id: props.id,
|
||||||
|
name: props.name,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+1
-5
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
|
|||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="alert"
|
type="alert"
|
||||||
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
||||||
:description="
|
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
|
||||||
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
|
|
||||||
contactName: props.selectedContact.name,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
||||||
@confirm="handleDialogConfirm"
|
@confirm="handleDialogConfirm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
|
||||||
|
const { updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const showCopilotTab = computed(() =>
|
||||||
|
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
const isContactSidebarOpen = computed(
|
||||||
|
() => uiSettings.value.is_contact_sidebar_open
|
||||||
|
);
|
||||||
|
const isCopilotPanelOpen = computed(
|
||||||
|
() => uiSettings.value.is_copilot_panel_open
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleConversationSidebarToggle = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: !isContactSidebarOpen.value,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConversationSidebarToggle = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: true,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopilotSidebarToggle = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
is_copilot_panel_open: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboardEvents = {
|
||||||
|
'Alt+KeyO': {
|
||||||
|
action: toggleConversationSidebarToggle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
class="!rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2': isContactSidebarOpen,
|
||||||
|
}"
|
||||||
|
icon="i-ph-user-bold"
|
||||||
|
@click="handleConversationSidebarToggle"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="showCopilotTab"
|
||||||
|
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
class="!rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
||||||
|
}"
|
||||||
|
sm
|
||||||
|
icon="i-woot-captain"
|
||||||
|
@click="handleCopilotSidebarToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
import SidebarActionsHeader from './SidebarActionsHeader.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Components/SidebarActionsHeader"
|
||||||
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
>
|
||||||
|
<!-- Default State -->
|
||||||
|
<Variant title="Default State">
|
||||||
|
<SidebarActionsHeader title="Default State" />
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<!-- With New Conversation Button -->
|
||||||
|
<Variant title="With New Conversation Button">
|
||||||
|
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
|
||||||
|
<SidebarActionsHeader
|
||||||
|
title="With New Conversation Button"
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
key: 'new_conversation',
|
||||||
|
icon: 'i-lucide-plus',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from './button/Button.vue';
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click', 'close']);
|
||||||
|
|
||||||
|
const handleButtonClick = button => {
|
||||||
|
emit('click', button.key);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-1">
|
||||||
|
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button
|
||||||
|
v-for="button in buttons"
|
||||||
|
:key="button.key"
|
||||||
|
v-tooltip="button.tooltip"
|
||||||
|
:icon="button.icon"
|
||||||
|
ghost
|
||||||
|
sm
|
||||||
|
@click="handleButtonClick(button)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="$t('GENERAL.CLOSE')"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
ghost
|
||||||
|
sm
|
||||||
|
@click="$emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -117,7 +117,7 @@ const STYLE_CONFIG = {
|
|||||||
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
|
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||||
ghost:
|
ghost:
|
||||||
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||||
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
solid:
|
solid:
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
import ConfirmButton from './ConfirmButton.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const count = ref(0);
|
||||||
|
|
||||||
|
const incrementCount = () => {
|
||||||
|
count.value += 1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Components/ConfirmButton"
|
||||||
|
:layout="{ type: 'grid', width: '400px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Basic">
|
||||||
|
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
|
||||||
|
<p>{{ count }}</p>
|
||||||
|
<ConfirmButton
|
||||||
|
label="Delete"
|
||||||
|
confirm-label="Confirm?"
|
||||||
|
@click="incrementCount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Color Change">
|
||||||
|
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
|
||||||
|
<p>{{ count }}</p>
|
||||||
|
<ConfirmButton
|
||||||
|
label="Archive"
|
||||||
|
confirm-label="Confirm?"
|
||||||
|
color="slate"
|
||||||
|
confirm-color="amber"
|
||||||
|
@click="incrementCount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import Button from './Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: { type: [String, Number], default: '' },
|
||||||
|
confirmLabel: { type: [String, Number], default: '' },
|
||||||
|
color: { type: String, default: 'blue' },
|
||||||
|
confirmColor: { type: String, default: 'ruby' },
|
||||||
|
confirmHint: { type: String, default: '' },
|
||||||
|
variant: { type: String, default: null },
|
||||||
|
size: { type: String, default: null },
|
||||||
|
justify: { type: String, default: null },
|
||||||
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
|
trailingIcon: { type: Boolean, default: false },
|
||||||
|
isLoading: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
|
const isConfirmMode = ref(false);
|
||||||
|
const isClicked = ref(false);
|
||||||
|
|
||||||
|
const currentLabel = computed(() => {
|
||||||
|
return isConfirmMode.value ? props.confirmLabel : props.label;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentColor = computed(() => {
|
||||||
|
return isConfirmMode.value ? props.confirmColor : props.color;
|
||||||
|
});
|
||||||
|
const resetConfirmMode = () => {
|
||||||
|
isConfirmMode.value = false;
|
||||||
|
isClicked.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isConfirmMode.value) {
|
||||||
|
isConfirmMode.value = true;
|
||||||
|
} else {
|
||||||
|
isClicked.value = true;
|
||||||
|
emit('click');
|
||||||
|
setTimeout(resetConfirmMode, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
:class="{
|
||||||
|
'animate-bounce-complete': isClicked,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:label="currentLabel"
|
||||||
|
:color="currentColor"
|
||||||
|
:variant="variant"
|
||||||
|
:size="size"
|
||||||
|
:justify="justify"
|
||||||
|
:icon="icon"
|
||||||
|
:trailing-icon="trailingIcon"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
@click="handleClick"
|
||||||
|
@blur="resetConfirmMode"
|
||||||
|
>
|
||||||
|
<template v-if="$slots.default" #default>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
<template v-if="$slots.icon" #icon>
|
||||||
|
<slot name="icon" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="isConfirmMode && confirmHint"
|
||||||
|
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ confirmHint }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes bounce-complete {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-bounce-complete {
|
||||||
|
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -76,7 +76,7 @@ const handlePageChange = event => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
<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"
|
||||||
@@ -116,7 +116,7 @@ const handlePageChange = event => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
||||||
<slot v-if="!showPaywall" name="controls" />
|
<slot v-if="!showPaywall" name="controls" />
|
||||||
<div
|
<div
|
||||||
|
|||||||
+23
-1
@@ -42,6 +42,7 @@ const initialState = {
|
|||||||
conversationFaqs: false,
|
conversationFaqs: false,
|
||||||
memories: false,
|
memories: false,
|
||||||
},
|
},
|
||||||
|
temperature: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
|
|||||||
conversationFaqs: config.feature_faq || false,
|
conversationFaqs: config.feature_faq || false,
|
||||||
memories: config.feature_memory || false,
|
memories: config.feature_memory || false,
|
||||||
};
|
};
|
||||||
|
state.temperature = config.temperature || 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBasicInfoUpdate = async () => {
|
const handleBasicInfoUpdate = async () => {
|
||||||
@@ -136,6 +138,7 @@ const handleInstructionsUpdate = async () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
config: {
|
config: {
|
||||||
...props.assistant.config,
|
...props.assistant.config,
|
||||||
|
temperature: state.temperature || 1,
|
||||||
instructions: state.instructions,
|
instructions: state.instructions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -212,7 +215,7 @@ watch(
|
|||||||
|
|
||||||
<!-- Instructions Section -->
|
<!-- Instructions Section -->
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.instructions"
|
v-model="state.instructions"
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||||
@@ -221,6 +224,25 @@ watch(
|
|||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
: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">
|
<div class="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, ref, watch } from 'vue';
|
import { nextTick, ref, watch, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
import CopilotInput from './CopilotInput.vue';
|
import CopilotInput from './CopilotInput.vue';
|
||||||
import CopilotLoader from './CopilotLoader.vue';
|
import CopilotLoader from './CopilotLoader.vue';
|
||||||
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
||||||
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
||||||
|
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||||
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||||
import Icon from '../icon/Icon.vue';
|
import CopilotEmptyState from './CopilotEmptyState.vue';
|
||||||
|
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
supportAgent: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
messages: {
|
messages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
isCaptainTyping: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
conversationInboxType: {
|
conversationInboxType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -42,22 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const COPILOT_USER_ROLES = ['assistant', 'system'];
|
|
||||||
|
|
||||||
const sendMessage = message => {
|
const sendMessage = message => {
|
||||||
emit('sendMessage', message);
|
emit('sendMessage', message);
|
||||||
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
|
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useSuggestion = opt => {
|
|
||||||
emit('sendMessage', t(opt.prompt));
|
|
||||||
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
emit('reset');
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatContainer = ref(null);
|
const chatContainer = ref(null);
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
@@ -67,23 +51,72 @@ const scrollToBottom = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptOptions = [
|
const groupedMessages = computed(() => {
|
||||||
{
|
const result = [];
|
||||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
let thinkingGroup = [];
|
||||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
props.messages.forEach(message => {
|
||||||
},
|
if (message.message_type === 'assistant_thinking') {
|
||||||
{
|
thinkingGroup.push(message);
|
||||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
} else {
|
||||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
if (thinkingGroup.length > 0) {
|
||||||
},
|
result.push({
|
||||||
{
|
id: thinkingGroup[0].id,
|
||||||
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
message_type: 'thinking_group',
|
||||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
messages: thinkingGroup,
|
||||||
},
|
});
|
||||||
];
|
thinkingGroup = [];
|
||||||
|
}
|
||||||
|
result.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (thinkingGroup.length > 0) {
|
||||||
|
result.push({
|
||||||
|
id: thinkingGroup[0].id,
|
||||||
|
message_type: 'thinking_group',
|
||||||
|
messages: thinkingGroup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLastMessageFromAssistant = computed(() => {
|
||||||
|
return (
|
||||||
|
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
|
||||||
|
'assistant'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const closeCopilotPanel = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSidebarAction = action => {
|
||||||
|
if (action === 'reset') {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAssistants = computed(() => props.assistants.length > 0);
|
||||||
|
const hasMessages = computed(() => props.messages.length > 0);
|
||||||
|
const copilotButtons = computed(() => {
|
||||||
|
if (hasMessages.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'reset',
|
||||||
|
icon: 'i-lucide-refresh-ccw',
|
||||||
|
tooltip: t('CAPTAIN.COPILOT.RESET'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
watch(
|
watch(
|
||||||
[() => props.messages, () => props.isCaptainTyping],
|
[() => props.messages],
|
||||||
() => {
|
() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
@@ -93,62 +126,59 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
|
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
|
||||||
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
<SidebarActionsHeader
|
||||||
<template v-for="message in messages" :key="message.id">
|
:title="$t('CAPTAIN.COPILOT.TITLE')"
|
||||||
<CopilotAgentMessage
|
:buttons="copilotButtons"
|
||||||
v-if="message.role === 'user'"
|
@click="handleSidebarAction"
|
||||||
:support-agent="supportAgent"
|
@close="closeCopilotPanel"
|
||||||
:message="message"
|
/>
|
||||||
/>
|
|
||||||
<CopilotAssistantMessage
|
|
||||||
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
|
|
||||||
:message="message"
|
|
||||||
:conversation-inbox-type="conversationInboxType"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<CopilotLoader v-if="isCaptainTyping" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!messages.length"
|
ref="chatContainer"
|
||||||
class="h-full w-full flex items-center justify-center"
|
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
|
||||||
>
|
>
|
||||||
<div class="h-fit px-3 py-3 space-y-1">
|
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
|
||||||
<span class="text-xs text-n-slate-10">
|
<template v-for="(item, index) in groupedMessages" :key="item.id">
|
||||||
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
|
<CopilotAgentMessage
|
||||||
</span>
|
v-if="item.message_type === 'user'"
|
||||||
<button
|
:message="item.message"
|
||||||
v-for="prompt in promptOptions"
|
/>
|
||||||
:key="prompt.label"
|
<CopilotAssistantMessage
|
||||||
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
|
v-else-if="item.message_type === 'assistant'"
|
||||||
@click="() => useSuggestion(prompt)"
|
:message="item.message"
|
||||||
>
|
:is-last-message="index === groupedMessages.length - 1"
|
||||||
<span>{{ t(prompt.label) }}</span>
|
:conversation-inbox-type="conversationInboxType"
|
||||||
<Icon icon="i-lucide-chevron-right" />
|
/>
|
||||||
</button>
|
<CopilotThinkingGroup
|
||||||
|
v-else
|
||||||
|
:messages="item.messages"
|
||||||
|
:default-collapsed="isLastMessageFromAssistant"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CopilotLoader v-if="!isLastMessageFromAssistant" />
|
||||||
</div>
|
</div>
|
||||||
|
<CopilotEmptyState
|
||||||
|
v-else
|
||||||
|
:has-assistants="hasAssistants"
|
||||||
|
@use-suggestion="sendMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-3 mt-px mb-2">
|
<div class="mx-3 mt-px mb-2">
|
||||||
<div class="flex items-center gap-2 justify-between w-full mb-1">
|
<div class="flex items-center gap-2 justify-between w-full mb-1">
|
||||||
<ToggleCopilotAssistant
|
<ToggleCopilotAssistant
|
||||||
v-if="assistants.length"
|
v-if="assistants.length > 1"
|
||||||
:assistants="assistants"
|
:assistants="assistants"
|
||||||
:active-assistant="activeAssistant"
|
:active-assistant="activeAssistant"
|
||||||
@set-assistant="$event => emit('setAssistant', $event)"
|
@set-assistant="$event => emit('setAssistant', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else />
|
<div v-else />
|
||||||
<button
|
|
||||||
v-if="messages.length"
|
|
||||||
class="text-xs flex items-center gap-1 hover:underline"
|
|
||||||
@click="handleReset"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-refresh-ccw" />
|
|
||||||
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
<CopilotInput
|
||||||
|
v-if="hasAssistants"
|
||||||
|
class="mb-1 w-full"
|
||||||
|
@send="sendMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Avatar from '../avatar/Avatar.vue';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
message: {
|
message: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
supportAgent: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="space-y-1 text-n-slate-12">
|
||||||
<Avatar
|
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
||||||
:name="supportAgent.available_name"
|
<div class="break-words">
|
||||||
:src="supportAgent.avatar_url"
|
{{ message.content }}
|
||||||
:size="24"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
<div class="space-y-1 text-n-slate-12">
|
|
||||||
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
|
||||||
<div class="break-words">
|
|
||||||
{{ message.content }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
|||||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from '../avatar/Avatar.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
isLastMessage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
message: {
|
message: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -21,6 +24,15 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||||
|
|
||||||
|
const showUseButton = computed(() => {
|
||||||
|
return (
|
||||||
|
!hasEmptyMessageContent.value &&
|
||||||
|
props.message.reply_suggestion &&
|
||||||
|
props.isLastMessage
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const messageContent = computed(() => {
|
const messageContent = computed(() => {
|
||||||
const formatter = new MessageFormatter(props.message.content);
|
const formatter = new MessageFormatter(props.message.content);
|
||||||
@@ -33,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
|
||||||
|
|
||||||
const useCopilotResponse = () => {
|
const useCopilotResponse = () => {
|
||||||
if (insertIntoRichEditor.value) {
|
if (insertIntoRichEditor.value) {
|
||||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||||
@@ -46,33 +56,25 @@ const useCopilotResponse = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||||
<Avatar
|
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||||
name="Captain Copilot"
|
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||||
icon-name="i-woot-captain"
|
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||||
:size="24"
|
</span>
|
||||||
rounded-full
|
<div
|
||||||
|
v-else
|
||||||
|
v-dompurify-html="messageContent"
|
||||||
|
class="prose-sm break-words"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
<div class="flex flex-row mt-1">
|
||||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
<Button
|
||||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
v-if="showUseButton"
|
||||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||||
</span>
|
faded
|
||||||
<div
|
sm
|
||||||
v-else
|
slate
|
||||||
v-dompurify-html="messageContent"
|
@click="useCopilotResponse"
|
||||||
class="prose-sm break-words"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-row mt-1">
|
|
||||||
<Button
|
|
||||||
v-if="!hasEmptyMessageContent"
|
|
||||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
|
||||||
faded
|
|
||||||
sm
|
|
||||||
slate
|
|
||||||
@click="useCopilotResponse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import Icon from '../icon/Icon.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
hasAssistants: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['useSuggestion']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const routePromptMap = {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dashboard: [
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentRoute = () => {
|
||||||
|
const path = route.path;
|
||||||
|
if (path.includes('/conversations')) return 'conversations';
|
||||||
|
if (path.includes('/dashboard')) return 'dashboard';
|
||||||
|
if (path.includes('/contacts')) return 'contacts';
|
||||||
|
if (path.includes('/articles')) return 'articles';
|
||||||
|
return 'dashboard';
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptOptions = computed(() => {
|
||||||
|
const currentRoute = getCurrentRoute();
|
||||||
|
return routePromptMap[currentRoute] || routePromptMap.conversations;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSuggestion = opt => {
|
||||||
|
emit('useSuggestion', t(opt.prompt));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 flex flex-col gap-6 px-2">
|
||||||
|
<div class="flex flex-col space-y-4 py-4">
|
||||||
|
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12 leading-8">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-n-slate-11 leading-6">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasAssistants" class="w-full space-y-2">
|
||||||
|
<p class="text-sm text-n-slate-11 leading-6">
|
||||||
|
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
|
||||||
|
</p>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'captain_assistants_index',
|
||||||
|
params: { accountId: route.params.accountId },
|
||||||
|
}"
|
||||||
|
class="text-n-slate-11 underline hover:text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full space-y-2">
|
||||||
|
<span class="text-xs text-n-slate-10 block">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
|
||||||
|
</span>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="prompt in promptOptions"
|
||||||
|
:key="prompt.label"
|
||||||
|
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
|
||||||
|
@click="handleSuggestion(prompt)"
|
||||||
|
>
|
||||||
|
<span>{{ t(prompt.label) }}</span>
|
||||||
|
<Icon icon="i-lucide-chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,19 +13,16 @@ const sendMessage = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<form class="relative" @submit.prevent="sendMessage">
|
||||||
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
|
|
||||||
@submit.prevent="sendMessage"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
v-model="message"
|
v-model="message"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
||||||
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
|
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
|
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<i class="i-ph-arrow-up" />
|
<i class="i-ph-arrow-up" />
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const isConversationRoute = computed(() => {
|
||||||
|
const CONVERSATION_ROUTES = [
|
||||||
|
'inbox_conversation',
|
||||||
|
'conversation_through_inbox',
|
||||||
|
'conversations_through_label',
|
||||||
|
'team_conversations_through_label',
|
||||||
|
'conversations_through_folders',
|
||||||
|
'conversation_through_mentions',
|
||||||
|
'conversation_through_unattended',
|
||||||
|
'conversation_through_participating',
|
||||||
|
];
|
||||||
|
return CONVERSATION_ROUTES.includes(route.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const showCopilotLauncher = computed(() => {
|
||||||
|
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
FEATURE_FLAGS.CAPTAIN
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
isCaptainEnabled &&
|
||||||
|
!uiSettings.value.is_copilot_panel_open &&
|
||||||
|
!isConversationRoute.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
|
||||||
|
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||||
|
<Button
|
||||||
|
icon="i-woot-captain"
|
||||||
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||||
|
lg
|
||||||
|
@click="toggleSidebar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import Icon from '../../components-next/icon/Icon.vue';
|
||||||
|
defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-3 rounded-lg bg-n-background/50 border border-n-weak hover:bg-n-background/80 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-sparkles"
|
||||||
|
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
|
||||||
|
/>
|
||||||
|
<div class="text-sm text-n-slate-12">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: 'Analyzing the user query',
|
||||||
|
reasoning: 'Breaking down the request into actionable steps',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: 'Searching codebase',
|
||||||
|
reasoning: 'Looking for relevant files and functions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: 'Generating response',
|
||||||
|
reasoning: 'Composing a helpful and accurate answer',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story title="Captain/Copilot/CopilotThinkingGroup" group="components">
|
||||||
|
<Variant title="Default">
|
||||||
|
<CopilotThinkingGroup :messages="messages" />
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="With Default Collapsed">
|
||||||
|
<!-- eslint-disable-next-line -->
|
||||||
|
<CopilotThinkingGroup :messages="messages" :default-collapsed="true" />
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from '../icon/Icon.vue';
|
||||||
|
import CopilotThinkingBlock from './CopilotThinkingBlock.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
messages: { type: Array, required: true },
|
||||||
|
defaultCollapsed: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const { t } = useI18n();
|
||||||
|
const isExpanded = ref(!props.defaultCollapsed);
|
||||||
|
|
||||||
|
const thinkingCount = computed(() => props.messages.length);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.defaultCollapsed,
|
||||||
|
newValue => {
|
||||||
|
if (newValue) {
|
||||||
|
isExpanded.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="group flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-11 transition-colors duration-200 -ml-3"
|
||||||
|
@click="isExpanded = !isExpanded"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
||||||
|
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{{ t('CAPTAIN.COPILOT.SHOW_STEPS') }}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center h-4 min-w-4 px-1 text-xs font-medium rounded-full bg-n-solid-3 text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ thinkingCount }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-show="isExpanded"
|
||||||
|
class="space-y-3 transition-all duration-200"
|
||||||
|
:class="{
|
||||||
|
'opacity-100': isExpanded,
|
||||||
|
'opacity-0 max-h-0 overflow-hidden': !isExpanded,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<CopilotThinkingBlock
|
||||||
|
v-for="copilotMessage in messages"
|
||||||
|
:key="copilotMessage.id"
|
||||||
|
:content="copilotMessage.message.content"
|
||||||
|
:reasoning="copilotMessage.message.reasoning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowContextMenu = computed(() => {
|
const shouldShowContextMenu = computed(() => {
|
||||||
return !(
|
return !props.contentAttributes?.isUnsupported;
|
||||||
props.status === MESSAGE_STATUS.FAILED ||
|
|
||||||
props.status === MESSAGE_STATUS.PROGRESS ||
|
|
||||||
props.contentAttributes?.isUnsupported
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBubble = computed(() => {
|
const isBubble = computed(() => {
|
||||||
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
|
|||||||
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
||||||
|
|
||||||
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
||||||
|
const isFailedOrProcessing =
|
||||||
|
props.status === MESSAGE_STATUS.FAILED ||
|
||||||
|
props.status === MESSAGE_STATUS.PROGRESS;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copy: hasText,
|
copy: hasText,
|
||||||
delete: hasText || hasAttachments,
|
delete:
|
||||||
cannedResponse: isOutgoing && hasText,
|
(hasText || hasAttachments) &&
|
||||||
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
|
!isFailedOrProcessing &&
|
||||||
|
!isMessageDeleted.value,
|
||||||
|
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
|
||||||
|
copyLink: !isFailedOrProcessing,
|
||||||
|
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
|
||||||
|
replyTo:
|
||||||
|
!props.private &&
|
||||||
|
props.inboxSupportsReplyTo.outgoing &&
|
||||||
|
!isFailedOrProcessing,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -499,8 +506,8 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
class="[grid-area:bubble] flex"
|
class="[grid-area:bubble] flex"
|
||||||
:class="{
|
:class="{
|
||||||
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||||
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
|
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||||
}"
|
}"
|
||||||
@contextmenu="openContextMenu($event)"
|
@contextmenu="openContextMenu($event)"
|
||||||
@@ -516,7 +523,7 @@ provideMessageContext({
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
v-if="isBubble && !isMessageDeleted"
|
v-if="isBubble"
|
||||||
:context-menu-position="contextMenuPosition"
|
:context-menu-position="contextMenuPosition"
|
||||||
:is-open="showContextMenu"
|
:is-open="showContextMenu"
|
||||||
:enabled-options="contextMenuEnabledOptions"
|
:enabled-options="contextMenuEnabledOptions"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const senderName = computed(() => {
|
|||||||
<Icon :icon="icon" class="text-white size-4" />
|
<Icon :icon="icon" class="text-white size-4" />
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1 overflow-hidden">
|
||||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||||
{{
|
{{
|
||||||
t(senderTranslationKey, {
|
t(senderTranslationKey, {
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ const menuItems = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
||||||
>
|
>
|
||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||||
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||||
<ul class="flex flex-col gap-2 m-0 list-none">
|
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownContainer,
|
DropdownContainer,
|
||||||
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
|||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||||
|
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
|
||||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||||
const statusList = computed(() => {
|
const statusList = computed(() => {
|
||||||
return [
|
return [
|
||||||
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function changeAvailabilityStatus(availability) {
|
function changeAvailabilityStatus(availability) {
|
||||||
|
if (isImpersonating.value) {
|
||||||
|
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
store.dispatch('updateAvailability', {
|
store.dispatch('updateAvailability', {
|
||||||
availability,
|
availability,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
watch,
|
watch,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
|
||||||
defineEmits,
|
defineEmits,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
@@ -44,7 +43,7 @@ import {
|
|||||||
useSnakeCase,
|
useSnakeCase,
|
||||||
} from 'dashboard/composables/useTransformKeys';
|
} from 'dashboard/composables/useTransformKeys';
|
||||||
import { useEmitter } from 'dashboard/composables/emitter';
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
import { useEventListener, useScrollLock } from '@vueuse/core';
|
import { useEventListener } from '@vueuse/core';
|
||||||
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
@@ -87,12 +86,8 @@ const store = useStore();
|
|||||||
|
|
||||||
const conversationListRef = ref(null);
|
const conversationListRef = ref(null);
|
||||||
const conversationDynamicScroller = ref(null);
|
const conversationDynamicScroller = ref(null);
|
||||||
const conversationListScrollableElement = computed(
|
|
||||||
() => conversationDynamicScroller.value?.$el
|
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||||
);
|
|
||||||
const conversationListScrollLock = useScrollLock(
|
|
||||||
conversationListScrollableElement
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||||
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
|
|||||||
|
|
||||||
function onContextMenuToggle(state) {
|
function onContextMenuToggle(state) {
|
||||||
isContextMenuOpen.value = state;
|
isContextMenuOpen.value = state;
|
||||||
conversationListScrollLock.value = state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelectAll(check) {
|
function toggleSelectAll(check) {
|
||||||
@@ -770,10 +764,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
conversationListScrollLock.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
provide('selectConversation', selectConversation);
|
provide('selectConversation', selectConversation);
|
||||||
provide('deSelectConversation', deSelectConversation);
|
provide('deSelectConversation', deSelectConversation);
|
||||||
provide('assignAgent', onAssignAgent);
|
provide('assignAgent', onAssignAgent);
|
||||||
@@ -824,7 +814,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
|
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
|
||||||
:class="[
|
:class="[
|
||||||
{ hidden: !showConversationList },
|
{ hidden: !showConversationList },
|
||||||
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
|
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -80,16 +80,14 @@ const toggleConversationLayout = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-2 px-4"
|
class="flex items-center justify-between gap-2 px-3 h-12"
|
||||||
:class="{
|
:class="{
|
||||||
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||||
'pt-3 pb-2': showV4View,
|
|
||||||
'mb-2 pb-0': !showV4View,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center min-w-0">
|
<div class="flex items-center justify-center min-w-0">
|
||||||
<h1
|
<h1
|
||||||
class="text-lg font-medium truncate text-n-slate-12"
|
class="text-base font-medium truncate text-n-slate-12"
|
||||||
:title="pageTitle"
|
:title="pageTitle"
|
||||||
>
|
>
|
||||||
{{ pageTitle }}
|
{{ pageTitle }}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
|
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium text-white dark:text-white">
|
<div class="text-sm font-medium text-white dark:text-white">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
@@ -29,7 +29,7 @@ export default {
|
|||||||
<router-link
|
<router-link
|
||||||
v-if="action.type == 'link'"
|
v-if="action.type == 'link'"
|
||||||
:to="action.to"
|
:to="action.to"
|
||||||
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
|
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand"
|
||||||
>
|
>
|
||||||
{{ action.message }}
|
{{ action.message }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -1,62 +1,72 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import WootSnackbar from './Snackbar.vue';
|
import WootSnackbar from './Snackbar.vue';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
duration: {
|
||||||
WootSnackbar,
|
type: Number,
|
||||||
},
|
default: 2500,
|
||||||
props: {
|
|
||||||
duration: {
|
|
||||||
type: Number,
|
|
||||||
default: 2500,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
data() {
|
const { t } = useI18n();
|
||||||
return {
|
|
||||||
snackMessages: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const snackMessages = ref([]);
|
||||||
emitter.on('newToastMessage', this.onNewToastMessage);
|
const snackbarContainer = ref(null);
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
emitter.off('newToastMessage', this.onNewToastMessage);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onNewToastMessage({ message: originalMessage, action }) {
|
|
||||||
// FIX ME: This is a temporary workaround to pass string from functions
|
|
||||||
// that doesn't have the context of the VueApp.
|
|
||||||
const usei18n = action?.usei18n;
|
|
||||||
const duration = action?.duration || this.duration;
|
|
||||||
const message = usei18n ? this.$t(originalMessage) : originalMessage;
|
|
||||||
|
|
||||||
this.snackMessages.push({
|
const showPopover = () => {
|
||||||
key: new Date().getTime(),
|
try {
|
||||||
message,
|
const el = snackbarContainer.value;
|
||||||
action,
|
if (el?.matches(':popover-open')) {
|
||||||
});
|
el.hidePopover();
|
||||||
window.setTimeout(() => {
|
}
|
||||||
this.snackMessages.splice(0, 1);
|
el?.showPopover();
|
||||||
}, duration);
|
} catch (e) {
|
||||||
},
|
// ignore
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onNewToastMessage = ({ message: originalMessage, action }) => {
|
||||||
|
const message = action?.usei18n ? t(originalMessage) : originalMessage;
|
||||||
|
const duration = action?.duration || props.duration;
|
||||||
|
|
||||||
|
snackMessages.value.push({
|
||||||
|
key: Date.now(),
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextTick(showPopover);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
snackMessages.value.shift();
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emitter.on('newToastMessage', onNewToastMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emitter.off('newToastMessage', onNewToastMessage);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<transition-group
|
<div
|
||||||
name="toast-fade"
|
ref="snackbarContainer"
|
||||||
tag="div"
|
popover="manual"
|
||||||
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
|
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible"
|
||||||
>
|
>
|
||||||
<WootSnackbar
|
<transition-group name="toast-fade" tag="div">
|
||||||
v-for="snackMessage in snackMessages"
|
<WootSnackbar
|
||||||
:key="snackMessage.key"
|
v-for="snackMessage in snackMessages"
|
||||||
:message="snackMessage.message"
|
:key="snackMessage.key"
|
||||||
:action="snackMessage.action"
|
:message="snackMessage.message"
|
||||||
/>
|
:action="snackMessage.action"
|
||||||
</transition-group>
|
/>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watchEffect } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||||
import ConversationAPI from 'dashboard/api/inbox/conversation';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
conversationId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
conversationInboxType: {
|
conversationInboxType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -20,12 +15,24 @@ const props = defineProps({
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||||
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
|
||||||
|
const selectedCopilotThreadId = ref(null);
|
||||||
|
const messages = computed(() =>
|
||||||
|
store.getters['copilotMessages/getMessagesByThreadId'](
|
||||||
|
selectedCopilotThreadId.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
const messages = ref([]);
|
|
||||||
const isCaptainTyping = ref(false);
|
|
||||||
const selectedAssistantId = ref(null);
|
const selectedAssistantId = ref(null);
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
const activeAssistant = computed(() => {
|
const activeAssistant = computed(() => {
|
||||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||||
@@ -55,68 +62,57 @@ const setAssistant = async assistant => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowCopilotPanel = computed(() => {
|
||||||
|
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
FEATURE_FLAGS.CAPTAIN
|
||||||
|
);
|
||||||
|
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
|
||||||
|
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
|
||||||
|
});
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
messages.value = [];
|
selectedCopilotThreadId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = async message => {
|
const sendMessage = async message => {
|
||||||
// Add user message
|
if (selectedCopilotThreadId.value) {
|
||||||
messages.value.push({
|
await store.dispatch('copilotMessages/create', {
|
||||||
id: messages.value.length + 1,
|
assistant_id: activeAssistant.value.id,
|
||||||
role: 'user',
|
conversation_id: currentChat.value?.id,
|
||||||
content: message,
|
threadId: selectedCopilotThreadId.value,
|
||||||
});
|
message,
|
||||||
isCaptainTyping.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await ConversationAPI.requestCopilot(
|
|
||||||
props.conversationId,
|
|
||||||
{
|
|
||||||
previous_history: messages.value
|
|
||||||
.map(m => ({
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
}))
|
|
||||||
.slice(0, -1),
|
|
||||||
message,
|
|
||||||
assistant_id: selectedAssistantId.value,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
messages.value.push({
|
|
||||||
id: new Date().getTime(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: data.message,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} else {
|
||||||
// eslint-disable-next-line
|
const response = await store.dispatch('copilotThreads/create', {
|
||||||
console.log(error);
|
assistant_id: activeAssistant.value.id,
|
||||||
} finally {
|
conversation_id: currentChat.value?.id,
|
||||||
isCaptainTyping.value = false;
|
message,
|
||||||
|
});
|
||||||
|
selectedCopilotThreadId.value = response.id;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('captainAssistants/get');
|
store.dispatch('captainAssistants/get');
|
||||||
});
|
});
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
if (props.conversationId) {
|
|
||||||
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
|
|
||||||
selectedAssistantId.value = activeAssistant.value?.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Copilot
|
<div
|
||||||
:messages="messages"
|
v-if="shouldShowCopilotPanel"
|
||||||
:support-agent="currentUser"
|
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
||||||
:is-captain-typing="isCaptainTyping"
|
>
|
||||||
:conversation-inbox-type="conversationInboxType"
|
<Copilot
|
||||||
:assistants="assistants"
|
:messages="messages"
|
||||||
:active-assistant="activeAssistant"
|
:support-agent="currentUser"
|
||||||
@set-assistant="setAssistant"
|
:conversation-inbox-type="conversationInboxType"
|
||||||
@send-message="sendMessage"
|
:assistants="assistants"
|
||||||
@reset="handleReset"
|
:active-assistant="activeAssistant"
|
||||||
/>
|
@set-assistant="setAssistant"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
@reset="handleReset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||||
@@ -20,6 +21,10 @@ export default {
|
|||||||
AvailabilityStatusBadge,
|
AvailabilityStatusBadge,
|
||||||
NextButton,
|
NextButton,
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
return { isImpersonating };
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isStatusMenuOpened: false,
|
isStatusMenuOpened: false,
|
||||||
@@ -73,6 +78,13 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
changeAvailabilityStatus(availability) {
|
changeAvailabilityStatus(availability) {
|
||||||
|
if (this.isImpersonating) {
|
||||||
|
useAlert(
|
||||||
|
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isUpdating) {
|
if (this.isUpdating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
import {
|
||||||
import { useWindowSize, useElementBounding } from '@vueuse/core';
|
computed,
|
||||||
|
onMounted,
|
||||||
|
nextTick,
|
||||||
|
onUnmounted,
|
||||||
|
useTemplateRef,
|
||||||
|
inject,
|
||||||
|
} from 'vue';
|
||||||
|
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
|
||||||
|
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
|
||||||
@@ -11,27 +18,34 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const elementToLock = inject('contextMenuElementTarget', null);
|
||||||
|
|
||||||
const menuRef = useTemplateRef('menuRef');
|
const menuRef = useTemplateRef('menuRef');
|
||||||
|
|
||||||
|
const scrollLockElement = computed(() => {
|
||||||
|
if (!elementToLock?.value) return null;
|
||||||
|
return elementToLock.value?.$el;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLocked = useScrollLock(scrollLockElement);
|
||||||
|
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
|
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
|
||||||
|
|
||||||
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
|
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
|
||||||
|
const PADDING = 16;
|
||||||
// Initial position
|
// Initial position
|
||||||
let left = x;
|
let left = x;
|
||||||
let top = y;
|
let top = y;
|
||||||
|
|
||||||
// Boundary checks
|
// Boundary checks
|
||||||
const isOverflowingRight = left + menuW > windowW;
|
const isOverflowingRight = left + menuW > windowW - PADDING;
|
||||||
const isOverflowingBottom = top + menuH > windowH;
|
const isOverflowingBottom = top + menuH > windowH - PADDING;
|
||||||
|
|
||||||
// Adjust position if overflowing
|
// Adjust position if overflowing
|
||||||
if (isOverflowingRight) left = windowW - menuW;
|
if (isOverflowingRight) left = windowW - menuW - PADDING;
|
||||||
if (isOverflowingBottom) top = windowH - menuH;
|
if (isOverflowingBottom) top = windowH - menuH - PADDING;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: Math.max(0, left),
|
left: Math.max(PADDING, left),
|
||||||
top: Math.max(0, top),
|
top: Math.max(PADDING, top),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,8 +68,18 @@ const position = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
isLocked.value = true;
|
||||||
nextTick(() => menuRef.value?.focus());
|
nextTick(() => menuRef.value?.focus());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
isLocked.value = false;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isLocked.value = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -65,7 +89,7 @@ onMounted(() => {
|
|||||||
class="fixed outline-none z-[9999] cursor-pointer"
|
class="fixed outline-none z-[9999] cursor-pointer"
|
||||||
:style="position"
|
:style="position"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@blur="emit('close')"
|
@blur="handleClose"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
<template>
|
<template>
|
||||||
<woot-tabs
|
<woot-tabs
|
||||||
:index="activeTabIndex"
|
:index="activeTabIndex"
|
||||||
class="w-full px-4 py-0 tab--chat-type"
|
class="w-full px-3 -mt-1 py-0 tab--chat-type"
|
||||||
@change="onTabChange"
|
@change="onTabChange"
|
||||||
>
|
>
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
|
class="text-sm"
|
||||||
:index="index"
|
:index="index"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:count="item.count"
|
:count="item.count"
|
||||||
|
|||||||
@@ -25,10 +25,7 @@ defineProps({
|
|||||||
:username="user.name"
|
:username="user.name"
|
||||||
:status="user.availability_status"
|
:status="user.availability_status"
|
||||||
/>
|
/>
|
||||||
<span
|
<span class="my-0 truncate text-capitalize" :class="textClass">
|
||||||
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
|
||||||
:class="textClass"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
|
|||||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||||
import EmptyState from './EmptyState/EmptyState.vue';
|
import EmptyState from './EmptyState/EmptyState.vue';
|
||||||
import MessagesView from './MessagesView.vue';
|
import MessagesView from './MessagesView.vue';
|
||||||
import ConversationSidebar from './ConversationSidebar.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ConversationSidebar,
|
|
||||||
ConversationHeader,
|
ConversationHeader,
|
||||||
DashboardAppFrame,
|
DashboardAppFrame,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
MessagesView,
|
MessagesView,
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
inboxId: {
|
inboxId: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
@@ -34,7 +31,6 @@ export default {
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ['contactPanelToggle'],
|
|
||||||
data() {
|
data() {
|
||||||
return { activeIndex: 0 };
|
return { activeIndex: 0 };
|
||||||
},
|
},
|
||||||
@@ -86,9 +82,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.$emit('contactPanelToggle');
|
|
||||||
},
|
|
||||||
onDashboardAppTabChange(index) {
|
onDashboardAppTabChange(index) {
|
||||||
this.activeIndex = index;
|
this.activeIndex = index;
|
||||||
},
|
},
|
||||||
@@ -98,7 +91,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="conversation-details-wrap bg-n-background"
|
class="conversation-details-wrap bg-n-background relative"
|
||||||
:class="{
|
:class="{
|
||||||
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
||||||
}"
|
}"
|
||||||
@@ -106,15 +99,12 @@ export default {
|
|||||||
<ConversationHeader
|
<ConversationHeader
|
||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:chat="currentChat"
|
:chat="currentChat"
|
||||||
:is-inbox-view="isInboxView"
|
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<woot-tabs
|
<woot-tabs
|
||||||
v-if="dashboardApps.length && currentChat.id"
|
v-if="dashboardApps.length && currentChat.id"
|
||||||
:index="activeIndex"
|
:index="activeIndex"
|
||||||
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
|
class="-mt-px dashboard-app--tabs border-t border-t-n-background"
|
||||||
@change="onDashboardAppTabChange"
|
@change="onDashboardAppTabChange"
|
||||||
>
|
>
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
@@ -130,18 +120,12 @@ export default {
|
|||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:inbox-id="inboxId"
|
:inbox-id="inboxId"
|
||||||
:is-inbox-view="isInboxView"
|
:is-inbox-view="isInboxView"
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="!currentChat.id && !isInboxView"
|
v-if="!currentChat.id && !isInboxView"
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
/>
|
/>
|
||||||
<ConversationSidebar
|
<slot />
|
||||||
v-if="showContactPanel"
|
|
||||||
:current-chat="currentChat"
|
|
||||||
@toggle-contact-panel="onToggleContactPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DashboardAppFrame
|
<DashboardAppFrame
|
||||||
v-for="(dashboardApp, index) in dashboardApps"
|
v-for="(dashboardApp, index) in dashboardApps"
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||||
:class="{
|
:class="{
|
||||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||||
isActiveChat,
|
isActiveChat,
|
||||||
@@ -278,7 +278,7 @@ export default {
|
|||||||
:badge="inboxBadge"
|
:badge="inboxBadge"
|
||||||
:username="currentContact.name"
|
:username="currentContact.name"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
size="40px"
|
size="32px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { computed, ref } from 'vue';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useElementSize } from '@vueuse/core';
|
||||||
import BackButton from '../BackButton.vue';
|
import BackButton from '../BackButton.vue';
|
||||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
import MoreActions from './MoreActions.vue';
|
import MoreActions from './MoreActions.vue';
|
||||||
import Thumbnail from '../Thumbnail.vue';
|
import Thumbnail from '../Thumbnail.vue';
|
||||||
@@ -12,203 +13,161 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
|||||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import Linear from './linear/index.vue';
|
import Linear from './linear/index.vue';
|
||||||
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
const props = defineProps({
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
showBackButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
const { t } = useI18n();
|
||||||
components: {
|
const store = useStore();
|
||||||
BackButton,
|
const route = useRoute();
|
||||||
InboxName,
|
const conversationHeader = ref(null);
|
||||||
MoreActions,
|
const { width } = useElementSize(conversationHeader);
|
||||||
Thumbnail,
|
const { isAWebWidgetInbox } = useInbox();
|
||||||
SLACardLabel,
|
|
||||||
Linear,
|
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||||
NextButton,
|
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||||
},
|
const isFeatureEnabledonAccount = computed(
|
||||||
mixins: [inboxMixin],
|
() => store.getters['accounts/isFeatureEnabledonAccount']
|
||||||
props: {
|
);
|
||||||
chat: {
|
const appIntegrations = computed(
|
||||||
type: Object,
|
() => store.getters['integrations/getAppIntegrations']
|
||||||
default: () => {},
|
);
|
||||||
},
|
|
||||||
isContactPanelOpen: {
|
const chatMetadata = computed(() => props.chat.meta);
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
const backButtonUrl = computed(() => {
|
||||||
},
|
const {
|
||||||
showBackButton: {
|
params: { inbox_id: inboxId, label, teamId },
|
||||||
type: Boolean,
|
name,
|
||||||
default: false,
|
} = route;
|
||||||
},
|
return conversationListPageURL({
|
||||||
isInboxView: {
|
accountId,
|
||||||
type: Boolean,
|
inboxId,
|
||||||
default: false,
|
label,
|
||||||
},
|
teamId,
|
||||||
},
|
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
||||||
emits: ['contactPanelToggle'],
|
});
|
||||||
setup(props, { emit }) {
|
});
|
||||||
const keyboardEvents = {
|
|
||||||
'Alt+KeyO': {
|
const isHMACVerified = computed(() => {
|
||||||
action: () => emit('contactPanelToggle'),
|
if (!isAWebWidgetInbox.value) {
|
||||||
},
|
return true;
|
||||||
};
|
}
|
||||||
useKeyboardEvents(keyboardEvents);
|
return chatMetadata.value.hmac_verified;
|
||||||
},
|
});
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
const currentContact = computed(() =>
|
||||||
currentChat: 'getSelectedChat',
|
store.getters['contacts/getContact'](props.chat.meta.sender.id)
|
||||||
accountId: 'getCurrentAccountId',
|
);
|
||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
|
||||||
appIntegrations: 'integrations/getAppIntegrations',
|
const isSnoozed = computed(
|
||||||
}),
|
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
|
||||||
chatMetadata() {
|
);
|
||||||
return this.chat.meta;
|
|
||||||
},
|
const snoozedDisplayText = computed(() => {
|
||||||
backButtonUrl() {
|
const { snoozed_until: snoozedUntil } = currentChat.value;
|
||||||
const {
|
if (snoozedUntil) {
|
||||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
|
||||||
name,
|
}
|
||||||
} = this.$route;
|
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||||
return conversationListPageURL({
|
});
|
||||||
accountId,
|
|
||||||
inboxId,
|
const inbox = computed(() => {
|
||||||
label,
|
const { inbox_id: inboxId } = props.chat;
|
||||||
teamId,
|
return store.getters['inboxes/getInbox'](inboxId);
|
||||||
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
});
|
||||||
});
|
|
||||||
},
|
const hasMultipleInboxes = computed(
|
||||||
isHMACVerified() {
|
() => store.getters['inboxes/getInboxes'].length > 1
|
||||||
if (!this.isAWebWidgetInbox) {
|
);
|
||||||
return true;
|
|
||||||
}
|
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||||
return this.chatMetadata.hmac_verified;
|
|
||||||
},
|
const isLinearIntegrationEnabled = computed(() =>
|
||||||
currentContact() {
|
appIntegrations.value.find(
|
||||||
return this.$store.getters['contacts/getContact'](
|
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||||
this.chat.meta.sender.id
|
)
|
||||||
);
|
);
|
||||||
},
|
|
||||||
isSnoozed() {
|
const isLinearFeatureEnabled = computed(() =>
|
||||||
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
|
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
|
||||||
},
|
);
|
||||||
snoozedDisplayText() {
|
|
||||||
const { snoozed_until: snoozedUntil } = this.currentChat;
|
|
||||||
if (snoozedUntil) {
|
|
||||||
return `${this.$t(
|
|
||||||
'CONVERSATION.HEADER.SNOOZED_UNTIL'
|
|
||||||
)} ${snoozedReopenTime(snoozedUntil)}`;
|
|
||||||
}
|
|
||||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
|
||||||
},
|
|
||||||
contactPanelToggleText() {
|
|
||||||
return `${
|
|
||||||
this.isContactPanelOpen
|
|
||||||
? this.$t('CONVERSATION.HEADER.CLOSE')
|
|
||||||
: this.$t('CONVERSATION.HEADER.OPEN')
|
|
||||||
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
|
|
||||||
},
|
|
||||||
inbox() {
|
|
||||||
const { inbox_id: inboxId } = this.chat;
|
|
||||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
|
||||||
},
|
|
||||||
hasMultipleInboxes() {
|
|
||||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
|
||||||
},
|
|
||||||
hasSlaPolicyId() {
|
|
||||||
return this.chat?.sla_policy_id;
|
|
||||||
},
|
|
||||||
isLinearIntegrationEnabled() {
|
|
||||||
return this.appIntegrations.find(
|
|
||||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isLinearFeatureEnabled() {
|
|
||||||
return this.isFeatureEnabledonAccount(
|
|
||||||
this.accountId,
|
|
||||||
FEATURE_FLAGS.LINEAR
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
|
ref="conversationHeader"
|
||||||
|
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
|
||||||
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
|
<BackButton
|
||||||
<BackButton
|
v-if="showBackButton"
|
||||||
v-if="showBackButton"
|
:back-url="backButtonUrl"
|
||||||
:back-url="backButtonUrl"
|
class="ltr:mr-2 rtl:ml-2"
|
||||||
class="ltr:mr-2 rtl:ml-2"
|
/>
|
||||||
/>
|
<Thumbnail
|
||||||
<Thumbnail
|
:src="currentContact.thumbnail"
|
||||||
:src="currentContact.thumbnail"
|
:username="currentContact.name"
|
||||||
:badge="inboxBadge"
|
:status="currentContact.availability_status"
|
||||||
:username="currentContact.name"
|
size="32px"
|
||||||
:status="currentContact.availability_status"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
>
|
||||||
>
|
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
|
||||||
<div
|
<span
|
||||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
class="text-sm font-medium truncate leading-tight text-n-slate-12"
|
||||||
>
|
>
|
||||||
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
|
{{ currentContact.name }}
|
||||||
<span
|
</span>
|
||||||
class="text-base font-medium truncate leading-tight text-n-slate-12"
|
<fluent-icon
|
||||||
>
|
v-if="!isHMACVerified"
|
||||||
{{ currentContact.name }}
|
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
||||||
</span>
|
size="14"
|
||||||
</NextButton>
|
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
|
||||||
<fluent-icon
|
icon="warning"
|
||||||
v-if="!isHMACVerified"
|
/>
|
||||||
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
</div>
|
||||||
size="14"
|
|
||||||
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
|
|
||||||
icon="warning"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
|
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
|
||||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||||
{{ snoozedDisplayText }}
|
{{ snoozedDisplayText }}
|
||||||
</span>
|
</span>
|
||||||
<NextButton
|
|
||||||
link
|
|
||||||
xs
|
|
||||||
blue
|
|
||||||
:label="contactPanelToggleText"
|
|
||||||
@click="$emit('contactPanelToggle')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
<div
|
||||||
:class="{ 'justify-end': isContactPanelOpen }"
|
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
|
||||||
>
|
>
|
||||||
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
<SLACardLabel
|
||||||
<Linear
|
v-if="hasSlaPolicyId"
|
||||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
:chat="chat"
|
||||||
:conversation-id="currentChat.id"
|
show-extended-info
|
||||||
/>
|
:parent-width="width"
|
||||||
<MoreActions :conversation-id="currentChat.id" />
|
class="hidden lg:flex"
|
||||||
</div>
|
/>
|
||||||
|
<Linear
|
||||||
|
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||||
|
:conversation-id="currentChat.id"
|
||||||
|
:parent-width="width"
|
||||||
|
class="hidden lg:flex"
|
||||||
|
/>
|
||||||
|
<MoreActions :conversation-id="currentChat.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.conversation--header--actions {
|
|
||||||
::v-deep .inbox--name {
|
|
||||||
@apply m-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,81 +1,36 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
|
||||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
currentChat: {
|
currentChat: {
|
||||||
required: true,
|
required: true,
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['toggleContactPanel']);
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const activeTab = computed(() => {
|
||||||
|
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||||
|
|
||||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
if (isContactSidebarOpen) {
|
||||||
|
return 0;
|
||||||
const CONTACT_TABS_OPTIONS = [
|
}
|
||||||
{ key: 'CONTACT', value: 'contact' },
|
return null;
|
||||||
{ key: 'COPILOT', value: 'copilot' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabs = computed(() => {
|
|
||||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
|
||||||
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
|
|
||||||
value: tab.value,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
const activeTab = ref(0);
|
|
||||||
const toggleContactPanel = () => {
|
|
||||||
emit('toggleContactPanel');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTabChange = selectedTab => {
|
|
||||||
activeTab.value = tabs.value.findIndex(
|
|
||||||
tabItem => tabItem.value === selectedTab.value
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
|
||||||
'accounts/isFeatureEnabledonAccount'
|
|
||||||
);
|
|
||||||
|
|
||||||
const showCopilotTab = computed(() =>
|
|
||||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
||||||
>
|
>
|
||||||
<div v-if="showCopilotTab" class="p-2">
|
|
||||||
<TabBar
|
|
||||||
:tabs="tabs"
|
|
||||||
:initial-active-tab="activeTab"
|
|
||||||
class="w-full [&>button]:w-full"
|
|
||||||
@tab-changed="handleTabChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 overflow-auto">
|
<div class="flex flex-1 overflow-auto">
|
||||||
<ContactPanel
|
<ContactPanel
|
||||||
v-if="!activeTab"
|
v-show="activeTab === 0"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:inbox-id="currentChat.inbox_id"
|
:inbox-id="currentChat.inbox_id"
|
||||||
:on-toggle="toggleContactPanel"
|
|
||||||
/>
|
|
||||||
<CopilotContainer
|
|
||||||
v-else-if="activeTab === 1 && showCopilotTab"
|
|
||||||
:key="currentChat.id"
|
|
||||||
:conversation-inbox-type="channelType"
|
|
||||||
:conversation-id="currentChat.id"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -185,8 +185,17 @@ export default {
|
|||||||
contextMenuEnabledOptions() {
|
contextMenuEnabledOptions() {
|
||||||
return {
|
return {
|
||||||
copy: this.hasText,
|
copy: this.hasText,
|
||||||
delete: this.hasText || this.hasAttachments,
|
delete:
|
||||||
cannedResponse: this.isOutgoing && this.hasText,
|
(this.hasText || this.hasAttachments) &&
|
||||||
|
!this.isMessageDeleted &&
|
||||||
|
!this.isFailed,
|
||||||
|
cannedResponse:
|
||||||
|
this.isOutgoing && this.hasText && !this.isMessageDeleted,
|
||||||
|
copyLink: !this.isFailed || !this.isProcessing,
|
||||||
|
translate:
|
||||||
|
(!this.isFailed || !this.isProcessing) &&
|
||||||
|
!this.isMessageDeleted &&
|
||||||
|
this.hasText,
|
||||||
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
|
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -328,7 +337,7 @@ export default {
|
|||||||
return !this.sender.type || this.sender.type === 'agent_bot';
|
return !this.sender.type || this.sender.type === 'agent_bot';
|
||||||
},
|
},
|
||||||
shouldShowContextMenu() {
|
shouldShowContextMenu() {
|
||||||
return !(this.isFailed || this.isPending || this.isUnsupported);
|
return !this.isUnsupported;
|
||||||
},
|
},
|
||||||
showAvatar() {
|
showAvatar() {
|
||||||
if (this.isOutgoing || this.isTemplate) {
|
if (this.isOutgoing || this.isTemplate) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
import { ref, provide } from 'vue';
|
||||||
// composable
|
// composable
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
@@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
|||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
@@ -47,22 +45,11 @@ export default {
|
|||||||
ReplyBox,
|
ReplyBox,
|
||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
NextButton,
|
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
props: {
|
|
||||||
isContactPanelOpen: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isInboxView: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['contactPanelToggle'],
|
|
||||||
setup() {
|
setup() {
|
||||||
const isPopOutReplyBox = ref(false);
|
const isPopOutReplyBox = ref(false);
|
||||||
|
const conversationPanelRef = ref(null);
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
const closePopOutReplyBox = () => {
|
const closePopOutReplyBox = () => {
|
||||||
@@ -98,6 +85,8 @@ export default {
|
|||||||
FEATURE_FLAGS.CHATWOOT_V4
|
FEATURE_FLAGS.CHATWOOT_V4
|
||||||
);
|
);
|
||||||
|
|
||||||
|
provide('contextMenuElementTarget', conversationPanelRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnterprise,
|
isEnterprise,
|
||||||
isPopOutReplyBox,
|
isPopOutReplyBox,
|
||||||
@@ -108,6 +97,7 @@ export default {
|
|||||||
fetchIntegrationsIfRequired,
|
fetchIntegrationsIfRequired,
|
||||||
fetchLabelSuggestions,
|
fetchLabelSuggestions,
|
||||||
showNextBubbles,
|
showNextBubbles,
|
||||||
|
conversationPanelRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -199,12 +189,6 @@ export default {
|
|||||||
isATweet() {
|
isATweet() {
|
||||||
return this.conversationType === 'tweet';
|
return this.conversationType === 'tweet';
|
||||||
},
|
},
|
||||||
isRightOrLeftIcon() {
|
|
||||||
if (this.isContactPanelOpen) {
|
|
||||||
return 'arrow-chevron-right';
|
|
||||||
}
|
|
||||||
return 'arrow-chevron-left';
|
|
||||||
},
|
|
||||||
getLastSeenAt() {
|
getLastSeenAt() {
|
||||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||||
return contactLastSeenAt;
|
return contactLastSeenAt;
|
||||||
@@ -440,9 +424,6 @@ export default {
|
|||||||
relevantMessages
|
relevantMessages
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.$emit('contactPanelToggle');
|
|
||||||
},
|
|
||||||
setScrollParams() {
|
setScrollParams() {
|
||||||
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
||||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||||
@@ -526,21 +507,9 @@ export default {
|
|||||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end">
|
|
||||||
<NextButton
|
|
||||||
faded
|
|
||||||
xs
|
|
||||||
slate
|
|
||||||
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
|
|
||||||
:icon="
|
|
||||||
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
|
|
||||||
"
|
|
||||||
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
|
|
||||||
@click="onToggleContactPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<NextMessageList
|
<NextMessageList
|
||||||
v-if="showNextBubbles"
|
v-if="showNextBubbles"
|
||||||
|
ref="conversationPanelRef"
|
||||||
class="conversation-panel"
|
class="conversation-panel"
|
||||||
:current-user-id="currentUserId"
|
:current-user-id="currentUserId"
|
||||||
:first-unread-id="unReadMessages[0]?.id"
|
:first-unread-id="unReadMessages[0]?.id"
|
||||||
@@ -572,7 +541,7 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</NextMessageList>
|
</NextMessageList>
|
||||||
<ul v-else class="conversation-panel">
|
<ul v-else ref="conversationPanelRef" class="conversation-panel">
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||||
<li class="min-h-[4rem]">
|
<li class="min-h-[4rem]">
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { computed, onUnmounted } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import EmailTranscriptModal from './EmailTranscriptModal.vue';
|
import EmailTranscriptModal from './EmailTranscriptModal.vue';
|
||||||
import ResolveAction from '../../buttons/ResolveAction.vue';
|
import ResolveAction from '../../buttons/ResolveAction.vue';
|
||||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CMD_MUTE_CONVERSATION,
|
CMD_MUTE_CONVERSATION,
|
||||||
@@ -12,97 +16,111 @@ import {
|
|||||||
CMD_UNMUTE_CONVERSATION,
|
CMD_UNMUTE_CONVERSATION,
|
||||||
} from 'dashboard/helper/commandbar/events';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
export default {
|
// No props needed as we're getting currentChat from the store directly
|
||||||
components: {
|
const store = useStore();
|
||||||
EmailTranscriptModal,
|
const { t } = useI18n();
|
||||||
ResolveAction,
|
|
||||||
ButtonV4,
|
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
|
||||||
},
|
const [showActionsDropdown, toggleDropdown] = useToggle(false);
|
||||||
data() {
|
|
||||||
return {
|
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||||
showEmailActionsModal: false,
|
|
||||||
};
|
const actionMenuItems = computed(() => {
|
||||||
},
|
const items = [];
|
||||||
computed: {
|
|
||||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
if (!currentChat.value.muted) {
|
||||||
},
|
items.push({
|
||||||
mounted() {
|
icon: 'i-lucide-volume-off',
|
||||||
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
|
label: t('CONTACT_PANEL.MUTE_CONTACT'),
|
||||||
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
|
action: 'mute',
|
||||||
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
value: 'mute',
|
||||||
},
|
});
|
||||||
unmounted() {
|
} else {
|
||||||
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
|
items.push({
|
||||||
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
|
icon: 'i-lucide-volume-1',
|
||||||
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
|
||||||
},
|
action: 'unmute',
|
||||||
methods: {
|
value: 'unmute',
|
||||||
mute() {
|
});
|
||||||
this.$store.dispatch('muteConversation', this.currentChat.id);
|
}
|
||||||
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
|
|
||||||
},
|
items.push({
|
||||||
unmute() {
|
icon: 'i-lucide-share',
|
||||||
this.$store.dispatch('unmuteConversation', this.currentChat.id);
|
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
|
||||||
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
action: 'send_transcript',
|
||||||
},
|
value: 'send_transcript',
|
||||||
toggleEmailActionsModal() {
|
});
|
||||||
this.showEmailActionsModal = !this.showEmailActionsModal;
|
|
||||||
},
|
return items;
|
||||||
},
|
});
|
||||||
|
|
||||||
|
const handleActionClick = ({ action }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
|
||||||
|
if (action === 'mute') {
|
||||||
|
store.dispatch('muteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
|
||||||
|
} else if (action === 'unmute') {
|
||||||
|
store.dispatch('unmuteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
||||||
|
} else if (action === 'send_transcript') {
|
||||||
|
toggleEmailModal();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These functions are needed for the event listeners
|
||||||
|
const mute = () => {
|
||||||
|
store.dispatch('muteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmute = () => {
|
||||||
|
store.dispatch('unmuteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.on(CMD_MUTE_CONVERSATION, mute);
|
||||||
|
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
|
||||||
|
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emitter.off(CMD_MUTE_CONVERSATION, mute);
|
||||||
|
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
|
||||||
|
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex items-center gap-2 actions--container">
|
<div class="relative flex items-center gap-2 actions--container">
|
||||||
<ButtonV4
|
|
||||||
v-if="!currentChat.muted"
|
|
||||||
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-volume-off"
|
|
||||||
@click="mute"
|
|
||||||
/>
|
|
||||||
<ButtonV4
|
|
||||||
v-else
|
|
||||||
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-volume-1"
|
|
||||||
@click="unmute"
|
|
||||||
/>
|
|
||||||
<ButtonV4
|
|
||||||
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-share"
|
|
||||||
@click="toggleEmailActionsModal"
|
|
||||||
/>
|
|
||||||
<ResolveAction
|
<ResolveAction
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:status="currentChat.status"
|
:status="currentChat.status"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<ButtonV4
|
||||||
|
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
icon="i-lucide-more-vertical"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showActionsDropdown"
|
||||||
|
:menu-items="actionMenuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||||
|
@action="handleActionClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<EmailTranscriptModal
|
<EmailTranscriptModal
|
||||||
v-if="showEmailActionsModal"
|
v-if="showEmailActionsModal"
|
||||||
:show="showEmailActionsModal"
|
:show="showEmailActionsModal"
|
||||||
:current-chat="currentChat"
|
:current-chat="currentChat"
|
||||||
@cancel="toggleEmailActionsModal"
|
@cancel="toggleEmailModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.more--button {
|
|
||||||
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-pane {
|
|
||||||
@apply -right-2 top-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+97
-114
@@ -1,132 +1,115 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
showExtendedInfo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
parentWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 60000;
|
const REFRESH_INTERVAL = 60000;
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
export default {
|
const timer = ref(null);
|
||||||
components: {
|
const slaStatus = ref({
|
||||||
SLAPopoverCard,
|
threshold: null,
|
||||||
},
|
isSlaMissed: false,
|
||||||
props: {
|
type: null,
|
||||||
chat: {
|
icon: null,
|
||||||
type: Object,
|
});
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
showExtendedInfo: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
timer: null,
|
|
||||||
showSlaPopover: false,
|
|
||||||
slaStatus: {
|
|
||||||
threshold: null,
|
|
||||||
isSlaMissed: false,
|
|
||||||
type: null,
|
|
||||||
icon: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
slaPolicyId() {
|
|
||||||
return this.chat?.sla_policy_id;
|
|
||||||
},
|
|
||||||
appliedSLA() {
|
|
||||||
return this.chat?.applied_sla;
|
|
||||||
},
|
|
||||||
slaEvents() {
|
|
||||||
return this.chat?.sla_events;
|
|
||||||
},
|
|
||||||
hasSlaThreshold() {
|
|
||||||
return this.slaStatus?.threshold;
|
|
||||||
},
|
|
||||||
isSlaMissed() {
|
|
||||||
return this.slaStatus?.isSlaMissed;
|
|
||||||
},
|
|
||||||
slaTextStyles() {
|
|
||||||
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
|
|
||||||
},
|
|
||||||
slaStatusText() {
|
|
||||||
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
|
|
||||||
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
|
|
||||||
|
|
||||||
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||||
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
const slaEvents = computed(() => props.chat?.sla_events);
|
||||||
});
|
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||||
},
|
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||||
showSlaPopoverCard() {
|
const slaTextStyles = computed(() =>
|
||||||
return (
|
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
|
||||||
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
|
);
|
||||||
);
|
|
||||||
},
|
const slaStatusText = computed(() => {
|
||||||
},
|
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||||
watch: {
|
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
|
||||||
chat() {
|
|
||||||
this.updateSlaStatus();
|
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||||
},
|
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||||
},
|
});
|
||||||
mounted() {
|
});
|
||||||
this.updateSlaStatus();
|
|
||||||
this.createTimer();
|
const showSlaPopoverCard = computed(
|
||||||
},
|
() => props.showExtendedInfo && slaEvents.value?.length > 0
|
||||||
unmounted() {
|
);
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
const groupClass = computed(() => {
|
||||||
}
|
return props.showExtendedInfo
|
||||||
},
|
? 'h-[26px] rounded-lg bg-n-alpha-1'
|
||||||
methods: {
|
: 'rounded h-5 border border-n-strong';
|
||||||
createTimer() {
|
});
|
||||||
this.timer = setTimeout(() => {
|
|
||||||
this.updateSlaStatus();
|
const updateSlaStatus = () => {
|
||||||
this.createTimer();
|
slaStatus.value = evaluateSLAStatus({
|
||||||
}, REFRESH_INTERVAL);
|
appliedSla: appliedSLA.value,
|
||||||
},
|
chat: props.chat,
|
||||||
updateSlaStatus() {
|
});
|
||||||
this.slaStatus = evaluateSLAStatus({
|
|
||||||
appliedSla: this.appliedSLA,
|
|
||||||
chat: this.chat,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openSlaPopover() {
|
|
||||||
if (!this.showExtendedInfo) return;
|
|
||||||
this.showSlaPopover = true;
|
|
||||||
},
|
|
||||||
closeSlaPopover() {
|
|
||||||
this.showSlaPopover = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createTimer = () => {
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
createTimer();
|
||||||
|
}, REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.chat,
|
||||||
|
() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const slaPopoverClass = computed(() => {
|
||||||
|
return props.showExtendedInfo
|
||||||
|
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
createTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="hasSlaThreshold"
|
v-if="hasSlaThreshold"
|
||||||
class="relative flex items-center cursor-pointer min-w-fit"
|
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||||
:class="
|
:class="groupClass"
|
||||||
showExtendedInfo
|
|
||||||
? 'h-[26px] rounded-lg bg-n-alpha-1'
|
|
||||||
: 'rounded h-5 border border-n-strong'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="closeSlaPopover"
|
class="flex items-center w-full truncate px-1.5"
|
||||||
class="flex items-center w-full truncate"
|
:class="showExtendedInfo ? '' : 'gap-1'"
|
||||||
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
|
|
||||||
@mouseover="openSlaPopover()"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex items-center gap-1" :class="slaPopoverClass">
|
||||||
class="flex items-center gap-1"
|
|
||||||
:class="
|
|
||||||
showExtendedInfo &&
|
|
||||||
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
size="14"
|
size="12"
|
||||||
:icon="slaStatus.icon"
|
:icon="slaStatus.icon"
|
||||||
type="outline"
|
type="outline"
|
||||||
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
||||||
@@ -134,7 +117,7 @@ export default {
|
|||||||
:class="slaTextStyles"
|
:class="slaTextStyles"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="showExtendedInfo"
|
v-if="showExtendedInfo && parentWidth > 650"
|
||||||
class="text-xs font-medium"
|
class="text-xs font-medium"
|
||||||
:class="slaTextStyles"
|
:class="slaTextStyles"
|
||||||
>
|
>
|
||||||
@@ -151,7 +134,7 @@ export default {
|
|||||||
<SLAPopoverCard
|
<SLAPopoverCard
|
||||||
v-if="showSlaPopoverCard"
|
v-if="showSlaPopoverCard"
|
||||||
:sla-missed-events="slaEvents"
|
:sla-missed-events="slaEvents"
|
||||||
class="right-0 top-7"
|
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const props = defineProps({
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
parentWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 10000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowIssueIdentifier = computed(() => {
|
||||||
|
if (!linkedIssue.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.parentWidth > 600;
|
||||||
|
});
|
||||||
|
|
||||||
const openIssue = () => {
|
const openIssue = () => {
|
||||||
if (!linkedIssue.value) shouldShowPopup.value = true;
|
if (!linkedIssue.value) shouldShowPopup.value = true;
|
||||||
shouldShow.value = true;
|
shouldShow.value = true;
|
||||||
@@ -119,7 +131,10 @@ onMounted(() => {
|
|||||||
class="text-[#5E6AD2] flex-shrink-0"
|
class="text-[#5E6AD2] flex-shrink-0"
|
||||||
view-box="0 0 19 19"
|
view-box="0 0 19 19"
|
||||||
/>
|
/>
|
||||||
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
|
<span
|
||||||
|
v-if="shouldShowIssueIdentifier"
|
||||||
|
class="text-xs font-medium text-n-slate-11"
|
||||||
|
>
|
||||||
{{ linkedIssue.issue.identifier }}
|
{{ linkedIssue.issue.identifier }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -127,7 +142,7 @@ onMounted(() => {
|
|||||||
v-if="linkedIssue"
|
v-if="linkedIssue"
|
||||||
:issue="linkedIssue.issue"
|
:issue="linkedIssue.issue"
|
||||||
:link-id="linkedIssue.id"
|
:link-id="linkedIssue.id"
|
||||||
class="absolute right-0 top-[36px] invisible group-hover:visible"
|
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
|
||||||
@unlink-issue="unlinkIssue"
|
@unlink-issue="unlinkIssue"
|
||||||
/>
|
/>
|
||||||
<woot-modal
|
<woot-modal
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
|
||||||
import { createStore } from 'vuex';
|
|
||||||
import MoreActions from '../MoreActions.vue';
|
|
||||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
|
||||||
|
|
||||||
vi.mock('shared/helpers/mitt', () => ({
|
|
||||||
emitter: {
|
|
||||||
emit: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
off: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockDirective = {
|
|
||||||
mounted: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
|
||||||
|
|
||||||
describe('MoveActions', () => {
|
|
||||||
let currentChat = { id: 8, muted: false };
|
|
||||||
let store = null;
|
|
||||||
let muteConversation = null;
|
|
||||||
let unmuteConversation = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
muteConversation = vi.fn(() => Promise.resolve());
|
|
||||||
unmuteConversation = vi.fn(() => Promise.resolve());
|
|
||||||
|
|
||||||
store = createStore({
|
|
||||||
state: {
|
|
||||||
authenticated: true,
|
|
||||||
currentChat,
|
|
||||||
},
|
|
||||||
getters: {
|
|
||||||
getSelectedChat: () => currentChat,
|
|
||||||
},
|
|
||||||
modules: {
|
|
||||||
conversations: {
|
|
||||||
namespaced: false,
|
|
||||||
actions: { muteConversation, unmuteConversation },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const createWrapper = () =>
|
|
||||||
mount(MoreActions, {
|
|
||||||
global: {
|
|
||||||
plugins: [store],
|
|
||||||
components: {
|
|
||||||
'fluent-icon': FluentIcon,
|
|
||||||
},
|
|
||||||
directives: {
|
|
||||||
'on-clickaway': mockDirective,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('muting discussion', () => {
|
|
||||||
it('triggers "muteConversation"', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(muteConversation).toHaveBeenCalledTimes(1);
|
|
||||||
expect(muteConversation).toHaveBeenCalledWith(
|
|
||||||
expect.any(Object), // First argument is the Vuex context object
|
|
||||||
currentChat.id // Second argument is the ID of the conversation
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows alert', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(emitter.emit).toBeCalledWith('newToastMessage', {
|
|
||||||
message:
|
|
||||||
'This contact is blocked successfully. You will not be notified of any future conversations.',
|
|
||||||
action: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unmuting discussion', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
currentChat.muted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('triggers "unmuteConversation"', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(unmuteConversation).toHaveBeenCalledTimes(1);
|
|
||||||
expect(unmuteConversation).toHaveBeenCalledWith(
|
|
||||||
expect.any(Object), // First argument is the Vuex context object
|
|
||||||
currentChat.id // Second argument is the ID of the conversation
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows alert', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(emitter.emit).toBeCalledWith('newToastMessage', {
|
|
||||||
message: 'This contact is unblocked successfully.',
|
|
||||||
action: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -43,7 +43,7 @@ describe('useFontSize', () => {
|
|||||||
|
|
||||||
it('returns fontSizeOptions with correct structure', () => {
|
it('returns fontSizeOptions with correct structure', () => {
|
||||||
const { fontSizeOptions } = useFontSize();
|
const { fontSizeOptions } = useFontSize();
|
||||||
expect(fontSizeOptions).toHaveLength(6);
|
expect(fontSizeOptions).toHaveLength(5);
|
||||||
expect(fontSizeOptions[0]).toHaveProperty('value');
|
expect(fontSizeOptions[0]).toHaveProperty('value');
|
||||||
expect(fontSizeOptions[0]).toHaveProperty('label');
|
expect(fontSizeOptions[0]).toHaveProperty('label');
|
||||||
|
|
||||||
@@ -59,12 +59,6 @@ describe('useFontSize', () => {
|
|||||||
label:
|
label:
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
|
|
||||||
value: '22px',
|
|
||||||
label:
|
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns currentFontSize from UI settings', () => {
|
it('returns currentFontSize from UI settings', () => {
|
||||||
@@ -84,9 +78,6 @@ describe('useFontSize', () => {
|
|||||||
applyFontSize('14px');
|
applyFontSize('14px');
|
||||||
expect(document.documentElement.style.fontSize).toBe('14px');
|
expect(document.documentElement.style.fontSize).toBe('14px');
|
||||||
|
|
||||||
applyFontSize('22px');
|
|
||||||
expect(document.documentElement.style.fontSize).toBe('22px');
|
|
||||||
|
|
||||||
applyFontSize('16px');
|
applyFontSize('16px');
|
||||||
expect(document.documentElement.style.fontSize).toBe('16px');
|
expect(document.documentElement.style.fontSize).toBe('16px');
|
||||||
});
|
});
|
||||||
@@ -145,8 +136,6 @@ describe('useFontSize', () => {
|
|||||||
'Smaller',
|
'Smaller',
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
|
||||||
'Default',
|
'Default',
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
|
|
||||||
'Extra Large',
|
|
||||||
};
|
};
|
||||||
return translations[key] || key;
|
return translations[key] || key;
|
||||||
});
|
});
|
||||||
@@ -160,9 +149,6 @@ describe('useFontSize', () => {
|
|||||||
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
|
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
|
||||||
'Default'
|
'Default'
|
||||||
);
|
);
|
||||||
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
|
|
||||||
'Extra Large'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify translation function was called with correct keys
|
// Verify translation function was called with correct keys
|
||||||
expect(mockTranslate).toHaveBeenCalledWith(
|
expect(mockTranslate).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useImpersonation } from '../useImpersonation';
|
||||||
|
|
||||||
|
vi.mock('shared/helpers/sessionStorage', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
|
|
||||||
|
describe('useImpersonation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if impersonation flag is set in session storage', () => {
|
||||||
|
SessionStorage.get.mockReturnValue(true);
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
expect(isImpersonating.value).toBe(true);
|
||||||
|
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||||
|
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if impersonation flag is not set in session storage', () => {
|
||||||
|
SessionStorage.get.mockReturnValue(false);
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
expect(isImpersonating.value).toBe(false);
|
||||||
|
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||||
|
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
|
|||||||
DEFAULT: '16px',
|
DEFAULT: '16px',
|
||||||
LARGE: '18px',
|
LARGE: '18px',
|
||||||
LARGER: '20px',
|
LARGER: '20px',
|
||||||
EXTRA_LARGE: '22px',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||||
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||||
|
|
||||||
|
export function useImpersonation() {
|
||||||
|
const isImpersonating = computed(() => {
|
||||||
|
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||||
|
});
|
||||||
|
return { isImpersonating };
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ export function usePolicy() {
|
|||||||
return {
|
return {
|
||||||
checkPermissions,
|
checkPermissions,
|
||||||
shouldShowPaywall,
|
shouldShowPaywall,
|
||||||
|
isFeatureFlagEnabled,
|
||||||
shouldShow,
|
shouldShow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const SESSION_STORAGE_KEYS = {
|
||||||
|
IMPERSONATION_USER: 'impersonationUser',
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
|
|||||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
|
|
||||||
|
const { isImpersonating } = useImpersonation();
|
||||||
|
|
||||||
class ActionCableConnector extends BaseActionCableConnector {
|
class ActionCableConnector extends BaseActionCableConnector {
|
||||||
constructor(app, pubsubToken) {
|
constructor(app, pubsubToken) {
|
||||||
@@ -30,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
'conversation.read': this.onConversationRead,
|
'conversation.read': this.onConversationRead,
|
||||||
'conversation.updated': this.onConversationUpdated,
|
'conversation.updated': this.onConversationUpdated,
|
||||||
'account.cache_invalidated': this.onCacheInvalidate,
|
'account.cache_invalidated': this.onCacheInvalidate,
|
||||||
|
'copilot.message.created': this.onCopilotMessageCreated,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +56,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onPresenceUpdate = data => {
|
onPresenceUpdate = data => {
|
||||||
|
if (isImpersonating.value) return;
|
||||||
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
|
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
|
||||||
this.app.$store.dispatch('agents/updatePresence', data.users);
|
this.app.$store.dispatch('agents/updatePresence', data.users);
|
||||||
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
|
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
|
||||||
@@ -185,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.app.$store.dispatch('notifications/updateNotification', data);
|
this.app.$store.dispatch('notifications/updateNotification', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onCopilotMessageCreated = data => {
|
||||||
|
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||||
|
};
|
||||||
|
|
||||||
onCacheInvalidate = data => {
|
onCacheInvalidate = data => {
|
||||||
const keys = data.cache_keys;
|
const keys = data.cache_keys;
|
||||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||||
|
import ActionCableConnector from '../actionCable';
|
||||||
|
|
||||||
|
vi.mock('shared/helpers/mitt', () => ({
|
||||||
|
emitter: {
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/useImpersonation', () => ({
|
||||||
|
useImpersonation: () => ({
|
||||||
|
isImpersonating: { value: false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.chatwootConfig = {
|
||||||
|
websocketURL: 'wss://test.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ActionCableConnector - Copilot Tests', () => {
|
||||||
|
let store;
|
||||||
|
let actionCable;
|
||||||
|
let mockDispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDispatch = vi.fn();
|
||||||
|
store = {
|
||||||
|
$store: {
|
||||||
|
dispatch: mockDispatch,
|
||||||
|
getters: {
|
||||||
|
getCurrentAccountId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||||
|
});
|
||||||
|
describe('copilot event handlers', () => {
|
||||||
|
it('should register the copilot.message.created event handler', () => {
|
||||||
|
expect(Object.keys(actionCable.events)).toContain(
|
||||||
|
'copilot.message.created'
|
||||||
|
);
|
||||||
|
expect(actionCable.events['copilot.message.created']).toBe(
|
||||||
|
actionCable.onCopilotMessageCreated
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the copilot.message.created event through the ActionCable system', () => {
|
||||||
|
const copilotData = {
|
||||||
|
id: 2,
|
||||||
|
content: 'This is a copilot message from ActionCable',
|
||||||
|
conversation_id: 456,
|
||||||
|
created_at: '2025-05-27T15:58:04-06:00',
|
||||||
|
account_id: 1,
|
||||||
|
};
|
||||||
|
actionCable.onReceived({
|
||||||
|
event: 'copilot.message.created',
|
||||||
|
data: copilotData,
|
||||||
|
});
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith(
|
||||||
|
'copilotMessages/upsert',
|
||||||
|
copilotData
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,13 @@
|
|||||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ACCESS_TOKEN": {
|
||||||
|
"TITLE": "Access Token",
|
||||||
|
"DESCRIPTION": "Copy the access token and save it securely",
|
||||||
|
"COPY_SUCCESSFUL": "Access token copied to clipboard",
|
||||||
|
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||||
|
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||||
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"AVATAR": {
|
"AVATAR": {
|
||||||
"LABEL": "Bot avatar"
|
"LABEL": "Bot avatar"
|
||||||
|
|||||||
@@ -458,6 +458,10 @@
|
|||||||
"PLACEHOLDER": "Add Twitter"
|
"PLACEHOLDER": "Add Twitter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"DELETE_CONTACT": {
|
||||||
|
"MESSAGE": "This action is permanent and irreversible.",
|
||||||
|
"BUTTON": "Delete now"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DETAILS": {
|
"DETAILS": {
|
||||||
@@ -467,7 +471,7 @@
|
|||||||
"DELETE_CONTACT": "Delete contact",
|
"DELETE_CONTACT": "Delete contact",
|
||||||
"DELETE_DIALOG": {
|
"DELETE_DIALOG": {
|
||||||
"TITLE": "Confirm Deletion",
|
"TITLE": "Confirm Deletion",
|
||||||
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?",
|
"DESCRIPTION": "Are you sure you want to delete this contact?",
|
||||||
"CONFIRM": "Yes, Delete",
|
"CONFIRM": "Yes, Delete",
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
"REOPEN_ACTION": "Reopen",
|
"REOPEN_ACTION": "Reopen",
|
||||||
"OPEN_ACTION": "Open",
|
"OPEN_ACTION": "Open",
|
||||||
|
"MORE_ACTIONS": "More actions",
|
||||||
"OPEN": "More",
|
"OPEN": "More",
|
||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"DETAILS": "details",
|
"DETAILS": "details",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"PHONE_INPUT": {
|
"PHONE_INPUT": {
|
||||||
"PLACEHOLDER": "Search",
|
"PLACEHOLDER": "Search",
|
||||||
"EMPTY_STATE": "No results found"
|
"EMPTY_STATE": "No results found"
|
||||||
}
|
},
|
||||||
|
"CLOSE": "Close"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,12 +327,17 @@
|
|||||||
"NAME": "Captain",
|
"NAME": "Captain",
|
||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
|
"TITLE": "Copilot",
|
||||||
|
"TRY_THESE_PROMPTS": "Try these prompts",
|
||||||
|
"PANEL_TITLE": "Get started with Copilot",
|
||||||
|
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilot’s here to speed things up.",
|
||||||
"SEND_MESSAGE": "Send message...",
|
"SEND_MESSAGE": "Send message...",
|
||||||
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"USE": "Use this",
|
"USE": "Use this",
|
||||||
"RESET": "Reset",
|
"RESET": "Reset",
|
||||||
|
"SHOW_STEPS": "Show steps",
|
||||||
"SELECT_ASSISTANT": "Select Assistant",
|
"SELECT_ASSISTANT": "Select Assistant",
|
||||||
"PROMPTS": {
|
"PROMPTS": {
|
||||||
"SUMMARIZE": {
|
"SUMMARIZE": {
|
||||||
@@ -346,6 +351,14 @@
|
|||||||
"RATE": {
|
"RATE": {
|
||||||
"LABEL": "Rate this conversation",
|
"LABEL": "Rate this conversation",
|
||||||
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
|
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
|
||||||
|
},
|
||||||
|
"HIGH_PRIORITY": {
|
||||||
|
"LABEL": "High priority conversations",
|
||||||
|
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
|
||||||
|
},
|
||||||
|
"LIST_CONTACTS": {
|
||||||
|
"LABEL": "List contacts",
|
||||||
|
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -365,7 +378,6 @@
|
|||||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||||
},
|
},
|
||||||
"ENTERPRISE_PAYWALL": {
|
"ENTERPRISE_PAYWALL": {
|
||||||
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
|
|
||||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||||
},
|
},
|
||||||
@@ -380,6 +392,7 @@
|
|||||||
},
|
},
|
||||||
"ASSISTANTS": {
|
"ASSISTANTS": {
|
||||||
"HEADER": "Assistants",
|
"HEADER": "Assistants",
|
||||||
|
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
|
||||||
"ADD_NEW": "Create a new assistant",
|
"ADD_NEW": "Create a new assistant",
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Are you sure to delete the assistant?",
|
"TITLE": "Are you sure to delete the assistant?",
|
||||||
@@ -408,6 +421,10 @@
|
|||||||
"PLACEHOLDER": "Enter assistant name",
|
"PLACEHOLDER": "Enter assistant name",
|
||||||
"ERROR": "The name is required"
|
"ERROR": "The name is required"
|
||||||
},
|
},
|
||||||
|
"TEMPERATURE": {
|
||||||
|
"LABEL": "Response Temperature",
|
||||||
|
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
|
||||||
|
},
|
||||||
"DESCRIPTION": {
|
"DESCRIPTION": {
|
||||||
"LABEL": "Description",
|
"LABEL": "Description",
|
||||||
"PLACEHOLDER": "Enter assistant description",
|
"PLACEHOLDER": "Enter assistant description",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"ALL": "All",
|
"ALL": "All",
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages",
|
||||||
|
"ARTICLES": "Articles"
|
||||||
},
|
},
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages",
|
||||||
|
"ARTICLES": "Articles"
|
||||||
},
|
},
|
||||||
"VIEW_MORE": "View more",
|
"VIEW_MORE": "View more",
|
||||||
"LOAD_MORE": "Load more",
|
"LOAD_MORE": "Load more",
|
||||||
|
|||||||
@@ -76,7 +76,12 @@
|
|||||||
"ACCESS_TOKEN": {
|
"ACCESS_TOKEN": {
|
||||||
"TITLE": "Access Token",
|
"TITLE": "Access Token",
|
||||||
"NOTE": "This token can be used if you are building an API based integration",
|
"NOTE": "This token can be used if you are building an API based integration",
|
||||||
"COPY": "Copy"
|
"COPY": "Copy",
|
||||||
|
"RESET": "Reset",
|
||||||
|
"CONFIRM_RESET": "Are you sure?",
|
||||||
|
"CONFIRM_HINT": "Click again to confirm",
|
||||||
|
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||||
|
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||||
},
|
},
|
||||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||||
"TITLE": "Audio Alerts",
|
"TITLE": "Audio Alerts",
|
||||||
@@ -185,7 +190,8 @@
|
|||||||
"OFFLINE": "Offline"
|
"OFFLINE": "Offline"
|
||||||
},
|
},
|
||||||
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
|
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
|
||||||
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
|
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
|
||||||
|
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
|
||||||
},
|
},
|
||||||
"EMAIL": {
|
"EMAIL": {
|
||||||
"LABEL": "Your email address",
|
"LABEL": "Your email address",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
emits: ['open', 'close', 'replyTo'],
|
emits: ['open', 'close', 'replyTo'],
|
||||||
setup() {
|
setup() {
|
||||||
const { getPlainText } = useMessageFormatter();
|
const { getPlainText } = useMessageFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPlainText,
|
getPlainText,
|
||||||
};
|
};
|
||||||
@@ -167,7 +168,7 @@ export default {
|
|||||||
</woot-modal>
|
</woot-modal>
|
||||||
<!-- Confirm Deletion -->
|
<!-- Confirm Deletion -->
|
||||||
<woot-delete-modal
|
<woot-delete-modal
|
||||||
v-if="showDeleteModal"
|
v-if="showDeleteModal && enabledOptions['delete']"
|
||||||
v-model:show="showDeleteModal"
|
v-model:show="showDeleteModal"
|
||||||
class="context-menu--delete-modal"
|
class="context-menu--delete-modal"
|
||||||
:on-close="closeDeleteModal"
|
:on-close="closeDeleteModal"
|
||||||
@@ -212,7 +213,7 @@ export default {
|
|||||||
@click.stop="handleCopy"
|
@click.stop="handleCopy"
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-if="enabledOptions['copy']"
|
v-if="enabledOptions['translate']"
|
||||||
:option="{
|
:option="{
|
||||||
icon: 'translate',
|
icon: 'translate',
|
||||||
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
|
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
|
||||||
@@ -222,6 +223,7 @@ export default {
|
|||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
v-if="enabledOptions['copyLink']"
|
||||||
:option="{
|
:option="{
|
||||||
icon: 'link',
|
icon: 'link',
|
||||||
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
|
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
|
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: { type: [String, Number], default: 0 },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
category: { type: String, default: '' },
|
||||||
|
locale: { type: String, default: '' },
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
portalSlug: { type: String, required: true },
|
||||||
|
accountId: { type: [String, Number], default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_LENGTH = 300;
|
||||||
|
|
||||||
|
const navigateTo = computed(() => {
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncatedContent = computed(() => {
|
||||||
|
if (!props.content) return props.description || '';
|
||||||
|
|
||||||
|
// Use MessageFormatter to properly convert markdown to plain text
|
||||||
|
const formatter = new MessageFormatter(props.content);
|
||||||
|
const plainText = formatter.plainText.trim();
|
||||||
|
|
||||||
|
return plainText.length > MAX_LENGTH
|
||||||
|
? `${plainText.substring(0, MAX_LENGTH)}...`
|
||||||
|
: plainText;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-link
|
||||||
|
:to="navigateTo"
|
||||||
|
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
|
||||||
|
</div>
|
||||||
|
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
|
||||||
|
{{ title }}
|
||||||
|
</h5>
|
||||||
|
<span
|
||||||
|
v-if="category"
|
||||||
|
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="truncatedContent"
|
||||||
|
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ truncatedContent }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
|
import SearchResultArticleItem from './SearchResultArticleItem.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
articles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isFetching: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SearchResultSection
|
||||||
|
:title="$t('SEARCH.SECTION.ARTICLES')"
|
||||||
|
:empty="!articles.length"
|
||||||
|
:query="query"
|
||||||
|
:show-title="showTitle"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
>
|
||||||
|
<ul v-if="articles.length" class="space-y-1.5 list-none">
|
||||||
|
<li v-for="article in articles" :key="article.id">
|
||||||
|
<SearchResultArticleItem
|
||||||
|
:id="article.id"
|
||||||
|
:title="article.title"
|
||||||
|
:description="article.description"
|
||||||
|
:content="article.content"
|
||||||
|
:portal-slug="article.portal_slug"
|
||||||
|
:locale="article.locale"
|
||||||
|
:account-id="accountId"
|
||||||
|
:category="article.category_name"
|
||||||
|
:status="article.status"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</SearchResultSection>
|
||||||
|
</template>
|
||||||
@@ -8,11 +8,10 @@ import {
|
|||||||
ROLES,
|
ROLES,
|
||||||
CONVERSATION_PERMISSIONS,
|
CONVERSATION_PERMISSIONS,
|
||||||
CONTACT_PERMISSIONS,
|
CONTACT_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
} from 'dashboard/constants/permissions.js';
|
} from 'dashboard/constants/permissions.js';
|
||||||
import {
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
getUserPermissions,
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
filterItemsByPermission,
|
|
||||||
} from 'dashboard/helper/permissionsHelper.js';
|
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
@@ -22,6 +21,7 @@ import SearchTabs from './SearchTabs.vue';
|
|||||||
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
||||||
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
||||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||||
|
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -34,15 +34,15 @@ const pages = ref({
|
|||||||
contacts: 1,
|
contacts: 1,
|
||||||
conversations: 1,
|
conversations: 1,
|
||||||
messages: 1,
|
messages: 1,
|
||||||
|
articles: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
|
||||||
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
||||||
const conversationRecords = useMapGetter(
|
const conversationRecords = useMapGetter(
|
||||||
'conversationSearch/getConversationRecords'
|
'conversationSearch/getConversationRecords'
|
||||||
);
|
);
|
||||||
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
||||||
|
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
||||||
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
||||||
|
|
||||||
const addTypeToRecords = (records, type) =>
|
const addTypeToRecords = (records, type) =>
|
||||||
@@ -57,6 +57,9 @@ const mappedConversations = computed(() =>
|
|||||||
const mappedMessages = computed(() =>
|
const mappedMessages = computed(() =>
|
||||||
addTypeToRecords(messageRecords, 'message')
|
addTypeToRecords(messageRecords, 'message')
|
||||||
);
|
);
|
||||||
|
const mappedArticles = computed(() =>
|
||||||
|
addTypeToRecords(articleRecords, 'article')
|
||||||
|
);
|
||||||
|
|
||||||
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ const sliceRecordsIfAllTab = items =>
|
|||||||
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
||||||
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
||||||
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
||||||
|
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
||||||
|
|
||||||
const filterByTab = tab =>
|
const filterByTab = tab =>
|
||||||
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
||||||
@@ -73,14 +77,18 @@ const filterByTab = tab =>
|
|||||||
const filterContacts = filterByTab('contacts');
|
const filterContacts = filterByTab('contacts');
|
||||||
const filterConversations = filterByTab('conversations');
|
const filterConversations = filterByTab('conversations');
|
||||||
const filterMessages = filterByTab('messages');
|
const filterMessages = filterByTab('messages');
|
||||||
|
const filterArticles = filterByTab('articles');
|
||||||
|
|
||||||
const userPermissions = computed(() =>
|
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
|
||||||
getUserPermissions(currentUser.value, currentAccountId.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const TABS_CONFIG = {
|
const TABS_CONFIG = {
|
||||||
all: {
|
all: {
|
||||||
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [
|
||||||
|
CONTACT_PERMISSIONS,
|
||||||
|
...ROLES,
|
||||||
|
...CONVERSATION_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
],
|
||||||
count: () => null, // No count for all tab
|
count: () => null, // No count for all tab
|
||||||
},
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
@@ -95,41 +103,69 @@ const TABS_CONFIG = {
|
|||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => mappedMessages.value.length,
|
count: () => mappedMessages.value.length,
|
||||||
},
|
},
|
||||||
|
articles: {
|
||||||
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||||
|
count: () => mappedArticles.value.length,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
|
return Object.entries(TABS_CONFIG)
|
||||||
key,
|
.map(([key, config]) => ({
|
||||||
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
key,
|
||||||
count: config.count(),
|
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
||||||
showBadge: key !== 'all',
|
count: config.count(),
|
||||||
permissions: config.permissions,
|
showBadge: key !== 'all',
|
||||||
}));
|
permissions: config.permissions,
|
||||||
|
featureFlag: config.featureFlag,
|
||||||
return filterItemsByPermission(
|
}))
|
||||||
configs,
|
.filter(config => {
|
||||||
userPermissions.value,
|
// why the double check, glad you asked.
|
||||||
item => item.permissions
|
// Some features are marked as premium features, that means
|
||||||
);
|
// the feature will be visible, but a Paywall will be shown instead
|
||||||
|
// this works for pages and routes, but fails for UI elements like search here
|
||||||
|
// so we explicitly check if the feature is enabled
|
||||||
|
return (
|
||||||
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
||||||
|
isFeatureFlagEnabled(config.featureFlag)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSearchResultsCount = computed(() => {
|
const totalSearchResultsCount = computed(() => {
|
||||||
const permissionCounts = {
|
const permissionCounts = [
|
||||||
contacts: {
|
{
|
||||||
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||||
count: () => contacts.value.length,
|
count: () => contacts.value.length,
|
||||||
},
|
},
|
||||||
conversations: {
|
{
|
||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => conversations.value.length + messages.value.length,
|
count: () => conversations.value.length + messages.value.length,
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
return filterItemsByPermission(
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
permissionCounts,
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||||
userPermissions.value,
|
count: () => articles.value.length,
|
||||||
item => item.permissions,
|
},
|
||||||
(_, item) => item.count
|
];
|
||||||
).reduce((total, count) => total + count(), 0);
|
|
||||||
|
return permissionCounts
|
||||||
|
.filter(config => {
|
||||||
|
// why the double check, glad you asked.
|
||||||
|
// Some features are marked as premium features, that means
|
||||||
|
// the feature will be visible, but a Paywall will be shown instead
|
||||||
|
// this works for pages and routes, but fails for UI elements like search here
|
||||||
|
// so we explicitly check if the feature is enabled
|
||||||
|
return (
|
||||||
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
||||||
|
isFeatureFlagEnabled(config.featureFlag)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(config => {
|
||||||
|
return config.count();
|
||||||
|
})
|
||||||
|
.reduce((sum, count) => sum + count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTabIndex = computed(() => {
|
const activeTabIndex = computed(() => {
|
||||||
@@ -138,12 +174,13 @@ const activeTabIndex = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFetchingAny = computed(() => {
|
const isFetchingAny = computed(() => {
|
||||||
const { contact, message, conversation, isFetching } = uiFlags.value;
|
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
||||||
return (
|
return (
|
||||||
isFetching ||
|
isFetching ||
|
||||||
contact.isFetching ||
|
contact.isFetching ||
|
||||||
message.isFetching ||
|
message.isFetching ||
|
||||||
conversation.isFetching
|
conversation.isFetching ||
|
||||||
|
article.isFetching
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +208,7 @@ const showLoadMore = computed(() => {
|
|||||||
contacts: mappedContacts.value,
|
contacts: mappedContacts.value,
|
||||||
conversations: mappedConversations.value,
|
conversations: mappedConversations.value,
|
||||||
messages: mappedMessages.value,
|
messages: mappedMessages.value,
|
||||||
|
articles: mappedArticles.value,
|
||||||
}[selectedTab.value];
|
}[selectedTab.value];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,10 +223,11 @@ const showViewMore = computed(() => ({
|
|||||||
conversations:
|
conversations:
|
||||||
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
||||||
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
||||||
|
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const clearSearchResult = () => {
|
const clearSearchResult = () => {
|
||||||
pages.value = { contacts: 1, conversations: 1, messages: 1 };
|
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
||||||
store.dispatch('conversationSearch/clearSearchResults');
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +253,7 @@ const loadMore = () => {
|
|||||||
contacts: 'conversationSearch/contactSearch',
|
contacts: 'conversationSearch/contactSearch',
|
||||||
conversations: 'conversationSearch/conversationSearch',
|
conversations: 'conversationSearch/conversationSearch',
|
||||||
messages: 'conversationSearch/messageSearch',
|
messages: 'conversationSearch/messageSearch',
|
||||||
|
articles: 'conversationSearch/articleSearch',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
||||||
@@ -328,6 +368,30 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</Policy>
|
</Policy>
|
||||||
|
|
||||||
|
<Policy
|
||||||
|
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
|
||||||
|
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||||
|
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
|
||||||
|
class="flex flex-col justify-center"
|
||||||
|
>
|
||||||
|
<SearchResultArticlesList
|
||||||
|
v-if="filterArticles"
|
||||||
|
:is-fetching="uiFlags.article.isFetching"
|
||||||
|
:articles="articles"
|
||||||
|
:query="query"
|
||||||
|
:show-title="isSelectedTabAll"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="showViewMore.articles"
|
||||||
|
:label="t(`SEARCH.VIEW_MORE`)"
|
||||||
|
icon="i-lucide-eye"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
outline
|
||||||
|
@click="selectedTab = 'articles'"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
|
||||||
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
|
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="!isSelectedTabAll"
|
v-if="!isSelectedTabAll"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user