Compare commits

..
179 changed files with 1555 additions and 5419 deletions
+40 -40
View File
@@ -99,17 +99,17 @@ jobs:
chmod +x ~/tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter
# Swagger verification # Swagger verification
- run: # - run:
name: Verify swagger API specification # name: Verify swagger API specification
command: | # command: |
bundle exec rake swagger:build # bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]] # if [[ `git status swagger/swagger.json --porcelain` ]]
then # then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." # echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1 # exit 1
fi # fi
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar # curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json # java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests # we remove the FRONTED_URL from the .env before running the tests
- run: - run:
@@ -139,26 +139,26 @@ jobs:
command: bundle exec bundle audit update && bundle exec bundle audit check -v command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting # Rubocop linting
- run: # - run:
name: Rubocop # name: Rubocop
command: bundle exec rubocop # command: bundle exec rubocop
# ESLint linting # # ESLint linting
- run: # - run:
name: eslint # name: eslint
command: pnpm run eslint # command: pnpm run eslint
- run: # - run:
name: Run frontend tests # name: Run frontend tests
command: | # command: |
mkdir -p ~/build/coverage/frontend # mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build # ~/tmp/cc-test-reporter before-build
pnpm run test:coverage # pnpm run test:coverage
- run: # - run:
name: Code Climate Test Coverage (Frontend) # name: Code Climate Test Coverage (Frontend)
command: | # command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" # ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests # Run backend tests
- run: - run:
@@ -175,17 +175,17 @@ jobs:
-- ${TESTFILES} -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
- run: # - run:
name: Code Climate Test Coverage (Backend) # name: Code Climate Test Coverage (Backend)
command: | # command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" # ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run: # - run:
name: List coverage directory contents # name: List coverage directory contents
command: | # command: |
ls -R ~/build/coverage # ls -R ~/build/coverage
- persist_to_workspace: # - persist_to_workspace:
root: ~/build # root: ~/build
paths: # paths:
- coverage # - coverage
+1 -11
View File
@@ -4,15 +4,5 @@ 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
+42 -65
View File
@@ -1,16 +1,12 @@
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 \
@@ -19,80 +15,61 @@ 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 NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ && apt-get -y install --no-install-recommends \
&& apt-get update \ build-essential \
&& apt-get -y install --no-install-recommends \ libssl-dev \
build-essential \ zlib1g-dev \
libssl-dev \ gnupg2 \
zlib1g-dev \ tar \
gnupg \ tzdata \
tar \ postgresql-client \
tzdata \ libpq-dev \
postgresql-client \ yarn \
libpq-dev \ git \
git \ imagemagick \
imagemagick \ tmux \
libyaml-dev \ zsh \
curl \ git-flow \
ca-certificates \ npm \
tmux \ libyaml-dev
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby for root user first # Install rbenv and ruby
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ RUN git clone 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 --depth 1 https://github.com/rbenv/ruby-build.git && \ RUN git clone 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
# Set up rbenv for vscode user # Install overmind
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 \
&& mv overmind /usr/local/bin \ && sudo 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 \
&& apt-get update \ # Install gh
&& apt-get install -y --no-install-recommends gh \ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& apt-get clean \ && 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 \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
RUN chown vscode:vscode /workspace COPY . /workspace
# set up node js, pnpm and claude code in single layer # set up ruby
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ COPY Gemfile Gemfile.lock ./
&& npm cache clean --force RUN gem install bundler && bundle install
# Switch to vscode user # set up node js
USER vscode RUN npm install n -g && \
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" n $NODE_VERSION
RUN npm install --global yarn
# Copy dependency files first for better caching RUN yarn
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
+8 -17
View File
@@ -4,26 +4,17 @@
"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": [
"Shopify.ruby-lsp", "rebornix.Ruby",
"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"
], ],
@@ -32,15 +23,15 @@
// 5432 postgres // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3036], "forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install", "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "Rails Server" "label": "Rails Server"
}, },
"3036": { "3035": {
"label": "Vite Dev Server" "label": "Webpack Dev Server"
}, },
"8025": { "8025": {
"label": "Mailhog UI" "label": "Mailhog UI"
-18
View File
@@ -1,18 +0,0 @@
# 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
+13
View File
@@ -5,6 +5,19 @@
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.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: base:latest
app: app:
build: build:
context: .. context: ..
+7 -10
View File
@@ -2,15 +2,12 @@ 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.app.github.dev/" .env sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# Setup Claude Code API key if available # uncomment the webpacker env variable
if [ -n "$CLAUDE_CODE_API_KEY" ]; then sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
mkdir -p ~/.claude # fix the error with webpacker
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
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 3036:public 8025:public -c $CODESPACE_NAME gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
@@ -19,5 +19,6 @@ jobs:
- name: Build the Codespace Base Image - name: Build the Codespace Base Image
run: | run: |
docker compose -f .devcontainer/docker-compose.base.yml build base docker-compose -f .devcontainer/docker-compose.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
-1
View File
@@ -41,7 +41,6 @@ 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:
-3
View File
@@ -1,3 +0,0 @@
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
@@ -29,11 +29,6 @@ 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
@@ -15,10 +15,6 @@ 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,11 +38,6 @@ 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
@@ -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, :tracking_pixel] before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@@ -15,21 +15,6 @@ 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
@@ -54,6 +39,7 @@ 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
@@ -21,10 +21,6 @@ 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();
-4
View File
@@ -102,8 +102,4 @@ 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);
},
}; };
@@ -1,18 +0,0 @@
/* 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();
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class CopilotThreads extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
}
export default new CopilotThreads();
@@ -51,9 +51,6 @@ 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,6 +134,10 @@ 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`);
} }
-9
View File
@@ -40,15 +40,6 @@ 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,6 +9,5 @@ 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 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 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;
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
@@ -3,7 +3,7 @@
} }
.tabs--container--with-border { .tabs--container--with-border {
@apply border-b border-b-n-weak; @apply border-b border-n-weak;
} }
.tabs--container--compact.tab--chat-type { .tabs--container--compact.tab--chat-type {
@@ -1,87 +0,0 @@
<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>
@@ -1,47 +0,0 @@
<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>
@@ -1,41 +0,0 @@
<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>
@@ -1,99 +0,0 @@
<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"> <header class="sticky top-0 z-10 px-6 xl:px-0">
<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"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<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
@@ -1,24 +1,29 @@
<script setup> <script setup>
import { nextTick, ref, watch, computed } from 'vue'; import { nextTick, ref, watch } 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 CopilotEmptyState from './CopilotEmptyState.vue'; import Icon from '../icon/Icon.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,
@@ -37,11 +42,22 @@ 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 () => {
@@ -51,72 +67,23 @@ const scrollToBottom = async () => {
} }
}; };
const groupedMessages = computed(() => { const promptOptions = [
const result = []; {
let thinkingGroup = []; label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
props.messages.forEach(message => { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
if (message.message_type === 'assistant_thinking') { },
thinkingGroup.push(message); {
} else { label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
if (thinkingGroup.length > 0) { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
result.push({ },
id: thinkingGroup[0].id, {
message_type: 'thinking_group', label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
messages: thinkingGroup, prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
}); },
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.messages, () => props.isCaptainTyping],
() => { () => {
scrollToBottom(); scrollToBottom();
}, },
@@ -126,59 +93,62 @@ 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">
<SidebarActionsHeader <div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
:title="$t('CAPTAIN.COPILOT.TITLE')" <template v-for="message in messages" :key="message.id">
:buttons="copilotButtons" <CopilotAgentMessage
@click="handleSidebarAction" v-if="message.role === 'user'"
@close="closeCopilotPanel" :support-agent="supportAgent"
/> :message="message"
<div />
ref="chatContainer" <CopilotAssistantMessage
class="flex-1 flex px-4 py-4 overflow-y-auto items-start" v-else-if="COPILOT_USER_ROLES.includes(message.role)"
> :message="message"
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full"> :conversation-inbox-type="conversationInboxType"
<template v-for="(item, index) in groupedMessages" :key="item.id"> />
<CopilotAgentMessage </template>
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" /> <CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</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 > 1" v-if="assistants.length"
: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 <CopilotInput class="mb-1 w-full" @send="sendMessage" />
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -11,10 +11,6 @@ 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';
const props = defineProps({ const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: { message: {
type: Object, type: Object,
required: true, required: true,
@@ -24,15 +20,6 @@ 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);
@@ -45,6 +32,8 @@ 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);
@@ -68,7 +57,7 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="showUseButton" v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -1,108 +0,0 @@
<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>
@@ -1,29 +1,21 @@
<script setup> <script setup>
import SidebarActionsHeader from './SidebarActionsHeader.vue'; import CopilotHeader from './CopilotHeader.vue';
</script> </script>
<template> <template>
<Story <Story
title="Components/SidebarActionsHeader" title="Captain/Copilot/CopilotHeader"
:layout="{ type: 'grid', width: '800px' }" :layout="{ type: 'grid', width: '800px' }"
> >
<!-- Default State --> <!-- Default State -->
<Variant title="Default State"> <Variant title="Default State">
<SidebarActionsHeader title="Default State" /> <CopilotHeader />
</Variant> </Variant>
<!-- With New Conversation Button --> <!-- With New Conversation Button -->
<Variant title="With New Conversation Button"> <Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand --> <!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<SidebarActionsHeader <CopilotHeader :has-messages="true" />
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant> </Variant>
</Story> </Story>
</template> </template>
@@ -0,0 +1,32 @@
<script setup>
import Button from '../button/Button.vue';
defineProps({
hasMessages: {
type: Boolean,
default: false,
},
});
defineEmits(['reset', 'close']);
</script>
<template>
<div
class="flex items-center justify-between px-5 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">
{{ $t('CAPTAIN.COPILOT.TITLE') }}
</span>
<div class="flex items-center">
<Button
v-if="hasMessages"
icon="i-lucide-plus"
ghost
sm
@click="$emit('reset')"
/>
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
</div>
</div>
</div>
</template>
@@ -1,61 +0,0 @@
<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>
@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles" icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9" class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/> />
<div class="text-sm text-n-slate-12"> <div class="text-sm text-n-slate-11">
{{ content }} {{ content }}
</div> </div>
</div> </div>
@@ -51,10 +51,10 @@ watch(
}" }"
> >
<CopilotThinkingBlock <CopilotThinkingBlock
v-for="copilotMessage in messages" v-for="message in messages"
:key="copilotMessage.id" :key="message.id"
:content="copilotMessage.message.content" :content="message.content"
:reasoning="copilotMessage.message.reasoning" :reasoning="message.reasoning"
/> />
</div> </div>
</div> </div>
@@ -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 overflow-hidden"> <div class="space-y-1">
<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-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1" 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"
> >
<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-1.5 m-0 list-none"> <ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup <SidebarGroup
v-for="item in menuItems" v-for="item in menuItems"
:key="item.name" :key="item.name"
@@ -814,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-[340px] 2xl:w-[412px]', isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
]" ]"
> >
<slot /> <slot />
@@ -80,14 +80,16 @@ const toggleConversationLayout = () => {
<template> <template>
<div <div
class="flex items-center justify-between gap-2 px-3 h-12" class="flex items-center justify-between gap-2 px-4"
:class="{ :class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders, 'pb-3 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-base font-medium truncate text-n-slate-12" class="text-lg 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-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" 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"
> >
<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-n-blue-10 hover:text-n-brand" class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
> >
{{ action.message }} {{ action.message }}
</router-link> </router-link>
@@ -1,72 +1,62 @@
<script setup> <script>
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';
const props = defineProps({ export default {
duration: { components: {
type: Number, WootSnackbar,
default: 2500, },
props: {
duration: {
type: Number,
default: 2500,
},
}, },
});
const { t } = useI18n(); data() {
return {
snackMessages: [],
};
},
const snackMessages = ref([]); mounted() {
const snackbarContainer = ref(null); emitter.on('newToastMessage', this.onNewToastMessage);
},
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;
const showPopover = () => { this.snackMessages.push({
try { key: new Date().getTime(),
const el = snackbarContainer.value; message,
if (el?.matches(':popover-open')) { action,
el.hidePopover(); });
} window.setTimeout(() => {
el?.showPopover(); this.snackMessages.splice(0, 1);
} catch (e) { }, duration);
// 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>
<div <transition-group
ref="snackbarContainer" name="toast-fade"
popover="manual" tag="div"
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" class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
> >
<transition-group name="toast-fade" tag="div"> <WootSnackbar
<WootSnackbar v-for="snackMessage in snackMessages"
v-for="snackMessage in snackMessages" :key="snackMessage.key"
:key="snackMessage.key" :message="snackMessage.message"
:message="snackMessage.message" :action="snackMessage.action"
:action="snackMessage.action" />
/> </transition-group>
</transition-group>
</div>
</template> </template>
@@ -1,11 +1,16 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, watchEffect } 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';
defineProps({ const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -15,25 +20,13 @@ 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 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 selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const activeAssistant = computed(() => { const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id; const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -62,57 +55,68 @@ 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 = () => {
selectedCopilotThreadId.value = null; messages.value = [];
}; };
const sendMessage = async message => { const sendMessage = async message => {
if (selectedCopilotThreadId.value) { // Add user message
await store.dispatch('copilotMessages/create', { messages.value.push({
assistant_id: activeAssistant.value.id, id: messages.value.length + 1,
conversation_id: currentChat.value?.id, role: 'user',
threadId: selectedCopilotThreadId.value, content: message,
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,
}); });
} else { } catch (error) {
const response = await store.dispatch('copilotThreads/create', { // eslint-disable-next-line
assistant_id: activeAssistant.value.id, console.log(error);
conversation_id: currentChat.value?.id, } finally {
message, isCaptainTyping.value = false;
});
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>
<div <Copilot
v-if="shouldShowCopilotPanel" :messages="messages"
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" :support-agent="currentUser"
> :is-captain-typing="isCaptainTyping"
<Copilot :conversation-inbox-type="conversationInboxType"
:messages="messages" :assistants="assistants"
:support-agent="currentUser" :active-assistant="activeAssistant"
:conversation-inbox-type="conversationInboxType" @set-assistant="setAssistant"
:assistants="assistants" @send-message="sendMessage"
:active-assistant="activeAssistant" @reset="handleReset"
@set-assistant="setAssistant" />
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template> </template>
@@ -48,13 +48,12 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<woot-tabs <woot-tabs
:index="activeTabIndex" :index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 tab--chat-type" class="w-full px-4 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,7 +25,10 @@ defineProps({
:username="user.name" :username="user.name"
:status="user.availability_status" :status="user.availability_status"
/> />
<span class="my-0 truncate text-capitalize" :class="textClass"> <span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
{{ user.name }} {{ user.name }}
</span> </span>
</div> </div>
@@ -4,14 +4,17 @@ 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],
@@ -31,6 +34,7 @@ export default {
default: true, default: true,
}, },
}, },
emits: ['contactPanelToggle'],
data() { data() {
return { activeIndex: 0 }; return { activeIndex: 0 };
}, },
@@ -82,6 +86,9 @@ 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;
}, },
@@ -91,7 +98,7 @@ export default {
<template> <template>
<div <div
class="conversation-details-wrap bg-n-background relative" class="conversation-details-wrap bg-n-background"
: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,
}" }"
@@ -99,12 +106,15 @@ 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 dashboard-app--tabs border-t border-t-n-background" class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
@change="onDashboardAppTabChange" @change="onDashboardAppTabChange"
> >
<woot-tabs-item <woot-tabs-item
@@ -120,12 +130,18 @@ 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"
/> />
<slot /> <ConversationSidebar
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-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="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="{ :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="32px" size="40px"
/> />
</div> </div>
<div <div
@@ -1,9 +1,8 @@
<script setup> <script>
import { computed, ref } from 'vue'; import { mapGetters } from 'vuex';
import { useRoute } from 'vue-router'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
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';
@@ -13,161 +12,203 @@ 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';
const props = defineProps({ import NextButton from 'dashboard/components-next/button/Button.vue';
chat: {
type: Object, export default {
default: () => ({}), components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
}, },
showBackButton: { mixins: [inboxMixin],
type: Boolean, props: {
default: false, chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
}, },
}); emits: ['contactPanelToggle'],
setup(props, { emit }) {
const { t } = useI18n(); const keyboardEvents = {
const store = useStore(); 'Alt+KeyO': {
const route = useRoute(); action: () => emit('contactPanelToggle'),
const conversationHeader = ref(null); },
const { width } = useElementSize(conversationHeader); };
const { isAWebWidgetInbox } = useInbox(); useKeyboardEvents(keyboardEvents);
},
const currentChat = computed(() => store.getters.getSelectedChat); computed: {
const accountId = computed(() => store.getters.getCurrentAccountId); ...mapGetters({
const isFeatureEnabledonAccount = computed( currentChat: 'getSelectedChat',
() => store.getters['accounts/isFeatureEnabledonAccount'] accountId: 'getCurrentAccountId',
); isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
const appIntegrations = computed( appIntegrations: 'integrations/getAppIntegrations',
() => store.getters['integrations/getAppIntegrations'] }),
); chatMetadata() {
return this.chat.meta;
const chatMetadata = computed(() => props.chat.meta); },
backButtonUrl() {
const backButtonUrl = computed(() => { const {
const { params: { accountId, inbox_id: inboxId, label, teamId },
params: { inbox_id: inboxId, label, teamId }, name,
name, } = this.$route;
} = route; return conversationListPageURL({
return conversationListPageURL({ accountId,
accountId, inboxId,
inboxId, label,
label, teamId,
teamId, conversationType: name === 'conversation_mentions' ? 'mention' : '',
conversationType: name === 'conversation_mentions' ? 'mention' : '', });
}); },
}); isHMACVerified() {
if (!this.isAWebWidgetInbox) {
const isHMACVerified = computed(() => { return true;
if (!isAWebWidgetInbox.value) { }
return true; return this.chatMetadata.hmac_verified;
} },
return chatMetadata.value.hmac_verified; currentContact() {
}); return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id
const currentContact = computed(() => );
store.getters['contacts/getContact'](props.chat.meta.sender.id) },
); isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
const isSnoozed = computed( },
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED snoozedDisplayText() {
); const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
const snoozedDisplayText = computed(() => { return `${this.$t(
const { snoozed_until: snoozedUntil } = currentChat.value; 'CONVERSATION.HEADER.SNOOZED_UNTIL'
if (snoozedUntil) { )} ${snoozedReopenTime(snoozedUntil)}`;
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`; }
} return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY'); },
}); contactPanelToggleText() {
return `${
const inbox = computed(() => { this.isContactPanelOpen
const { inbox_id: inboxId } = props.chat; ? this.$t('CONVERSATION.HEADER.CLOSE')
return store.getters['inboxes/getInbox'](inboxId); : this.$t('CONVERSATION.HEADER.OPEN')
}); } ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
const hasMultipleInboxes = computed( inbox() {
() => store.getters['inboxes/getInboxes'].length > 1 const { inbox_id: inboxId } = this.chat;
); return this.$store.getters['inboxes/getInbox'](inboxId);
},
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
const isLinearIntegrationEnabled = computed(() => },
appIntegrations.value.find( hasSlaPolicyId() {
integration => integration.id === 'linear' && !!integration.hooks.length return this.chat?.sla_policy_id;
) },
); isLinearIntegrationEnabled() {
return this.appIntegrations.find(
const isLinearFeatureEnabled = computed(() => integration => integration.id === 'linear' && !!integration.hooks.length
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR) );
); },
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
</script> </script>
<template> <template>
<div <div
ref="conversationHeader" class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
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 items-center justify-start w-full xl:w-auto max-w-full min-w-0" class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
> >
<BackButton <div class="flex items-center justify-start max-w-full min-w-0 w-fit">
v-if="showBackButton" <BackButton
:back-url="backButtonUrl" v-if="showBackButton"
class="ltr:mr-2 rtl:ml-2" :back-url="backButtonUrl"
/> class="ltr:mr-2 rtl:ml-2"
<Thumbnail />
:src="currentContact.thumbnail" <Thumbnail
:username="currentContact.name" :src="currentContact.thumbnail"
:status="currentContact.availability_status" :badge="inboxBadge"
size="32px" :username="currentContact.name"
/> :status="currentContact.availability_status"
<div />
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">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
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 flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
> >
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" /> <div
<span v-if="isSnoozed" class="font-medium text-n-amber-10"> class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
{{ snoozedDisplayText }} >
</span> <NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
</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"
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" :class="{ 'justify-end': isContactPanelOpen }"
> >
<SLACardLabel <SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
v-if="hasSlaPolicyId" <Linear
:chat="chat" v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
show-extended-info :conversation-id="currentChat.id"
:parent-width="width" />
class="hidden lg:flex" <MoreActions :conversation-id="currentChat.id" />
/> </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,36 +1,81 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } 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 { useUISettings } from 'dashboard/composables/useUISettings'; import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
defineProps({ const props = defineProps({
currentChat: { currentChat: {
required: true, required: true,
type: Object, type: Object,
}, },
}); });
const { uiSettings } = useUISettings(); const emit = defineEmits(['toggleContactPanel']);
const activeTab = computed(() => { const { t } = useI18n();
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
if (isContactSidebarOpen) { const channelType = computed(() => props.currentChat?.meta?.channel || '');
return 0;
} const CONTACT_TABS_OPTIONS = [
return null; { key: 'CONTACT', value: 'contact' },
{ 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-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background" 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"
> >
<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-show="activeTab === 0" v-if="!activeTab"
: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>
@@ -38,6 +38,8 @@ 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,
@@ -45,8 +47,20 @@ 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 conversationPanelRef = ref(null);
@@ -189,6 +203,12 @@ 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;
@@ -424,6 +444,9 @@ 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;
@@ -507,6 +530,19 @@ 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" ref="conversationPanelRef"
@@ -1,14 +1,10 @@
<script setup> <script>
import { computed, onUnmounted } from 'vue'; import { mapGetters } from 'vuex';
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,
@@ -16,111 +12,97 @@ import {
CMD_UNMUTE_CONVERSATION, CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
// No props needed as we're getting currentChat from the store directly export default {
const store = useStore(); components: {
const { t } = useI18n(); EmailTranscriptModal,
ResolveAction,
const [showEmailActionsModal, toggleEmailModal] = useToggle(false); ButtonV4,
const [showActionsDropdown, toggleDropdown] = useToggle(false); },
data() {
const currentChat = computed(() => store.getters.getSelectedChat); return {
showEmailActionsModal: false,
const actionMenuItems = computed(() => { };
const items = []; },
computed: {
if (!currentChat.value.muted) { ...mapGetters({ currentChat: 'getSelectedChat' }),
items.push({ },
icon: 'i-lucide-volume-off', mounted() {
label: t('CONTACT_PANEL.MUTE_CONTACT'), emitter.on(CMD_MUTE_CONVERSATION, this.mute);
action: 'mute', emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
value: 'mute', emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
}); },
} else { unmounted() {
items.push({ emitter.off(CMD_MUTE_CONVERSATION, this.mute);
icon: 'i-lucide-volume-1', emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
label: t('CONTACT_PANEL.UNMUTE_CONTACT'), emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
action: 'unmute', },
value: 'unmute', methods: {
}); mute() {
} this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
items.push({ },
icon: 'i-lucide-share', unmute() {
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'), this.$store.dispatch('unmuteConversation', this.currentChat.id);
action: 'send_transcript', useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
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="toggleEmailModal" @cancel="toggleEmailActionsModal"
/> />
</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>
@@ -1,115 +1,132 @@
<script setup> <script>
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();
const timer = ref(null); export default {
const slaStatus = ref({ components: {
threshold: null, SLAPopoverCard,
isSlaMissed: false, },
type: null, props: {
icon: null, chat: {
}); 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';
const appliedSLA = computed(() => props.chat?.applied_sla); return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
const slaEvents = computed(() => props.chat?.sla_events); status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
const hasSlaThreshold = computed(() => slaStatus.value?.threshold); });
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed); },
const slaTextStyles = computed(() => showSlaPopoverCard() {
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11' return (
); this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
const slaStatusText = computed(() => { },
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT },
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE'; watch: {
chat() {
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, { this.updateSlaStatus();
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`), },
}); },
}); mounted() {
this.updateSlaStatus();
const showSlaPopoverCard = computed( this.createTimer();
() => props.showExtendedInfo && slaEvents.value?.length > 0 },
); unmounted() {
if (this.timer) {
const groupClass = computed(() => { clearTimeout(this.timer);
return props.showExtendedInfo }
? 'h-[26px] rounded-lg bg-n-alpha-1' },
: 'rounded h-5 border border-n-strong'; methods: {
}); createTimer() {
this.timer = setTimeout(() => {
const updateSlaStatus = () => { this.updateSlaStatus();
slaStatus.value = evaluateSLAStatus({ this.createTimer();
appliedSla: appliedSLA.value, }, REFRESH_INTERVAL);
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 group" class="relative flex items-center cursor-pointer min-w-fit"
:class="groupClass" :class="
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
> >
<div <div
class="flex items-center w-full truncate px-1.5" v-on-clickaway="closeSlaPopover"
:class="showExtendedInfo ? '' : 'gap-1'" class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
> >
<div class="flex items-center gap-1" :class="slaPopoverClass"> <div
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="12" size="14"
:icon="slaStatus.icon" :icon="slaStatus.icon"
type="outline" type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'" :icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -117,7 +134,7 @@ onUnmounted(() => {
:class="slaTextStyles" :class="slaTextStyles"
/> />
<span <span
v-if="showExtendedInfo && parentWidth > 650" v-if="showExtendedInfo"
class="text-xs font-medium" class="text-xs font-medium"
:class="slaTextStyles" :class="slaTextStyles"
> >
@@ -134,7 +151,7 @@ onUnmounted(() => {
<SLAPopoverCard <SLAPopoverCard
v-if="showSlaPopoverCard" v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents" :sla-missed-events="slaEvents"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex" class="right-0 top-7"
/> />
</div> </div>
</template> </template>
@@ -16,10 +16,6 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
required: true, required: true,
}, },
parentWidth: {
type: Number,
default: 10000,
},
}); });
defineOptions({ defineOptions({
@@ -77,14 +73,6 @@ 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;
@@ -131,10 +119,7 @@ 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 <span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }} {{ linkedIssue.issue.identifier }}
</span> </span>
</Button> </Button>
@@ -142,7 +127,7 @@ onMounted(() => {
v-if="linkedIssue" v-if="linkedIssue"
:issue="linkedIssue.issue" :issue="linkedIssue.issue"
:link-id="linkedIssue.id" :link-id="linkedIssue.id"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible" class="absolute right-0 top-[36px] invisible group-hover:visible"
@unlink-issue="unlinkIssue" @unlink-issue="unlinkIssue"
/> />
<woot-modal <woot-modal
@@ -0,0 +1,110 @@
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(5); expect(fontSizeOptions).toHaveLength(6);
expect(fontSizeOptions[0]).toHaveProperty('value'); expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label'); expect(fontSizeOptions[0]).toHaveProperty('label');
@@ -59,6 +59,12 @@ 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', () => {
@@ -78,6 +84,9 @@ 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');
}); });
@@ -136,6 +145,8 @@ 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;
}); });
@@ -149,6 +160,9 @@ 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(
@@ -19,6 +19,7 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px', DEFAULT: '16px',
LARGE: '18px', LARGE: '18px',
LARGER: '20px', LARGER: '20px',
EXTRA_LARGE: '22px',
}; };
/** /**
@@ -129,7 +129,6 @@ export function usePolicy() {
return { return {
checkPermissions, checkPermissions,
shouldShowPaywall, shouldShowPaywall,
isFeatureFlagEnabled,
shouldShow, shouldShow,
}; };
} }
@@ -33,7 +33,6 @@ 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,
}; };
} }
@@ -190,10 +189,6 @@ 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 });
@@ -1,67 +0,0 @@
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
);
});
});
});
@@ -62,9 +62,7 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely", "DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard", "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": {
@@ -70,7 +70,6 @@
"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,7 +4,6 @@
"PHONE_INPUT": { "PHONE_INPUT": {
"PLACEHOLDER": "Search", "PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found" "EMPTY_STATE": "No results found"
}, }
"CLOSE": "Close"
} }
} }
@@ -328,9 +328,6 @@
"HEADER_KNOW_MORE": "Know more", "HEADER_KNOW_MORE": "Know more",
"COPILOT": { "COPILOT": {
"TITLE": "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? Copilots 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",
@@ -351,14 +348,6 @@
"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)."
} }
} }
}, },
@@ -392,7 +381,6 @@
}, },
"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?",
@@ -4,14 +4,12 @@
"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,12 +76,7 @@
"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",
@@ -1,69 +0,0 @@
<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>
@@ -1,53 +0,0 @@
<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,10 +8,11 @@ import {
ROLES, ROLES,
CONVERSATION_PERMISSIONS, CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS, CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js'; } from 'dashboard/constants/permissions.js';
import { usePolicy } from 'dashboard/composables/usePolicy'; import {
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; getUserPermissions,
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';
@@ -21,7 +22,6 @@ 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,9 +57,6 @@ 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');
@@ -69,7 +66,6 @@ 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);
@@ -77,18 +73,14 @@ 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 { shouldShow, isFeatureFlagEnabled } = usePolicy(); const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
);
const TABS_CONFIG = { const TABS_CONFIG = {
all: { all: {
permissions: [ permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
CONTACT_PERMISSIONS,
...ROLES,
...CONVERSATION_PERMISSIONS,
PORTAL_PERMISSIONS,
],
count: () => null, // No count for all tab count: () => null, // No count for all tab
}, },
contacts: { contacts: {
@@ -103,69 +95,41 @@ 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(() => {
return Object.entries(TABS_CONFIG) const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
.map(([key, config]) => ({ key,
key, name: t(`SEARCH.TABS.${key.toUpperCase()}`),
name: t(`SEARCH.TABS.${key.toUpperCase()}`), count: config.count(),
count: config.count(), showBadge: key !== 'all',
showBadge: key !== 'all', permissions: config.permissions,
permissions: config.permissions, }));
featureFlag: config.featureFlag,
})) return filterItemsByPermission(
.filter(config => { configs,
// why the double check, glad you asked. userPermissions.value,
// Some features are marked as premium features, that means item => item.permissions
// 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,
}, },
{ };
permissions: [...ROLES, PORTAL_PERMISSIONS], return filterItemsByPermission(
featureFlag: FEATURE_FLAGS.HELP_CENTER, permissionCounts,
count: () => articles.value.length, userPermissions.value,
}, 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(() => {
@@ -174,13 +138,12 @@ const activeTabIndex = computed(() => {
}); });
const isFetchingAny = computed(() => { const isFetchingAny = computed(() => {
const { contact, message, conversation, article, isFetching } = uiFlags.value; const { contact, message, conversation, isFetching } = uiFlags.value;
return ( return (
isFetching || isFetching ||
contact.isFetching || contact.isFetching ||
message.isFetching || message.isFetching ||
conversation.isFetching || conversation.isFetching
article.isFetching
); );
}); });
@@ -208,7 +171,6 @@ 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 (
@@ -223,11 +185,10 @@ 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, articles: 1 }; pages.value = { contacts: 1, conversations: 1, messages: 1 };
store.dispatch('conversationSearch/clearSearchResults'); store.dispatch('conversationSearch/clearSearchResults');
}; };
@@ -253,7 +214,6 @@ 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;
@@ -368,30 +328,6 @@ 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"
@@ -3,7 +3,6 @@ import {
ROLES, ROLES,
CONVERSATION_PERMISSIONS, CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS, CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js'; } from 'dashboard/constants/permissions.js';
import SearchView from './components/SearchView.vue'; import SearchView from './components/SearchView.vue';
@@ -13,12 +12,7 @@ export const routes = [
path: frontendURL('accounts/:accountId/search'), path: frontendURL('accounts/:accountId/search'),
name: 'search', name: 'search',
meta: { meta: {
permissions: [ permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
...ROLES,
...CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
],
}, },
component: SearchView, component: SearchView,
}, },
@@ -25,8 +25,6 @@ const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue') () => import('../../components/layout/Sidebar.vue')
); );
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
export default { export default {
components: { components: {
@@ -39,8 +37,6 @@ export default {
AddLabelModal, AddLabelModal,
NotificationPanel, NotificationPanel,
UpgradePage, UpgradePage,
CopilotLauncher,
CopilotContainer,
}, },
setup() { setup() {
const upgradePageRef = ref(null); const upgradePageRef = ref(null);
@@ -223,9 +219,6 @@ export default {
<template v-if="!showUpgradePage"> <template v-if="!showUpgradePage">
<router-view /> <router-view />
<CommandBar /> <CommandBar />
<CopilotLauncher />
<CopilotContainer />
<NotificationPanel <NotificationPanel
v-if="isNotificationPanel" v-if="isNotificationPanel"
@close="closeNotificationPanel" @close="closeNotificationPanel"
@@ -16,7 +16,7 @@ onMounted(() => {
<template> <template>
<div <div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6" class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
> >
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive"> <keep-alive v-if="keepAlive">
@@ -11,14 +11,14 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue'; import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue'; import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue'; import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue'; import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue'; import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue'; import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue'; import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue'; import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue'; import ShopifyOrdersList from '../../../components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
@@ -29,6 +29,10 @@ const props = defineProps({
type: Number, type: Number,
default: undefined, default: undefined,
}, },
onToggle: {
type: Function,
default: () => {},
},
}); });
const { const {
@@ -85,6 +89,8 @@ watch(conversationId, (newConversationId, prevConversationId) => {
watch(contactId, getContactDetails); watch(contactId, getContactDetails);
const onPanelToggle = props.onToggle;
const onDragEnd = () => { const onDragEnd = () => {
dragging.value = false; dragging.value = false;
updateUISettings({ updateUISettings({
@@ -92,13 +98,6 @@ const onDragEnd = () => {
}); });
}; };
const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
onMounted(() => { onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value; conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails(); getContactDetails();
@@ -108,11 +107,11 @@ onMounted(() => {
<template> <template>
<div class="w-full"> <div class="w-full">
<SidebarActionsHeader <ContactInfo
:title="$t('CONVERSATION.SIDEBAR.CONTACT')" :contact="contact"
@close="closeContactPanel" :channel-type="channelType"
@toggle-panel="onPanelToggle"
/> />
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="list-group pb-8"> <div class="list-group pb-8">
<Draggable <Draggable
:list="conversationSidebarItems" :list="conversationSidebarItems"
@@ -10,8 +10,6 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue'; import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default { export default {
components: { components: {
@@ -19,8 +17,6 @@ export default {
ConversationBox, ConversationBox,
PopOverSearch, PopOverSearch,
CmdBarConversationSnooze, CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
}, },
beforeRouteLeave(to, from, next) { beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues // Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
@@ -91,14 +87,13 @@ export default {
this.uiSettings; this.uiSettings;
return conversationDisplayType !== CONDENSED; return conversationDisplayType !== CONDENSED;
}, },
isContactPanelOpen() {
shouldShowSidebar() { if (this.currentChat.id) {
if (!this.currentChat.id) { const { is_contact_sidebar_open: isContactSidebarOpen } =
return false; this.uiSettings;
return isContactSidebarOpen;
} }
return false;
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
}, },
showPopOverSearch() { showPopOverSearch() {
return !this.isFeatureEnabledonAccount( return !this.isFeatureEnabledonAccount(
@@ -194,6 +189,11 @@ export default {
this.$store.dispatch('clearSelectedState'); this.$store.dispatch('clearSelectedState');
} }
}, },
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
onSearch() { onSearch() {
this.showSearchModal = true; this.showSearchModal = true;
}, },
@@ -225,11 +225,10 @@ export default {
<ConversationBox <ConversationBox
v-if="showMessageView" v-if="showMessageView"
:inbox-id="inboxId" :inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout" :is-on-expanded-layout="isOnExpandedLayout"
> @contact-panel-toggle="onToggleContactPanel"
<SidepanelSwitch v-if="currentChat.id" /> />
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CmdBarConversationSnooze /> <CmdBarConversationSnooze />
</section> </section>
</template> </template>
@@ -183,6 +183,7 @@ export default {
</div> </div>
<div v-else class="flex flex-col w-full h-full"> <div v-else class="flex flex-col w-full h-full">
<InboxItemHeader <InboxItemHeader
class="flex-1"
:total-length="totalNotificationCount" :total-length="totalNotificationCount"
:current-index="activeNotificationIndex" :current-index="activeNotificationIndex"
:active-notification="activeNotification" :active-notification="activeNotification"
@@ -109,7 +109,7 @@ export default {
<template> <template>
<div <div
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak" class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<BackButton <BackButton
@@ -228,20 +228,7 @@ const initializeForm = () => {
const onCopyToken = async value => { const onCopyToken = async value => {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL')); useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
};
const onResetToken = async () => {
const response = await store.dispatch(
'agentBots/resetAccessToken',
props.selectedBot.id
);
if (response) {
accessToken.value = response.access_token;
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
}
}; };
const closeModal = () => { const closeModal = () => {
@@ -325,18 +312,7 @@ defineExpose({ dialogRef });
> >
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }} {{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label> </label>
<AccessToken <AccessToken :value="accessToken" @on-copy="onCopyToken" />
v-if="type === MODAL_TYPES.EDIT"
:value="accessToken"
@on-copy="onCopyToken"
@on-reset="onResetToken"
/>
<AccessToken
v-else
:value="accessToken"
:show-reset-button="false"
@on-copy="onCopyToken"
/>
</div> </div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2"> <div class="flex items-center justify-end w-full gap-2 px-0 py-2">
@@ -1,17 +1,14 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import FormButton from 'v3/components/Form/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({ const props = defineProps({
value: { type: String, default: '' }, value: {
showResetButton: { type: Boolean, default: true }, type: String,
default: '',
},
}); });
const emit = defineEmits(['onCopy']);
const emit = defineEmits(['onCopy', 'onReset']);
const inputType = ref('password'); const inputType = ref('password');
const toggleMasked = () => { const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password'; inputType.value = inputType.value === 'password' ? 'text' : 'password';
}; };
@@ -23,10 +20,6 @@ const maskIcon = computed(() => {
const onClick = () => { const onClick = () => {
emit('onCopy', props.value); emit('onCopy', props.value);
}; };
const onReset = () => {
emit('onReset');
};
</script> </script>
<template> <template>
@@ -45,7 +38,7 @@ const onReset = () => {
> >
<template #masked> <template #masked>
<button <button
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5" class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
type="button" type="button"
@click="toggleMasked" @click="toggleMasked"
> >
@@ -53,28 +46,15 @@ const onReset = () => {
</button> </button>
</template> </template>
</woot-input> </woot-input>
<div class="flex flex-row gap-2"> <FormButton
<NextButton type="button"
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY')" size="large"
slate icon="text-copy"
outline variant="outline"
type="button" color-scheme="secondary"
icon="i-lucide-copy" @click="onClick"
class="rounded-xl" >
@click="onClick" {{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
/> </FormButton>
<ConfirmButton
v-if="showResetButton"
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET')"
:confirm-label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_RESET')"
:confirm-hint="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_HINT')"
color="slate"
confirm-color="ruby"
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
@click="onReset"
/>
</div>
</div> </div>
</template> </template>
@@ -181,14 +181,6 @@ export default {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
}, },
}; };
</script> </script>
@@ -245,12 +237,7 @@ export default {
<button <button
v-for="hotKey in hotKeys" v-for="hotKey in hotKeys"
:key="hotKey.key" :key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline" class="px-0 reset-base w-full sm:flex-1"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
> >
<HotKeyCard <HotKeyCard
:key="hotKey.title" :key="hotKey.title"
@@ -294,11 +281,7 @@ export default {
) )
" "
> >
<AccessToken <AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" />
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@@ -1,6 +1,4 @@
<script setup> <script setup>
import { computed } from 'vue';
const { row } = defineProps({ const { row } = defineProps({
row: { row: {
type: Object, type: Object,
@@ -8,10 +6,10 @@ const { row } = defineProps({
}, },
}); });
const routerParams = computed(() => ({ const routerParams = {
name: 'inbox_conversation', name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId }, params: { conversation_id: row.original.conversationId },
})); };
</script> </script>
<template> <template>
@@ -47,17 +47,14 @@ const tableData = computed(() => {
})); }));
}); });
const defaultSpanRender = cellProps => { const defaulSpanRender = cellProps =>
const value = cellProps.getValue() || '---'; h(
return h(
'span', 'span',
{ {
class: 'line-clamp-5 break-words max-w-full text-n-slate-12', class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
title: value,
}, },
value cellProps.getValue() ? cellProps.getValue() : '---'
); );
};
const columnHelper = createColumnHelper(); const columnHelper = createColumnHelper();
@@ -68,10 +65,7 @@ const columns = [
cell: cellProps => { cell: cellProps => {
const { contact } = cellProps.row.original; const { contact } = cellProps.row.original;
if (contact) { if (contact) {
return h(UserAvatarWithName, { return h(UserAvatarWithName, { user: contact });
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
} }
return '--'; return '--';
}, },
@@ -82,10 +76,7 @@ const columns = [
cell: cellProps => { cell: cellProps => {
const { assignedAgent } = cellProps.row.original; const { assignedAgent } = cellProps.row.original;
if (assignedAgent) { if (assignedAgent) {
return h(UserAvatarWithName, { return h(UserAvatarWithName, { user: assignedAgent });
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
} }
return '--'; return '--';
}, },
@@ -114,7 +105,7 @@ const columns = [
columnHelper.accessor('feedbackText', { columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'), header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400, width: 400,
cell: defaultSpanRender, cell: defaulSpanRender,
}), }),
columnHelper.accessor('conversationId', { columnHelper.accessor('conversationId', {
header: '', header: '',
@@ -1,5 +1,7 @@
<template> <template>
<div class="reports--wrapper overflow-auto bg-n-background w-full px-6"> <div
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
>
<div class="max-w-[60rem] mx-auto pb-12"> <div class="max-w-[60rem] mx-auto pb-12">
<router-view /> <router-view />
</div> </div>
@@ -1,19 +0,0 @@
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import { createStore } from './storeFactory';
export default createStore({
name: 'CopilotMessages',
API: CopilotMessagesAPI,
getters: {
getMessagesByThreadId: state => copilotThreadId => {
return state.records
.filter(record => record.copilot_thread?.id === Number(copilotThreadId))
.sort((a, b) => a.id - b.id);
},
},
actions: mutationTypes => ({
upsert({ commit }, data) {
commit(mutationTypes.UPSERT, data);
},
}),
});
@@ -1,7 +0,0 @@
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
import { createStore } from './storeFactory';
export default createStore({
name: 'CopilotThreads',
API: CopilotThreadsAPI,
});
@@ -1,11 +1,5 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => { export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase(); const capitalizedName = name.toUpperCase();
@@ -16,7 +10,6 @@ export const generateMutationTypes = name => {
EDIT: `EDIT_${capitalizedName}`, EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`, DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`, SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
}; };
}; };
@@ -40,6 +33,7 @@ export const createGetters = () => ({
getMeta: state => state.meta, getMeta: state => state.meta,
}); });
// store/mutations.js
export const createMutations = mutationTypes => ({ export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) { [mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = { state.uiFlags = {
@@ -57,19 +51,78 @@ export const createMutations = mutationTypes => ({
[mutationTypes.ADD]: MutationHelpers.create, [mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update, [mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy, [mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
}); });
// store/actions/crud.js
export const createCrudActions = (API, mutationTypes) => ({ export const createCrudActions = (API, mutationTypes) => ({
get: getRecords(mutationTypes, API), async get({ commit }, params = {}) {
show: showRecord(mutationTypes, API), commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
create: createRecord(mutationTypes, API), try {
update: updateRecord(mutationTypes, API), const response = await API.get(params);
delete: deleteRecord(mutationTypes, API), commit(mutationTypes.SET, response.data.payload);
}); commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
},
async show({ commit }, id) {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
},
async create({ commit }, dataObj) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
},
async update({ commit }, { id, ...updateObj }) {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
},
async delete({ commit }, id) {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
},
});
export const createStore = options => { export const createStore = options => {
const { name, API, actions, getters } = options; const { name, API, actions } = options;
const mutationTypes = generateMutationTypes(name); const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {}; const customActions = actions ? actions(mutationTypes) : {};
@@ -77,10 +130,7 @@ export const createStore = options => {
return { return {
namespaced: true, namespaced: true,
state: createInitialState(), state: createInitialState(),
getters: { getters: createGetters(),
...createGetters(),
...(getters || {}),
},
mutations: createMutations(mutationTypes), mutations: createMutations(mutationTypes),
actions: { actions: {
...createCrudActions(API, mutationTypes), ...createCrudActions(API, mutationTypes),
@@ -1,380 +0,0 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
generateMutationTypes,
createInitialState,
createGetters,
createMutations,
createCrudActions,
createStore,
} from './storeFactory';
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(),
}));
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
set: vi.fn(),
create: vi.fn(),
update: vi.fn(),
destroy: vi.fn(),
setSingleRecord: vi.fn(),
}));
describe('storeFactory', () => {
describe('generateMutationTypes', () => {
it('generates correct mutation types with capitalized name', () => {
const result = generateMutationTypes('test');
expect(result).toEqual({
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
SET: 'SET_TEST',
ADD: 'ADD_TEST',
EDIT: 'EDIT_TEST',
DELETE: 'DELETE_TEST',
SET_META: 'SET_TEST_META',
UPSERT: 'UPSERT_TEST',
});
});
});
describe('createInitialState', () => {
it('returns the correct initial state structure', () => {
const result = createInitialState();
expect(result).toEqual({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
});
});
describe('createGetters', () => {
it('returns getters with correct implementations', () => {
const getters = createGetters();
const state = {
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
uiFlags: { fetchingList: true },
meta: { totalCount: 10, page: 1 },
};
expect(getters.getRecords(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
expect(getters.getRecord(state)(4)).toEqual({});
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
});
expect(getters.getMeta(state)).toEqual({
totalCount: 10,
page: 1,
});
});
});
describe('createMutations', () => {
it('creates mutations with correct implementations', () => {
const mutationTypes = generateMutationTypes('test');
const mutations = createMutations(mutationTypes);
const state = { uiFlags: { fetchingList: false } };
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
expect(state.uiFlags).toEqual({ fetchingList: true });
const metaState = { meta: {} };
mutations[mutationTypes.SET_META](metaState, {
total_count: '10',
page: '2',
});
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
expect(mutations[mutationTypes.UPSERT]).toBe(
MutationHelpers.setSingleRecord
);
});
});
describe('createCrudActions', () => {
let API;
let commit;
let mutationTypes;
let actions;
beforeEach(() => {
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
commit = vi.fn();
mutationTypes = generateMutationTypes('test');
actions = createCrudActions(API, mutationTypes);
});
describe('get action', () => {
it('handles successful API response', async () => {
const payload = [{ id: 1 }];
const meta = { total_count: 10, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual(payload);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('show action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.show.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('create action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.create.mockResolvedValue({ data });
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.create.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('update action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.update.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('delete action', () => {
it('handles successful API response', async () => {
API.delete.mockResolvedValue({});
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(API.delete).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual(1);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.delete.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
});
describe('createStore', () => {
it('creates a complete store with default options', () => {
const API = {};
const store = createStore({ name: 'test', API });
expect(store.namespaced).toBe(true);
expect(store.state).toEqual(createInitialState());
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
]);
expect(Object.keys(store.mutations)).toEqual([
'SET_TEST_UI_FLAG',
'SET_TEST_META',
'SET_TEST',
'ADD_TEST',
'EDIT_TEST',
'DELETE_TEST',
'UPSERT_TEST',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
]);
});
it('creates a store with custom actions and getters', () => {
const API = {};
const customGetters = { customGetter: () => 'custom' };
const customActions = () => ({
customAction: () => 'custom',
});
const store = createStore({
name: 'test',
API,
getters: customGetters,
actions: customActions,
});
expect(store.getters).toHaveProperty('customGetter');
expect(store.actions).toHaveProperty('customAction');
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
'customGetter',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
'customAction',
]);
});
});
});
@@ -1,77 +0,0 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
export const getRecords =
(mutationTypes, API) =>
async ({ commit }, params = {}) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
};
export const showRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
};
export const createRecord =
(mutationTypes, API) =>
async ({ commit }, dataObj) => {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
};
export const updateRecord =
(mutationTypes, API) =>
async ({ commit }, { id, ...updateObj }) => {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
};
export const deleteRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
};
-5
View File
@@ -51,9 +51,6 @@ import captainDocuments from './captain/document';
import captainResponses from './captain/response'; import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes'; import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions'; import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
const plugins = []; const plugins = [];
export default createStore({ export default createStore({
@@ -109,8 +106,6 @@ export default createStore({
captainResponses, captainResponses,
captainInboxes, captainInboxes,
captainBulkActions, captainBulkActions,
copilotThreads,
copilotMessages,
}, },
plugins, plugins,
}); });
@@ -172,17 +172,6 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false }); commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
} }
}, },
resetAccessToken: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetAccessToken(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
}; };
export const mutations = { export const mutations = {
@@ -213,16 +213,6 @@ export const actions = {
} }
}, },
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => { resendConfirmation: async () => {
try { try {
await authAPI.resendConfirmation(); await authAPI.resendConfirmation();
@@ -5,14 +5,12 @@ export const initialState = {
contactRecords: [], contactRecords: [],
conversationRecords: [], conversationRecords: [],
messageRecords: [], messageRecords: [],
articleRecords: [],
uiFlags: { uiFlags: {
isFetching: false, isFetching: false,
isSearchCompleted: false, isSearchCompleted: false,
contact: { isFetching: false }, contact: { isFetching: false },
conversation: { isFetching: false }, conversation: { isFetching: false },
message: { isFetching: false }, message: { isFetching: false },
article: { isFetching: false },
}, },
}; };
@@ -29,9 +27,6 @@ export const getters = {
getMessageRecords(state) { getMessageRecords(state) {
return state.messageRecords; return state.messageRecords;
}, },
getArticleRecords(state) {
return state.articleRecords;
},
getUIFlags(state) { getUIFlags(state) {
return state.uiFlags; return state.uiFlags;
}, },
@@ -70,7 +65,6 @@ export const actions = {
dispatch('contactSearch', { q }), dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }), dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }), dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
]); ]);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
@@ -114,17 +108,6 @@ export const actions = {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }); commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
} }
}, },
async articleSearch({ commit }, { q, page = 1 }) {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ q, page });
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
} catch (error) {
// Ignore error
} finally {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async clearSearchResults({ commit }) { async clearSearchResults({ commit }) {
commit(types.CLEAR_SEARCH_RESULTS); commit(types.CLEAR_SEARCH_RESULTS);
}, },
@@ -143,9 +126,6 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET](state, records) { [types.MESSAGE_SEARCH_SET](state, records) {
state.messageRecords = [...state.messageRecords, ...records]; state.messageRecords = [...state.messageRecords, ...records];
}, },
[types.ARTICLE_SEARCH_SET](state, records) {
state.articleRecords = [...state.articleRecords, ...records];
},
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) { [types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags }; state.uiFlags = { ...state.uiFlags, ...uiFlags };
}, },
@@ -161,14 +141,10 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) { [types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags }; state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
}, },
[types.ARTICLE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.article = { ...state.uiFlags.article, ...uiFlags };
},
[types.CLEAR_SEARCH_RESULTS](state) { [types.CLEAR_SEARCH_RESULTS](state) {
state.contactRecords = []; state.contactRecords = [];
state.conversationRecords = []; state.conversationRecords = [];
state.messageRecords = []; state.messageRecords = [];
state.articleRecords = [];
}, },
}; };
@@ -18,34 +18,13 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => { getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey)); return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
}, },
getFilteredConversations: ( getFilteredConversations: ({
{ allConversations, chatSortFilter, appliedFilters }, allConversations,
_, chatSortFilter,
__, appliedFilters,
rootGetters }) => {
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations return allConversations
.filter(conversation => { .filter(conversation => matchesFilters(conversation, appliedFilters))
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter)); .sort((a, b) => sortComparator(a, b, chatSortFilter));
}, },
getSelectedChat: ({ selectedChatId, allConversations }) => { getSelectedChat: ({ selectedChatId, allConversations }) => {
@@ -170,21 +170,4 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { ...agentBotRecords[0], access_token: 'new_token_123' },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken(
{ commit },
agentBotRecords[0].id
);
expect(commit.mock.calls).toEqual([
[types.EDIT_AGENT_BOT, mockResponse.data],
]);
expect(result).toBe(mockResponse.data);
});
});
}); });
@@ -228,20 +228,4 @@ describe('#actions', () => {
); );
}); });
}); });
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { id: 1, name: 'John', access_token: 'new_token_123' },
headers: { expiry: 581842904 },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER, mockResponse.data],
]);
expect(result).toBe(true);
});
});
}); });
@@ -75,7 +75,6 @@ describe('#actions', () => {
q: 'test', q: 'test',
}); });
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' }); expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
}); });
}); });
@@ -151,30 +150,6 @@ describe('#actions', () => {
}); });
}); });
describe('#articleSearch', () => {
it('should handle successful article search', async () => {
axios.get.mockResolvedValue({
data: { payload: { articles: [{ id: 1 }] } },
});
await actions.articleSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed article search', async () => {
axios.get.mockRejectedValue({});
await actions.articleSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#clearSearchResults', () => { describe('#clearSearchResults', () => {
it('should commit clear search results mutation', () => { it('should commit clear search results mutation', () => {
actions.clearSearchResults({ commit }); actions.clearSearchResults({ commit });
@@ -37,15 +37,6 @@ describe('#getters', () => {
]); ]);
}); });
it('getArticleRecords', () => {
const state = {
articleRecords: [{ id: 1, title: 'Article 1' }],
};
expect(getters.getArticleRecords(state)).toEqual([
{ id: 1, title: 'Article 1' },
]);
});
it('getUIFlags', () => { it('getUIFlags', () => {
const state = { const state = {
uiFlags: { uiFlags: {
@@ -54,7 +45,6 @@ describe('#getters', () => {
contact: { isFetching: true }, contact: { isFetching: true },
message: { isFetching: false }, message: { isFetching: false },
conversation: { isFetching: false }, conversation: { isFetching: false },
article: { isFetching: false },
}, },
}; };
expect(getters.getUIFlags(state)).toEqual({ expect(getters.getUIFlags(state)).toEqual({
@@ -63,7 +53,6 @@ describe('#getters', () => {
contact: { isFetching: true }, contact: { isFetching: true },
message: { isFetching: false }, message: { isFetching: false },
conversation: { isFetching: false }, conversation: { isFetching: false },
article: { isFetching: false },
}); });
}); });
}); });
@@ -101,39 +101,17 @@ describe('#mutations', () => {
}); });
}); });
describe('#ARTICLE_SEARCH_SET', () => {
it('should append new article records to existing ones', () => {
const state = { articleRecords: [{ id: 1 }] };
mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]);
expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => {
it('set article search UI flags correctly', () => {
const state = {
uiFlags: {
article: { isFetching: true },
},
};
mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.article).toEqual({ isFetching: false });
});
});
describe('#CLEAR_SEARCH_RESULTS', () => { describe('#CLEAR_SEARCH_RESULTS', () => {
it('should clear all search records', () => { it('should clear all search records', () => {
const state = { const state = {
contactRecords: [{ id: 1 }], contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }], conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }], messageRecords: [{ id: 1 }],
articleRecords: [{ id: 1 }],
}; };
mutations[types.CLEAR_SEARCH_RESULTS](state); mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]); expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]); expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]); expect(state.messageRecords).toEqual([]);
expect(state.articleRecords).toEqual([]);
}); });
}); });
}); });
@@ -325,308 +325,4 @@ describe('#getters', () => {
}); });
}); });
}); });
describe('#getFilteredConversations', () => {
const mockConversations = [
{
id: 1,
status: 'open',
meta: { assignee: { id: 1 } },
last_activity_at: 1000,
},
{
id: 2,
status: 'open',
meta: {},
last_activity_at: 2000,
},
{
id: 3,
status: 'resolved',
meta: { assignee: { id: 2 } },
last_activity_at: 3000,
},
];
const mockRootGetters = {
getCurrentUser: {
id: 1,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
getCurrentAccountId: 1,
};
it('filters conversations based on role permissions for administrator', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations based on role permissions for agent', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_unassigned_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should include conversation assigned to user (id: 1) and unassigned conversation
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
});
it('filters conversations for custom role with conversation_participating_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('filters conversations for custom role with no permissions', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: [],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should return empty array as user has no permissions
expect(result).toEqual([]);
});
it('applies filters and role permissions together', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
},
],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include open conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('returns empty array when no conversations match filters', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending'],
query_operator: 'and',
},
],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([]);
});
it('sorts filtered conversations according to chatSortFilter', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_asc',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([
mockConversations[0],
mockConversations[1],
mockConversations[2],
]);
});
});
}); });
@@ -317,10 +317,8 @@ export default {
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET', CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG', CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET', MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS', CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG', MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
ARTICLE_SEARCH_SET_UI_FLAG: 'ARTICLE_SEARCH_SET_UI_FLAG',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG', FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG: SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG', 'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
+17 -10
View File
@@ -2,7 +2,6 @@ import { createApp } from 'vue';
import VueDOMPurifyHTML from 'vue-dompurify-html'; import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer'; import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { directive as onClickaway } from 'vue3-click-away'; import { directive as onClickaway } from 'vue3-click-away';
import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify'; import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue'; import PublicArticleSearch from './components/PublicArticleSearch.vue';
@@ -28,23 +27,31 @@ export const getHeadingsfromTheArticle = () => {
export const openExternalLinksInNewTab = () => { export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig; const { customDomain, hostURL } = window.portalConfig;
const isSameHost =
window.location.href.includes(customDomain) ||
window.location.href.includes(hostURL);
// Modify external links only on articles page
const isOnArticlePage = const isOnArticlePage =
document.querySelector('#cw-article-content') !== null; isSameHost && document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => { document.addEventListener('click', event => {
if (!isOnArticlePage) return; if (!isOnArticlePage) return;
const link = event.target.closest('a'); // Some of the links come wrapped in strong tag through prosemirror
if (link) { const isTagAnchor = event.target.tagName === 'A';
const currentLocation = window.location.href; const isParentTagAnchor =
const linkHref = link.href; event.target.tagName === 'STRONG' &&
event.target.parentNode.tagName === 'A';
if (isTagAnchor || isParentTagAnchor) {
const link = isTagAnchor ? event.target : event.target.parentNode;
// Check against current location and custom domains
const isInternalLink = const isInternalLink =
isSameHost(linkHref, currentLocation) || link.hostname === window.location.hostname ||
(customDomain && isSameHost(linkHref, customDomain)) || link.href.includes(customDomain) ||
(hostURL && isSameHost(linkHref, hostURL)); link.href.includes(hostURL);
if (!isInternalLink) { if (!isInternalLink) {
link.target = '_blank'; link.target = '_blank';

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