Compare commits

...
40 changed files with 952 additions and 84 deletions
@@ -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)
@@ -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,
});
}
+9
View File
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
},
});
}
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
},
});
}
}
export default new SearchAPI();
@@ -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,
}
);
});
@@ -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'));
@@ -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,
},
@@ -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',
+2 -9
View File
@@ -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) ||
+97 -1
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { InitializationHelpers } from '../portalHelpers';
import {
InitializationHelpers,
openExternalLinksInNewTab,
} from '../portalHelpers';
describe('InitializationHelpers.navigateToLocalePage', () => {
let dom;
@@ -44,3 +47,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 &gt; <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
</ul>
</div>
</body>
</html>`,
{ url: 'https://app.chatwoot.com/hc/article' }
);
document = dom.window.document;
window = dom.window;
window.portalConfig = {
customDomain: 'custom.domain.com',
hostURL: 'app.chatwoot.com',
};
global.document = document;
global.window = window;
});
afterEach(() => {
dom = null;
document = null;
window = null;
delete global.document;
delete global.window;
});
const simulateClick = selector => {
const element = document.querySelector(selector);
const event = new window.MouseEvent('click', { bubbles: true });
element.dispatchEvent(event);
return element.closest('a') || element;
};
it('opens external links in new tab', () => {
openExternalLinksInNewTab();
const link = simulateClick('#external');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('preserves internal links', () => {
openExternalLinksInNewTab();
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(internal.target).not.toBe('_blank');
expect(custom.target).not.toBe('_blank');
});
it('handles clicks on nested elements', () => {
openExternalLinksInNewTab();
simulateClick('#nested code');
simulateClick('#nested strong');
const link = document.getElementById('nested');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('handles links inside list items with strong tags', () => {
openExternalLinksInNewTab();
// Click on the strong element inside the link in the list
simulateClick('#list-link strong');
const link = document.getElementById('list-link');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
});
@@ -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
+11 -1
View File
@@ -9,8 +9,10 @@ class SearchService
{ conversations: filter_conversations }
when 'Contact'
{ contacts: filter_contacts }
when 'Article'
{ articles: filter_articles }
else
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations }
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations, articles: filter_articles }
end
end
@@ -90,4 +92,12 @@ class SearchService
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
end
def filter_articles
@articles = current_account.articles
.text_search(search_query)
.reorder('updated_at DESC')
.page(params[:page])
.per(15)
end
end
@@ -0,0 +1,8 @@
json.id article.id
json.title article.title
json.locale article.locale
json.content article.content
json.slug article.slug
json.portal_slug article.portal.slug
json.account_id article.account_id
json.category_name article.category&.name
@@ -0,0 +1,15 @@
json.id conversation.display_id
json.account_id conversation.account_id
json.created_at conversation.created_at.to_i
json.message do
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
end
json.contact do
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
end
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
end
json.agent do
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
end
@@ -0,0 +1,7 @@
json.payload do
json.articles do
json.array! @result[:articles] do |article|
json.partial! 'article', formats: [:json], article: article
end
end
end
@@ -1,21 +1,7 @@
json.payload do
json.conversations do
json.array! @result[:conversations] do |conversation|
json.id conversation.display_id
json.account_id conversation.account_id
json.created_at conversation.created_at.to_i
json.message do
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
end
json.contact do
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
end
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
end
json.agent do
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
end
json.partial! 'conversation_search_result', formats: [:json], conversation: conversation
end
end
json.contacts do
@@ -23,10 +9,14 @@ json.payload do
json.partial! 'contact', formats: [:json], contact: contact
end
end
json.messages do
json.array! @result[:messages] do |message|
json.partial! 'message', formats: [:json], message: message
end
end
json.articles do
json.array! @result[:articles] do |article|
json.partial! 'article', formats: [:json], article: article
end
end
end
+4
View File
@@ -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.'
+1
View File
@@ -137,6 +137,7 @@ Rails.application.routes.draw do
get :conversations
get :messages
get :contacts
get :articles
end
end
+9 -1
View File
@@ -7,6 +7,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
def text(node)
content = node.string_content
@@ -52,7 +53,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed,
WISTIA_REGEX => :make_wistia_embed
WISTIA_REGEX => :make_wistia_embed,
BUNNY_REGEX => :make_bunny_embed
}
embedding_methods.each do |regex, method|
@@ -104,4 +106,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
video_id = arcade_match[1]
EmbedRenderer.arcade(video_id)
end
def make_bunny_embed(bunny_match)
library_id = bunny_match[1]
video_id = bunny_match[2]
EmbedRenderer.bunny(library_id, video_id)
end
end
+15
View File
@@ -84,4 +84,19 @@ module EmbedRenderer
</div>
)
end
def self.bunny(library_id, video_id)
%(
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
)
end
end
+2 -1
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ module Linear::Mutations
issue {
id
title
identifier
}
}
}
@@ -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
+17
View File
@@ -162,5 +162,22 @@ describe CustomMarkdownRenderer do
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
end
end
context 'when link is a Bunny.net URL' do
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
it 'renders an iframe with Bunny embed code' do
output = render_markdown_link(bunny_url)
expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
expect(output).to include('allowfullscreen')
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(bunny_url)
expect(output).to include('position: relative; padding-top: 56.25%;')
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
end
end
end
end
@@ -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
+56 -1
View File
@@ -10,6 +10,11 @@ describe SearchService do
let!(:harry) { create(:contact, name: 'Harry Potter', email: 'test@test.com', account_id: account.id) }
let!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) }
let!(:message) { create(:message, account: account, inbox: inbox, content: 'Harry Potter is a wizard') }
let!(:portal) { create(:portal, account: account) }
let(:article) do
create(:article, title: 'Harry Potter Magic Guide', content: 'Learn about wizardry', account: account, portal: portal, author: user,
status: 'published')
end
before do
create(:inbox_member, user: user, inbox: inbox)
@@ -27,7 +32,7 @@ describe SearchService do
it 'returns all for all' do
search_type = 'all'
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(search.perform.keys).to match_array(%i[contacts messages conversations])
expect(search.perform.keys).to match_array(%i[contacts messages conversations articles])
end
it 'returns contacts for contacts' do
@@ -47,6 +52,12 @@ describe SearchService do
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(search.perform.keys).to match_array(%i[conversations])
end
it 'returns articles for articles' do
search_type = 'Article'
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(search.perform.keys).to match_array(%i[articles])
end
end
context 'when contact search' do
@@ -143,6 +154,50 @@ describe SearchService do
expect(search.perform[:conversations].map(&:id)).to include new_converstion.id
end
end
context 'when article search' do
it 'orders results by updated_at desc' do
# Create articles with explicit timestamps
older_time = 2.days.ago
newer_time = 1.hour.ago
article2 = create(:article, title: 'Spellcasting Guide',
account: account, portal: portal, author: user, status: 'published')
# rubocop:disable Rails/SkipsModelValidations
article2.update_column(:updated_at, older_time)
# rubocop:enable Rails/SkipsModelValidations
article3 = create(:article, title: 'Spellcasting Manual',
account: account, portal: portal, author: user, status: 'published')
# rubocop:disable Rails/SkipsModelValidations
article3.update_column(:updated_at, newer_time)
# rubocop:enable Rails/SkipsModelValidations
params = { q: 'Spellcasting' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
results = search.perform[:articles]
# Check the timestamps to understand ordering
results.map { |a| [a.id, a.updated_at] }
# Should be ordered by updated_at desc (newer first)
expect(results.length).to eq(2)
expect(results.first.updated_at).to be > results.second.updated_at
end
it 'returns paginated results' do
# Create many articles to test pagination
16.times do |i|
create(:article, title: "Magic Article #{i}", account: account, portal: portal, author: user, status: 'published')
end
params = { q: 'Magic', page: 1 }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
results = search.perform[:articles]
expect(results.length).to eq(15) # Default per_page is 15
end
end
end
describe '#use_gin_search' do