Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7152ee04a | ||
|
|
dc335e88c9 | ||
|
|
b1120ae7fb | ||
|
|
443214e9a0 | ||
|
|
3ce026e2bc | ||
|
|
f42fddd38e | ||
|
|
22b5e12a53 | ||
|
|
03bde0a8aa | ||
|
|
3a0b5f387d |
@@ -1,5 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@@ -31,6 +31,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
create_linear_activity_message(:issue_created, { id: issue[:data]['identifier'] })
|
||||
render json: issue[:data], status: :ok
|
||||
end
|
||||
end
|
||||
@@ -42,17 +43,20 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
create_linear_activity_message(:issue_linked, { id: issue_id })
|
||||
render json: issue[:data], status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_issue
|
||||
link_id = permitted_params[:link_id]
|
||||
issue_id = permitted_params[:issue_id]
|
||||
issue = linear_processor_service.unlink_issue(link_id)
|
||||
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
create_linear_activity_message(:issue_unlinked, { id: issue_id })
|
||||
render json: issue[:data], status: :ok
|
||||
end
|
||||
end
|
||||
@@ -101,4 +105,17 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
|
||||
end
|
||||
|
||||
def create_linear_activity_message(action_type, issue_data = {})
|
||||
return unless @conversation
|
||||
|
||||
case action_type
|
||||
when :issue_created
|
||||
@conversation.create_linear_issue_created_activity(issue_data)
|
||||
when :issue_linked
|
||||
@conversation.create_linear_issue_linked_activity(issue_data)
|
||||
when :issue_unlinked
|
||||
@conversation.create_linear_issue_unlinked_activity(issue_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
||||
@result = search('Message')
|
||||
end
|
||||
|
||||
def articles
|
||||
@result = search('Article')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search(search_type)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CopilotMessages extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/copilot_threads', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(threadId) {
|
||||
return axios.get(`${this.url}/${threadId}/copilot_messages`);
|
||||
}
|
||||
|
||||
create({ threadId, ...rest }) {
|
||||
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CopilotMessages();
|
||||
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CopilotThreads extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/copilot_threads', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CopilotThreads();
|
||||
@@ -33,9 +33,11 @@ class LinearAPI extends ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
unlinkIssue(linkId) {
|
||||
unlinkIssue(linkId, issueIdentifier, conversationId) {
|
||||
return axios.post(`${this.url}/unlink_issue`, {
|
||||
link_id: linkId,
|
||||
issue_id: issueIdentifier,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
articles({ q, page = 1 }) {
|
||||
return axios.get(`${this.url}/articles`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SearchAPI();
|
||||
|
||||
@@ -91,6 +91,19 @@ describe('#linearAPI', () => {
|
||||
issueData
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a valid request with conversation_id', () => {
|
||||
const issueData = {
|
||||
title: 'New Issue',
|
||||
description: 'Issue description',
|
||||
conversation_id: 123,
|
||||
};
|
||||
LinearAPIClient.createIssue(issueData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/create_issue',
|
||||
issueData
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('link_issue', () => {
|
||||
@@ -120,6 +133,18 @@ describe('#linearAPI', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a valid request with title', () => {
|
||||
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/link_issue',
|
||||
{
|
||||
issue_id: 'ENG-123',
|
||||
conversation_id: 1,
|
||||
title: 'Sample Issue',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkedIssue', () => {
|
||||
@@ -164,12 +189,26 @@ describe('#linearAPI', () => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.unlinkIssue(1);
|
||||
it('creates a valid request with link_id only', () => {
|
||||
LinearAPIClient.unlinkIssue('link123');
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/unlink_issue',
|
||||
{
|
||||
link_id: 1,
|
||||
link_id: 'link123',
|
||||
issue_id: undefined,
|
||||
conversation_id: undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a valid request with all parameters', () => {
|
||||
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/unlink_issue',
|
||||
{
|
||||
link_id: 'link123',
|
||||
issue_id: 'ENG-456',
|
||||
conversation_id: 789,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ const senderName = computed(() => {
|
||||
<Icon :icon="icon" class="text-white size-4" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-1 overflow-hidden">
|
||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||
{{
|
||||
t(senderTranslationKey, {
|
||||
|
||||
@@ -183,13 +183,18 @@ const createIssue = async () => {
|
||||
state_id: formState.stateId || undefined,
|
||||
priority: formState.priority || undefined,
|
||||
label_ids: formState.labelId ? [formState.labelId] : undefined,
|
||||
conversation_id: props.conversationId,
|
||||
};
|
||||
|
||||
try {
|
||||
isCreating.value = true;
|
||||
const response = await LinearAPI.createIssue(payload);
|
||||
const { id: issueId } = response.data;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
||||
const { identifier: issueIdentifier } = response.data;
|
||||
await LinearAPI.link_issue(
|
||||
props.conversationId,
|
||||
issueIdentifier,
|
||||
props.title
|
||||
);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
|
||||
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
|
||||
onClose();
|
||||
|
||||
@@ -50,7 +50,7 @@ const priorityLabel = computed(() => {
|
||||
});
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', props.linkId);
|
||||
emit('unlinkIssue', props.linkId, props.issue.identifier);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ const onSearch = async value => {
|
||||
isFetching.value = true;
|
||||
const response = await LinearAPI.searchIssues(value);
|
||||
issues.value = response.data.map(issue => ({
|
||||
id: issue.id,
|
||||
id: issue.identifier,
|
||||
name: `${issue.identifier} ${issue.title}`,
|
||||
icon: 'status',
|
||||
iconColor: issue.state.color,
|
||||
|
||||
@@ -55,10 +55,10 @@ const loadLinkedIssue = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
const unlinkIssue = async (linkId, issueIdentifier) => {
|
||||
try {
|
||||
isUnlinking.value = true;
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssue.value = null;
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
|
||||
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('notifications/updateNotification', data);
|
||||
};
|
||||
|
||||
onCopilotMessageCreated = data => {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
import ActionCableConnector from '../actionCable';
|
||||
|
||||
vi.mock('shared/helpers/mitt', () => ({
|
||||
emitter: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useImpersonation', () => ({
|
||||
useImpersonation: () => ({
|
||||
isImpersonating: { value: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
global.chatwootConfig = {
|
||||
websocketURL: 'wss://test.chatwoot.com',
|
||||
};
|
||||
|
||||
describe('ActionCableConnector - Copilot Tests', () => {
|
||||
let store;
|
||||
let actionCable;
|
||||
let mockDispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDispatch = vi.fn();
|
||||
store = {
|
||||
$store: {
|
||||
dispatch: mockDispatch,
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||
});
|
||||
describe('copilot event handlers', () => {
|
||||
it('should register the copilot.message.created event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain(
|
||||
'copilot.message.created'
|
||||
);
|
||||
expect(actionCable.events['copilot.message.created']).toBe(
|
||||
actionCable.onCopilotMessageCreated
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle the copilot.message.created event through the ActionCable system', () => {
|
||||
const copilotData = {
|
||||
id: 2,
|
||||
content: 'This is a copilot message from ActionCable',
|
||||
conversation_id: 456,
|
||||
created_at: '2025-05-27T15:58:04-06:00',
|
||||
account_id: 1,
|
||||
};
|
||||
actionCable.onReceived({
|
||||
event: 'copilot.message.created',
|
||||
data: copilotData,
|
||||
});
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
'copilotMessages/upsert',
|
||||
copilotData
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,14 @@
|
||||
"ALL": "All",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages"
|
||||
"MESSAGES": "Messages",
|
||||
"ARTICLES": "Articles"
|
||||
},
|
||||
"SECTION": {
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages"
|
||||
"MESSAGES": "Messages",
|
||||
"ARTICLES": "Articles"
|
||||
},
|
||||
"VIEW_MORE": "View more",
|
||||
"LOAD_MORE": "Load more",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: [String, Number], default: 0 },
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
category: { type: String, default: '' },
|
||||
locale: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
portalSlug: { type: String, required: true },
|
||||
accountId: { type: [String, Number], default: 0 },
|
||||
});
|
||||
|
||||
const MAX_LENGTH = 300;
|
||||
|
||||
const navigateTo = computed(() => {
|
||||
return frontendURL(
|
||||
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
|
||||
);
|
||||
});
|
||||
|
||||
const truncatedContent = computed(() => {
|
||||
if (!props.content) return props.description || '';
|
||||
|
||||
// Use MessageFormatter to properly convert markdown to plain text
|
||||
const formatter = new MessageFormatter(props.content);
|
||||
const plainText = formatter.plainText.trim();
|
||||
|
||||
return plainText.length > MAX_LENGTH
|
||||
? `${plainText.substring(0, MAX_LENGTH)}...`
|
||||
: plainText;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
:to="navigateTo"
|
||||
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
|
||||
>
|
||||
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
|
||||
</div>
|
||||
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<span
|
||||
v-if="category"
|
||||
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
|
||||
>
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="truncatedContent"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
|
||||
>
|
||||
{{ truncatedContent }}
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import SearchResultSection from './SearchResultSection.vue';
|
||||
import SearchResultArticleItem from './SearchResultArticleItem.vue';
|
||||
|
||||
defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SearchResultSection
|
||||
:title="$t('SEARCH.SECTION.ARTICLES')"
|
||||
:empty="!articles.length"
|
||||
:query="query"
|
||||
:show-title="showTitle"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<ul v-if="articles.length" class="space-y-1.5 list-none">
|
||||
<li v-for="article in articles" :key="article.id">
|
||||
<SearchResultArticleItem
|
||||
:id="article.id"
|
||||
:title="article.title"
|
||||
:description="article.description"
|
||||
:content="article.content"
|
||||
:portal-slug="article.portal_slug"
|
||||
:locale="article.locale"
|
||||
:account-id="accountId"
|
||||
:category="article.category_name"
|
||||
:status="article.status"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</SearchResultSection>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ROLES,
|
||||
CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
import {
|
||||
getUserPermissions,
|
||||
@@ -22,6 +23,7 @@ import SearchTabs from './SearchTabs.vue';
|
||||
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
||||
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
@@ -34,6 +36,7 @@ const pages = ref({
|
||||
contacts: 1,
|
||||
conversations: 1,
|
||||
messages: 1,
|
||||
articles: 1,
|
||||
});
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
@@ -43,6 +46,7 @@ const conversationRecords = useMapGetter(
|
||||
'conversationSearch/getConversationRecords'
|
||||
);
|
||||
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
||||
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
||||
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
||||
|
||||
const addTypeToRecords = (records, type) =>
|
||||
@@ -57,6 +61,9 @@ const mappedConversations = computed(() =>
|
||||
const mappedMessages = computed(() =>
|
||||
addTypeToRecords(messageRecords, 'message')
|
||||
);
|
||||
const mappedArticles = computed(() =>
|
||||
addTypeToRecords(articleRecords, 'article')
|
||||
);
|
||||
|
||||
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
||||
|
||||
@@ -66,6 +73,7 @@ const sliceRecordsIfAllTab = items =>
|
||||
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
||||
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
||||
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
||||
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
||||
|
||||
const filterByTab = tab =>
|
||||
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
||||
@@ -73,6 +81,7 @@ const filterByTab = tab =>
|
||||
const filterContacts = filterByTab('contacts');
|
||||
const filterConversations = filterByTab('conversations');
|
||||
const filterMessages = filterByTab('messages');
|
||||
const filterArticles = filterByTab('articles');
|
||||
|
||||
const userPermissions = computed(() =>
|
||||
getUserPermissions(currentUser.value, currentAccountId.value)
|
||||
@@ -80,7 +89,12 @@ const userPermissions = computed(() =>
|
||||
|
||||
const TABS_CONFIG = {
|
||||
all: {
|
||||
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
permissions: [
|
||||
CONTACT_PERMISSIONS,
|
||||
...ROLES,
|
||||
...CONVERSATION_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
],
|
||||
count: () => null, // No count for all tab
|
||||
},
|
||||
contacts: {
|
||||
@@ -95,6 +109,10 @@ const TABS_CONFIG = {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
count: () => mappedMessages.value.length,
|
||||
},
|
||||
articles: {
|
||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||
count: () => mappedArticles.value.length,
|
||||
},
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
@@ -123,6 +141,10 @@ const totalSearchResultsCount = computed(() => {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
count: () => conversations.value.length + messages.value.length,
|
||||
},
|
||||
articles: {
|
||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||
count: () => articles.value.length,
|
||||
},
|
||||
};
|
||||
return filterItemsByPermission(
|
||||
permissionCounts,
|
||||
@@ -138,12 +160,13 @@ const activeTabIndex = computed(() => {
|
||||
});
|
||||
|
||||
const isFetchingAny = computed(() => {
|
||||
const { contact, message, conversation, isFetching } = uiFlags.value;
|
||||
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
||||
return (
|
||||
isFetching ||
|
||||
contact.isFetching ||
|
||||
message.isFetching ||
|
||||
conversation.isFetching
|
||||
conversation.isFetching ||
|
||||
article.isFetching
|
||||
);
|
||||
});
|
||||
|
||||
@@ -171,6 +194,7 @@ const showLoadMore = computed(() => {
|
||||
contacts: mappedContacts.value,
|
||||
conversations: mappedConversations.value,
|
||||
messages: mappedMessages.value,
|
||||
articles: mappedArticles.value,
|
||||
}[selectedTab.value];
|
||||
|
||||
return (
|
||||
@@ -185,10 +209,11 @@ const showViewMore = computed(() => ({
|
||||
conversations:
|
||||
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
||||
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
||||
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
||||
}));
|
||||
|
||||
const clearSearchResult = () => {
|
||||
pages.value = { contacts: 1, conversations: 1, messages: 1 };
|
||||
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
};
|
||||
|
||||
@@ -214,6 +239,7 @@ const loadMore = () => {
|
||||
contacts: 'conversationSearch/contactSearch',
|
||||
conversations: 'conversationSearch/conversationSearch',
|
||||
messages: 'conversationSearch/messageSearch',
|
||||
articles: 'conversationSearch/articleSearch',
|
||||
};
|
||||
|
||||
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
||||
@@ -328,6 +354,28 @@ onUnmounted(() => {
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
<Policy
|
||||
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<SearchResultArticlesList
|
||||
v-if="filterArticles"
|
||||
:is-fetching="uiFlags.article.isFetching"
|
||||
:articles="articles"
|
||||
:query="query"
|
||||
:show-title="isSelectedTabAll"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showViewMore.articles"
|
||||
:label="t(`SEARCH.VIEW_MORE`)"
|
||||
icon="i-lucide-eye"
|
||||
slate
|
||||
sm
|
||||
outline
|
||||
@click="selectedTab = 'articles'"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
|
||||
<NextButton
|
||||
v-if="!isSelectedTabAll"
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ROLES,
|
||||
CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
|
||||
import SearchView from './components/SearchView.vue';
|
||||
@@ -12,7 +13,12 @@ export const routes = [
|
||||
path: frontendURL('accounts/:accountId/search'),
|
||||
name: 'search',
|
||||
meta: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
|
||||
permissions: [
|
||||
...ROLES,
|
||||
...CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
],
|
||||
},
|
||||
component: SearchView,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CopilotMessages',
|
||||
API: CopilotMessagesAPI,
|
||||
getters: {
|
||||
getMessagesByThreadId: state => copilotThreadId => {
|
||||
return state.records.filter(
|
||||
record => record.copilot_thread?.id === Number(copilotThreadId)
|
||||
);
|
||||
},
|
||||
},
|
||||
actions: mutationTypes => ({
|
||||
upsert({ commit }, data) {
|
||||
commit(mutationTypes.UPSERT, data);
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CopilotThreads',
|
||||
API: CopilotThreadsAPI,
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
createRecord,
|
||||
deleteRecord,
|
||||
getRecords,
|
||||
showRecord,
|
||||
updateRecord,
|
||||
} from './storeFactoryHelper';
|
||||
|
||||
export const generateMutationTypes = name => {
|
||||
const capitalizedName = name.toUpperCase();
|
||||
@@ -10,6 +16,7 @@ export const generateMutationTypes = name => {
|
||||
EDIT: `EDIT_${capitalizedName}`,
|
||||
DELETE: `DELETE_${capitalizedName}`,
|
||||
SET_META: `SET_${capitalizedName}_META`,
|
||||
UPSERT: `UPSERT_${capitalizedName}`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,7 +40,6 @@ export const createGetters = () => ({
|
||||
getMeta: state => state.meta,
|
||||
});
|
||||
|
||||
// store/mutations.js
|
||||
export const createMutations = mutationTypes => ({
|
||||
[mutationTypes.SET_UI_FLAG](state, data) {
|
||||
state.uiFlags = {
|
||||
@@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({
|
||||
[mutationTypes.ADD]: MutationHelpers.create,
|
||||
[mutationTypes.EDIT]: MutationHelpers.update,
|
||||
[mutationTypes.DELETE]: MutationHelpers.destroy,
|
||||
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
|
||||
});
|
||||
|
||||
// store/actions/crud.js
|
||||
export const createCrudActions = (API, mutationTypes) => ({
|
||||
async get({ commit }, params = {}) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const response = await API.get(params);
|
||||
commit(mutationTypes.SET, response.data.payload);
|
||||
commit(mutationTypes.SET_META, response.data.meta);
|
||||
return response.data.payload;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async show({ commit }, id) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
||||
try {
|
||||
const response = await API.show(id);
|
||||
commit(mutationTypes.ADD, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async create({ commit }, dataObj) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await API.create(dataObj);
|
||||
commit(mutationTypes.ADD, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async update({ commit }, { id, ...updateObj }) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await API.update(id, updateObj);
|
||||
commit(mutationTypes.EDIT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async delete({ commit }, id) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await API.delete(id);
|
||||
commit(mutationTypes.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
},
|
||||
get: getRecords(mutationTypes, API),
|
||||
show: showRecord(mutationTypes, API),
|
||||
create: createRecord(mutationTypes, API),
|
||||
update: updateRecord(mutationTypes, API),
|
||||
delete: deleteRecord(mutationTypes, API),
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions } = options;
|
||||
const { name, API, actions, getters } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
@@ -130,7 +77,10 @@ export const createStore = options => {
|
||||
return {
|
||||
namespaced: true,
|
||||
state: createInitialState(),
|
||||
getters: createGetters(),
|
||||
getters: {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: createMutations(mutationTypes),
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
generateMutationTypes,
|
||||
createInitialState,
|
||||
createGetters,
|
||||
createMutations,
|
||||
createCrudActions,
|
||||
createStore,
|
||||
} from './storeFactory';
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
|
||||
set: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setSingleRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storeFactory', () => {
|
||||
describe('generateMutationTypes', () => {
|
||||
it('generates correct mutation types with capitalized name', () => {
|
||||
const result = generateMutationTypes('test');
|
||||
expect(result).toEqual({
|
||||
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
|
||||
SET: 'SET_TEST',
|
||||
ADD: 'ADD_TEST',
|
||||
EDIT: 'EDIT_TEST',
|
||||
DELETE: 'DELETE_TEST',
|
||||
SET_META: 'SET_TEST_META',
|
||||
UPSERT: 'UPSERT_TEST',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInitialState', () => {
|
||||
it('returns the correct initial state structure', () => {
|
||||
const result = createInitialState();
|
||||
expect(result).toEqual({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGetters', () => {
|
||||
it('returns getters with correct implementations', () => {
|
||||
const getters = createGetters();
|
||||
|
||||
const state = {
|
||||
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
|
||||
uiFlags: { fetchingList: true },
|
||||
meta: { totalCount: 10, page: 1 },
|
||||
};
|
||||
expect(getters.getRecords(state)).toEqual([
|
||||
{ id: 3 },
|
||||
{ id: 2 },
|
||||
{ id: 1 },
|
||||
]);
|
||||
|
||||
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
|
||||
expect(getters.getRecord(state)(4)).toEqual({});
|
||||
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
fetchingList: true,
|
||||
});
|
||||
|
||||
expect(getters.getMeta(state)).toEqual({
|
||||
totalCount: 10,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMutations', () => {
|
||||
it('creates mutations with correct implementations', () => {
|
||||
const mutationTypes = generateMutationTypes('test');
|
||||
const mutations = createMutations(mutationTypes);
|
||||
|
||||
const state = { uiFlags: { fetchingList: false } };
|
||||
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
|
||||
expect(state.uiFlags).toEqual({ fetchingList: true });
|
||||
|
||||
const metaState = { meta: {} };
|
||||
mutations[mutationTypes.SET_META](metaState, {
|
||||
total_count: '10',
|
||||
page: '2',
|
||||
});
|
||||
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
|
||||
|
||||
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
|
||||
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
|
||||
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
|
||||
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
|
||||
expect(mutations[mutationTypes.UPSERT]).toBe(
|
||||
MutationHelpers.setSingleRecord
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCrudActions', () => {
|
||||
let API;
|
||||
let commit;
|
||||
let mutationTypes;
|
||||
let actions;
|
||||
|
||||
beforeEach(() => {
|
||||
API = {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
commit = vi.fn();
|
||||
mutationTypes = generateMutationTypes('test');
|
||||
actions = createCrudActions(API, mutationTypes);
|
||||
});
|
||||
|
||||
describe('get action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const payload = [{ id: 1 }];
|
||||
const meta = { total_count: 10, page: 1 };
|
||||
API.get.mockResolvedValue({ data: { payload, meta } });
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.get.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.show.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.show.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.create.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.create.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Updated' };
|
||||
API.update.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.update.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
API.delete.mockResolvedValue({});
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(API.delete).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual(1);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.delete.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStore', () => {
|
||||
it('creates a complete store with default options', () => {
|
||||
const API = {};
|
||||
const store = createStore({ name: 'test', API });
|
||||
|
||||
expect(store.namespaced).toBe(true);
|
||||
expect(store.state).toEqual(createInitialState());
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
]);
|
||||
expect(Object.keys(store.mutations)).toEqual([
|
||||
'SET_TEST_UI_FLAG',
|
||||
'SET_TEST_META',
|
||||
'SET_TEST',
|
||||
'ADD_TEST',
|
||||
'EDIT_TEST',
|
||||
'DELETE_TEST',
|
||||
'UPSERT_TEST',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a store with custom actions and getters', () => {
|
||||
const API = {};
|
||||
const customGetters = { customGetter: () => 'custom' };
|
||||
const customActions = () => ({
|
||||
customAction: () => 'custom',
|
||||
});
|
||||
|
||||
const store = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
getters: customGetters,
|
||||
actions: customActions,
|
||||
});
|
||||
|
||||
expect(store.getters).toHaveProperty('customGetter');
|
||||
expect(store.actions).toHaveProperty('customAction');
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
'customGetter',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'customAction',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export const getRecords =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, params = {}) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const response = await API.get(params);
|
||||
commit(mutationTypes.SET, response.data.payload);
|
||||
commit(mutationTypes.SET_META, response.data.meta);
|
||||
return response.data.payload;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const showRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
||||
try {
|
||||
const response = await API.show(id);
|
||||
commit(mutationTypes.ADD, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const createRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, dataObj) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await API.create(dataObj);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, { id, ...updateObj }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await API.update(id, updateObj);
|
||||
commit(mutationTypes.EDIT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await API.delete(id);
|
||||
commit(mutationTypes.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
};
|
||||
@@ -51,6 +51,9 @@ import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
import captainInboxes from './captain/inboxes';
|
||||
import captainBulkActions from './captain/bulkActions';
|
||||
import copilotThreads from './captain/copilotThreads';
|
||||
import copilotMessages from './captain/copilotMessages';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
export default createStore({
|
||||
@@ -106,6 +109,8 @@ export default createStore({
|
||||
captainResponses,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
copilotThreads,
|
||||
copilotMessages,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@@ -5,12 +5,14 @@ export const initialState = {
|
||||
contactRecords: [],
|
||||
conversationRecords: [],
|
||||
messageRecords: [],
|
||||
articleRecords: [],
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isSearchCompleted: false,
|
||||
contact: { isFetching: false },
|
||||
conversation: { isFetching: false },
|
||||
message: { isFetching: false },
|
||||
article: { isFetching: false },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +29,9 @@ export const getters = {
|
||||
getMessageRecords(state) {
|
||||
return state.messageRecords;
|
||||
},
|
||||
getArticleRecords(state) {
|
||||
return state.articleRecords;
|
||||
},
|
||||
getUIFlags(state) {
|
||||
return state.uiFlags;
|
||||
},
|
||||
@@ -65,6 +70,7 @@ export const actions = {
|
||||
dispatch('contactSearch', { q }),
|
||||
dispatch('conversationSearch', { q }),
|
||||
dispatch('messageSearch', { q }),
|
||||
dispatch('articleSearch', { q }),
|
||||
]);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
@@ -108,6 +114,17 @@ export const actions = {
|
||||
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
async articleSearch({ commit }, { q, page = 1 }) {
|
||||
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const { data } = await SearchAPI.articles({ q, page });
|
||||
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} finally {
|
||||
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
async clearSearchResults({ commit }) {
|
||||
commit(types.CLEAR_SEARCH_RESULTS);
|
||||
},
|
||||
@@ -126,6 +143,9 @@ export const mutations = {
|
||||
[types.MESSAGE_SEARCH_SET](state, records) {
|
||||
state.messageRecords = [...state.messageRecords, ...records];
|
||||
},
|
||||
[types.ARTICLE_SEARCH_SET](state, records) {
|
||||
state.articleRecords = [...state.articleRecords, ...records];
|
||||
},
|
||||
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
||||
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||
},
|
||||
@@ -141,10 +161,14 @@ export const mutations = {
|
||||
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
|
||||
},
|
||||
[types.ARTICLE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||
state.uiFlags.article = { ...state.uiFlags.article, ...uiFlags };
|
||||
},
|
||||
[types.CLEAR_SEARCH_RESULTS](state) {
|
||||
state.contactRecords = [];
|
||||
state.conversationRecords = [];
|
||||
state.messageRecords = [];
|
||||
state.articleRecords = [];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('#actions', () => {
|
||||
q: 'test',
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
|
||||
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +151,30 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#articleSearch', () => {
|
||||
it('should handle successful article search', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: { payload: { articles: [{ id: 1 }] } },
|
||||
});
|
||||
|
||||
await actions.articleSearch({ commit }, { q: 'test', page: 1 });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
|
||||
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle failed article search', async () => {
|
||||
axios.get.mockRejectedValue({});
|
||||
await actions.articleSearch({ commit }, { q: 'test' });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clearSearchResults', () => {
|
||||
it('should commit clear search results mutation', () => {
|
||||
actions.clearSearchResults({ commit });
|
||||
|
||||
@@ -37,6 +37,15 @@ describe('#getters', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('getArticleRecords', () => {
|
||||
const state = {
|
||||
articleRecords: [{ id: 1, title: 'Article 1' }],
|
||||
};
|
||||
expect(getters.getArticleRecords(state)).toEqual([
|
||||
{ id: 1, title: 'Article 1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
@@ -45,6 +54,7 @@ describe('#getters', () => {
|
||||
contact: { isFetching: true },
|
||||
message: { isFetching: false },
|
||||
conversation: { isFetching: false },
|
||||
article: { isFetching: false },
|
||||
},
|
||||
};
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
@@ -53,6 +63,7 @@ describe('#getters', () => {
|
||||
contact: { isFetching: true },
|
||||
message: { isFetching: false },
|
||||
conversation: { isFetching: false },
|
||||
article: { isFetching: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,17 +101,39 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ARTICLE_SEARCH_SET', () => {
|
||||
it('should append new article records to existing ones', () => {
|
||||
const state = { articleRecords: [{ id: 1 }] };
|
||||
mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]);
|
||||
expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => {
|
||||
it('set article search UI flags correctly', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
article: { isFetching: true },
|
||||
},
|
||||
};
|
||||
mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
|
||||
expect(state.uiFlags.article).toEqual({ isFetching: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CLEAR_SEARCH_RESULTS', () => {
|
||||
it('should clear all search records', () => {
|
||||
const state = {
|
||||
contactRecords: [{ id: 1 }],
|
||||
conversationRecords: [{ id: 1 }],
|
||||
messageRecords: [{ id: 1 }],
|
||||
articleRecords: [{ id: 1 }],
|
||||
};
|
||||
mutations[types.CLEAR_SEARCH_RESULTS](state);
|
||||
expect(state.contactRecords).toEqual([]);
|
||||
expect(state.conversationRecords).toEqual([]);
|
||||
expect(state.messageRecords).toEqual([]);
|
||||
expect(state.articleRecords).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,8 +317,10 @@ export default {
|
||||
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
|
||||
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
|
||||
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
|
||||
ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET',
|
||||
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
|
||||
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
|
||||
ARTICLE_SEARCH_SET_UI_FLAG: 'ARTICLE_SEARCH_SET_UI_FLAG',
|
||||
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
||||
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
|
||||
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
|
||||
|
||||
@@ -38,16 +38,9 @@ export const openExternalLinksInNewTab = () => {
|
||||
document.addEventListener('click', event => {
|
||||
if (!isOnArticlePage) return;
|
||||
|
||||
// Some of the links come wrapped in strong tag through prosemirror
|
||||
|
||||
const isTagAnchor = event.target.tagName === 'A';
|
||||
const isParentTagAnchor =
|
||||
event.target.tagName === 'STRONG' &&
|
||||
event.target.parentNode.tagName === 'A';
|
||||
|
||||
if (isTagAnchor || isParentTagAnchor) {
|
||||
const link = isTagAnchor ? event.target : event.target.parentNode;
|
||||
const link = event.target.closest('a');
|
||||
|
||||
if (link) {
|
||||
const isInternalLink =
|
||||
link.hostname === window.location.hostname ||
|
||||
link.href.includes(customDomain) ||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { InitializationHelpers } from '../portalHelpers';
|
||||
import {
|
||||
InitializationHelpers,
|
||||
openExternalLinksInNewTab,
|
||||
} from '../portalHelpers';
|
||||
|
||||
describe('InitializationHelpers.navigateToLocalePage', () => {
|
||||
let dom;
|
||||
@@ -44,3 +47,96 @@ describe('InitializationHelpers.navigateToLocalePage', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExternalLinksInNewTab', () => {
|
||||
let dom;
|
||||
let document;
|
||||
let window;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="cw-article-content">
|
||||
<a href="https://external.com" id="external">External</a>
|
||||
<a href="https://app.chatwoot.com/page" id="internal">Internal</a>
|
||||
<a href="https://custom.domain.com/page" id="custom">Custom</a>
|
||||
<a href="https://example.com" id="nested"><code>Code</code><strong>Bold</strong></a>
|
||||
<ul>
|
||||
<li>Visit the preferences centre here > <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: 'https://app.chatwoot.com/hc/article' }
|
||||
);
|
||||
|
||||
document = dom.window.document;
|
||||
window = dom.window;
|
||||
|
||||
window.portalConfig = {
|
||||
customDomain: 'custom.domain.com',
|
||||
hostURL: 'app.chatwoot.com',
|
||||
};
|
||||
|
||||
global.document = document;
|
||||
global.window = window;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom = null;
|
||||
document = null;
|
||||
window = null;
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
});
|
||||
|
||||
const simulateClick = selector => {
|
||||
const element = document.querySelector(selector);
|
||||
const event = new window.MouseEvent('click', { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
return element.closest('a') || element;
|
||||
};
|
||||
|
||||
it('opens external links in new tab', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('preserves internal links', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const internal = simulateClick('#internal');
|
||||
const custom = simulateClick('#custom');
|
||||
|
||||
expect(internal.target).not.toBe('_blank');
|
||||
expect(custom.target).not.toBe('_blank');
|
||||
});
|
||||
|
||||
it('handles clicks on nested elements', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
simulateClick('#nested code');
|
||||
simulateClick('#nested strong');
|
||||
|
||||
const link = document.getElementById('nested');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('handles links inside list items with strong tags', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
// Click on the strong element inside the link in the list
|
||||
simulateClick('#list-link strong');
|
||||
|
||||
const link = document.getElementById('list-link');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ module ActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
include TeamActivityMessageHandler
|
||||
include LinearActivityMessageHandler
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
module LinearActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def create_linear_issue_created_activity(issue_data)
|
||||
return unless issue_data[:id]
|
||||
|
||||
content = I18n.t('conversations.activity.linear.issue_created', issue_id: issue_data[:id])
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def create_linear_issue_linked_activity(issue_data)
|
||||
return unless issue_data[:id]
|
||||
|
||||
content = I18n.t('conversations.activity.linear.issue_linked', issue_id: issue_data[:id])
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def create_linear_issue_unlinked_activity(issue_data)
|
||||
return unless issue_data[:id]
|
||||
|
||||
content = I18n.t('conversations.activity.linear.issue_unlinked', issue_id: issue_data[:id])
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
end
|
||||
@@ -6,17 +6,18 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
||||
# so this limits it
|
||||
ACTIVITY_NOTE_MAX_SIZE = 1800
|
||||
|
||||
def self.map_conversation_activity(conversation)
|
||||
new(conversation).conversation_activity
|
||||
def self.map_conversation_activity(hook, conversation)
|
||||
new(hook, conversation).conversation_activity
|
||||
end
|
||||
|
||||
def self.map_transcript_activity(conversation, messages = nil)
|
||||
new(conversation, messages).transcript_activity
|
||||
def self.map_transcript_activity(hook, conversation)
|
||||
new(hook, conversation).transcript_activity
|
||||
end
|
||||
|
||||
def initialize(conversation, messages = nil)
|
||||
def initialize(hook, conversation)
|
||||
@hook = hook
|
||||
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||
@conversation = conversation
|
||||
@messages = messages
|
||||
end
|
||||
|
||||
def conversation_activity
|
||||
@@ -41,14 +42,14 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :messages
|
||||
attr_reader :conversation
|
||||
|
||||
def formatted_creation_time
|
||||
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S')
|
||||
end
|
||||
|
||||
def transcript_messages
|
||||
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
|
||||
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
|
||||
end
|
||||
|
||||
def format_messages
|
||||
@@ -77,8 +78,7 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
||||
end
|
||||
|
||||
def message_time(message)
|
||||
# TODO: Figure out what timezone to send the time in
|
||||
message.created_at.strftime('%Y-%m-%d %H:%M')
|
||||
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
|
||||
end
|
||||
|
||||
def sender_name(message)
|
||||
|
||||
@@ -37,7 +37,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
||||
activity_type: 'conversation',
|
||||
activity_code_key: 'conversation_activity_code',
|
||||
metadata_key: 'created_activity_id',
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation)
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
||||
activity_type: 'transcript',
|
||||
activity_code_key: 'transcript_activity_code',
|
||||
metadata_key: 'transcript_activity_id',
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation)
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ class Crm::Leadsquared::SetupService
|
||||
response = @client.get('Authentication.svc/UserByAccessKey.Get')
|
||||
endpoint_host = response['LSQCommonServiceURLs']['api']
|
||||
app_host = response['LSQCommonServiceURLs']['app']
|
||||
timezone = response['TimeZone']
|
||||
|
||||
endpoint_url = "https://#{endpoint_host}/v2/"
|
||||
app_url = "https://#{app_host}/"
|
||||
|
||||
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
|
||||
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone })
|
||||
|
||||
# replace the clients
|
||||
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
|
||||
|
||||
@@ -9,8 +9,10 @@ class SearchService
|
||||
{ conversations: filter_conversations }
|
||||
when 'Contact'
|
||||
{ contacts: filter_contacts }
|
||||
when 'Article'
|
||||
{ articles: filter_articles }
|
||||
else
|
||||
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations }
|
||||
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations, articles: filter_articles }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -90,4 +92,12 @@ class SearchService
|
||||
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
|
||||
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
|
||||
end
|
||||
|
||||
def filter_articles
|
||||
@articles = current_account.articles
|
||||
.text_search(search_query)
|
||||
.reorder('updated_at DESC')
|
||||
.page(params[:page])
|
||||
.per(15)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
json.id article.id
|
||||
json.title article.title
|
||||
json.locale article.locale
|
||||
json.content article.content
|
||||
json.slug article.slug
|
||||
json.portal_slug article.portal.slug
|
||||
json.account_id article.account_id
|
||||
json.category_name article.category&.name
|
||||
@@ -0,0 +1,15 @@
|
||||
json.id conversation.display_id
|
||||
json.account_id conversation.account_id
|
||||
json.created_at conversation.created_at.to_i
|
||||
json.message do
|
||||
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
|
||||
end
|
||||
json.contact do
|
||||
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
|
||||
end
|
||||
json.inbox do
|
||||
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
|
||||
end
|
||||
json.agent do
|
||||
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
json.payload do
|
||||
json.articles do
|
||||
json.array! @result[:articles] do |article|
|
||||
json.partial! 'article', formats: [:json], article: article
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,21 +1,7 @@
|
||||
json.payload do
|
||||
json.conversations do
|
||||
json.array! @result[:conversations] do |conversation|
|
||||
json.id conversation.display_id
|
||||
json.account_id conversation.account_id
|
||||
json.created_at conversation.created_at.to_i
|
||||
json.message do
|
||||
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
|
||||
end
|
||||
json.contact do
|
||||
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
|
||||
end
|
||||
json.inbox do
|
||||
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
|
||||
end
|
||||
json.agent do
|
||||
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
|
||||
end
|
||||
json.partial! 'conversation_search_result', formats: [:json], conversation: conversation
|
||||
end
|
||||
end
|
||||
json.contacts do
|
||||
@@ -23,10 +9,14 @@ json.payload do
|
||||
json.partial! 'contact', formats: [:json], contact: contact
|
||||
end
|
||||
end
|
||||
|
||||
json.messages do
|
||||
json.array! @result[:messages] do |message|
|
||||
json.partial! 'message', formats: [:json], message: message
|
||||
end
|
||||
end
|
||||
json.articles do
|
||||
json.array! @result[:articles] do |article|
|
||||
json.partial! 'article', formats: [:json], article: article
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -205,6 +205,7 @@ leadsquared:
|
||||
'secret_key': { 'type': 'string' },
|
||||
'endpoint_url': { 'type': 'string' },
|
||||
'app_url': { 'type': 'string' },
|
||||
'timezone': { 'type': 'string' },
|
||||
'enable_conversation_activity': { 'type': 'boolean' },
|
||||
'enable_transcript_activity': { 'type': 'boolean' },
|
||||
'conversation_activity_score': { 'type': 'string' },
|
||||
|
||||
@@ -186,6 +186,10 @@ en:
|
||||
sla:
|
||||
added: '%{user_name} added SLA policy %{sla_name}'
|
||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||
linear:
|
||||
issue_created: 'New Linear issue %{issue_id} has been created'
|
||||
issue_linked: 'Linear issue %{issue_id} is now linked'
|
||||
issue_unlinked: 'Linear issue %{issue_id} has been unlinked'
|
||||
muted: '%{user_name} has muted the conversation'
|
||||
unmuted: '%{user_name} has unmuted the conversation'
|
||||
auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
|
||||
|
||||
@@ -137,6 +137,7 @@ Rails.application.routes.draw do
|
||||
get :conversations
|
||||
get :messages
|
||||
get :contacts
|
||||
get :articles
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts:
|
||||
|
||||
def create
|
||||
@copilot_message = @copilot_thread.copilot_messages.create!(
|
||||
message: params[:message],
|
||||
message: { content: params[:message] },
|
||||
message_type: :user
|
||||
)
|
||||
@copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -18,7 +18,12 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
||||
assistant: assistant
|
||||
)
|
||||
|
||||
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
|
||||
copilot_message = @copilot_thread.copilot_messages.create!(
|
||||
message_type: :user,
|
||||
message: { content: copilot_thread_params[:message] }
|
||||
)
|
||||
|
||||
copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
def copilot_thread_params
|
||||
params.permit(:message, :assistant_id)
|
||||
params.permit(:message, :assistant_id, :conversation_id)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -13,6 +13,9 @@ module Captain::ChatHelper
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class Captain::Copilot::ResponseJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
|
||||
Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}")
|
||||
generate_chat_response(
|
||||
assistant: assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread_id,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
|
||||
Captain::Copilot::ChatService.new(
|
||||
assistant,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread_id,
|
||||
conversation_id: conversation_id
|
||||
).generate_response(message)
|
||||
end
|
||||
end
|
||||
@@ -19,14 +19,12 @@ class CopilotMessage < ApplicationRecord
|
||||
belongs_to :copilot_thread
|
||||
belongs_to :account
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
|
||||
|
||||
validates :message_type, presence: true, inclusion: { in: message_types.keys }
|
||||
validates :message_type, presence: true
|
||||
validates :message, presence: true
|
||||
before_validation :ensure_account
|
||||
validate :validate_message_attributes
|
||||
|
||||
after_create_commit :broadcast_message
|
||||
|
||||
def push_event_data
|
||||
@@ -39,10 +37,20 @@ class CopilotMessage < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def enqueue_response_job(conversation_id, user_id)
|
||||
Captain::Copilot::ResponseJob.perform_later(
|
||||
assistant: copilot_thread.assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message: message['content']
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = copilot_thread.account
|
||||
self.account_id = copilot_thread&.account_id
|
||||
end
|
||||
|
||||
def broadcast_message
|
||||
|
||||
@@ -51,7 +51,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
|
||||
)
|
||||
|
||||
@copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present?
|
||||
@copilot_thread = @account.copilot_threads.find_by(id: config[:copilot_thread_id]) if config[:copilot_thread_id].present?
|
||||
@previous_history = if @copilot_thread.present?
|
||||
@copilot_thread.previous_history
|
||||
else
|
||||
|
||||
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
|
||||
end
|
||||
|
||||
def active?
|
||||
@user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
|
||||
@user.present? && @assistant.account.hooks.exists?(app_id: 'linear')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -7,6 +7,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
||||
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
|
||||
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
|
||||
|
||||
def text(node)
|
||||
content = node.string_content
|
||||
@@ -52,7 +53,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
MP4_REGEX => :make_video_embed,
|
||||
LOOM_REGEX => :make_loom_embed,
|
||||
ARCADE_REGEX => :make_arcade_embed,
|
||||
WISTIA_REGEX => :make_wistia_embed
|
||||
WISTIA_REGEX => :make_wistia_embed,
|
||||
BUNNY_REGEX => :make_bunny_embed
|
||||
}
|
||||
|
||||
embedding_methods.each do |regex, method|
|
||||
@@ -104,4 +106,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
video_id = arcade_match[1]
|
||||
EmbedRenderer.arcade(video_id)
|
||||
end
|
||||
|
||||
def make_bunny_embed(bunny_match)
|
||||
library_id = bunny_match[1]
|
||||
video_id = bunny_match[2]
|
||||
EmbedRenderer.bunny(library_id, video_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,4 +84,19 @@ module EmbedRenderer
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.bunny(library_id, video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-top: 56.25%;">
|
||||
<iframe
|
||||
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
title="Bunny video player"
|
||||
loading="lazy"
|
||||
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,7 +28,8 @@ class Integrations::Linear::ProcessorService
|
||||
|
||||
{
|
||||
data: { id: response['issueCreate']['issue']['id'],
|
||||
title: response['issueCreate']['issue']['title'] }
|
||||
title: response['issueCreate']['issue']['title'],
|
||||
identifier: response['issueCreate']['issue']['identifier'] }
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ module Linear::Mutations
|
||||
issue {
|
||||
id
|
||||
title
|
||||
identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ db_namespace = namespace :db do
|
||||
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
|
||||
ActiveRecord::Base.establish_connection(db_config.configuration_hash)
|
||||
unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata'
|
||||
db_namespace['load_config'].invoke if ActiveRecord::Base.schema_format == :ruby
|
||||
db_namespace['load_config'].invoke if ActiveRecord.schema_format == :ruby
|
||||
ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil))
|
||||
db_namespace['seed'].invoke
|
||||
end
|
||||
|
||||
@@ -93,6 +93,8 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/integrations/linear/create_issue' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:issue_params) do
|
||||
{
|
||||
team_id: 'team1',
|
||||
@@ -101,32 +103,56 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
assignee_id: 'user1',
|
||||
priority: 'high',
|
||||
state_id: 'state1',
|
||||
label_ids: ['label1']
|
||||
label_ids: ['label1'],
|
||||
conversation_id: conversation.display_id
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when the issue is created successfully' do
|
||||
let(:created_issue) { { data: { 'id' => 'issue1', 'title' => 'Sample Issue' } } }
|
||||
let(:created_issue) { { data: { 'identifier' => 'ENG-123', 'title' => 'Sample Issue' } } }
|
||||
|
||||
it 'returns the created issue' do
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
params: issue_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('Sample Issue')
|
||||
end
|
||||
|
||||
it 'creates activity message when conversation is provided' do
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
params: issue_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'New Linear issue ENG-123 has been created'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when issue creation fails' do
|
||||
it 'returns error message' do
|
||||
it 'returns error message and does not create activity message' do
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(error: 'error message')
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
params: issue_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
params: issue_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('error message')
|
||||
end
|
||||
@@ -135,7 +161,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/integrations/linear/link_issue' do
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:issue_id) { 'ENG-456' }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" }
|
||||
let(:title) { 'Sample Issue' }
|
||||
@@ -144,24 +170,38 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
context 'when the issue is linked successfully' do
|
||||
let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
|
||||
|
||||
it 'returns the linked issue' do
|
||||
it 'returns the linked issue and creates activity message' do
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(linked_issue)
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'Linear issue ENG-456 is now linked'
|
||||
})
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('https://linear.app/issue1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when issue linking fails' do
|
||||
it 'returns error message' do
|
||||
it 'returns error message and does not create activity message' do
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(error: 'error message')
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('error message')
|
||||
end
|
||||
@@ -171,29 +211,45 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue' do
|
||||
let(:link_id) { 'attachment1' }
|
||||
let(:issue_id) { 'ENG-789' }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when the issue is unlinked successfully' do
|
||||
let(:unlinked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
|
||||
|
||||
it 'returns the unlinked issue' do
|
||||
it 'returns the unlinked issue and creates activity message' do
|
||||
allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(unlinked_issue)
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
|
||||
params: { link_id: link_id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
|
||||
params: { link_id: link_id, issue_id: issue_id, conversation_id: conversation.display_id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'Linear issue ENG-789 has been unlinked'
|
||||
})
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('https://linear.app/issue1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when issue unlinking fails' do
|
||||
it 'returns error message' do
|
||||
it 'returns error message and does not create activity message' do
|
||||
allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(error: 'error message')
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
|
||||
params: { link_id: link_id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
|
||||
params: { link_id: link_id, issue_id: issue_id, conversation_id: conversation.display_id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('error message')
|
||||
end
|
||||
|
||||
@@ -11,6 +11,11 @@ RSpec.describe 'Search', type: :request do
|
||||
create(:message, conversation: conversation, account: account, content: 'test2')
|
||||
create(:contact_inbox, contact_id: contact.id, inbox_id: conversation.inbox.id)
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
|
||||
# Create articles for testing
|
||||
portal = create(:portal, account: account)
|
||||
create(:article, title: 'Test Article Guide', content: 'This is a test article content',
|
||||
account: account, portal: portal, author: agent, status: 'published')
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/search' do
|
||||
@@ -33,10 +38,11 @@ RSpec.describe 'Search', type: :request do
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
|
||||
expect(response_data[:payload][:messages].first[:content]).to eq 'test2'
|
||||
expect(response_data[:payload].keys).to contain_exactly(:contacts, :conversations, :messages)
|
||||
expect(response_data[:payload].keys).to contain_exactly(:contacts, :conversations, :messages, :articles)
|
||||
expect(response_data[:payload][:messages].length).to eq 2
|
||||
expect(response_data[:payload][:conversations].length).to eq 1
|
||||
expect(response_data[:payload][:contacts].length).to eq 1
|
||||
expect(response_data[:payload][:articles].length).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -115,4 +121,60 @@ RSpec.describe 'Search', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/search/articles' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/search/articles", params: { q: 'test' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all articles containing the search query' do
|
||||
get "/api/v1/accounts/#{account.id}/search/articles",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { q: 'test' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
|
||||
expect(response_data[:payload].keys).to contain_exactly(:articles)
|
||||
expect(response_data[:payload][:articles].length).to eq 1
|
||||
expect(response_data[:payload][:articles].first[:title]).to eq 'Test Article Guide'
|
||||
end
|
||||
|
||||
it 'returns empty results when no articles match the search query' do
|
||||
get "/api/v1/accounts/#{account.id}/search/articles",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { q: 'nonexistent' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
|
||||
expect(response_data[:payload].keys).to contain_exactly(:articles)
|
||||
expect(response_data[:payload][:articles].length).to eq 0
|
||||
end
|
||||
|
||||
it 'supports pagination' do
|
||||
portal = create(:portal, account: account)
|
||||
16.times do |i|
|
||||
create(:article, title: "Test Article #{i}", account: account, portal: portal, author: agent, status: 'published')
|
||||
end
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/search/articles",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { q: 'test', page: 1 },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
|
||||
expect(response_data[:payload][:articles].length).to eq 15 # Default per_page is 15
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
|
||||
end.to change(CopilotMessage, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(CopilotMessage.last.message).to eq(message_content)
|
||||
expect(CopilotMessage.last.message).to eq({ 'content' => message_content })
|
||||
expect(CopilotMessage.last.message_type).to eq('user')
|
||||
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
|
||||
end
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
@@ -50,7 +51,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } }
|
||||
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id, conversation_id: conversation.display_id } }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -103,7 +104,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
|
||||
message = thread.copilot_messages.last
|
||||
expect(message.message_type).to eq('user')
|
||||
expect(message.message).to eq(valid_params[:message])
|
||||
expect(message.message).to eq({ 'content' => valid_params[:message] })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Copilot::ResponseJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
let(:conversation_id) { 123 }
|
||||
let(:message) { { 'content' => 'Test message' } }
|
||||
|
||||
describe '#perform' do
|
||||
let(:chat_service) { instance_double(Captain::Copilot::ChatService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Copilot::ChatService).to receive(:new).with(
|
||||
assistant,
|
||||
user_id: user.id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
conversation_id: conversation_id
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).with(message)
|
||||
end
|
||||
|
||||
it 'initializes ChatService with correct parameters and calls generate_response' do
|
||||
expect(Captain::Copilot::ChatService).to receive(:new).with(
|
||||
assistant,
|
||||
user_id: user.id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
conversation_id: conversation_id
|
||||
)
|
||||
expect(chat_service).to receive(:generate_response).with(message)
|
||||
described_class.perform_now(
|
||||
assistant: assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user.id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,14 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CopilotMessage, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:copilot_thread) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:message_type) }
|
||||
it { is_expected.to validate_presence_of(:message) }
|
||||
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
@@ -31,7 +30,7 @@ RSpec.describe CopilotMessage, type: :model do
|
||||
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
|
||||
.with('copilot.message.created', anything, copilot_message: message)
|
||||
|
||||
message.save!
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
|
||||
|
||||
let(:config) do
|
||||
{ user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id }
|
||||
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
|
||||
end
|
||||
|
||||
before do
|
||||
@@ -157,16 +157,16 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
end
|
||||
|
||||
describe '#setup_message_history' do
|
||||
context 'when thread_id is present' do
|
||||
context 'when copilot_thread_id is present' do
|
||||
it 'finds the copilot thread and sets previous history from it' do
|
||||
service = described_class.new(assistant, { thread_id: copilot_thread.id })
|
||||
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
|
||||
|
||||
expect(service.copilot_thread).to eq(copilot_thread)
|
||||
expect(service.previous_history).to eq previous_history
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread_id is not present' do
|
||||
context 'when copilot_thread_id is not present' do
|
||||
it 'uses previous_history from config if present' do
|
||||
custom_history = [{ role: 'user', content: 'Custom message' }]
|
||||
service = described_class.new(assistant, { previous_history: custom_history })
|
||||
@@ -222,7 +222,7 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
}.with_indifferent_access)
|
||||
|
||||
expect do
|
||||
described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello')
|
||||
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
|
||||
end.to change(CopilotMessage, :count).by(1)
|
||||
|
||||
last_message = CopilotMessage.last
|
||||
|
||||
@@ -102,10 +102,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
||||
end
|
||||
|
||||
context 'when no articles are found' do
|
||||
before do
|
||||
allow(Article).to receive(:where).and_return(Article.none)
|
||||
end
|
||||
|
||||
it 'returns no articles found message' do
|
||||
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
|
||||
end
|
||||
@@ -113,13 +109,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
||||
|
||||
context 'when articles are found' do
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
|
||||
let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
|
||||
|
||||
before do
|
||||
article1
|
||||
article2
|
||||
end
|
||||
let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
|
||||
let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
|
||||
|
||||
it 'returns formatted articles with count' do
|
||||
result = service.execute({ 'query' => 'Test' })
|
||||
@@ -130,11 +121,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
||||
|
||||
context 'when filtered by category' do
|
||||
let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
|
||||
let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
|
||||
|
||||
before do
|
||||
article3
|
||||
end
|
||||
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
|
||||
|
||||
it 'returns only articles from the specified category' do
|
||||
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
|
||||
@@ -146,15 +133,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
||||
end
|
||||
|
||||
context 'when filtered by status' do
|
||||
let(:article3) do
|
||||
create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published')
|
||||
end
|
||||
let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
|
||||
|
||||
before do
|
||||
article3
|
||||
article4
|
||||
end
|
||||
let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') }
|
||||
let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
|
||||
|
||||
it 'returns only articles with the specified status' do
|
||||
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
|
||||
|
||||
+26
-5
@@ -3,7 +3,8 @@ require 'rails_helper'
|
||||
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
@@ -40,14 +41,34 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
context 'when user is present' do
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not present' do
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is not enabled' do
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
context 'when user is present' do
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not present' do
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
FactoryBot.define do
|
||||
factory :article, class: 'Article' do
|
||||
account_id { 1 }
|
||||
category_id { 1 }
|
||||
account
|
||||
category { nil }
|
||||
portal
|
||||
locale { 'en' }
|
||||
author_id { 1 }
|
||||
association :author, factory: :user
|
||||
title { "#{Faker::Movie.title} #{SecureRandom.hex}" }
|
||||
content { 'MyText' }
|
||||
description { 'MyDescrption' }
|
||||
status { 1 }
|
||||
status { :published }
|
||||
views { 0 }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FactoryBot.define do
|
||||
factory :category, class: 'Category' do
|
||||
portal { portal }
|
||||
portal
|
||||
name { 'MyString' }
|
||||
description { 'MyText' }
|
||||
position { 1 }
|
||||
slug { name.parameterize }
|
||||
|
||||
after(:build) do |category|
|
||||
category.account ||= category.portal.account
|
||||
|
||||
@@ -162,5 +162,22 @@ describe CustomMarkdownRenderer do
|
||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net URL' do
|
||||
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
it 'renders an iframe with Bunny embed code' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('position: relative; padding-top: 56.25%;')
|
||||
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -82,15 +82,27 @@ describe Integrations::Linear::ProcessorService do
|
||||
end
|
||||
let(:issue_response) do
|
||||
{
|
||||
'issueCreate' => { 'issue' => { 'id' => 'issue1', 'title' => 'Issue title' } }
|
||||
'issueCreate' => {
|
||||
'issue' => {
|
||||
'id' => 'issue1',
|
||||
'title' => 'Issue title',
|
||||
'identifier' => 'ENG-123'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed issue data' do
|
||||
it 'returns parsed issue data with identifier' do
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq({ data: { id: 'issue1', title: 'Issue title' } })
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,13 +145,13 @@ describe Integrations::Linear::ProcessorService do
|
||||
|
||||
describe '#unlink_issue' do
|
||||
let(:link_id) { 'attachment1' }
|
||||
let(:unlink_response) { { data: { link_id: link_id } } }
|
||||
let(:linear_client_response) { { success: true } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed unlink data' do
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(unlink_response)
|
||||
it 'returns unlink data with link_id' do
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
expect(result).to eq(unlink_response)
|
||||
expect(result).to eq({ data: { link_id: link_id } })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -207,4 +219,61 @@ describe Integrations::Linear::ProcessorService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Tests specifically for activity message integration
|
||||
describe 'activity message data compatibility' do
|
||||
let(:linear_client_response) { { success: true } }
|
||||
|
||||
describe '#create_issue' do
|
||||
it 'includes identifier field needed for activity messages' do
|
||||
params = { title: 'Test Issue', team_id: 'team1' }
|
||||
response = {
|
||||
'issueCreate' => {
|
||||
'issue' => {
|
||||
'id' => 'internal_id_123',
|
||||
'title' => 'Test Issue',
|
||||
'identifier' => 'ENG-456'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(response)
|
||||
result = service.create_issue(params)
|
||||
|
||||
expect(result[:data]).to have_key(:identifier)
|
||||
expect(result[:data][:identifier]).to eq('ENG-456')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#link_issue' do
|
||||
it 'returns issue_id in response for activity messages' do
|
||||
link = 'https://example.com'
|
||||
issue_id = 'ENG-789'
|
||||
title = 'Test Issue'
|
||||
response = {
|
||||
'attachmentLinkURL' => {
|
||||
'attachment' => { 'id' => 'attachment123' }
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
|
||||
expect(result[:data][:id]).to eq(issue_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unlink_issue' do
|
||||
it 'returns structured data for activity messages' do
|
||||
link_id = 'attachment456'
|
||||
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
|
||||
expect(result).to have_key(:data)
|
||||
expect(result[:data]).to have_key(:link_id)
|
||||
expect(result[:data][:link_id]).to eq(link_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LinearActivityMessageHandler, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
describe '#create_linear_issue_created_activity' do
|
||||
it 'enqueues an activity message job when issue data contains id' do
|
||||
issue_data = { id: 'ENG-123' }
|
||||
|
||||
expect do
|
||||
conversation.create_linear_issue_created_activity(issue_data)
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'New Linear issue ENG-123 has been created'
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue data lacks id' do
|
||||
issue_data = { title: 'Some issue' }
|
||||
|
||||
expect do
|
||||
conversation.create_linear_issue_created_activity(issue_data)
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue_data is empty' do
|
||||
expect do
|
||||
conversation.create_linear_issue_created_activity({})
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_linear_issue_linked_activity' do
|
||||
it 'enqueues an activity message job when issue data contains id' do
|
||||
issue_data = { id: 'ENG-456' }
|
||||
|
||||
expect do
|
||||
conversation.create_linear_issue_linked_activity(issue_data)
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'Linear issue ENG-456 is now linked'
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue data lacks id' do
|
||||
issue_data = { title: 'Some issue' }
|
||||
|
||||
expect do
|
||||
conversation.create_linear_issue_linked_activity(issue_data)
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue_data is empty' do
|
||||
expect do
|
||||
conversation.create_linear_issue_linked_activity({})
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_linear_issue_unlinked_activity' do
|
||||
it 'enqueues an activity message job when issue data contains id' do
|
||||
issue_data = { id: 'ENG-789' }
|
||||
|
||||
expect do
|
||||
conversation.create_linear_issue_unlinked_activity(issue_data)
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'Linear issue ENG-789 has been unlinked'
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue data lacks id' do
|
||||
issue_data = { title: 'Some issue' }
|
||||
|
||||
expect do
|
||||
conversation.create_linear_issue_unlinked_activity(issue_data)
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue_data is empty' do
|
||||
expect do
|
||||
conversation.create_linear_issue_unlinked_activity({})
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,15 +6,39 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:user) { create(:user, name: 'John Doe') }
|
||||
let(:contact) { create(:contact, name: 'Jane Smith') }
|
||||
let(:hook) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||
'timezone' => 'UTC'
|
||||
})
|
||||
end
|
||||
let(:hook_with_pst) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||
'timezone' => 'America/Los_Angeles'
|
||||
})
|
||||
end
|
||||
let(:hook_without_timezone) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2'
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features('crm_integration')
|
||||
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
|
||||
end
|
||||
|
||||
describe '.map_conversation_activity' do
|
||||
it 'generates conversation activity note' do
|
||||
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
|
||||
result = described_class.map_conversation_activity(conversation)
|
||||
it 'generates conversation activity note with UTC timezone' do
|
||||
travel_to(Time.zone.parse('2024-01-01 10:00:00 UTC')) do
|
||||
result = described_class.map_conversation_activity(hook, conversation)
|
||||
|
||||
expect(result).to include('New conversation started on TestBrand')
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
@@ -23,12 +47,29 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
expect(result).to include('View in TestBrand: http://')
|
||||
end
|
||||
end
|
||||
|
||||
it 'formats time according to hook timezone setting' do
|
||||
travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do
|
||||
result = described_class.map_conversation_activity(hook_with_pst, conversation)
|
||||
|
||||
# PST is UTC-8, so 18:00 UTC becomes 10:00:00 PST
|
||||
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to system timezone when hook has no timezone setting' do
|
||||
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
|
||||
result = described_class.map_conversation_activity(hook_without_timezone, conversation)
|
||||
|
||||
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.map_transcript_activity' do
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns no messages message' do
|
||||
result = described_class.map_transcript_activity(conversation)
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
expect(result).to eq('No messages in conversation')
|
||||
end
|
||||
end
|
||||
@@ -68,7 +109,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
end
|
||||
|
||||
it 'generates transcript with messages in reverse chronological order' do
|
||||
result = described_class.map_transcript_activity(conversation)
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
expect(result).to include('Conversation Transcript from TestBrand')
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
@@ -83,6 +124,22 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
|
||||
end
|
||||
|
||||
it 'formats message times according to hook timezone setting' do
|
||||
travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: 'Test message',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 18:00:00 UTC'))
|
||||
|
||||
result = described_class.map_transcript_activity(hook_with_pst, conversation)
|
||||
|
||||
# PST is UTC-8, so 18:00 UTC becomes 10:00 PST
|
||||
expect(result).to include('[2024-01-01 10:00] John Doe: Test message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has attachments' do
|
||||
let(:message_with_attachment) do
|
||||
create(:message, :with_attachment,
|
||||
@@ -96,7 +153,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
before { message_with_attachment }
|
||||
|
||||
it 'includes attachment information' do
|
||||
result = described_class.map_transcript_activity(conversation)
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
expect(result).to include('See attachment')
|
||||
expect(result).to include('[Attachment: image]')
|
||||
@@ -116,7 +173,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
before { empty_message }
|
||||
|
||||
it 'shows no content placeholder' do
|
||||
result = described_class.map_transcript_activity(conversation)
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
expect(result).to include('[No content]')
|
||||
end
|
||||
end
|
||||
@@ -134,25 +191,12 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
before { unnamed_sender_message }
|
||||
|
||||
it 'uses sender type and id' do
|
||||
result = described_class.map_transcript_activity(conversation)
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
expect(result).to include("User #{unnamed_sender_message.sender_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when specific messages are provided' do
|
||||
let(:message1) { create(:message, conversation: conversation, content: 'Message 1', message_type: :outgoing) }
|
||||
let(:message2) { create(:message, conversation: conversation, content: 'Message 2', message_type: :outgoing) }
|
||||
let(:specific_messages) { [message1] }
|
||||
|
||||
it 'only includes provided messages' do
|
||||
result = described_class.map_transcript_activity(conversation, specific_messages)
|
||||
|
||||
expect(result).to include('Message 1')
|
||||
expect(result).not_to include('Message 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do
|
||||
it 'truncates messages to stay within the character limit' do
|
||||
# Create a large number of messages with reasonably sized content
|
||||
@@ -169,7 +213,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
|
||||
end
|
||||
|
||||
result = described_class.map_transcript_activity(conversation, messages)
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
# Verify latest message is included (message 14)
|
||||
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
|
||||
@@ -189,13 +233,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do
|
||||
# Create a single message that would exceed the limit by itself
|
||||
giant_content = 'A' * 2000
|
||||
message = create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: giant_content,
|
||||
message_type: :outgoing)
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: giant_content,
|
||||
message_type: :outgoing)
|
||||
|
||||
result = described_class.map_transcript_activity(conversation, [message])
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
# Extract just the formatted messages part
|
||||
id = conversation.display_id
|
||||
|
||||
@@ -116,7 +116,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
||||
|
||||
before do
|
||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
|
||||
.with(conversation)
|
||||
.with(hook, conversation)
|
||||
.and_return(activity_note)
|
||||
end
|
||||
|
||||
@@ -180,7 +180,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
||||
|
||||
before do
|
||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
|
||||
.with(conversation)
|
||||
.with(hook, conversation)
|
||||
.and_return(activity_note)
|
||||
end
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
|
||||
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
|
||||
let(:endpoint_response) do
|
||||
{
|
||||
'TimeZone' => 'Asia/Kolkata',
|
||||
'LSQCommonServiceURLs' => {
|
||||
'api' => 'api-in.leadsquared.com',
|
||||
'app' => 'app.leadsquared.com'
|
||||
@@ -45,6 +46,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
|
||||
updated_settings = hook.reload.settings
|
||||
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
||||
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
||||
expect(updated_settings['timezone']).to eq('Asia/Kolkata')
|
||||
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
||||
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
||||
end
|
||||
@@ -71,6 +73,7 @@ RSpec.describe Crm::Leadsquared::SetupService do
|
||||
updated_settings = hook.reload.settings
|
||||
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
||||
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
||||
expect(updated_settings['timezone']).to eq('Asia/Kolkata')
|
||||
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
||||
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
||||
end
|
||||
|
||||
@@ -10,6 +10,11 @@ describe SearchService do
|
||||
let!(:harry) { create(:contact, name: 'Harry Potter', email: 'test@test.com', account_id: account.id) }
|
||||
let!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) }
|
||||
let!(:message) { create(:message, account: account, inbox: inbox, content: 'Harry Potter is a wizard') }
|
||||
let!(:portal) { create(:portal, account: account) }
|
||||
let(:article) do
|
||||
create(:article, title: 'Harry Potter Magic Guide', content: 'Learn about wizardry', account: account, portal: portal, author: user,
|
||||
status: 'published')
|
||||
end
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
@@ -27,7 +32,7 @@ describe SearchService do
|
||||
it 'returns all for all' do
|
||||
search_type = 'all'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[contacts messages conversations])
|
||||
expect(search.perform.keys).to match_array(%i[contacts messages conversations articles])
|
||||
end
|
||||
|
||||
it 'returns contacts for contacts' do
|
||||
@@ -47,6 +52,12 @@ describe SearchService do
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[conversations])
|
||||
end
|
||||
|
||||
it 'returns articles for articles' do
|
||||
search_type = 'Article'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[articles])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact search' do
|
||||
@@ -143,6 +154,50 @@ describe SearchService do
|
||||
expect(search.perform[:conversations].map(&:id)).to include new_converstion.id
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article search' do
|
||||
it 'orders results by updated_at desc' do
|
||||
# Create articles with explicit timestamps
|
||||
older_time = 2.days.ago
|
||||
newer_time = 1.hour.ago
|
||||
|
||||
article2 = create(:article, title: 'Spellcasting Guide',
|
||||
account: account, portal: portal, author: user, status: 'published')
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
article2.update_column(:updated_at, older_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
article3 = create(:article, title: 'Spellcasting Manual',
|
||||
account: account, portal: portal, author: user, status: 'published')
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
article3.update_column(:updated_at, newer_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
params = { q: 'Spellcasting' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
# Check the timestamps to understand ordering
|
||||
results.map { |a| [a.id, a.updated_at] }
|
||||
|
||||
# Should be ordered by updated_at desc (newer first)
|
||||
expect(results.length).to eq(2)
|
||||
expect(results.first.updated_at).to be > results.second.updated_at
|
||||
end
|
||||
|
||||
it 'returns paginated results' do
|
||||
# Create many articles to test pagination
|
||||
16.times do |i|
|
||||
create(:article, title: "Magic Article #{i}", account: account, portal: portal, author: user, status: 'published')
|
||||
end
|
||||
|
||||
params = { q: 'Magic', page: 1 }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
expect(results.length).to eq(15) # Default per_page is 15
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#use_gin_search' do
|
||||
|
||||
Reference in New Issue
Block a user