Compare commits

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

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