Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0438a8465 | ||
|
|
fdd35ff549 | ||
|
|
2ea10ae065 | ||
|
|
23a804512a | ||
|
|
f6510e0d43 | ||
|
|
c3d98fc064 | ||
|
|
b5ebc47637 | ||
|
|
f916fb2924 | ||
|
|
dc335e88c9 | ||
|
|
b1120ae7fb | ||
|
|
443214e9a0 | ||
|
|
3ce026e2bc | ||
|
|
f42fddd38e | ||
|
|
22b5e12a53 | ||
|
|
03bde0a8aa | ||
|
|
3a0b5f387d |
+40
-40
@@ -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
|
||||||
|
|||||||
@@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
|
|||||||
|
|
||||||
# Do the set up required for chatwoot app
|
# Do the set up required for chatwoot app
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Copy dependency files first for better caching
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
COPY Gemfile Gemfile.lock ./
|
||||||
|
|
||||||
|
# Install dependencies (will be cached if files don't change)
|
||||||
|
RUN pnpm install --frozen-lockfile && \
|
||||||
|
gem install bundler && \
|
||||||
|
bundle install --jobs=$(nproc)
|
||||||
|
|
||||||
|
# Copy source code after dependencies are installed
|
||||||
COPY . /workspace
|
COPY . /workspace
|
||||||
RUN yarn && gem install bundler && bundle install
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
ARG VARIANT="ubuntu-22.04"
|
||||||
ARG VARIANT
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
|
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
ARG NODE_VERSION
|
ARG NODE_VERSION
|
||||||
ARG RUBY_VERSION
|
ARG RUBY_VERSION
|
||||||
ARG USER_UID
|
ARG USER_UID
|
||||||
ARG USER_GID
|
ARG USER_GID
|
||||||
|
ARG PNPM_VERSION="10.2.0"
|
||||||
|
ENV PNPM_VERSION ${PNPM_VERSION}
|
||||||
|
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
|
||||||
|
|
||||||
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
||||||
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
||||||
@@ -15,61 +19,80 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
|||||||
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
|
||||||
&& apt-get -y install --no-install-recommends \
|
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
build-essential \
|
&& apt-get update \
|
||||||
libssl-dev \
|
&& apt-get -y install --no-install-recommends \
|
||||||
zlib1g-dev \
|
build-essential \
|
||||||
gnupg2 \
|
libssl-dev \
|
||||||
tar \
|
zlib1g-dev \
|
||||||
tzdata \
|
gnupg \
|
||||||
postgresql-client \
|
tar \
|
||||||
libpq-dev \
|
tzdata \
|
||||||
yarn \
|
postgresql-client \
|
||||||
git \
|
libpq-dev \
|
||||||
imagemagick \
|
git \
|
||||||
tmux \
|
imagemagick \
|
||||||
zsh \
|
libyaml-dev \
|
||||||
git-flow \
|
curl \
|
||||||
npm \
|
ca-certificates \
|
||||||
libyaml-dev
|
tmux \
|
||||||
|
nodejs \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# Install rbenv and ruby
|
# Install rbenv and ruby for root user first
|
||||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||||
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
||||||
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
||||||
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
|
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
|
||||||
RUN git clone https://github.com/rbenv/ruby-build.git && \
|
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
|
||||||
PREFIX=/usr/local ./ruby-build/install.sh
|
PREFIX=/usr/local ./ruby-build/install.sh
|
||||||
|
|
||||||
RUN rbenv install $RUBY_VERSION && \
|
RUN rbenv install $RUBY_VERSION && \
|
||||||
rbenv global $RUBY_VERSION && \
|
rbenv global $RUBY_VERSION && \
|
||||||
rbenv versions
|
rbenv versions
|
||||||
|
|
||||||
# Install overmind
|
# Set up rbenv for vscode user
|
||||||
|
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
|
||||||
|
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
|
||||||
|
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
|
||||||
|
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
|
||||||
|
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
|
||||||
|
|
||||||
|
# Install overmind and gh in single layer
|
||||||
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
||||||
&& gunzip overmind.gz \
|
&& gunzip overmind.gz \
|
||||||
&& sudo mv overmind /usr/local/bin \
|
&& mv overmind /usr/local/bin \
|
||||||
&& chmod +x /usr/local/bin/overmind
|
&& chmod +x /usr/local/bin/overmind \
|
||||||
|
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||||
# Install gh
|
&& apt-get update \
|
||||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
&& apt-get install -y --no-install-recommends gh \
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
&& apt-get clean \
|
||||||
&& sudo apt update \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
&& sudo apt install gh
|
|
||||||
|
|
||||||
|
|
||||||
# Do the set up required for chatwoot app
|
# Do the set up required for chatwoot app
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY . /workspace
|
RUN chown vscode:vscode /workspace
|
||||||
|
|
||||||
# set up ruby
|
# set up node js and pnpm in single layer
|
||||||
COPY Gemfile Gemfile.lock ./
|
RUN npm install -g pnpm@${PNPM_VERSION} \
|
||||||
RUN gem install bundler && bundle install
|
&& npm cache clean --force
|
||||||
|
|
||||||
# set up node js
|
# Switch to vscode user
|
||||||
RUN npm install n -g && \
|
USER vscode
|
||||||
n $NODE_VERSION
|
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
|
||||||
RUN npm install --global yarn
|
|
||||||
RUN yarn
|
# Copy dependency files first for better caching
|
||||||
|
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies as vscode user
|
||||||
|
RUN eval "$(rbenv init -)" \
|
||||||
|
&& gem install bundler -N \
|
||||||
|
&& bundle install --jobs=$(nproc) \
|
||||||
|
&& pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code after dependencies are installed
|
||||||
|
COPY --chown=vscode:vscode . /workspace
|
||||||
|
|||||||
@@ -23,15 +23,15 @@
|
|||||||
// 5432 postgres
|
// 5432 postgres
|
||||||
// 6379 redis
|
// 6379 redis
|
||||||
// 1025,8025 mailhog
|
// 1025,8025 mailhog
|
||||||
"forwardPorts": [8025, 3000, 3035],
|
"forwardPorts": [8025, 3000, 3036],
|
||||||
|
|
||||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
|
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"3000": {
|
"3000": {
|
||||||
"label": "Rails Server"
|
"label": "Rails Server"
|
||||||
},
|
},
|
||||||
"3035": {
|
"3036": {
|
||||||
"label": "Webpack Dev Server"
|
"label": "Vite Dev Server"
|
||||||
},
|
},
|
||||||
"8025": {
|
"8025": {
|
||||||
"label": "Mailhog UI"
|
"label": "Mailhog UI"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Docker Compose file for building the base image in GitHub Actions
|
||||||
|
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
|
||||||
|
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
base:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: .devcontainer/Dockerfile.base
|
||||||
|
args:
|
||||||
|
VARIANT: 'ubuntu-22.04'
|
||||||
|
NODE_VERSION: '23.7.0'
|
||||||
|
RUBY_VERSION: '3.4.4'
|
||||||
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
|
USER_UID: '1000'
|
||||||
|
USER_GID: '1000'
|
||||||
|
image: ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||||
@@ -5,19 +5,6 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
base:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: .devcontainer/Dockerfile.base
|
|
||||||
args:
|
|
||||||
VARIANT: 'ubuntu-22.04'
|
|
||||||
NODE_VERSION: '23.7.0'
|
|
||||||
RUBY_VERSION: '3.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: ..
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ cp .env.example .env
|
|||||||
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
||||||
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
||||||
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
|
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
|
||||||
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
|
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
|
||||||
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
|
||||||
# uncomment the webpacker env variable
|
|
||||||
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
|
|
||||||
# fix the error with webpacker
|
|
||||||
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
|
|
||||||
|
|
||||||
# codespaces make the ports public
|
# codespaces make the ports public
|
||||||
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
|
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the Codespace Base Image
|
- name: Build the Codespace Base Image
|
||||||
run: |
|
run: |
|
||||||
docker-compose -f .devcontainer/docker-compose.yml build base
|
docker compose -f .devcontainer/docker-compose.base.yml build base
|
||||||
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
|
|
||||||
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
|||||||
@result = search('Message')
|
@result = search('Message')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def articles
|
||||||
|
@result = search('Article')
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def search(search_type)
|
def search(search_type)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CopilotMessages extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/copilot_threads', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(threadId) {
|
||||||
|
return axios.get(`${this.url}/${threadId}/copilot_messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create({ threadId, ...rest }) {
|
||||||
|
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CopilotMessages();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CopilotThreads extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/copilot_threads', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CopilotThreads();
|
||||||
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
articles({ q, page = 1 }) {
|
||||||
|
return axios.get(`${this.url}/articles`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
page: page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SearchAPI();
|
export default new SearchAPI();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs--container--with-border {
|
.tabs--container--with-border {
|
||||||
@apply border-b border-n-weak;
|
@apply border-b border-b-n-weak;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs--container--compact.tab--chat-type {
|
.tabs--container--compact.tab--chat-type {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
|
||||||
|
const { updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const showCopilotTab = computed(() =>
|
||||||
|
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
const isContactSidebarOpen = computed(
|
||||||
|
() => uiSettings.value.is_contact_sidebar_open
|
||||||
|
);
|
||||||
|
const isCopilotPanelOpen = computed(
|
||||||
|
() => uiSettings.value.is_copilot_panel_open
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleConversationSidebarToggle = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: !isContactSidebarOpen.value,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConversationSidebarToggle = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: true,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopilotSidebarToggle = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
is_copilot_panel_open: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboardEvents = {
|
||||||
|
'Alt+KeyO': {
|
||||||
|
action: toggleConversationSidebarToggle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
class="!rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2': isContactSidebarOpen,
|
||||||
|
}"
|
||||||
|
icon="i-ph-user-bold"
|
||||||
|
@click="handleConversationSidebarToggle"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="showCopilotTab"
|
||||||
|
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
class="!rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
||||||
|
}"
|
||||||
|
sm
|
||||||
|
icon="i-woot-captain"
|
||||||
|
@click="handleCopilotSidebarToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+12
-4
@@ -1,21 +1,29 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import CopilotHeader from './CopilotHeader.vue';
|
import SidebarActionsHeader from './SidebarActionsHeader.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Copilot/CopilotHeader"
|
title="Components/SidebarActionsHeader"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
>
|
>
|
||||||
<!-- Default State -->
|
<!-- Default State -->
|
||||||
<Variant title="Default State">
|
<Variant title="Default State">
|
||||||
<CopilotHeader />
|
<SidebarActionsHeader title="Default State" />
|
||||||
</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 -->
|
||||||
<CopilotHeader :has-messages="true" />
|
<SidebarActionsHeader
|
||||||
|
title="With New Conversation Button"
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
key: 'new_conversation',
|
||||||
|
icon: 'i-lucide-plus',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</Variant>
|
</Variant>
|
||||||
</Story>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from './button/Button.vue';
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click', 'close']);
|
||||||
|
|
||||||
|
const handleButtonClick = button => {
|
||||||
|
emit('click', button.key);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-1">
|
||||||
|
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button
|
||||||
|
v-for="button in buttons"
|
||||||
|
:key="button.key"
|
||||||
|
v-tooltip="button.tooltip"
|
||||||
|
:icon="button.icon"
|
||||||
|
ghost
|
||||||
|
sm
|
||||||
|
@click="handleButtonClick(button)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="$t('GENERAL.CLOSE')"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
ghost
|
||||||
|
sm
|
||||||
|
@click="$emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,13 +3,15 @@ import { nextTick, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
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 ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||||
import Icon from '../icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
supportAgent: {
|
supportAgent: {
|
||||||
@@ -54,10 +56,6 @@ const useSuggestion = opt => {
|
|||||||
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
emit('reset');
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatContainer = ref(null);
|
const chatContainer = ref(null);
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
@@ -82,6 +80,21 @@ const promptOptions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const closeCopilotPanel = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSidebarAction = action => {
|
||||||
|
if (action === 'reset') {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.messages, () => props.isCaptainTyping],
|
[() => props.messages, () => props.isCaptainTyping],
|
||||||
() => {
|
() => {
|
||||||
@@ -93,6 +106,18 @@ 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
|
||||||
|
:title="$t('CAPTAIN.COPILOT.TITLE')"
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
key: 'reset',
|
||||||
|
icon: 'i-lucide-refresh-ccw',
|
||||||
|
tooltip: $t('CAPTAIN.COPILOT.RESET'),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
@click="handleSidebarAction"
|
||||||
|
@close="closeCopilotPanel"
|
||||||
|
/>
|
||||||
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
||||||
<template v-for="message in messages" :key="message.id">
|
<template v-for="message in messages" :key="message.id">
|
||||||
<CopilotAgentMessage
|
<CopilotAgentMessage
|
||||||
@@ -139,14 +164,6 @@ watch(
|
|||||||
@set-assistant="$event => emit('setAssistant', $event)"
|
@set-assistant="$event => emit('setAssistant', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else />
|
<div v-else />
|
||||||
<button
|
|
||||||
v-if="messages.length"
|
|
||||||
class="text-xs flex items-center gap-1 hover:underline"
|
|
||||||
@click="handleReset"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-refresh-ccw" />
|
|
||||||
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -40,7 +40,7 @@ const senderName = computed(() => {
|
|||||||
<Icon :icon="icon" class="text-white size-4" />
|
<Icon :icon="icon" class="text-white size-4" />
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1 overflow-hidden">
|
||||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||||
{{
|
{{
|
||||||
t(senderTranslationKey, {
|
t(senderTranslationKey, {
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||||
<ul class="flex flex-col gap-2 m-0 list-none">
|
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
|
|||||||
@@ -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-[360px] 2xl:w-[420px]',
|
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -80,16 +80,14 @@ const toggleConversationLayout = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-2 px-4"
|
class="flex items-center justify-between gap-2 px-3 h-12"
|
||||||
:class="{
|
:class="{
|
||||||
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||||
'pt-3 pb-2': showV4View,
|
|
||||||
'mb-2 pb-0': !showV4View,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center min-w-0">
|
<div class="flex items-center justify-center min-w-0">
|
||||||
<h1
|
<h1
|
||||||
class="text-lg font-medium truncate text-n-slate-12"
|
class="text-base font-medium truncate text-n-slate-12"
|
||||||
:title="pageTitle"
|
:title="pageTitle"
|
||||||
>
|
>
|
||||||
{{ pageTitle }}
|
{{ pageTitle }}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
|
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium text-white dark:text-white">
|
<div class="text-sm font-medium text-white dark:text-white">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
@@ -29,7 +29,7 @@ export default {
|
|||||||
<router-link
|
<router-link
|
||||||
v-if="action.type == 'link'"
|
v-if="action.type == 'link'"
|
||||||
:to="action.to"
|
:to="action.to"
|
||||||
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
|
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand"
|
||||||
>
|
>
|
||||||
{{ action.message }}
|
{{ action.message }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -1,62 +1,72 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import WootSnackbar from './Snackbar.vue';
|
import WootSnackbar from './Snackbar.vue';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
duration: {
|
||||||
WootSnackbar,
|
type: Number,
|
||||||
},
|
default: 2500,
|
||||||
props: {
|
|
||||||
duration: {
|
|
||||||
type: Number,
|
|
||||||
default: 2500,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
data() {
|
const { t } = useI18n();
|
||||||
return {
|
|
||||||
snackMessages: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const snackMessages = ref([]);
|
||||||
emitter.on('newToastMessage', this.onNewToastMessage);
|
const snackbarContainer = ref(null);
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
emitter.off('newToastMessage', this.onNewToastMessage);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onNewToastMessage({ message: originalMessage, action }) {
|
|
||||||
// FIX ME: This is a temporary workaround to pass string from functions
|
|
||||||
// that doesn't have the context of the VueApp.
|
|
||||||
const usei18n = action?.usei18n;
|
|
||||||
const duration = action?.duration || this.duration;
|
|
||||||
const message = usei18n ? this.$t(originalMessage) : originalMessage;
|
|
||||||
|
|
||||||
this.snackMessages.push({
|
const showPopover = () => {
|
||||||
key: new Date().getTime(),
|
try {
|
||||||
message,
|
const el = snackbarContainer.value;
|
||||||
action,
|
if (el?.matches(':popover-open')) {
|
||||||
});
|
el.hidePopover();
|
||||||
window.setTimeout(() => {
|
}
|
||||||
this.snackMessages.splice(0, 1);
|
el?.showPopover();
|
||||||
}, duration);
|
} catch (e) {
|
||||||
},
|
// ignore
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onNewToastMessage = ({ message: originalMessage, action }) => {
|
||||||
|
const message = action?.usei18n ? t(originalMessage) : originalMessage;
|
||||||
|
const duration = action?.duration || props.duration;
|
||||||
|
|
||||||
|
snackMessages.value.push({
|
||||||
|
key: Date.now(),
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextTick(showPopover);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
snackMessages.value.shift();
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emitter.on('newToastMessage', onNewToastMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emitter.off('newToastMessage', onNewToastMessage);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<transition-group
|
<div
|
||||||
name="toast-fade"
|
ref="snackbarContainer"
|
||||||
tag="div"
|
popover="manual"
|
||||||
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
|
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible"
|
||||||
>
|
>
|
||||||
<WootSnackbar
|
<transition-group name="toast-fade" tag="div">
|
||||||
v-for="snackMessage in snackMessages"
|
<WootSnackbar
|
||||||
:key="snackMessage.key"
|
v-for="snackMessage in snackMessages"
|
||||||
:message="snackMessage.message"
|
:key="snackMessage.key"
|
||||||
:action="snackMessage.action"
|
:message="snackMessage.message"
|
||||||
/>
|
:action="snackMessage.action"
|
||||||
</transition-group>
|
/>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
<template>
|
<template>
|
||||||
<woot-tabs
|
<woot-tabs
|
||||||
:index="activeTabIndex"
|
:index="activeTabIndex"
|
||||||
class="w-full px-4 py-0 tab--chat-type"
|
class="w-full px-3 -mt-1 py-0 tab--chat-type"
|
||||||
@change="onTabChange"
|
@change="onTabChange"
|
||||||
>
|
>
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
|
class="text-sm"
|
||||||
:index="index"
|
:index="index"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:count="item.count"
|
:count="item.count"
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
|
|||||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||||
import EmptyState from './EmptyState/EmptyState.vue';
|
import EmptyState from './EmptyState/EmptyState.vue';
|
||||||
import MessagesView from './MessagesView.vue';
|
import MessagesView from './MessagesView.vue';
|
||||||
import ConversationSidebar from './ConversationSidebar.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ConversationSidebar,
|
|
||||||
ConversationHeader,
|
ConversationHeader,
|
||||||
DashboardAppFrame,
|
DashboardAppFrame,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
MessagesView,
|
MessagesView,
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
inboxId: {
|
inboxId: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
@@ -34,7 +31,6 @@ export default {
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ['contactPanelToggle'],
|
|
||||||
data() {
|
data() {
|
||||||
return { activeIndex: 0 };
|
return { activeIndex: 0 };
|
||||||
},
|
},
|
||||||
@@ -86,9 +82,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.$emit('contactPanelToggle');
|
|
||||||
},
|
|
||||||
onDashboardAppTabChange(index) {
|
onDashboardAppTabChange(index) {
|
||||||
this.activeIndex = index;
|
this.activeIndex = index;
|
||||||
},
|
},
|
||||||
@@ -98,7 +91,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="conversation-details-wrap bg-n-background"
|
class="conversation-details-wrap bg-n-background relative"
|
||||||
:class="{
|
:class="{
|
||||||
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
||||||
}"
|
}"
|
||||||
@@ -107,14 +100,12 @@ export default {
|
|||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:chat="currentChat"
|
:chat="currentChat"
|
||||||
:is-inbox-view="isInboxView"
|
:is-inbox-view="isInboxView"
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<woot-tabs
|
<woot-tabs
|
||||||
v-if="dashboardApps.length && currentChat.id"
|
v-if="dashboardApps.length && currentChat.id"
|
||||||
:index="activeIndex"
|
:index="activeIndex"
|
||||||
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
|
class="-mt-px dashboard-app--tabs border-t border-t-n-background"
|
||||||
@change="onDashboardAppTabChange"
|
@change="onDashboardAppTabChange"
|
||||||
>
|
>
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
@@ -130,18 +121,12 @@ export default {
|
|||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:inbox-id="inboxId"
|
:inbox-id="inboxId"
|
||||||
:is-inbox-view="isInboxView"
|
:is-inbox-view="isInboxView"
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="!currentChat.id && !isInboxView"
|
v-if="!currentChat.id && !isInboxView"
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
/>
|
/>
|
||||||
<ConversationSidebar
|
<slot />
|
||||||
v-if="showContactPanel"
|
|
||||||
:current-chat="currentChat"
|
|
||||||
@toggle-contact-panel="onToggleContactPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DashboardAppFrame
|
<DashboardAppFrame
|
||||||
v-for="(dashboardApp, index) in dashboardApps"
|
v-for="(dashboardApp, index) in dashboardApps"
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||||
:class="{
|
:class="{
|
||||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||||
isActiveChat,
|
isActiveChat,
|
||||||
@@ -278,7 +278,7 @@ export default {
|
|||||||
:badge="inboxBadge"
|
:badge="inboxBadge"
|
||||||
:username="currentContact.name"
|
:username="currentContact.name"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
size="40px"
|
size="32px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
||||||
import BackButton from '../BackButton.vue';
|
import BackButton from '../BackButton.vue';
|
||||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
@@ -29,11 +28,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
chat: {
|
chat: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => ({}),
|
||||||
},
|
|
||||||
isContactPanelOpen: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
},
|
||||||
showBackButton: {
|
showBackButton: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -44,15 +39,6 @@ export default {
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ['contactPanelToggle'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const keyboardEvents = {
|
|
||||||
'Alt+KeyO': {
|
|
||||||
action: () => emit('contactPanelToggle'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
@@ -99,13 +85,6 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||||
},
|
},
|
||||||
contactPanelToggleText() {
|
|
||||||
return `${
|
|
||||||
this.isContactPanelOpen
|
|
||||||
? this.$t('CONVERSATION.HEADER.CLOSE')
|
|
||||||
: this.$t('CONVERSATION.HEADER.OPEN')
|
|
||||||
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
|
|
||||||
},
|
|
||||||
inbox() {
|
inbox() {
|
||||||
const { inbox_id: inboxId } = this.chat;
|
const { inbox_id: inboxId } = this.chat;
|
||||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
return this.$store.getters['inboxes/getInbox'](inboxId);
|
||||||
@@ -133,7 +112,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
|
class="flex flex-col items-center justify-between px-3 py-2 border-b bg-n-background border-n-weak md:flex-row h-12"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
||||||
@@ -150,6 +129,7 @@ export default {
|
|||||||
:badge="inboxBadge"
|
:badge="inboxBadge"
|
||||||
:username="currentContact.name"
|
:username="currentContact.name"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
|
size="32px"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||||
@@ -157,9 +137,9 @@ export default {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
||||||
>
|
>
|
||||||
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
|
<NextButton link slate>
|
||||||
<span
|
<span
|
||||||
class="text-base font-medium truncate leading-tight text-n-slate-12"
|
class="text-sm font-medium truncate leading-tight text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
</span>
|
</span>
|
||||||
@@ -180,19 +160,11 @@ export default {
|
|||||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||||
{{ snoozedDisplayText }}
|
{{ snoozedDisplayText }}
|
||||||
</span>
|
</span>
|
||||||
<NextButton
|
|
||||||
link
|
|
||||||
xs
|
|
||||||
blue
|
|
||||||
:label="contactPanelToggleText"
|
|
||||||
@click="$emit('contactPanelToggle')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<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-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
||||||
:class="{ 'justify-end': isContactPanelOpen }"
|
|
||||||
>
|
>
|
||||||
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||||
<Linear
|
<Linear
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
||||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentChat: {
|
currentChat: {
|
||||||
@@ -14,33 +13,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['toggleContactPanel']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
||||||
|
|
||||||
const CONTACT_TABS_OPTIONS = [
|
|
||||||
{ 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 currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
@@ -49,29 +23,37 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
const showCopilotTab = computed(() =>
|
const showCopilotTab = computed(() =>
|
||||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
|
const activeTab = computed(() => {
|
||||||
|
const {
|
||||||
|
is_contact_sidebar_open: isContactSidebarOpen,
|
||||||
|
is_copilot_panel_open: isCopilotPanelOpen,
|
||||||
|
} = uiSettings.value;
|
||||||
|
|
||||||
|
if (isContactSidebarOpen) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (isCopilotPanelOpen) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-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-if="!activeTab"
|
v-show="activeTab === 0"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:inbox-id="currentChat.inbox_id"
|
:inbox-id="currentChat.inbox_id"
|
||||||
:on-toggle="toggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<CopilotContainer
|
<CopilotContainer
|
||||||
v-else-if="activeTab === 1 && showCopilotTab"
|
v-show="activeTab === 1 && showCopilotTab"
|
||||||
:key="currentChat.id"
|
:key="currentChat.id"
|
||||||
:conversation-inbox-type="channelType"
|
:conversation-inbox-type="channelType"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
|||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
@@ -47,20 +45,8 @@ 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);
|
||||||
@@ -203,12 +189,6 @@ export default {
|
|||||||
isATweet() {
|
isATweet() {
|
||||||
return this.conversationType === 'tweet';
|
return this.conversationType === 'tweet';
|
||||||
},
|
},
|
||||||
isRightOrLeftIcon() {
|
|
||||||
if (this.isContactPanelOpen) {
|
|
||||||
return 'arrow-chevron-right';
|
|
||||||
}
|
|
||||||
return 'arrow-chevron-left';
|
|
||||||
},
|
|
||||||
getLastSeenAt() {
|
getLastSeenAt() {
|
||||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||||
return contactLastSeenAt;
|
return contactLastSeenAt;
|
||||||
@@ -444,9 +424,6 @@ export default {
|
|||||||
relevantMessages
|
relevantMessages
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.$emit('contactPanelToggle');
|
|
||||||
},
|
|
||||||
setScrollParams() {
|
setScrollParams() {
|
||||||
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
||||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||||
@@ -530,19 +507,6 @@ 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"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
'conversation.read': this.onConversationRead,
|
'conversation.read': this.onConversationRead,
|
||||||
'conversation.updated': this.onConversationUpdated,
|
'conversation.updated': this.onConversationUpdated,
|
||||||
'account.cache_invalidated': this.onCacheInvalidate,
|
'account.cache_invalidated': this.onCacheInvalidate,
|
||||||
|
'copilot.message.created': this.onCopilotMessageCreated,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.app.$store.dispatch('notifications/updateNotification', data);
|
this.app.$store.dispatch('notifications/updateNotification', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onCopilotMessageCreated = data => {
|
||||||
|
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||||
|
};
|
||||||
|
|
||||||
onCacheInvalidate = data => {
|
onCacheInvalidate = data => {
|
||||||
const keys = data.cache_keys;
|
const keys = data.cache_keys;
|
||||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||||
|
import ActionCableConnector from '../actionCable';
|
||||||
|
|
||||||
|
vi.mock('shared/helpers/mitt', () => ({
|
||||||
|
emitter: {
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/useImpersonation', () => ({
|
||||||
|
useImpersonation: () => ({
|
||||||
|
isImpersonating: { value: false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.chatwootConfig = {
|
||||||
|
websocketURL: 'wss://test.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ActionCableConnector - Copilot Tests', () => {
|
||||||
|
let store;
|
||||||
|
let actionCable;
|
||||||
|
let mockDispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDispatch = vi.fn();
|
||||||
|
store = {
|
||||||
|
$store: {
|
||||||
|
dispatch: mockDispatch,
|
||||||
|
getters: {
|
||||||
|
getCurrentAccountId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||||
|
});
|
||||||
|
describe('copilot event handlers', () => {
|
||||||
|
it('should register the copilot.message.created event handler', () => {
|
||||||
|
expect(Object.keys(actionCable.events)).toContain(
|
||||||
|
'copilot.message.created'
|
||||||
|
);
|
||||||
|
expect(actionCable.events['copilot.message.created']).toBe(
|
||||||
|
actionCable.onCopilotMessageCreated
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the copilot.message.created event through the ActionCable system', () => {
|
||||||
|
const copilotData = {
|
||||||
|
id: 2,
|
||||||
|
content: 'This is a copilot message from ActionCable',
|
||||||
|
conversation_id: 456,
|
||||||
|
created_at: '2025-05-27T15:58:04-06:00',
|
||||||
|
account_id: 1,
|
||||||
|
};
|
||||||
|
actionCable.onReceived({
|
||||||
|
event: 'copilot.message.created',
|
||||||
|
data: copilotData,
|
||||||
|
});
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith(
|
||||||
|
'copilotMessages/upsert',
|
||||||
|
copilotData
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"PHONE_INPUT": {
|
"PHONE_INPUT": {
|
||||||
"PLACEHOLDER": "Search",
|
"PLACEHOLDER": "Search",
|
||||||
"EMPTY_STATE": "No results found"
|
"EMPTY_STATE": "No results found"
|
||||||
}
|
},
|
||||||
|
"CLOSE": "Close"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"ALL": "All",
|
"ALL": "All",
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages",
|
||||||
|
"ARTICLES": "Articles"
|
||||||
},
|
},
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"CONTACTS": "Contacts",
|
"CONTACTS": "Contacts",
|
||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages",
|
||||||
|
"ARTICLES": "Articles"
|
||||||
},
|
},
|
||||||
"VIEW_MORE": "View more",
|
"VIEW_MORE": "View more",
|
||||||
"LOAD_MORE": "Load more",
|
"LOAD_MORE": "Load more",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
|
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: { type: [String, Number], default: 0 },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
category: { type: String, default: '' },
|
||||||
|
locale: { type: String, default: '' },
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
portalSlug: { type: String, required: true },
|
||||||
|
accountId: { type: [String, Number], default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_LENGTH = 300;
|
||||||
|
|
||||||
|
const navigateTo = computed(() => {
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncatedContent = computed(() => {
|
||||||
|
if (!props.content) return props.description || '';
|
||||||
|
|
||||||
|
// Use MessageFormatter to properly convert markdown to plain text
|
||||||
|
const formatter = new MessageFormatter(props.content);
|
||||||
|
const plainText = formatter.plainText.trim();
|
||||||
|
|
||||||
|
return plainText.length > MAX_LENGTH
|
||||||
|
? `${plainText.substring(0, MAX_LENGTH)}...`
|
||||||
|
: plainText;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-link
|
||||||
|
:to="navigateTo"
|
||||||
|
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
|
||||||
|
</div>
|
||||||
|
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
|
||||||
|
{{ title }}
|
||||||
|
</h5>
|
||||||
|
<span
|
||||||
|
v-if="category"
|
||||||
|
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="truncatedContent"
|
||||||
|
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ truncatedContent }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
|
import SearchResultArticleItem from './SearchResultArticleItem.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
articles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isFetching: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SearchResultSection
|
||||||
|
:title="$t('SEARCH.SECTION.ARTICLES')"
|
||||||
|
:empty="!articles.length"
|
||||||
|
:query="query"
|
||||||
|
:show-title="showTitle"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
>
|
||||||
|
<ul v-if="articles.length" class="space-y-1.5 list-none">
|
||||||
|
<li v-for="article in articles" :key="article.id">
|
||||||
|
<SearchResultArticleItem
|
||||||
|
:id="article.id"
|
||||||
|
:title="article.title"
|
||||||
|
:description="article.description"
|
||||||
|
:content="article.content"
|
||||||
|
:portal-slug="article.portal_slug"
|
||||||
|
:locale="article.locale"
|
||||||
|
:account-id="accountId"
|
||||||
|
:category="article.category_name"
|
||||||
|
:status="article.status"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</SearchResultSection>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ROLES,
|
ROLES,
|
||||||
CONVERSATION_PERMISSIONS,
|
CONVERSATION_PERMISSIONS,
|
||||||
CONTACT_PERMISSIONS,
|
CONTACT_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
} from 'dashboard/constants/permissions.js';
|
} from 'dashboard/constants/permissions.js';
|
||||||
import {
|
import {
|
||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
@@ -22,6 +23,7 @@ import SearchTabs from './SearchTabs.vue';
|
|||||||
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
||||||
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
||||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||||
|
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -34,6 +36,7 @@ const pages = ref({
|
|||||||
contacts: 1,
|
contacts: 1,
|
||||||
conversations: 1,
|
conversations: 1,
|
||||||
messages: 1,
|
messages: 1,
|
||||||
|
articles: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
@@ -43,6 +46,7 @@ const conversationRecords = useMapGetter(
|
|||||||
'conversationSearch/getConversationRecords'
|
'conversationSearch/getConversationRecords'
|
||||||
);
|
);
|
||||||
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
||||||
|
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
||||||
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
||||||
|
|
||||||
const addTypeToRecords = (records, type) =>
|
const addTypeToRecords = (records, type) =>
|
||||||
@@ -57,6 +61,9 @@ const mappedConversations = computed(() =>
|
|||||||
const mappedMessages = computed(() =>
|
const mappedMessages = computed(() =>
|
||||||
addTypeToRecords(messageRecords, 'message')
|
addTypeToRecords(messageRecords, 'message')
|
||||||
);
|
);
|
||||||
|
const mappedArticles = computed(() =>
|
||||||
|
addTypeToRecords(articleRecords, 'article')
|
||||||
|
);
|
||||||
|
|
||||||
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ const sliceRecordsIfAllTab = items =>
|
|||||||
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
||||||
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
||||||
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
||||||
|
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
||||||
|
|
||||||
const filterByTab = tab =>
|
const filterByTab = tab =>
|
||||||
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
||||||
@@ -73,6 +81,7 @@ const filterByTab = tab =>
|
|||||||
const filterContacts = filterByTab('contacts');
|
const filterContacts = filterByTab('contacts');
|
||||||
const filterConversations = filterByTab('conversations');
|
const filterConversations = filterByTab('conversations');
|
||||||
const filterMessages = filterByTab('messages');
|
const filterMessages = filterByTab('messages');
|
||||||
|
const filterArticles = filterByTab('articles');
|
||||||
|
|
||||||
const userPermissions = computed(() =>
|
const userPermissions = computed(() =>
|
||||||
getUserPermissions(currentUser.value, currentAccountId.value)
|
getUserPermissions(currentUser.value, currentAccountId.value)
|
||||||
@@ -80,7 +89,12 @@ const userPermissions = computed(() =>
|
|||||||
|
|
||||||
const TABS_CONFIG = {
|
const TABS_CONFIG = {
|
||||||
all: {
|
all: {
|
||||||
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [
|
||||||
|
CONTACT_PERMISSIONS,
|
||||||
|
...ROLES,
|
||||||
|
...CONVERSATION_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
],
|
||||||
count: () => null, // No count for all tab
|
count: () => null, // No count for all tab
|
||||||
},
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
@@ -95,6 +109,10 @@ const TABS_CONFIG = {
|
|||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => mappedMessages.value.length,
|
count: () => mappedMessages.value.length,
|
||||||
},
|
},
|
||||||
|
articles: {
|
||||||
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
count: () => mappedArticles.value.length,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
@@ -123,6 +141,10 @@ const totalSearchResultsCount = computed(() => {
|
|||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => conversations.value.length + messages.value.length,
|
count: () => conversations.value.length + messages.value.length,
|
||||||
},
|
},
|
||||||
|
articles: {
|
||||||
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
count: () => articles.value.length,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return filterItemsByPermission(
|
return filterItemsByPermission(
|
||||||
permissionCounts,
|
permissionCounts,
|
||||||
@@ -138,12 +160,13 @@ const activeTabIndex = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFetchingAny = computed(() => {
|
const isFetchingAny = computed(() => {
|
||||||
const { contact, message, conversation, isFetching } = uiFlags.value;
|
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
||||||
return (
|
return (
|
||||||
isFetching ||
|
isFetching ||
|
||||||
contact.isFetching ||
|
contact.isFetching ||
|
||||||
message.isFetching ||
|
message.isFetching ||
|
||||||
conversation.isFetching
|
conversation.isFetching ||
|
||||||
|
article.isFetching
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +194,7 @@ const showLoadMore = computed(() => {
|
|||||||
contacts: mappedContacts.value,
|
contacts: mappedContacts.value,
|
||||||
conversations: mappedConversations.value,
|
conversations: mappedConversations.value,
|
||||||
messages: mappedMessages.value,
|
messages: mappedMessages.value,
|
||||||
|
articles: mappedArticles.value,
|
||||||
}[selectedTab.value];
|
}[selectedTab.value];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,10 +209,11 @@ const showViewMore = computed(() => ({
|
|||||||
conversations:
|
conversations:
|
||||||
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
||||||
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
||||||
|
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const clearSearchResult = () => {
|
const clearSearchResult = () => {
|
||||||
pages.value = { contacts: 1, conversations: 1, messages: 1 };
|
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
||||||
store.dispatch('conversationSearch/clearSearchResults');
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +239,7 @@ const loadMore = () => {
|
|||||||
contacts: 'conversationSearch/contactSearch',
|
contacts: 'conversationSearch/contactSearch',
|
||||||
conversations: 'conversationSearch/conversationSearch',
|
conversations: 'conversationSearch/conversationSearch',
|
||||||
messages: 'conversationSearch/messageSearch',
|
messages: 'conversationSearch/messageSearch',
|
||||||
|
articles: 'conversationSearch/articleSearch',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
||||||
@@ -328,6 +354,28 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</Policy>
|
</Policy>
|
||||||
|
|
||||||
|
<Policy
|
||||||
|
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||||
|
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,6 +3,7 @@ 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';
|
||||||
@@ -12,7 +13,12 @@ export const routes = [
|
|||||||
path: frontendURL('accounts/:accountId/search'),
|
path: frontendURL('accounts/:accountId/search'),
|
||||||
name: 'search',
|
name: 'search',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
|
permissions: [
|
||||||
|
...ROLES,
|
||||||
|
...CONVERSATION_PERMISSIONS,
|
||||||
|
CONTACT_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
component: SearchView,
|
component: SearchView,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 '../../../components/widgets/conversation/ShopifyOrdersList.vue';
|
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
|
||||||
|
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversationId: {
|
conversationId: {
|
||||||
@@ -29,10 +29,6 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
onToggle: {
|
|
||||||
type: Function,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -89,8 +85,6 @@ 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({
|
||||||
@@ -98,6 +92,13 @@ 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();
|
||||||
@@ -107,11 +108,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<ContactInfo
|
<SidebarActionsHeader
|
||||||
:contact="contact"
|
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
:channel-type="channelType"
|
@close="closeContactPanel"
|
||||||
@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,6 +10,8 @@ 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: {
|
||||||
@@ -17,6 +19,8 @@ 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
|
||||||
@@ -87,13 +91,17 @@ export default {
|
|||||||
this.uiSettings;
|
this.uiSettings;
|
||||||
return conversationDisplayType !== CONDENSED;
|
return conversationDisplayType !== CONDENSED;
|
||||||
},
|
},
|
||||||
isContactPanelOpen() {
|
|
||||||
if (this.currentChat.id) {
|
shouldShowSidebar() {
|
||||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
if (!this.currentChat.id) {
|
||||||
this.uiSettings;
|
return false;
|
||||||
return isContactSidebarOpen;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
const {
|
||||||
|
is_contact_sidebar_open: isContactSidebarOpen,
|
||||||
|
is_copilot_panel_open: isCopilotPanelOpen,
|
||||||
|
} = this.uiSettings;
|
||||||
|
return isContactSidebarOpen || isCopilotPanelOpen;
|
||||||
},
|
},
|
||||||
showPopOverSearch() {
|
showPopOverSearch() {
|
||||||
return !this.isFeatureEnabledonAccount(
|
return !this.isFeatureEnabledonAccount(
|
||||||
@@ -189,11 +197,6 @@ 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,10 +228,11 @@ 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>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const PortalsSettingsIndexPage = () =>
|
|||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
permissions: ['administrator', 'knowledge_base_manage'],
|
||||||
};
|
};
|
||||||
const portalRoutes = [
|
const portalRoutes = [
|
||||||
{
|
{
|
||||||
@@ -85,19 +85,13 @@ const portalRoutes = [
|
|||||||
{
|
{
|
||||||
path: getPortalRoute('new'),
|
path: getPortalRoute('new'),
|
||||||
name: 'portals_new',
|
name: 'portals_new',
|
||||||
meta: {
|
meta,
|
||||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
|
||||||
permissions: ['administrator', 'knowledge_base_manage'],
|
|
||||||
},
|
|
||||||
component: PortalsNew,
|
component: PortalsNew,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: getPortalRoute(':navigationPath'),
|
path: getPortalRoute(':navigationPath'),
|
||||||
name: 'portals_index',
|
name: 'portals_index',
|
||||||
meta: {
|
meta,
|
||||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
|
||||||
permissions: ['administrator', 'knowledge_base_manage'],
|
|
||||||
},
|
|
||||||
component: PortalsIndex,
|
component: PortalsIndex,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: mutationTypes => ({
|
||||||
|
upsert({ commit }, data) {
|
||||||
|
commit(mutationTypes.UPSERT, data);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
|
||||||
|
import { createStore } from './storeFactory';
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
name: 'CopilotThreads',
|
||||||
|
API: CopilotThreadsAPI,
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
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();
|
||||||
@@ -10,6 +16,7 @@ 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}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +40,6 @@ 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 = {
|
||||||
@@ -51,78 +57,19 @@ 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) => ({
|
||||||
async get({ commit }, params = {}) {
|
get: getRecords(mutationTypes, API),
|
||||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
show: showRecord(mutationTypes, API),
|
||||||
try {
|
create: createRecord(mutationTypes, API),
|
||||||
const response = await API.get(params);
|
update: updateRecord(mutationTypes, API),
|
||||||
commit(mutationTypes.SET, response.data.payload);
|
delete: deleteRecord(mutationTypes, API),
|
||||||
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 } = options;
|
const { name, API, actions, getters } = options;
|
||||||
const mutationTypes = generateMutationTypes(name);
|
const mutationTypes = generateMutationTypes(name);
|
||||||
|
|
||||||
const customActions = actions ? actions(mutationTypes) : {};
|
const customActions = actions ? actions(mutationTypes) : {};
|
||||||
@@ -130,7 +77,10 @@ export const createStore = options => {
|
|||||||
return {
|
return {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state: createInitialState(),
|
state: createInitialState(),
|
||||||
getters: createGetters(),
|
getters: {
|
||||||
|
...createGetters(),
|
||||||
|
...(getters || {}),
|
||||||
|
},
|
||||||
mutations: createMutations(mutationTypes),
|
mutations: createMutations(mutationTypes),
|
||||||
actions: {
|
actions: {
|
||||||
...createCrudActions(API, mutationTypes),
|
...createCrudActions(API, mutationTypes),
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -51,6 +51,9 @@ 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({
|
||||||
@@ -106,6 +109,8 @@ export default createStore({
|
|||||||
captainResponses,
|
captainResponses,
|
||||||
captainInboxes,
|
captainInboxes,
|
||||||
captainBulkActions,
|
captainBulkActions,
|
||||||
|
copilotThreads,
|
||||||
|
copilotMessages,
|
||||||
},
|
},
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ 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 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +29,9 @@ 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;
|
||||||
},
|
},
|
||||||
@@ -65,6 +70,7 @@ 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
|
||||||
@@ -108,6 +114,17 @@ 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);
|
||||||
},
|
},
|
||||||
@@ -126,6 +143,9 @@ 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 };
|
||||||
},
|
},
|
||||||
@@ -141,10 +161,14 @@ 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 = [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ describe('#actions', () => {
|
|||||||
q: 'test',
|
q: 'test',
|
||||||
});
|
});
|
||||||
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
|
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +151,30 @@ 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,6 +37,15 @@ 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: {
|
||||||
@@ -45,6 +54,7 @@ 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({
|
||||||
@@ -53,6 +63,7 @@ describe('#getters', () => {
|
|||||||
contact: { isFetching: true },
|
contact: { isFetching: true },
|
||||||
message: { isFetching: false },
|
message: { isFetching: false },
|
||||||
conversation: { isFetching: false },
|
conversation: { isFetching: false },
|
||||||
|
article: { isFetching: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,17 +101,39 @@ 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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -317,8 +317,10 @@ 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',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
||||||
@@ -27,31 +28,23 @@ 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 =
|
||||||
isSameHost && document.querySelector('#cw-article-content') !== null;
|
document.querySelector('#cw-article-content') !== null;
|
||||||
|
|
||||||
document.addEventListener('click', event => {
|
document.addEventListener('click', event => {
|
||||||
if (!isOnArticlePage) return;
|
if (!isOnArticlePage) return;
|
||||||
|
|
||||||
// Some of the links come wrapped in strong tag through prosemirror
|
const link = event.target.closest('a');
|
||||||
|
|
||||||
const isTagAnchor = event.target.tagName === 'A';
|
if (link) {
|
||||||
const isParentTagAnchor =
|
const currentLocation = window.location.href;
|
||||||
event.target.tagName === 'STRONG' &&
|
const linkHref = link.href;
|
||||||
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 =
|
||||||
link.hostname === window.location.hostname ||
|
isSameHost(linkHref, currentLocation) ||
|
||||||
link.href.includes(customDomain) ||
|
(customDomain && isSameHost(linkHref, customDomain)) ||
|
||||||
link.href.includes(hostURL);
|
(hostURL && isSameHost(linkHref, hostURL));
|
||||||
|
|
||||||
if (!isInternalLink) {
|
if (!isInternalLink) {
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
import { InitializationHelpers } from '../portalHelpers';
|
import {
|
||||||
|
InitializationHelpers,
|
||||||
|
openExternalLinksInNewTab,
|
||||||
|
} from '../portalHelpers';
|
||||||
|
|
||||||
describe('InitializationHelpers.navigateToLocalePage', () => {
|
describe('InitializationHelpers.navigateToLocalePage', () => {
|
||||||
let dom;
|
let dom;
|
||||||
@@ -44,3 +47,139 @@ describe('InitializationHelpers.navigateToLocalePage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('openExternalLinksInNewTab', () => {
|
||||||
|
let dom;
|
||||||
|
let document;
|
||||||
|
let window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dom = new JSDOM(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div id="cw-article-content">
|
||||||
|
<a href="https://external.com" id="external">External</a>
|
||||||
|
<a href="https://app.chatwoot.com/page" id="internal">Internal</a>
|
||||||
|
<a href="https://custom.domain.com/page" id="custom">Custom</a>
|
||||||
|
<a href="https://example.com" id="nested"><code>Code</code><strong>Bold</strong></a>
|
||||||
|
<ul>
|
||||||
|
<li>Visit the preferences centre here > <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ url: 'https://app.chatwoot.com/hc/article' }
|
||||||
|
);
|
||||||
|
|
||||||
|
document = dom.window.document;
|
||||||
|
window = dom.window;
|
||||||
|
|
||||||
|
window.portalConfig = {
|
||||||
|
customDomain: 'custom.domain.com',
|
||||||
|
hostURL: 'app.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.document = document;
|
||||||
|
global.window = window;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dom = null;
|
||||||
|
document = null;
|
||||||
|
window = null;
|
||||||
|
delete global.document;
|
||||||
|
delete global.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
const simulateClick = selector => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
const event = new window.MouseEvent('click', { bubbles: true });
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
return element.closest('a') || element;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('opens external links in new tab', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const link = simulateClick('#external');
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves internal links', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const internal = simulateClick('#internal');
|
||||||
|
const custom = simulateClick('#custom');
|
||||||
|
|
||||||
|
expect(internal.target).not.toBe('_blank');
|
||||||
|
expect(custom.target).not.toBe('_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles clicks on nested elements', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
simulateClick('#nested code');
|
||||||
|
simulateClick('#nested strong');
|
||||||
|
|
||||||
|
const link = document.getElementById('nested');
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles links inside list items with strong tags', () => {
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
// Click on the strong element inside the link in the list
|
||||||
|
simulateClick('#list-link strong');
|
||||||
|
|
||||||
|
const link = document.getElementById('list-link');
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens external links in a new tab even if customDomain is empty', () => {
|
||||||
|
window = dom.window;
|
||||||
|
window.portalConfig = {
|
||||||
|
hostURL: 'app.chatwoot.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.window = window;
|
||||||
|
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const link = simulateClick('#external');
|
||||||
|
const internal = simulateClick('#internal');
|
||||||
|
const custom = simulateClick('#custom');
|
||||||
|
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
|
||||||
|
expect(internal.target).not.toBe('_blank');
|
||||||
|
// this will be blank since the configs customDomain is empty
|
||||||
|
// which is a fair expectation
|
||||||
|
expect(custom.target).toBe('_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens external links in a new tab even if hostURL is empty', () => {
|
||||||
|
window = dom.window;
|
||||||
|
window.portalConfig = {
|
||||||
|
customDomain: 'custom.domain.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.window = window;
|
||||||
|
|
||||||
|
openExternalLinksInNewTab();
|
||||||
|
|
||||||
|
const link = simulateClick('#external');
|
||||||
|
const internal = simulateClick('#internal');
|
||||||
|
const custom = simulateClick('#custom');
|
||||||
|
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
|
||||||
|
expect(internal.target).not.toBe('_blank');
|
||||||
|
expect(custom.target).not.toBe('_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module ActivityMessageHandler
|
|||||||
include LabelActivityMessageHandler
|
include LabelActivityMessageHandler
|
||||||
include SlaActivityMessageHandler
|
include SlaActivityMessageHandler
|
||||||
include TeamActivityMessageHandler
|
include TeamActivityMessageHandler
|
||||||
|
include CsatActivityMessageHandler
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module CsatActivityMessageHandler
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def create_csat_not_sent_activity_message
|
||||||
|
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||||
|
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,17 +6,18 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
|||||||
# so this limits it
|
# so this limits it
|
||||||
ACTIVITY_NOTE_MAX_SIZE = 1800
|
ACTIVITY_NOTE_MAX_SIZE = 1800
|
||||||
|
|
||||||
def self.map_conversation_activity(conversation)
|
def self.map_conversation_activity(hook, conversation)
|
||||||
new(conversation).conversation_activity
|
new(hook, conversation).conversation_activity
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.map_transcript_activity(conversation, messages = nil)
|
def self.map_transcript_activity(hook, conversation)
|
||||||
new(conversation, messages).transcript_activity
|
new(hook, conversation).transcript_activity
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(conversation, messages = nil)
|
def initialize(hook, conversation)
|
||||||
|
@hook = hook
|
||||||
|
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
@messages = messages
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_activity
|
def conversation_activity
|
||||||
@@ -41,14 +42,14 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :conversation, :messages
|
attr_reader :conversation
|
||||||
|
|
||||||
def formatted_creation_time
|
def formatted_creation_time
|
||||||
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
end
|
end
|
||||||
|
|
||||||
def transcript_messages
|
def transcript_messages
|
||||||
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
|
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_messages
|
def format_messages
|
||||||
@@ -77,8 +78,7 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def message_time(message)
|
def message_time(message)
|
||||||
# TODO: Figure out what timezone to send the time in
|
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
|
||||||
message.created_at.strftime('%Y-%m-%d %H:%M')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sender_name(message)
|
def sender_name(message)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
|||||||
activity_type: 'conversation',
|
activity_type: 'conversation',
|
||||||
activity_code_key: 'conversation_activity_code',
|
activity_code_key: 'conversation_activity_code',
|
||||||
metadata_key: 'created_activity_id',
|
metadata_key: 'created_activity_id',
|
||||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation)
|
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
|||||||
activity_type: 'transcript',
|
activity_type: 'transcript',
|
||||||
activity_code_key: 'transcript_activity_code',
|
activity_code_key: 'transcript_activity_code',
|
||||||
metadata_key: 'transcript_activity_id',
|
metadata_key: 'transcript_activity_id',
|
||||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation)
|
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ class Crm::Leadsquared::SetupService
|
|||||||
response = @client.get('Authentication.svc/UserByAccessKey.Get')
|
response = @client.get('Authentication.svc/UserByAccessKey.Get')
|
||||||
endpoint_host = response['LSQCommonServiceURLs']['api']
|
endpoint_host = response['LSQCommonServiceURLs']['api']
|
||||||
app_host = response['LSQCommonServiceURLs']['app']
|
app_host = response['LSQCommonServiceURLs']['app']
|
||||||
|
timezone = response['TimeZone']
|
||||||
|
|
||||||
endpoint_url = "https://#{endpoint_host}/v2/"
|
endpoint_url = "https://#{endpoint_host}/v2/"
|
||||||
app_url = "https://#{app_host}/"
|
app_url = "https://#{app_host}/"
|
||||||
|
|
||||||
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
|
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone })
|
||||||
|
|
||||||
# replace the clients
|
# replace the clients
|
||||||
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
|
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
|
|||||||
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
|
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
|
||||||
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
|
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
|
||||||
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
|
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
|
||||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
|
handle_csat_survey
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_send_out_of_office_message?
|
def should_send_out_of_office_message?
|
||||||
@@ -65,13 +65,26 @@ class MessageTemplates::HookExecutionService
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_send_csat_survey?
|
def handle_csat_survey
|
||||||
return unless csat_enabled_conversation?
|
return unless csat_enabled_conversation?
|
||||||
|
|
||||||
# only send CSAT once in a conversation
|
# only send CSAT once in a conversation
|
||||||
return if conversation.messages.where(content_type: :input_csat).present?
|
return if csat_already_sent?
|
||||||
|
|
||||||
true
|
# Only send CSAT if agent can still reply by checking the messaging window restriction
|
||||||
|
# https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
|
||||||
|
if within_messaging_window?
|
||||||
|
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||||
|
else
|
||||||
|
conversation.create_csat_not_sent_activity_message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def csat_already_sent?
|
||||||
|
conversation.messages.where(content_type: :input_csat).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def within_messaging_window?
|
||||||
|
conversation.can_reply?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
|
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ class SearchService
|
|||||||
{ conversations: filter_conversations }
|
{ conversations: filter_conversations }
|
||||||
when 'Contact'
|
when 'Contact'
|
||||||
{ contacts: filter_contacts }
|
{ contacts: filter_contacts }
|
||||||
|
when 'Article'
|
||||||
|
{ articles: filter_articles }
|
||||||
else
|
else
|
||||||
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations }
|
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations, articles: filter_articles }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -90,4 +92,12 @@ class SearchService
|
|||||||
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
|
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
|
||||||
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
|
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_articles
|
||||||
|
@articles = current_account.articles
|
||||||
|
.text_search(search_query)
|
||||||
|
.reorder('updated_at DESC')
|
||||||
|
.page(params[:page])
|
||||||
|
.per(15)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
json.id article.id
|
||||||
|
json.title article.title
|
||||||
|
json.locale article.locale
|
||||||
|
json.content article.content
|
||||||
|
json.slug article.slug
|
||||||
|
json.portal_slug article.portal.slug
|
||||||
|
json.account_id article.account_id
|
||||||
|
json.category_name article.category&.name
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
json.id conversation.display_id
|
||||||
|
json.account_id conversation.account_id
|
||||||
|
json.created_at conversation.created_at.to_i
|
||||||
|
json.message do
|
||||||
|
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
|
||||||
|
end
|
||||||
|
json.contact do
|
||||||
|
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
|
||||||
|
end
|
||||||
|
json.inbox do
|
||||||
|
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
|
||||||
|
end
|
||||||
|
json.agent do
|
||||||
|
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
json.payload do
|
||||||
|
json.articles do
|
||||||
|
json.array! @result[:articles] do |article|
|
||||||
|
json.partial! 'article', formats: [:json], article: article
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,21 +1,7 @@
|
|||||||
json.payload do
|
json.payload do
|
||||||
json.conversations do
|
json.conversations do
|
||||||
json.array! @result[:conversations] do |conversation|
|
json.array! @result[:conversations] do |conversation|
|
||||||
json.id conversation.display_id
|
json.partial! 'conversation_search_result', formats: [:json], conversation: conversation
|
||||||
json.account_id conversation.account_id
|
|
||||||
json.created_at conversation.created_at.to_i
|
|
||||||
json.message do
|
|
||||||
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
|
|
||||||
end
|
|
||||||
json.contact do
|
|
||||||
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
|
|
||||||
end
|
|
||||||
json.inbox do
|
|
||||||
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
|
|
||||||
end
|
|
||||||
json.agent do
|
|
||||||
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
json.contacts do
|
json.contacts do
|
||||||
@@ -23,10 +9,14 @@ json.payload do
|
|||||||
json.partial! 'contact', formats: [:json], contact: contact
|
json.partial! 'contact', formats: [:json], contact: contact
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
json.messages do
|
json.messages do
|
||||||
json.array! @result[:messages] do |message|
|
json.array! @result[:messages] do |message|
|
||||||
json.partial! 'message', formats: [:json], message: message
|
json.partial! 'message', formats: [:json], message: message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
json.articles do
|
||||||
|
json.array! @result[:articles] do |article|
|
||||||
|
json.partial! 'article', formats: [:json], article: article
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Disable host check during development
|
# Disable host check during development
|
||||||
config.hosts = nil
|
config.hosts = nil
|
||||||
|
|
||||||
|
# GitHub Codespaces configuration
|
||||||
|
if ENV['CODESPACES']
|
||||||
|
# Allow web console access from any IP
|
||||||
|
config.web_console.whitelisted_ips = %w(0.0.0.0/0 ::/0)
|
||||||
|
# Allow CSRF from codespace URLs
|
||||||
|
config.force_ssl = false
|
||||||
|
config.action_controller.forgery_protection_origin_check = false
|
||||||
|
end
|
||||||
|
|
||||||
# customize using the environment variables
|
# customize using the environment variables
|
||||||
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
|
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
|
||||||
|
|||||||
+1
-1
@@ -146,7 +146,7 @@
|
|||||||
premium: true
|
premium: true
|
||||||
- name: chatwoot_v4
|
- name: chatwoot_v4
|
||||||
display_name: Chatwoot V4
|
display_name: Chatwoot V4
|
||||||
enabled: false
|
enabled: true
|
||||||
- name: report_v4
|
- name: report_v4
|
||||||
display_name: Report V4
|
display_name: Report V4
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ leadsquared:
|
|||||||
'secret_key': { 'type': 'string' },
|
'secret_key': { 'type': 'string' },
|
||||||
'endpoint_url': { 'type': 'string' },
|
'endpoint_url': { 'type': 'string' },
|
||||||
'app_url': { 'type': 'string' },
|
'app_url': { 'type': 'string' },
|
||||||
|
'timezone': { 'type': 'string' },
|
||||||
'enable_conversation_activity': { 'type': 'boolean' },
|
'enable_conversation_activity': { 'type': 'boolean' },
|
||||||
'enable_transcript_activity': { 'type': 'boolean' },
|
'enable_transcript_activity': { 'type': 'boolean' },
|
||||||
'conversation_activity_score': { 'type': 'string' },
|
'conversation_activity_score': { 'type': 'string' },
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ en:
|
|||||||
removed: '%{user_name} removed %{labels}'
|
removed: '%{user_name} removed %{labels}'
|
||||||
sla:
|
sla:
|
||||||
added: '%{user_name} added SLA policy %{sla_name}'
|
added: '%{user_name} added SLA policy %{sla_name}'
|
||||||
|
csat:
|
||||||
|
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||||
muted: '%{user_name} has muted the conversation'
|
muted: '%{user_name} has muted the conversation'
|
||||||
unmuted: '%{user_name} has unmuted the conversation'
|
unmuted: '%{user_name} has unmuted the conversation'
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ Rails.application.routes.draw do
|
|||||||
get :conversations
|
get :conversations
|
||||||
get :messages
|
get :messages
|
||||||
get :contacts
|
get :contacts
|
||||||
|
get :articles
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts:
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@copilot_message = @copilot_thread.copilot_messages.create!(
|
@copilot_message = @copilot_thread.copilot_messages.create!(
|
||||||
message: params[:message],
|
message: { content: params[:message] },
|
||||||
message_type: :user
|
message_type: :user
|
||||||
)
|
)
|
||||||
|
@copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -13,19 +13,24 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
|||||||
def create
|
def create
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@copilot_thread = Current.account.copilot_threads.create!(
|
@copilot_thread = Current.account.copilot_threads.create!(
|
||||||
title: copilot_thread_params[:message][:content],
|
title: copilot_thread_params[:message],
|
||||||
user: Current.user,
|
user: Current.user,
|
||||||
assistant: assistant
|
assistant: assistant
|
||||||
)
|
)
|
||||||
|
|
||||||
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
|
copilot_message = @copilot_thread.copilot_messages.create!(
|
||||||
|
message_type: :user,
|
||||||
|
message: { content: copilot_thread_params[:message] }
|
||||||
|
)
|
||||||
|
|
||||||
|
copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_message
|
def ensure_message
|
||||||
return render_could_not_create_error('Message is required') if copilot_thread_params[:message][:content].blank?
|
return render_could_not_create_error('Message is required') if copilot_thread_params[:message].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def assistant
|
def assistant
|
||||||
@@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
|||||||
end
|
end
|
||||||
|
|
||||||
def copilot_thread_params
|
def copilot_thread_params
|
||||||
params.permit(:assistant_id, message: [:content])
|
params.permit(:message, :assistant_id, :conversation_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ module Captain::ChatHelper
|
|||||||
)
|
)
|
||||||
|
|
||||||
handle_response(response)
|
handle_response(response)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||||
|
raise e
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
class Captain::Copilot::ResponseJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
|
||||||
|
Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}")
|
||||||
|
generate_chat_response(
|
||||||
|
assistant: assistant,
|
||||||
|
conversation_id: conversation_id,
|
||||||
|
user_id: user_id,
|
||||||
|
copilot_thread_id: copilot_thread_id,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
|
||||||
|
Captain::Copilot::ChatService.new(
|
||||||
|
assistant,
|
||||||
|
user_id: user_id,
|
||||||
|
copilot_thread_id: copilot_thread_id,
|
||||||
|
conversation_id: conversation_id
|
||||||
|
).generate_response(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -19,14 +19,12 @@ class CopilotMessage < ApplicationRecord
|
|||||||
belongs_to :copilot_thread
|
belongs_to :copilot_thread
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|
||||||
before_validation :ensure_account
|
|
||||||
|
|
||||||
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
|
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
|
||||||
|
|
||||||
validates :message_type, presence: true, inclusion: { in: message_types.keys }
|
validates :message_type, presence: true
|
||||||
validates :message, presence: true
|
validates :message, presence: true
|
||||||
|
before_validation :ensure_account
|
||||||
validate :validate_message_attributes
|
validate :validate_message_attributes
|
||||||
|
|
||||||
after_create_commit :broadcast_message
|
after_create_commit :broadcast_message
|
||||||
|
|
||||||
def push_event_data
|
def push_event_data
|
||||||
@@ -39,10 +37,20 @@ class CopilotMessage < ApplicationRecord
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enqueue_response_job(conversation_id, user_id)
|
||||||
|
Captain::Copilot::ResponseJob.perform_later(
|
||||||
|
assistant: copilot_thread.assistant,
|
||||||
|
conversation_id: conversation_id,
|
||||||
|
user_id: user_id,
|
||||||
|
copilot_thread_id: copilot_thread.id,
|
||||||
|
message: message['content']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_account
|
def ensure_account
|
||||||
self.account = copilot_thread.account
|
self.account_id = copilot_thread&.account_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_message
|
def broadcast_message
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
|||||||
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
|
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present?
|
@copilot_thread = @account.copilot_threads.find_by(id: config[:copilot_thread_id]) if config[:copilot_thread_id].present?
|
||||||
@previous_history = if @copilot_thread.present?
|
@previous_history = if @copilot_thread.present?
|
||||||
@copilot_thread.previous_history
|
@copilot_thread.previous_history
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
|
|||||||
end
|
end
|
||||||
|
|
||||||
def active?
|
def active?
|
||||||
@user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
|
@user.present? && @assistant.account.hooks.exists?(app_id: 'linear')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||||
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
||||||
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
|
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
|
||||||
|
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
|
||||||
|
|
||||||
def text(node)
|
def text(node)
|
||||||
content = node.string_content
|
content = node.string_content
|
||||||
@@ -52,7 +53,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
MP4_REGEX => :make_video_embed,
|
MP4_REGEX => :make_video_embed,
|
||||||
LOOM_REGEX => :make_loom_embed,
|
LOOM_REGEX => :make_loom_embed,
|
||||||
ARCADE_REGEX => :make_arcade_embed,
|
ARCADE_REGEX => :make_arcade_embed,
|
||||||
WISTIA_REGEX => :make_wistia_embed
|
WISTIA_REGEX => :make_wistia_embed,
|
||||||
|
BUNNY_REGEX => :make_bunny_embed
|
||||||
}
|
}
|
||||||
|
|
||||||
embedding_methods.each do |regex, method|
|
embedding_methods.each do |regex, method|
|
||||||
@@ -104,4 +106,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
video_id = arcade_match[1]
|
video_id = arcade_match[1]
|
||||||
EmbedRenderer.arcade(video_id)
|
EmbedRenderer.arcade(video_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def make_bunny_embed(bunny_match)
|
||||||
|
library_id = bunny_match[1]
|
||||||
|
video_id = bunny_match[2]
|
||||||
|
EmbedRenderer.bunny(library_id, video_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -84,4 +84,19 @@ module EmbedRenderer
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.bunny(library_id, video_id)
|
||||||
|
%(
|
||||||
|
<div style="position: relative; padding-top: 56.25%;">
|
||||||
|
<iframe
|
||||||
|
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||||
|
title="Bunny video player"
|
||||||
|
loading="lazy"
|
||||||
|
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
||||||
|
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
||||||
|
allowfullscreen>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ db_namespace = namespace :db do
|
|||||||
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
|
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
|
||||||
ActiveRecord::Base.establish_connection(db_config.configuration_hash)
|
ActiveRecord::Base.establish_connection(db_config.configuration_hash)
|
||||||
unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata'
|
unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata'
|
||||||
db_namespace['load_config'].invoke if ActiveRecord::Base.schema_format == :ruby
|
db_namespace['load_config'].invoke if ActiveRecord.schema_format == :ruby
|
||||||
ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil))
|
ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil))
|
||||||
db_namespace['seed'].invoke
|
db_namespace['seed'].invoke
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@chatwoot/ninja-keys": "1.2.3",
|
||||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||||
"@chatwoot/utils": "^0.0.43",
|
"@chatwoot/utils": "^0.0.45",
|
||||||
"@formkit/core": "^1.6.7",
|
"@formkit/core": "^1.6.7",
|
||||||
"@formkit/vue": "^1.6.7",
|
"@formkit/vue": "^1.6.7",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
|
|||||||
Generated
+5
-5
@@ -23,8 +23,8 @@ importers:
|
|||||||
specifier: 1.1.1-next
|
specifier: 1.1.1-next
|
||||||
version: 1.1.1-next
|
version: 1.1.1-next
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.43
|
specifier: ^0.0.45
|
||||||
version: 0.0.43
|
version: 0.0.45
|
||||||
'@formkit/core':
|
'@formkit/core':
|
||||||
specifier: ^1.6.7
|
specifier: ^1.6.7
|
||||||
version: 1.6.7
|
version: 1.6.7
|
||||||
@@ -406,8 +406,8 @@ packages:
|
|||||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.43':
|
'@chatwoot/utils@0.0.45':
|
||||||
resolution: {integrity: sha512-kMIXAGebCak9qOi68QnGer+rQLLo/z2N9cR+7tvGdZCW0ThDiVCF7JbHYHVDlYsdDFIx0FLlyIdCfEbooVT2Dw==}
|
resolution: {integrity: sha512-zqmuri6MrEFAY1tLv7Z3HBy4Ig60LhSrLkEiHegVsOVSxPv4Bedq+xmAW7LphvcLNgbkkvu17MU91gvMVlpEHw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@codemirror/commands@6.7.0':
|
'@codemirror/commands@6.7.0':
|
||||||
@@ -5255,7 +5255,7 @@ snapshots:
|
|||||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||||
prosemirror-view: 1.34.1
|
prosemirror-view: 1.34.1
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.43':
|
'@chatwoot/utils@0.0.45':
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 2.30.0
|
date-fns: 2.30.0
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ RSpec.describe 'Search', type: :request do
|
|||||||
create(:message, conversation: conversation, account: account, content: 'test2')
|
create(:message, conversation: conversation, account: account, content: 'test2')
|
||||||
create(:contact_inbox, contact_id: contact.id, inbox_id: conversation.inbox.id)
|
create(:contact_inbox, contact_id: contact.id, inbox_id: conversation.inbox.id)
|
||||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||||
|
|
||||||
|
# Create articles for testing
|
||||||
|
portal = create(:portal, account: account)
|
||||||
|
create(:article, title: 'Test Article Guide', content: 'This is a test article content',
|
||||||
|
account: account, portal: portal, author: agent, status: 'published')
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/{account.id}/search' do
|
describe 'GET /api/v1/accounts/{account.id}/search' do
|
||||||
@@ -33,10 +38,11 @@ RSpec.describe 'Search', type: :request do
|
|||||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|
||||||
expect(response_data[:payload][:messages].first[:content]).to eq 'test2'
|
expect(response_data[:payload][:messages].first[:content]).to eq 'test2'
|
||||||
expect(response_data[:payload].keys).to contain_exactly(:contacts, :conversations, :messages)
|
expect(response_data[:payload].keys).to contain_exactly(:contacts, :conversations, :messages, :articles)
|
||||||
expect(response_data[:payload][:messages].length).to eq 2
|
expect(response_data[:payload][:messages].length).to eq 2
|
||||||
expect(response_data[:payload][:conversations].length).to eq 1
|
expect(response_data[:payload][:conversations].length).to eq 1
|
||||||
expect(response_data[:payload][:contacts].length).to eq 1
|
expect(response_data[:payload][:contacts].length).to eq 1
|
||||||
|
expect(response_data[:payload][:articles].length).to eq 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -115,4 +121,60 @@ RSpec.describe 'Search', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/accounts/{account.id}/search/articles' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/search/articles", params: { q: 'test' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
it 'returns all articles containing the search query' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/search/articles",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { q: 'test' },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|
||||||
|
expect(response_data[:payload].keys).to contain_exactly(:articles)
|
||||||
|
expect(response_data[:payload][:articles].length).to eq 1
|
||||||
|
expect(response_data[:payload][:articles].first[:title]).to eq 'Test Article Guide'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty results when no articles match the search query' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/search/articles",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { q: 'nonexistent' },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|
||||||
|
expect(response_data[:payload].keys).to contain_exactly(:articles)
|
||||||
|
expect(response_data[:payload][:articles].length).to eq 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'supports pagination' do
|
||||||
|
portal = create(:portal, account: account)
|
||||||
|
16.times do |i|
|
||||||
|
create(:article, title: "Test Article #{i}", account: account, portal: portal, author: agent, status: 'published')
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/search/articles",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { q: 'test', page: 1 },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|
||||||
|
expect(response_data[:payload][:articles].length).to eq 15 # Default per_page is 15
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
|
|||||||
end.to change(CopilotMessage, :count).by(1)
|
end.to change(CopilotMessage, :count).by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(CopilotMessage.last.message).to eq(message_content)
|
expect(CopilotMessage.last.message).to eq({ 'content' => message_content })
|
||||||
expect(CopilotMessage.last.message_type).to eq('user')
|
expect(CopilotMessage.last.message_type).to eq('user')
|
||||||
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
|
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
|
||||||
end
|
end
|
||||||
|
|||||||
+6
-5
@@ -4,6 +4,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
|||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:agent) { create(:user, account: account, role: :agent) }
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
let(:conversation) { create(:conversation, account: account) }
|
||||||
|
|
||||||
def json_response
|
def json_response
|
||||||
JSON.parse(response.body, symbolize_names: true)
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
@@ -50,7 +51,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
|||||||
|
|
||||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||||
let(:assistant) { create(:captain_assistant, account: account) }
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
let(:valid_params) { { message: { content: 'Hello, how can you help me?' }, assistant_id: assistant.id } }
|
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id, conversation_id: conversation.display_id } }
|
||||||
|
|
||||||
context 'when it is an un-authenticated user' do
|
context 'when it is an un-authenticated user' do
|
||||||
it 'returns unauthorized' do
|
it 'returns unauthorized' do
|
||||||
@@ -66,7 +67,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
|||||||
context 'with invalid params' do
|
context 'with invalid params' do
|
||||||
it 'returns error when message is blank' do
|
it 'returns error when message is blank' do
|
||||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||||
params: { message: { content: '' }, assistant_id: assistant.id },
|
params: { message: '', assistant_id: assistant.id },
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
|||||||
|
|
||||||
it 'returns error when assistant_id is invalid' do
|
it 'returns error when assistant_id is invalid' do
|
||||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||||
params: { message: { content: 'Hello' }, assistant_id: 0 },
|
params: { message: 'Hello', assistant_id: 0 },
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
@@ -97,13 +98,13 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
|
|
||||||
thread = CopilotThread.last
|
thread = CopilotThread.last
|
||||||
expect(thread.title).to eq(valid_params[:message][:content])
|
expect(thread.title).to eq(valid_params[:message])
|
||||||
expect(thread.user_id).to eq(agent.id)
|
expect(thread.user_id).to eq(agent.id)
|
||||||
expect(thread.assistant_id).to eq(assistant.id)
|
expect(thread.assistant_id).to eq(assistant.id)
|
||||||
|
|
||||||
message = thread.copilot_messages.last
|
message = thread.copilot_messages.last
|
||||||
expect(message.message_type).to eq('user')
|
expect(message.message_type).to eq('user')
|
||||||
expect(message.message).to eq(valid_params[:message].stringify_keys)
|
expect(message.message).to eq({ 'content' => valid_params[:message] })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Copilot::ResponseJob, type: :job do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||||
|
let(:conversation_id) { 123 }
|
||||||
|
let(:message) { { 'content' => 'Test message' } }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
let(:chat_service) { instance_double(Captain::Copilot::ChatService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Captain::Copilot::ChatService).to receive(:new).with(
|
||||||
|
assistant,
|
||||||
|
user_id: user.id,
|
||||||
|
copilot_thread_id: copilot_thread.id,
|
||||||
|
conversation_id: conversation_id
|
||||||
|
).and_return(chat_service)
|
||||||
|
allow(chat_service).to receive(:generate_response).with(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'initializes ChatService with correct parameters and calls generate_response' do
|
||||||
|
expect(Captain::Copilot::ChatService).to receive(:new).with(
|
||||||
|
assistant,
|
||||||
|
user_id: user.id,
|
||||||
|
copilot_thread_id: copilot_thread.id,
|
||||||
|
conversation_id: conversation_id
|
||||||
|
)
|
||||||
|
expect(chat_service).to receive(:generate_response).with(message)
|
||||||
|
described_class.perform_now(
|
||||||
|
assistant: assistant,
|
||||||
|
conversation_id: conversation_id,
|
||||||
|
user_id: user.id,
|
||||||
|
copilot_thread_id: copilot_thread.id,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe CopilotMessage, type: :model do
|
RSpec.describe CopilotMessage, type: :model do
|
||||||
describe 'associations' do
|
let(:account) { create(:account) }
|
||||||
it { is_expected.to belong_to(:copilot_thread) }
|
let(:user) { create(:user, account: account) }
|
||||||
it { is_expected.to belong_to(:account) }
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
end
|
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||||
|
|
||||||
describe 'validations' do
|
describe 'validations' do
|
||||||
it { is_expected.to validate_presence_of(:message_type) }
|
it { is_expected.to validate_presence_of(:message_type) }
|
||||||
it { is_expected.to validate_presence_of(:message) }
|
it { is_expected.to validate_presence_of(:message) }
|
||||||
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'callbacks' do
|
describe 'callbacks' do
|
||||||
@@ -31,7 +30,7 @@ RSpec.describe CopilotMessage, type: :model do
|
|||||||
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
|
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
|
||||||
|
|
||||||
expect(Rails.configuration.dispatcher).to receive(:dispatch)
|
expect(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
|
.with('copilot.message.created', anything, copilot_message: message)
|
||||||
|
|
||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RSpec.describe Captain::Copilot::ChatService do
|
|||||||
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
|
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
|
||||||
|
|
||||||
let(:config) do
|
let(:config) do
|
||||||
{ user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id }
|
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
@@ -157,16 +157,16 @@ RSpec.describe Captain::Copilot::ChatService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#setup_message_history' do
|
describe '#setup_message_history' do
|
||||||
context 'when thread_id is present' do
|
context 'when copilot_thread_id is present' do
|
||||||
it 'finds the copilot thread and sets previous history from it' do
|
it 'finds the copilot thread and sets previous history from it' do
|
||||||
service = described_class.new(assistant, { thread_id: copilot_thread.id })
|
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
|
||||||
|
|
||||||
expect(service.copilot_thread).to eq(copilot_thread)
|
expect(service.copilot_thread).to eq(copilot_thread)
|
||||||
expect(service.previous_history).to eq previous_history
|
expect(service.previous_history).to eq previous_history
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when thread_id is not present' do
|
context 'when copilot_thread_id is not present' do
|
||||||
it 'uses previous_history from config if present' do
|
it 'uses previous_history from config if present' do
|
||||||
custom_history = [{ role: 'user', content: 'Custom message' }]
|
custom_history = [{ role: 'user', content: 'Custom message' }]
|
||||||
service = described_class.new(assistant, { previous_history: custom_history })
|
service = described_class.new(assistant, { previous_history: custom_history })
|
||||||
@@ -222,7 +222,7 @@ RSpec.describe Captain::Copilot::ChatService do
|
|||||||
}.with_indifferent_access)
|
}.with_indifferent_access)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello')
|
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
|
||||||
end.to change(CopilotMessage, :count).by(1)
|
end.to change(CopilotMessage, :count).by(1)
|
||||||
|
|
||||||
last_message = CopilotMessage.last
|
last_message = CopilotMessage.last
|
||||||
|
|||||||
@@ -102,10 +102,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when no articles are found' do
|
context 'when no articles are found' do
|
||||||
before do
|
|
||||||
allow(Article).to receive(:where).and_return(Article.none)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns no articles found message' do
|
it 'returns no articles found message' do
|
||||||
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
|
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
|
||||||
end
|
end
|
||||||
@@ -113,13 +109,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
|||||||
|
|
||||||
context 'when articles are found' do
|
context 'when articles are found' do
|
||||||
let(:portal) { create(:portal, account: account) }
|
let(:portal) { create(:portal, account: account) }
|
||||||
let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
|
let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
|
||||||
let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
|
let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
|
||||||
|
|
||||||
before do
|
|
||||||
article1
|
|
||||||
article2
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns formatted articles with count' do
|
it 'returns formatted articles with count' do
|
||||||
result = service.execute({ 'query' => 'Test' })
|
result = service.execute({ 'query' => 'Test' })
|
||||||
@@ -130,11 +121,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
|||||||
|
|
||||||
context 'when filtered by category' do
|
context 'when filtered by category' do
|
||||||
let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
|
let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
|
||||||
let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
|
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
|
||||||
|
|
||||||
before do
|
|
||||||
article3
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns only articles from the specified category' do
|
it 'returns only articles from the specified category' do
|
||||||
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
|
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
|
||||||
@@ -146,15 +133,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when filtered by status' do
|
context 'when filtered by status' do
|
||||||
let(:article3) do
|
let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') }
|
||||||
create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published')
|
let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
|
||||||
end
|
|
||||||
let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
article3
|
|
||||||
article4
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns only articles with the specified status' do
|
it 'returns only articles with the specified status' do
|
||||||
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
|
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
|
||||||
|
|||||||
+25
-5
@@ -2,8 +2,8 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:user) { create(:user, account: account) }
|
|
||||||
let(:assistant) { create(:captain_assistant, account: account) }
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
let(:service) { described_class.new(assistant, user: user) }
|
let(:service) { described_class.new(assistant, user: user) }
|
||||||
|
|
||||||
describe '#name' do
|
describe '#name' do
|
||||||
@@ -41,14 +41,34 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
|||||||
create(:integrations_hook, :linear, account: account)
|
create(:integrations_hook, :linear, account: account)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true' do
|
context 'when user is present' do
|
||||||
expect(service.active?).to be true
|
it 'returns true' do
|
||||||
|
expect(service.active?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not present' do
|
||||||
|
let(:service) { described_class.new(assistant) }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.active?).to be false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when Linear integration is not enabled' do
|
context 'when Linear integration is not enabled' do
|
||||||
it 'returns false' do
|
context 'when user is present' do
|
||||||
expect(service.active?).to be false
|
it 'returns false' do
|
||||||
|
expect(service.active?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not present' do
|
||||||
|
let(:service) { described_class.new(assistant) }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.active?).to be false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :article, class: 'Article' do
|
factory :article, class: 'Article' do
|
||||||
account_id { 1 }
|
account
|
||||||
category_id { 1 }
|
category { nil }
|
||||||
|
portal
|
||||||
locale { 'en' }
|
locale { 'en' }
|
||||||
author_id { 1 }
|
association :author, factory: :user
|
||||||
title { "#{Faker::Movie.title} #{SecureRandom.hex}" }
|
title { "#{Faker::Movie.title} #{SecureRandom.hex}" }
|
||||||
content { 'MyText' }
|
content { 'MyText' }
|
||||||
description { 'MyDescrption' }
|
description { 'MyDescrption' }
|
||||||
status { 1 }
|
status { :published }
|
||||||
views { 0 }
|
views { 0 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :category, class: 'Category' do
|
factory :category, class: 'Category' do
|
||||||
portal { portal }
|
portal
|
||||||
name { 'MyString' }
|
name { 'MyString' }
|
||||||
description { 'MyText' }
|
description { 'MyText' }
|
||||||
position { 1 }
|
position { 1 }
|
||||||
|
slug { name.parameterize }
|
||||||
|
|
||||||
after(:build) do |category|
|
after(:build) do |category|
|
||||||
category.account ||= category.portal.account
|
category.account ||= category.portal.account
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
stub_request(:post, /graph\.facebook\.com/)
|
stub_request(:post, /graph\.facebook\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: '', headers: {})
|
||||||
|
|
||||||
# Clear Redis mutex key to prevent lock conflicts in parallel tests
|
|
||||||
mutex_key = format(Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: 'Sender-id-1', ig_account_id: 'chatwoot-app-user-id-1')
|
|
||||||
Redis::Alfred.delete(mutex_key)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
@@ -188,11 +184,7 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
before do
|
before do
|
||||||
instagram_channel.update(access_token: 'valid_instagram_token')
|
instagram_channel.update(access_token: 'valid_instagram_token')
|
||||||
|
|
||||||
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
|
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
|
||||||
.with(query: hash_including(
|
|
||||||
'fields' => 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user',
|
|
||||||
'access_token' => 'valid_instagram_token'
|
|
||||||
))
|
|
||||||
.to_return(
|
.to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
@@ -209,7 +201,6 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# skip failing test
|
|
||||||
it 'creates incoming message with correct contact info in the instagram direct inbox' do
|
it 'creates incoming message with correct contact info in the instagram direct inbox' do
|
||||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||||
instagram_inbox.reload
|
instagram_inbox.reload
|
||||||
@@ -277,8 +268,7 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create contact or messages when Instagram API call fails' do
|
it 'does not create contact or messages when Instagram API call fails' do
|
||||||
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
|
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
|
||||||
.with(query: hash_including('access_token' => 'valid_instagram_token'))
|
|
||||||
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
|
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
|
||||||
|
|
||||||
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||||
|
|||||||
@@ -162,5 +162,22 @@ describe CustomMarkdownRenderer do
|
|||||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when link is a Bunny.net URL' do
|
||||||
|
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||||
|
|
||||||
|
it 'renders an iframe with Bunny embed code' do
|
||||||
|
output = render_markdown_link(bunny_url)
|
||||||
|
expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||||
|
expect(output).to include('allowfullscreen')
|
||||||
|
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'wraps iframe in responsive container' do
|
||||||
|
output = render_markdown_link(bunny_url)
|
||||||
|
expect(output).to include('position: relative; padding-top: 56.25%;')
|
||||||
|
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -435,6 +435,20 @@ RSpec.describe Conversation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#create_csat_not_sent_activity_message' do
|
||||||
|
subject(:create_csat_not_sent_activity_message) { conversation.create_csat_not_sent_activity_message }
|
||||||
|
|
||||||
|
let(:conversation) { create(:conversation) }
|
||||||
|
|
||||||
|
it 'creates CSAT not sent activity message' do
|
||||||
|
create_csat_not_sent_activity_message
|
||||||
|
expect(Conversations::ActivityMessageJob)
|
||||||
|
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
|
||||||
|
message_type: :activity,
|
||||||
|
content: 'CSAT survey not sent due to outgoing message restrictions' }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'unread_messages' do
|
describe 'unread_messages' do
|
||||||
subject(:unread_messages) { conversation.unread_messages }
|
subject(:unread_messages) { conversation.unread_messages }
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,39 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||||
let(:user) { create(:user, name: 'John Doe') }
|
let(:user) { create(:user, name: 'John Doe') }
|
||||||
let(:contact) { create(:contact, name: 'Jane Smith') }
|
let(:contact) { create(:contact, name: 'Jane Smith') }
|
||||||
|
let(:hook) do
|
||||||
|
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||||
|
'access_key' => 'test_access_key',
|
||||||
|
'secret_key' => 'test_secret_key',
|
||||||
|
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||||
|
'timezone' => 'UTC'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
let(:hook_with_pst) do
|
||||||
|
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||||
|
'access_key' => 'test_access_key',
|
||||||
|
'secret_key' => 'test_secret_key',
|
||||||
|
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||||
|
'timezone' => 'America/Los_Angeles'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
let(:hook_without_timezone) do
|
||||||
|
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||||
|
'access_key' => 'test_access_key',
|
||||||
|
'secret_key' => 'test_secret_key',
|
||||||
|
'endpoint_url' => 'https://api.leadsquared.com/v2'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
account.enable_features('crm_integration')
|
||||||
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
|
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.map_conversation_activity' do
|
describe '.map_conversation_activity' do
|
||||||
it 'generates conversation activity note' do
|
it 'generates conversation activity note with UTC timezone' do
|
||||||
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
|
travel_to(Time.zone.parse('2024-01-01 10:00:00 UTC')) do
|
||||||
result = described_class.map_conversation_activity(conversation)
|
result = described_class.map_conversation_activity(hook, conversation)
|
||||||
|
|
||||||
expect(result).to include('New conversation started on TestBrand')
|
expect(result).to include('New conversation started on TestBrand')
|
||||||
expect(result).to include('Channel: Test Inbox')
|
expect(result).to include('Channel: Test Inbox')
|
||||||
@@ -23,12 +47,29 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
expect(result).to include('View in TestBrand: http://')
|
expect(result).to include('View in TestBrand: http://')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'formats time according to hook timezone setting' do
|
||||||
|
travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do
|
||||||
|
result = described_class.map_conversation_activity(hook_with_pst, conversation)
|
||||||
|
|
||||||
|
# PST is UTC-8, so 18:00 UTC becomes 10:00:00 PST
|
||||||
|
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to system timezone when hook has no timezone setting' do
|
||||||
|
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
|
||||||
|
result = described_class.map_conversation_activity(hook_without_timezone, conversation)
|
||||||
|
|
||||||
|
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.map_transcript_activity' do
|
describe '.map_transcript_activity' do
|
||||||
context 'when conversation has no messages' do
|
context 'when conversation has no messages' do
|
||||||
it 'returns no messages message' do
|
it 'returns no messages message' do
|
||||||
result = described_class.map_transcript_activity(conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
expect(result).to eq('No messages in conversation')
|
expect(result).to eq('No messages in conversation')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -68,7 +109,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'generates transcript with messages in reverse chronological order' do
|
it 'generates transcript with messages in reverse chronological order' do
|
||||||
result = described_class.map_transcript_activity(conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
|
|
||||||
expect(result).to include('Conversation Transcript from TestBrand')
|
expect(result).to include('Conversation Transcript from TestBrand')
|
||||||
expect(result).to include('Channel: Test Inbox')
|
expect(result).to include('Channel: Test Inbox')
|
||||||
@@ -83,6 +124,22 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
|
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'formats message times according to hook timezone setting' do
|
||||||
|
travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do
|
||||||
|
create(:message,
|
||||||
|
conversation: conversation,
|
||||||
|
sender: user,
|
||||||
|
content: 'Test message',
|
||||||
|
message_type: :outgoing,
|
||||||
|
created_at: Time.zone.parse('2024-01-01 18:00:00 UTC'))
|
||||||
|
|
||||||
|
result = described_class.map_transcript_activity(hook_with_pst, conversation)
|
||||||
|
|
||||||
|
# PST is UTC-8, so 18:00 UTC becomes 10:00 PST
|
||||||
|
expect(result).to include('[2024-01-01 10:00] John Doe: Test message')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when message has attachments' do
|
context 'when message has attachments' do
|
||||||
let(:message_with_attachment) do
|
let(:message_with_attachment) do
|
||||||
create(:message, :with_attachment,
|
create(:message, :with_attachment,
|
||||||
@@ -96,7 +153,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
before { message_with_attachment }
|
before { message_with_attachment }
|
||||||
|
|
||||||
it 'includes attachment information' do
|
it 'includes attachment information' do
|
||||||
result = described_class.map_transcript_activity(conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
|
|
||||||
expect(result).to include('See attachment')
|
expect(result).to include('See attachment')
|
||||||
expect(result).to include('[Attachment: image]')
|
expect(result).to include('[Attachment: image]')
|
||||||
@@ -116,7 +173,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
before { empty_message }
|
before { empty_message }
|
||||||
|
|
||||||
it 'shows no content placeholder' do
|
it 'shows no content placeholder' do
|
||||||
result = described_class.map_transcript_activity(conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
expect(result).to include('[No content]')
|
expect(result).to include('[No content]')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -134,25 +191,12 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
before { unnamed_sender_message }
|
before { unnamed_sender_message }
|
||||||
|
|
||||||
it 'uses sender type and id' do
|
it 'uses sender type and id' do
|
||||||
result = described_class.map_transcript_activity(conversation)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
expect(result).to include("User #{unnamed_sender_message.sender_id}")
|
expect(result).to include("User #{unnamed_sender_message.sender_id}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when specific messages are provided' do
|
|
||||||
let(:message1) { create(:message, conversation: conversation, content: 'Message 1', message_type: :outgoing) }
|
|
||||||
let(:message2) { create(:message, conversation: conversation, content: 'Message 2', message_type: :outgoing) }
|
|
||||||
let(:specific_messages) { [message1] }
|
|
||||||
|
|
||||||
it 'only includes provided messages' do
|
|
||||||
result = described_class.map_transcript_activity(conversation, specific_messages)
|
|
||||||
|
|
||||||
expect(result).to include('Message 1')
|
|
||||||
expect(result).not_to include('Message 2')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do
|
context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do
|
||||||
it 'truncates messages to stay within the character limit' do
|
it 'truncates messages to stay within the character limit' do
|
||||||
# Create a large number of messages with reasonably sized content
|
# Create a large number of messages with reasonably sized content
|
||||||
@@ -169,7 +213,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
|
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
|
||||||
end
|
end
|
||||||
|
|
||||||
result = described_class.map_transcript_activity(conversation, messages)
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
|
|
||||||
# Verify latest message is included (message 14)
|
# Verify latest message is included (message 14)
|
||||||
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
|
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
|
||||||
@@ -189,13 +233,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
|||||||
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do
|
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do
|
||||||
# Create a single message that would exceed the limit by itself
|
# Create a single message that would exceed the limit by itself
|
||||||
giant_content = 'A' * 2000
|
giant_content = 'A' * 2000
|
||||||
message = create(:message,
|
create(:message,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
sender: user,
|
sender: user,
|
||||||
content: giant_content,
|
content: giant_content,
|
||||||
message_type: :outgoing)
|
message_type: :outgoing)
|
||||||
|
|
||||||
result = described_class.map_transcript_activity(conversation, [message])
|
result = described_class.map_transcript_activity(hook, conversation)
|
||||||
|
|
||||||
# Extract just the formatted messages part
|
# Extract just the formatted messages part
|
||||||
id = conversation.display_id
|
id = conversation.display_id
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
|||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
|
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
|
||||||
.with(conversation)
|
.with(hook, conversation)
|
||||||
.and_return(activity_note)
|
.and_return(activity_note)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
|||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
|
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
|
||||||
.with(conversation)
|
.with(hook, conversation)
|
||||||
.and_return(activity_note)
|
.and_return(activity_note)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
|
|||||||
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
|
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
|
||||||
let(:endpoint_response) do
|
let(:endpoint_response) do
|
||||||
{
|
{
|
||||||
|
'TimeZone' => 'Asia/Kolkata',
|
||||||
'LSQCommonServiceURLs' => {
|
'LSQCommonServiceURLs' => {
|
||||||
'api' => 'api-in.leadsquared.com',
|
'api' => 'api-in.leadsquared.com',
|
||||||
'app' => 'app.leadsquared.com'
|
'app' => 'app.leadsquared.com'
|
||||||
@@ -45,6 +46,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
|
|||||||
updated_settings = hook.reload.settings
|
updated_settings = hook.reload.settings
|
||||||
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
||||||
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
||||||
|
expect(updated_settings['timezone']).to eq('Asia/Kolkata')
|
||||||
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
||||||
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
||||||
end
|
end
|
||||||
@@ -71,6 +73,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
|
|||||||
updated_settings = hook.reload.settings
|
updated_settings = hook.reload.settings
|
||||||
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
||||||
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
||||||
|
expect(updated_settings['timezone']).to eq('Asia/Kolkata')
|
||||||
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
||||||
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ describe MessageTemplates::HookExecutionService do
|
|||||||
create(:message, conversation: conversation, message_type: 'incoming')
|
create(:message, conversation: conversation, message_type: 'incoming')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled' do
|
it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled and can reply' do
|
||||||
conversation.inbox.update(csat_survey_enabled: true)
|
conversation.inbox.update(csat_survey_enabled: true)
|
||||||
|
allow(conversation).to receive(:can_reply?).and_return(true)
|
||||||
|
|
||||||
conversation.resolved!
|
conversation.resolved!
|
||||||
Conversations::ActivityMessageJob.perform_now(conversation,
|
Conversations::ActivityMessageJob.perform_now(conversation,
|
||||||
@@ -172,6 +173,32 @@ describe MessageTemplates::HookExecutionService do
|
|||||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
|
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
|
||||||
expect(csat_survey).not_to have_received(:perform)
|
expect(csat_survey).not_to have_received(:perform)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'will not call ::MessageTemplates::Template::CsatSurvey if cannot reply' do
|
||||||
|
conversation.inbox.update(csat_survey_enabled: true)
|
||||||
|
allow(conversation).to receive(:can_reply?).and_return(false)
|
||||||
|
|
||||||
|
conversation.resolved!
|
||||||
|
Conversations::ActivityMessageJob.perform_now(conversation,
|
||||||
|
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
||||||
|
content: 'Conversation marked resolved!!' })
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
|
||||||
|
expect(csat_survey).not_to have_received(:perform)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates activity message when CSAT not sent due to messaging window restriction' do
|
||||||
|
conversation.inbox.update(csat_survey_enabled: true)
|
||||||
|
allow(conversation).to receive(:can_reply?).and_return(false)
|
||||||
|
allow(conversation).to receive(:create_csat_not_sent_activity_message)
|
||||||
|
|
||||||
|
conversation.resolved!
|
||||||
|
Conversations::ActivityMessageJob.perform_now(conversation,
|
||||||
|
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
||||||
|
content: 'Conversation marked resolved!!' })
|
||||||
|
|
||||||
|
expect(conversation).to have_received(:create_csat_not_sent_activity_message)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when it is after working hours' do
|
context 'when it is after working hours' do
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ describe SearchService do
|
|||||||
let!(:harry) { create(:contact, name: 'Harry Potter', email: 'test@test.com', account_id: account.id) }
|
let!(:harry) { create(:contact, name: 'Harry Potter', email: 'test@test.com', account_id: account.id) }
|
||||||
let!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) }
|
let!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) }
|
||||||
let!(:message) { create(:message, account: account, inbox: inbox, content: 'Harry Potter is a wizard') }
|
let!(:message) { create(:message, account: account, inbox: inbox, content: 'Harry Potter is a wizard') }
|
||||||
|
let!(:portal) { create(:portal, account: account) }
|
||||||
|
let(:article) do
|
||||||
|
create(:article, title: 'Harry Potter Magic Guide', content: 'Learn about wizardry', account: account, portal: portal, author: user,
|
||||||
|
status: 'published')
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
create(:inbox_member, user: user, inbox: inbox)
|
create(:inbox_member, user: user, inbox: inbox)
|
||||||
@@ -27,7 +32,7 @@ describe SearchService do
|
|||||||
it 'returns all for all' do
|
it 'returns all for all' do
|
||||||
search_type = 'all'
|
search_type = 'all'
|
||||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||||
expect(search.perform.keys).to match_array(%i[contacts messages conversations])
|
expect(search.perform.keys).to match_array(%i[contacts messages conversations articles])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns contacts for contacts' do
|
it 'returns contacts for contacts' do
|
||||||
@@ -47,6 +52,12 @@ describe SearchService do
|
|||||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||||
expect(search.perform.keys).to match_array(%i[conversations])
|
expect(search.perform.keys).to match_array(%i[conversations])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns articles for articles' do
|
||||||
|
search_type = 'Article'
|
||||||
|
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||||
|
expect(search.perform.keys).to match_array(%i[articles])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when contact search' do
|
context 'when contact search' do
|
||||||
@@ -143,6 +154,50 @@ describe SearchService do
|
|||||||
expect(search.perform[:conversations].map(&:id)).to include new_converstion.id
|
expect(search.perform[:conversations].map(&:id)).to include new_converstion.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when article search' do
|
||||||
|
it 'orders results by updated_at desc' do
|
||||||
|
# Create articles with explicit timestamps
|
||||||
|
older_time = 2.days.ago
|
||||||
|
newer_time = 1.hour.ago
|
||||||
|
|
||||||
|
article2 = create(:article, title: 'Spellcasting Guide',
|
||||||
|
account: account, portal: portal, author: user, status: 'published')
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
article2.update_column(:updated_at, older_time)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
article3 = create(:article, title: 'Spellcasting Manual',
|
||||||
|
account: account, portal: portal, author: user, status: 'published')
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
article3.update_column(:updated_at, newer_time)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
params = { q: 'Spellcasting' }
|
||||||
|
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||||
|
results = search.perform[:articles]
|
||||||
|
|
||||||
|
# Check the timestamps to understand ordering
|
||||||
|
results.map { |a| [a.id, a.updated_at] }
|
||||||
|
|
||||||
|
# Should be ordered by updated_at desc (newer first)
|
||||||
|
expect(results.length).to eq(2)
|
||||||
|
expect(results.first.updated_at).to be > results.second.updated_at
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns paginated results' do
|
||||||
|
# Create many articles to test pagination
|
||||||
|
16.times do |i|
|
||||||
|
create(:article, title: "Magic Article #{i}", account: account, portal: portal, author: user, status: 'published')
|
||||||
|
end
|
||||||
|
|
||||||
|
params = { q: 'Magic', page: 1 }
|
||||||
|
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||||
|
results = search.perform[:articles]
|
||||||
|
|
||||||
|
expect(results.length).to eq(15) # Default per_page is 15
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#use_gin_search' do
|
describe '#use_gin_search' do
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user