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
110 changed files with 3347 additions and 575 deletions
+11 -1
View File
@@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
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
RUN yarn && gem install bundler && bundle install
+65 -42
View File
@@ -1,12 +1,16 @@
ARG VARIANT
ARG VARIANT="ubuntu-22.04"
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
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.
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; \
fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh \
git-flow \
npm \
libyaml-dev
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get update \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg \
tar \
tzdata \
postgresql-client \
libpq-dev \
git \
imagemagick \
libyaml-dev \
curl \
ca-certificates \
tmux \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
# Install rbenv and ruby for root user first
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
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
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
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 \
&& gunzip overmind.gz \
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo 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" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
&& mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN chown vscode:vscode /workspace
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# set up node js and pnpm in single layer
RUN npm install -g pnpm@${PNPM_VERSION} \
&& npm cache clean --force
# set up node js
RUN npm install n -g && \
n $NODE_VERSION
RUN npm install --global yarn
RUN yarn
# Switch to vscode user
USER vscode
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
# 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
// 6379 redis
// 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": {
"3000": {
"label": "Rails Server"
},
"3035": {
"label": "Webpack Dev Server"
"3036": {
"label": "Vite Dev Server"
},
"8025": {
"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'
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:
build:
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 '/POSTGRES_HOST/ 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 "/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
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# 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
run: |
docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker compose -f .devcontainer/docker-compose.base.yml build base
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
@@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
@result = search('Message')
end
def articles
@result = search('Article')
end
private
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();
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-n-weak;
@apply border-b border-b-n-weak;
}
.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>
import CopilotHeader from './CopilotHeader.vue';
import SidebarActionsHeader from './SidebarActionsHeader.vue';
</script>
<template>
<Story
title="Captain/Copilot/CopilotHeader"
title="Components/SidebarActionsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<CopilotHeader />
<SidebarActionsHeader title="Default State" />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- 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>
</Story>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -3,13 +3,15 @@ import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.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({
supportAgent: {
@@ -54,10 +56,6 @@ const useSuggestion = opt => {
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null);
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(
[() => props.messages, () => props.isCaptainTyping],
() => {
@@ -93,6 +106,18 @@ watch(
<template>
<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">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
@@ -139,14 +164,6 @@ watch(
@set-assistant="$event => emit('setAssistant', $event)"
/>
<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>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</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" />
</slot>
</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">
{{
t(senderTranslationKey, {
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
</div>
</section>
<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
v-for="item in menuItems"
: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="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
]"
>
<slot />
@@ -80,16 +80,14 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-4"
class="flex items-center justify-between gap-2 px-3 h-12"
:class="{
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-center min-w-0">
<h1
class="text-lg font-medium truncate text-n-slate-12"
class="text-base font-medium truncate text-n-slate-12"
:title="pageTitle"
>
{{ pageTitle }}
@@ -20,7 +20,7 @@ export default {
<template>
<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">
{{ message }}
@@ -29,7 +29,7 @@ export default {
<router-link
v-if="action.type == 'link'"
: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 }}
</router-link>
@@ -1,62 +1,72 @@
<script>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import WootSnackbar from './Snackbar.vue';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
export default {
components: {
WootSnackbar,
},
props: {
duration: {
type: Number,
default: 2500,
},
const props = defineProps({
duration: {
type: Number,
default: 2500,
},
});
data() {
return {
snackMessages: [],
};
},
const { t } = useI18n();
mounted() {
emitter.on('newToastMessage', this.onNewToastMessage);
},
unmounted() {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
const snackMessages = ref([]);
const snackbarContainer = ref(null);
this.snackMessages.push({
key: new Date().getTime(),
message,
action,
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, duration);
},
},
const showPopover = () => {
try {
const el = snackbarContainer.value;
if (el?.matches(':popover-open')) {
el.hidePopover();
}
el?.showPopover();
} 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>
<template>
<transition-group
name="toast-fade"
tag="div"
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
<div
ref="snackbarContainer"
popover="manual"
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
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
<transition-group name="toast-fade" tag="div">
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</div>
</template>
@@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
: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"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm"
:index="index"
:name="item.name"
:count="item.count"
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
@@ -34,7 +31,6 @@ export default {
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -86,9 +82,6 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -98,7 +91,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background"
class="conversation-details-wrap bg-n-background relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -107,14 +100,12 @@ export default {
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
: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"
>
<woot-tabs-item
@@ -130,18 +121,12 @@ export default {
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -243,7 +243,7 @@ export default {
<template>
<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="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
@@ -278,7 +278,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
size="32px"
/>
</div>
<div
@@ -1,6 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
@@ -29,11 +28,7 @@ export default {
props: {
chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
default: () => ({}),
},
showBackButton: {
type: Boolean,
@@ -44,15 +39,6 @@ export default {
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -99,13 +85,6 @@ export default {
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
@@ -133,7 +112,7 @@ export default {
<template>
<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
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
@@ -150,6 +129,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
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
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
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 }}
</span>
@@ -180,19 +160,11 @@ export default {
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
</div>
</div>
<div
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 />
<Linear
@@ -1,11 +1,10 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
currentChat: {
@@ -14,33 +13,8 @@ const props = defineProps({
},
});
const emit = defineEmits(['toggleContactPanel']);
const { t } = useI18n();
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 isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -49,29 +23,37 @@ const isFeatureEnabledonAccount = useMapGetter(
const showCopilotTab = computed(() =>
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>
<template>
<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"
>
<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">
<ContactPanel
v-if="!activeTab"
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
v-show="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
@@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
@@ -47,20 +45,8 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
NextButton,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
@@ -203,12 +189,6 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
@@ -444,9 +424,6 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -530,19 +507,6 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
: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
v-if="showNextBubbles"
ref="conversationPanelRef"
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'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);
};
onCopilotMessageCreated = data => {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
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": {
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
}
},
"CLOSE": "Close"
}
}
@@ -4,12 +4,14 @@
"ALL": "All",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
"MESSAGES": "Messages",
"ARTICLES": "Articles"
},
"SECTION": {
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
"MESSAGES": "Messages",
"ARTICLES": "Articles"
},
"VIEW_MORE": "View 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,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import {
getUserPermissions,
@@ -22,6 +23,7 @@ import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import SearchResultArticlesList from './SearchResultArticlesList.vue';
const router = useRouter();
const store = useStore();
@@ -34,6 +36,7 @@ const pages = ref({
contacts: 1,
conversations: 1,
messages: 1,
articles: 1,
});
const currentUser = useMapGetter('getCurrentUser');
@@ -43,6 +46,7 @@ const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
);
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
const addTypeToRecords = (records, type) =>
@@ -57,6 +61,9 @@ const mappedConversations = computed(() =>
const mappedMessages = computed(() =>
addTypeToRecords(messageRecords, 'message')
);
const mappedArticles = computed(() =>
addTypeToRecords(articleRecords, 'article')
);
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
@@ -66,6 +73,7 @@ const sliceRecordsIfAllTab = items =>
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
const filterByTab = tab =>
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
@@ -73,6 +81,7 @@ const filterByTab = tab =>
const filterContacts = filterByTab('contacts');
const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages');
const filterArticles = filterByTab('articles');
const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
@@ -80,7 +89,12 @@ const userPermissions = computed(() =>
const TABS_CONFIG = {
all: {
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
permissions: [
CONTACT_PERMISSIONS,
...ROLES,
...CONVERSATION_PERMISSIONS,
PORTAL_PERMISSIONS,
],
count: () => null, // No count for all tab
},
contacts: {
@@ -95,6 +109,10 @@ const TABS_CONFIG = {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedMessages.value.length,
},
articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS],
count: () => mappedArticles.value.length,
},
};
const tabs = computed(() => {
@@ -123,6 +141,10 @@ const totalSearchResultsCount = computed(() => {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS],
count: () => articles.value.length,
},
};
return filterItemsByPermission(
permissionCounts,
@@ -138,12 +160,13 @@ const activeTabIndex = computed(() => {
});
const isFetchingAny = computed(() => {
const { contact, message, conversation, isFetching } = uiFlags.value;
const { contact, message, conversation, article, isFetching } = uiFlags.value;
return (
isFetching ||
contact.isFetching ||
message.isFetching ||
conversation.isFetching
conversation.isFetching ||
article.isFetching
);
});
@@ -171,6 +194,7 @@ const showLoadMore = computed(() => {
contacts: mappedContacts.value,
conversations: mappedConversations.value,
messages: mappedMessages.value,
articles: mappedArticles.value,
}[selectedTab.value];
return (
@@ -185,10 +209,11 @@ const showViewMore = computed(() => ({
conversations:
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
}));
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1 };
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
@@ -214,6 +239,7 @@ const loadMore = () => {
contacts: 'conversationSearch/contactSearch',
conversations: 'conversationSearch/conversationSearch',
messages: 'conversationSearch/messageSearch',
articles: 'conversationSearch/articleSearch',
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
@@ -328,6 +354,28 @@ onUnmounted(() => {
/>
</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">
<NextButton
v-if="!isSelectedTabAll"
@@ -3,6 +3,7 @@ import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import SearchView from './components/SearchView.vue';
@@ -12,7 +13,12 @@ export const routes = [
path: frontendURL('accounts/:accountId/search'),
name: 'search',
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
permissions: [
...ROLES,
...CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
],
},
component: SearchView,
},
@@ -11,14 +11,14 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
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({
conversationId: {
@@ -29,10 +29,6 @@ const props = defineProps({
type: Number,
default: undefined,
},
onToggle: {
type: Function,
default: () => {},
},
});
const {
@@ -89,8 +85,6 @@ watch(conversationId, (newConversationId, prevConversationId) => {
watch(contactId, getContactDetails);
const onPanelToggle = props.onToggle;
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
@@ -98,6 +92,13 @@ const onDragEnd = () => {
});
};
const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
@@ -107,11 +108,11 @@ onMounted(() => {
<template>
<div class="w-full">
<ContactInfo
:contact="contact"
:channel-type="channelType"
@toggle-panel="onPanelToggle"
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
@close="closeContactPanel"
/>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="list-group pb-8">
<Draggable
:list="conversationSidebarItems"
@@ -10,6 +10,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
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 {
components: {
@@ -17,6 +19,8 @@ export default {
ConversationBox,
PopOverSearch,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
},
beforeRouteLeave(to, from, next) {
// 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;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
return false;
const {
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = this.uiSettings;
return isContactSidebarOpen || isCopilotPanelOpen;
},
showPopOverSearch() {
return !this.isFeatureEnabledonAccount(
@@ -189,11 +197,6 @@ export default {
this.$store.dispatch('clearSelectedState');
}
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
onSearch() {
this.showSearchModal = true;
},
@@ -225,10 +228,11 @@ export default {
<ConversationBox
v-if="showMessageView"
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CmdBarConversationSnooze />
</section>
</template>
@@ -24,7 +24,7 @@ const PortalsSettingsIndexPage = () =>
const meta = {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
permissions: ['administrator', 'knowledge_base_manage'],
};
const portalRoutes = [
{
@@ -85,19 +85,13 @@ const portalRoutes = [
{
path: getPortalRoute('new'),
name: 'portals_new',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
meta,
component: PortalsNew,
},
{
path: getPortalRoute(':navigationPath'),
name: 'portals_index',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
meta,
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 {
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
@@ -10,6 +16,7 @@ export const generateMutationTypes = name => {
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
};
};
@@ -33,7 +40,6 @@ export const createGetters = () => ({
getMeta: state => state.meta,
});
// store/mutations.js
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
@@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
});
// store/actions/crud.js
export const createCrudActions = (API, mutationTypes) => ({
async get({ 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 });
}
},
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 });
}
},
get: getRecords(mutationTypes, API),
show: showRecord(mutationTypes, API),
create: createRecord(mutationTypes, API),
update: updateRecord(mutationTypes, API),
delete: deleteRecord(mutationTypes, API),
});
export const createStore = options => {
const { name, API, actions } = options;
const { name, API, actions, getters } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
@@ -130,7 +77,10 @@ export const createStore = options => {
return {
namespaced: true,
state: createInitialState(),
getters: createGetters(),
getters: {
...createGetters(),
...(getters || {}),
},
mutations: createMutations(mutationTypes),
actions: {
...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 captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
const plugins = [];
export default createStore({
@@ -106,6 +109,8 @@ export default createStore({
captainResponses,
captainInboxes,
captainBulkActions,
copilotThreads,
copilotMessages,
},
plugins,
});
@@ -5,12 +5,14 @@ export const initialState = {
contactRecords: [],
conversationRecords: [],
messageRecords: [],
articleRecords: [],
uiFlags: {
isFetching: false,
isSearchCompleted: false,
contact: { isFetching: false },
conversation: { isFetching: false },
message: { isFetching: false },
article: { isFetching: false },
},
};
@@ -27,6 +29,9 @@ export const getters = {
getMessageRecords(state) {
return state.messageRecords;
},
getArticleRecords(state) {
return state.articleRecords;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -65,6 +70,7 @@ export const actions = {
dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
]);
} catch (error) {
// Ignore error
@@ -108,6 +114,17 @@ export const actions = {
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 }) {
commit(types.CLEAR_SEARCH_RESULTS);
},
@@ -126,6 +143,9 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET](state, 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) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
@@ -141,10 +161,14 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, 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) {
state.contactRecords = [];
state.conversationRecords = [];
state.messageRecords = [];
state.articleRecords = [];
},
};
@@ -75,6 +75,7 @@ describe('#actions', () => {
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', () => {
it('should commit clear search results mutation', () => {
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', () => {
const state = {
uiFlags: {
@@ -45,6 +54,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
},
};
expect(getters.getUIFlags(state)).toEqual({
@@ -53,6 +63,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { 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', () => {
it('should clear all search records', () => {
const state = {
contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }],
articleRecords: [{ id: 1 }],
};
mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]);
expect(state.articleRecords).toEqual([]);
});
});
});
@@ -317,8 +317,10 @@ export default {
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
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',
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 { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { directive as onClickaway } from 'vue3-click-away';
import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
@@ -27,31 +28,23 @@ export const getHeadingsfromTheArticle = () => {
export const openExternalLinksInNewTab = () => {
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 =
isSameHost && document.querySelector('#cw-article-content') !== null;
document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => {
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';
const isParentTagAnchor =
event.target.tagName === 'STRONG' &&
event.target.parentNode.tagName === 'A';
if (isTagAnchor || isParentTagAnchor) {
const link = isTagAnchor ? event.target : event.target.parentNode;
if (link) {
const currentLocation = window.location.href;
const linkHref = link.href;
// Check against current location and custom domains
const isInternalLink =
link.hostname === window.location.hostname ||
link.href.includes(customDomain) ||
link.href.includes(hostURL);
isSameHost(linkHref, currentLocation) ||
(customDomain && isSameHost(linkHref, customDomain)) ||
(hostURL && isSameHost(linkHref, hostURL));
if (!isInternalLink) {
link.target = '_blank';
+140 -1
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { InitializationHelpers } from '../portalHelpers';
import {
InitializationHelpers,
openExternalLinksInNewTab,
} from '../portalHelpers';
describe('InitializationHelpers.navigateToLocalePage', () => {
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 SlaActivityMessageHandler
include TeamActivityMessageHandler
include CsatActivityMessageHandler
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
ACTIVITY_NOTE_MAX_SIZE = 1800
def self.map_conversation_activity(conversation)
new(conversation).conversation_activity
def self.map_conversation_activity(hook, conversation)
new(hook, conversation).conversation_activity
end
def self.map_transcript_activity(conversation, messages = nil)
new(conversation, messages).transcript_activity
def self.map_transcript_activity(hook, conversation)
new(hook, conversation).transcript_activity
end
def initialize(conversation, messages = nil)
def initialize(hook, conversation)
@hook = hook
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
@conversation = conversation
@messages = messages
end
def conversation_activity
@@ -41,14 +42,14 @@ class Crm::Leadsquared::Mappers::ConversationMapper
private
attr_reader :conversation, :messages
attr_reader :conversation
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
def transcript_messages
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
end
def format_messages
@@ -77,8 +78,7 @@ class Crm::Leadsquared::Mappers::ConversationMapper
end
def message_time(message)
# TODO: Figure out what timezone to send the time in
message.created_at.strftime('%Y-%m-%d %H:%M')
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
end
def sender_name(message)
@@ -37,7 +37,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
activity_type: 'conversation',
activity_code_key: 'conversation_activity_code',
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
@@ -50,7 +50,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
activity_type: 'transcript',
activity_code_key: 'transcript_activity_code',
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
@@ -25,11 +25,12 @@ class Crm::Leadsquared::SetupService
response = @client.get('Authentication.svc/UserByAccessKey.Get')
endpoint_host = response['LSQCommonServiceURLs']['api']
app_host = response['LSQCommonServiceURLs']['app']
timezone = response['TimeZone']
endpoint_url = "https://#{endpoint_host}/v2/"
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
@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::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::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
handle_csat_survey
end
def should_send_out_of_office_message?
@@ -65,13 +65,26 @@ class MessageTemplates::HookExecutionService
true
end
def should_send_csat_survey?
def handle_csat_survey
return unless csat_enabled_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
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
+11 -1
View File
@@ -9,8 +9,10 @@ class SearchService
{ conversations: filter_conversations }
when 'Contact'
{ contacts: filter_contacts }
when 'Article'
{ articles: filter_articles }
else
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations }
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations, articles: filter_articles }
end
end
@@ -90,4 +92,12 @@ class SearchService
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
end
def filter_articles
@articles = current_account.articles
.text_search(search_query)
.reorder('updated_at DESC')
.page(params[:page])
.per(15)
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.conversations do
json.array! @result[:conversations] do |conversation|
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
json.partial! 'conversation_search_result', formats: [:json], conversation: conversation
end
end
json.contacts do
@@ -23,10 +9,14 @@ json.payload do
json.partial! 'contact', formats: [:json], contact: contact
end
end
json.messages do
json.array! @result[:messages] do |message|
json.partial! 'message', formats: [:json], message: message
end
end
json.articles do
json.array! @result[:articles] do |article|
json.partial! 'article', formats: [:json], article: article
end
end
end
+9
View File
@@ -62,6 +62,15 @@ Rails.application.configure do
# Disable host check during development
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
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
+1 -1
View File
@@ -146,7 +146,7 @@
premium: true
- name: chatwoot_v4
display_name: Chatwoot V4
enabled: false
enabled: true
- name: report_v4
display_name: Report V4
enabled: true
+1
View File
@@ -205,6 +205,7 @@ leadsquared:
'secret_key': { 'type': 'string' },
'endpoint_url': { 'type': 'string' },
'app_url': { 'type': 'string' },
'timezone': { 'type': 'string' },
'enable_conversation_activity': { 'type': 'boolean' },
'enable_transcript_activity': { 'type': 'boolean' },
'conversation_activity_score': { 'type': 'string' },
+2
View File
@@ -185,6 +185,8 @@ en:
removed: '%{user_name} removed %{labels}'
sla:
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}'
muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation'
+1
View File
@@ -137,6 +137,7 @@ Rails.application.routes.draw do
get :conversations
get :messages
get :contacts
get :articles
end
end
@@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts:
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: params[:message],
message: { content: params[:message] },
message_type: :user
)
@copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id)
end
private
@@ -18,7 +18,12 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
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
@@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
end
def copilot_thread_params
params.permit(:message, :assistant_id)
params.permit(:message, :assistant_id, :conversation_id)
end
def permitted_params
@@ -13,6 +13,9 @@ module Captain::ChatHelper
)
handle_response(response)
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
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 :account
before_validation :ensure_account
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
before_validation :ensure_account
validate :validate_message_attributes
after_create_commit :broadcast_message
def push_event_data
@@ -39,10 +37,20 @@ class CopilotMessage < ApplicationRecord
}
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
def ensure_account
self.account = copilot_thread.account
self.account_id = copilot_thread&.account_id
end
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]}"
)
@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?
@copilot_thread.previous_history
else
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
end
def active?
@user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
@user.present? && @assistant.account.hooks.exists?(app_id: 'linear')
end
private
+9 -1
View File
@@ -7,6 +7,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
def text(node)
content = node.string_content
@@ -52,7 +53,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_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|
@@ -104,4 +106,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
video_id = arcade_match[1]
EmbedRenderer.arcade(video_id)
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
+15
View File
@@ -84,4 +84,19 @@ module EmbedRenderer
</div>
)
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
+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.establish_connection(db_config.configuration_hash)
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))
db_namespace['seed'].invoke
end
+1 -1
View File
@@ -34,7 +34,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.43",
"@chatwoot/utils": "^0.0.45",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
+5 -5
View File
@@ -23,8 +23,8 @@ importers:
specifier: 1.1.1-next
version: 1.1.1-next
'@chatwoot/utils':
specifier: ^0.0.43
version: 0.0.43
specifier: ^0.0.45
version: 0.0.45
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -406,8 +406,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
'@chatwoot/utils@0.0.43':
resolution: {integrity: sha512-kMIXAGebCak9qOi68QnGer+rQLLo/z2N9cR+7tvGdZCW0ThDiVCF7JbHYHVDlYsdDFIx0FLlyIdCfEbooVT2Dw==}
'@chatwoot/utils@0.0.45':
resolution: {integrity: sha512-zqmuri6MrEFAY1tLv7Z3HBy4Ig60LhSrLkEiHegVsOVSxPv4Bedq+xmAW7LphvcLNgbkkvu17MU91gvMVlpEHw==}
engines: {node: '>=10'}
'@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-view: 1.34.1
'@chatwoot/utils@0.0.43':
'@chatwoot/utils@0.0.45':
dependencies:
date-fns: 2.30.0
@@ -11,6 +11,11 @@ RSpec.describe 'Search', type: :request do
create(:message, conversation: conversation, account: account, content: 'test2')
create(:contact_inbox, contact_id: contact.id, inbox_id: conversation.inbox.id)
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
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)
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][:conversations].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
@@ -115,4 +121,60 @@ RSpec.describe 'Search', type: :request do
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
@@ -44,7 +44,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end.to change(CopilotMessage, :count).by(1)
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.copilot_thread_id).to eq(copilot_thread.id)
end
@@ -4,6 +4,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account) }
def json_response
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
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) { { message: '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
it 'returns unauthorized' do
@@ -103,7 +104,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
message = thread.copilot_messages.last
expect(message.message_type).to eq('user')
expect(message.message).to eq(valid_params[:message])
expect(message.message).to eq({ 'content' => valid_params[:message] })
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'
RSpec.describe CopilotMessage, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:copilot_thread) }
it { is_expected.to belong_to(:account) }
end
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) }
describe 'validations' do
it { is_expected.to validate_presence_of(:message_type) }
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
describe 'callbacks' do
@@ -31,7 +30,7 @@ RSpec.describe CopilotMessage, type: :model do
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
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!
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(: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
before do
@@ -157,16 +157,16 @@ RSpec.describe Captain::Copilot::ChatService do
end
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
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.previous_history).to eq previous_history
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
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
@@ -222,7 +222,7 @@ RSpec.describe Captain::Copilot::ChatService do
}.with_indifferent_access)
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)
last_message = CopilotMessage.last
@@ -102,10 +102,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
end
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
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
end
@@ -113,13 +109,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
context 'when articles are found' do
let(:portal) { create(:portal, account: account) }
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') }
before do
article1
article2
end
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') }
it 'returns formatted articles with count' do
result = service.execute({ 'query' => 'Test' })
@@ -130,11 +121,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
context 'when filtered by category' do
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') }
before do
article3
end
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
it 'returns only articles from the specified category' do
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
@@ -146,15 +133,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
end
context 'when filtered by status' do
let(:article3) do
create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published')
end
let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
before do
article3
article4
end
let!(:article3) { 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') }
it 'returns only articles with the specified status' do
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
@@ -3,7 +3,8 @@ require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant) }
let(:user) { create(:user, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
@@ -40,14 +41,34 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
create(:integrations_hook, :linear, account: account)
end
it 'returns true' do
expect(service.active?).to be true
context 'when user is present' do
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
context 'when Linear integration is not enabled' do
it 'returns false' do
expect(service.active?).to be false
context 'when user is present' do
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
+5 -4
View File
@@ -1,13 +1,14 @@
FactoryBot.define do
factory :article, class: 'Article' do
account_id { 1 }
category_id { 1 }
account
category { nil }
portal
locale { 'en' }
author_id { 1 }
association :author, factory: :user
title { "#{Faker::Movie.title} #{SecureRandom.hex}" }
content { 'MyText' }
description { 'MyDescrption' }
status { 1 }
status { :published }
views { 0 }
end
end
+2 -1
View File
@@ -1,9 +1,10 @@
FactoryBot.define do
factory :category, class: 'Category' do
portal { portal }
portal
name { 'MyString' }
description { 'MyText' }
position { 1 }
slug { name.parameterize }
after(:build) do |category|
category.account ||= category.portal.account
+17
View File
@@ -162,5 +162,22 @@ describe CustomMarkdownRenderer do
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
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
+14
View File
@@ -435,6 +435,20 @@ RSpec.describe Conversation do
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
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(:user) { create(:user, name: 'John Doe') }
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
account.enable_features('crm_integration')
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
end
describe '.map_conversation_activity' do
it 'generates conversation activity note' do
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
result = described_class.map_conversation_activity(conversation)
it 'generates conversation activity note with UTC timezone' do
travel_to(Time.zone.parse('2024-01-01 10:00:00 UTC')) do
result = described_class.map_conversation_activity(hook, conversation)
expect(result).to include('New conversation started on TestBrand')
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://')
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
describe '.map_transcript_activity' do
context 'when conversation has no messages' 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')
end
end
@@ -68,7 +109,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
end
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('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']
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
let(:message_with_attachment) do
create(:message, :with_attachment,
@@ -96,7 +153,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before { message_with_attachment }
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('[Attachment: image]')
@@ -116,7 +173,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before { empty_message }
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]')
end
end
@@ -134,25 +191,12 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before { unnamed_sender_message }
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}")
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
it 'truncates messages to stay within the character limit' do
# 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"))
end
result = described_class.map_transcript_activity(conversation, messages)
result = described_class.map_transcript_activity(hook, conversation)
# Verify latest message is included (message 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
# Create a single message that would exceed the limit by itself
giant_content = 'A' * 2000
message = create(:message,
conversation: conversation,
sender: user,
content: giant_content,
message_type: :outgoing)
create(:message,
conversation: conversation,
sender: user,
content: giant_content,
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
id = conversation.display_id
@@ -116,7 +116,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
.with(conversation)
.with(hook, conversation)
.and_return(activity_note)
end
@@ -180,7 +180,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
.with(conversation)
.with(hook, conversation)
.and_return(activity_note)
end
@@ -8,6 +8,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
let(:endpoint_response) do
{
'TimeZone' => 'Asia/Kolkata',
'LSQCommonServiceURLs' => {
'api' => 'api-in.leadsquared.com',
'app' => 'app.leadsquared.com'
@@ -45,6 +46,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
updated_settings = hook.reload.settings
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['timezone']).to eq('Asia/Kolkata')
expect(updated_settings['conversation_activity_code']).to eq(1001)
expect(updated_settings['transcript_activity_code']).to eq(1002)
end
@@ -71,6 +73,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
updated_settings = hook.reload.settings
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['timezone']).to eq('Asia/Kolkata')
expect(updated_settings['conversation_activity_code']).to eq(1001)
expect(updated_settings['transcript_activity_code']).to eq(1002)
end
@@ -121,8 +121,9 @@ describe MessageTemplates::HookExecutionService do
create(:message, conversation: conversation, message_type: 'incoming')
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)
allow(conversation).to receive(:can_reply?).and_return(true)
conversation.resolved!
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(csat_survey).not_to have_received(:perform)
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
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!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) }
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
create(:inbox_member, user: user, inbox: inbox)
@@ -27,7 +32,7 @@ describe SearchService do
it 'returns all for all' do
search_type = 'all'
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
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)
expect(search.perform.keys).to match_array(%i[conversations])
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
context 'when contact search' do
@@ -143,6 +154,50 @@ describe SearchService do
expect(search.perform[:conversations].map(&:id)).to include new_converstion.id
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
describe '#use_gin_search' do
+7
View File
@@ -60,6 +60,10 @@ webhook:
$ref: ./resource/webhook.yml
account:
$ref: ./resource/account.yml
account_detail:
$ref: ./resource/account_detail.yml
account_show_response:
$ref: ./resource/account_show_response.yml
account_user:
$ref: ./resource/account_user.yml
platform_account:
@@ -87,6 +91,9 @@ public_inbox:
account_create_update_payload:
$ref: ./request/account/create_update_payload.yml
account_update_payload:
$ref: ./request/account/update_payload.yml
account_user_create_update_payload:
$ref: ./request/account_user/create_update_payload.yml
@@ -0,0 +1,49 @@
type: object
properties:
name:
type: string
description: Name of the account
example: 'My Account'
locale:
type: string
description: The locale of the account
example: 'en'
domain:
type: string
description: The domain of the account
example: 'example.com'
support_email:
type: string
description: The support email of the account
example: 'support@example.com'
# Settings parameters (stored in settings JSONB column)
auto_resolve_after:
type: integer
minimum: 10
maximum: 1439856
nullable: true
description: Auto resolve conversations after specified minutes
example: 1440
auto_resolve_message:
type: string
nullable: true
description: Message to send when auto resolving
example: "This conversation has been automatically resolved due to inactivity"
auto_resolve_ignore_waiting:
type: boolean
nullable: true
description: Whether to ignore waiting conversations for auto resolve
example: false
# Custom attributes parameters (stored in custom_attributes JSONB column)
industry:
type: string
description: Industry type
example: "Technology"
company_size:
type: string
description: Company size
example: "50-100"
timezone:
type: string
description: Account timezone
example: "UTC"

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