Compare commits

..
Author SHA1 Message Date
Shivam Mishra a0438a8465 fix: help center perms 2025-05-29 13:30:59 +05:30
Sojan JoseandGitHub fdd35ff549 chore: Update Codespaces (#11621)
- Fix issues with the current Chatwoot development codespaces
- Switch from webpacket to vite 
- Add additional configs to make the development easier with codespaces
- toggles v4 feature true as default
2025-05-29 01:45:00 -06:00
Sivin VargheseandGitHub 2ea10ae065 fix: Snackbar notifications hidden behind modal dialogs (#11616) 2025-05-29 12:49:38 +05:30
23a804512a feat: Update the UI to support the change for Copilot as a universal copilot (#11618)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-29 12:35:10 +05:30
Shivam MishraandGitHub f6510e0d43 docs: add swagger spec for accounts API (#11620) 2025-05-29 11:23:27 +05:30
Sivin VargheseandGitHub c3d98fc064 chore: Move URL comparison logic to utils (#11617) 2025-05-29 10:19:56 +05:30
Muhsin KelothandGitHub b5ebc47637 fix: Send CSAT survey only when agent can reply in conversation (#11584)
Fixes https://github.com/chatwoot/chatwoot/issues/11569

 ## Problem
On platforms like WhatsApp and Facebook Messenger, customers cannot
reply to messages after 24 hours (or other channel-specific messaging
windows). Despite this limitation, the system continued sending CSAT
surveys to customers outside their messaging window, making it
impossible for them to respond.

  ## Solution
Added a check for `conversation.can_reply?` in the
`should_send_csat_survey?` method. This leverages the existing
`MessageWindowService` which already handles all channel-specific
messaging window logic.
2025-05-28 19:34:11 +05:30
Shivam MishraandGitHub f916fb2924 fix: handle empty customDomain when checking for isInternalLink (#11609)
This PR improves the portal's internal link detection logic to be more
robust when handling empty or undefined configuration values.
Previously, the code could fail when `customDomain` was empty, causing
external links to incorrectly behave as internal links. The fix
introduces a new `isSameOrigin` helper function that safely compares
URLs using proper URL parsing and origin comparison, gracefully handling
edge cases like missing domains, relative paths, and malformed URLs.
This ensures external links consistently open in new tabs regardless of
portal configuration completeness.
2025-05-28 17:56:32 +05:30
Sivin VargheseandGitHub dc335e88c9 fix: External links in widget not opening in new tab (#11608) 2025-05-28 15:15:05 +05:30
b1120ae7fb feat: allow searching articles in omnisearch (#11558)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-28 13:50:50 +05:30
443214e9a0 feat: add support for bunny CDN videos (#11601)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-28 13:50:43 +05:30
Shivam MishraandGitHub 3ce026e2bc feat: save timezone from leadsquared API (#11583) 2025-05-28 09:46:59 +05:30
PranavandGitHub f42fddd38e feat: Add stores for copilotMessages and copilotThreads (#11603)
- Set up stores for copilotThreads and copilotMessages.
- Add support for upsert messages to the copilotMessages store on
receiving ActionCable events.
- Implement support for the upsert option.
2025-05-27 18:36:32 -06:00
22b5e12a53 fix: use supported access method for schema_format in Rails 7 (#11576)
Description
In Rails 7.1+, accessing `schema_format` via `ActiveRecord::Base` is no
longer supported, causing a method error.
The correct approach is to use
`ActiveRecord.schema_format`, which aligns
with the public API.
This issue occurs locally because the environment uses newer versions of
Ruby and Rails than others.

Fixes: https://github.com/chatwoot/chatwoot/issues/11594

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-27 15:34:59 -06:00
03bde0a8aa fix: Truncate name in attachment bubble (#11540)
# Pull Request

## Description

This PR fixes a UI issue where very long attachment filenames (sometimes
including parameters or hashes) would overflow or break the layout in
the message bubble. The fix applies Tailwind's truncate utility class to
ensure these filenames are properly truncated with ellipsis, preserving
layout consistency and improving readability.

Fixes #11514 

## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- Manually tested with attachment messages containing long filenames
(e.g., with hashes, query params, or excessive length).
- Verified that filenames are now truncated with ellipsis.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-27 14:24:43 -06:00
PranavandGitHub 3a0b5f387d fix: Update specs, add background response job implementation for copilot threads (#11600)
- Enable jobs by default when a copilot thread or a message is created.
- Rename thread_id to copilot_thread_id to keep it consistent with the
model name
- Add a spec for search_linear_issues service
2025-05-27 14:10:27 -06:00
112 changed files with 3393 additions and 632 deletions
+40 -40
View File
@@ -99,17 +99,17 @@ jobs:
chmod +x ~/tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter
# Swagger verification # Swagger verification
# - run: - run:
# name: Verify swagger API specification name: Verify swagger API specification
# command: | command: |
# bundle exec rake swagger:build bundle exec rake swagger:build
# if [[ `git status swagger/swagger.json --porcelain` ]] if [[ `git status swagger/swagger.json --porcelain` ]]
# then then
# echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
# exit 1 exit 1
# fi fi
# curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
# java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests # we remove the FRONTED_URL from the .env before running the tests
- run: - run:
@@ -139,26 +139,26 @@ jobs:
command: bundle exec bundle audit update && bundle exec bundle audit check -v command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting # Rubocop linting
# - run: - run:
# name: Rubocop name: Rubocop
# command: bundle exec rubocop command: bundle exec rubocop
# # ESLint linting # ESLint linting
# - run: - run:
# name: eslint name: eslint
# command: pnpm run eslint command: pnpm run eslint
# - run: - run:
# name: Run frontend tests name: Run frontend tests
# command: | command: |
# mkdir -p ~/build/coverage/frontend mkdir -p ~/build/coverage/frontend
# ~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter before-build
# pnpm run test:coverage pnpm run test:coverage
# - run: - run:
# name: Code Climate Test Coverage (Frontend) name: Code Climate Test Coverage (Frontend)
# command: | command: |
# ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests # Run backend tests
- run: - run:
@@ -175,17 +175,17 @@ jobs:
-- ${TESTFILES} -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
# - run: - run:
# name: Code Climate Test Coverage (Backend) name: Code Climate Test Coverage (Backend)
# command: | command: |
# ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
# - run: - run:
# name: List coverage directory contents name: List coverage directory contents
# command: | command: |
# ls -R ~/build/coverage ls -R ~/build/coverage
# - persist_to_workspace: - persist_to_workspace:
# root: ~/build root: ~/build
# paths: paths:
# - coverage - coverage
+11 -1
View File
@@ -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
+65 -42
View File
@@ -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
+4 -4
View File
@@ -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"
+18
View File
@@ -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
-13
View File
@@ -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 -7
View File
@@ -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();
+9
View File
@@ -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>
@@ -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 });
}
};
+5
View File
@@ -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',
+10 -17
View File
@@ -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';
+140 -1
View File
@@ -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 &gt; <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')
+11 -1
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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' },
+2
View File
@@ -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'
+1
View File
@@ -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
+13 -5
View File
@@ -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
+9 -1
View File
@@ -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
+15
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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",
+5 -5
View File
@@ -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
@@ -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
@@ -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' })
@@ -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
+5 -4
View File
@@ -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
+2 -1
View File
@@ -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])
+17
View File
@@ -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
+14
View File
@@ -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
+56 -1
View File
@@ -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