Merge branch 'develop' into feature/10945-search-results-page

This commit is contained in:
Sojan Jose
2025-05-28 14:44:50 -06:00
committed by GitHub
72 changed files with 1683 additions and 269 deletions
@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CopilotMessages extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
get(threadId) {
return axios.get(`${this.url}/${threadId}/copilot_messages`);
}
create({ threadId, ...rest }) {
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
}
}
export default new CopilotMessages();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CopilotThreads extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
}
export default new CopilotThreads();
+9
View File
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
},
});
}
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
},
});
}
}
export default new SearchAPI();
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
return !props.contentAttributes?.isUnsupported;
});
const isBubble = computed(() => {
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
delete:
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
};
});
@@ -499,8 +506,8 @@ provideMessageContext({
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
@@ -516,7 +523,7 @@ provideMessageContext({
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
v-if="isBubble"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
@@ -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, {
@@ -8,7 +8,6 @@ import {
computed,
watch,
onMounted,
onUnmounted,
defineEmits,
} from 'vue';
import { useStore } from 'vuex';
@@ -44,7 +43,7 @@ import {
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener, useScrollLock } from '@vueuse/core';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
@@ -87,12 +86,8 @@ const store = useStore();
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
const conversationListScrollableElement = computed(
() => conversationDynamicScroller.value?.$el
);
const conversationListScrollLock = useScrollLock(
conversationListScrollableElement
);
provide('contextMenuElementTarget', conversationDynamicScroller);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
conversationListScrollLock.value = state;
}
function toggleSelectAll(check) {
@@ -770,10 +764,6 @@ onMounted(() => {
}
});
onUnmounted(() => {
conversationListScrollLock.value = false;
});
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -1,6 +1,13 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
import {
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -11,27 +18,34 @@ const props = defineProps({
const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH - PADDING;
return {
left: Math.max(0, left),
top: Math.max(0, top),
left: Math.max(PADDING, left),
top: Math.max(PADDING, top),
};
};
@@ -54,8 +68,18 @@ const position = computed(() => {
});
onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus());
});
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script>
<template>
@@ -65,7 +89,7 @@ onMounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="emit('close')"
@blur="handleClose"
>
<slot />
</div>
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
delete:
(this.hasText || this.hasAttachments) &&
!this.isMessageDeleted &&
!this.isFailed,
cannedResponse:
this.isOutgoing && this.hasText && !this.isMessageDeleted,
copyLink: !this.isFailed || !this.isProcessing,
translate:
(!this.isFailed || !this.isProcessing) &&
!this.isMessageDeleted &&
this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
@@ -328,7 +337,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending || this.isUnsupported);
return !this.isUnsupported;
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script>
import { ref } from 'vue';
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -63,6 +63,7 @@ export default {
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
@@ -98,6 +99,8 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4
);
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
@@ -108,6 +111,7 @@ export default {
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
showNextBubbles,
conversationPanelRef,
};
},
data() {
@@ -541,6 +545,7 @@ export default {
</div>
<NextMessageList
v-if="showNextBubbles"
ref="conversationPanelRef"
class="conversation-panel"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
@@ -572,7 +577,7 @@ export default {
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<ul v-else ref="conversationPanelRef" class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
@@ -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",
@@ -47,6 +47,7 @@ export default {
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
@@ -167,7 +168,7 @@ export default {
</woot-modal>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
v-if="showDeleteModal && enabledOptions['delete']"
v-model:show="showDeleteModal"
class="context-menu--delete-modal"
:on-close="closeDeleteModal"
@@ -212,7 +213,7 @@ export default {
@click.stop="handleCopy"
/>
<MenuItem
v-if="enabledOptions['copy']"
v-if="enabledOptions['translate']"
:option="{
icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
@@ -222,6 +223,7 @@ export default {
/>
<hr />
<MenuItem
v-if="enabledOptions['copyLink']"
:option="{
icon: 'link',
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
@@ -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 });
}
};
+5
View File
@@ -51,6 +51,9 @@ import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
const plugins = [];
export default createStore({
@@ -106,6 +109,8 @@ export default createStore({
captainResponses,
captainInboxes,
captainBulkActions,
copilotThreads,
copilotMessages,
},
plugins,
});
@@ -5,12 +5,14 @@ export const initialState = {
contactRecords: [],
conversationRecords: [],
messageRecords: [],
articleRecords: [],
uiFlags: {
isFetching: false,
isSearchCompleted: false,
contact: { isFetching: false },
conversation: { isFetching: false },
message: { isFetching: false },
article: { isFetching: false },
},
};
@@ -27,6 +29,9 @@ export const getters = {
getMessageRecords(state) {
return state.messageRecords;
},
getArticleRecords(state) {
return state.articleRecords;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -65,6 +70,7 @@ export const actions = {
dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
]);
} catch (error) {
// Ignore error
@@ -108,6 +114,17 @@ export const actions = {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async articleSearch({ commit }, { q, page = 1 }) {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ q, page });
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
} catch (error) {
// Ignore error
} finally {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async clearSearchResults({ commit }) {
commit(types.CLEAR_SEARCH_RESULTS);
},
@@ -126,6 +143,9 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET](state, records) {
state.messageRecords = [...state.messageRecords, ...records];
},
[types.ARTICLE_SEARCH_SET](state, records) {
state.articleRecords = [...state.articleRecords, ...records];
},
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
@@ -141,10 +161,14 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
},
[types.ARTICLE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.article = { ...state.uiFlags.article, ...uiFlags };
},
[types.CLEAR_SEARCH_RESULTS](state) {
state.contactRecords = [];
state.conversationRecords = [];
state.messageRecords = [];
state.articleRecords = [];
},
};
@@ -75,6 +75,7 @@ describe('#actions', () => {
q: 'test',
});
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
});
});
@@ -150,6 +151,30 @@ describe('#actions', () => {
});
});
describe('#articleSearch', () => {
it('should handle successful article search', async () => {
axios.get.mockResolvedValue({
data: { payload: { articles: [{ id: 1 }] } },
});
await actions.articleSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed article search', async () => {
axios.get.mockRejectedValue({});
await actions.articleSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#clearSearchResults', () => {
it('should commit clear search results mutation', () => {
actions.clearSearchResults({ commit });
@@ -37,6 +37,15 @@ describe('#getters', () => {
]);
});
it('getArticleRecords', () => {
const state = {
articleRecords: [{ id: 1, title: 'Article 1' }],
};
expect(getters.getArticleRecords(state)).toEqual([
{ id: 1, title: 'Article 1' },
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
@@ -45,6 +54,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
},
};
expect(getters.getUIFlags(state)).toEqual({
@@ -53,6 +63,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
});
});
});
@@ -101,17 +101,39 @@ describe('#mutations', () => {
});
});
describe('#ARTICLE_SEARCH_SET', () => {
it('should append new article records to existing ones', () => {
const state = { articleRecords: [{ id: 1 }] };
mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]);
expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => {
it('set article search UI flags correctly', () => {
const state = {
uiFlags: {
article: { isFetching: true },
},
};
mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.article).toEqual({ isFetching: false });
});
});
describe('#CLEAR_SEARCH_RESULTS', () => {
it('should clear all search records', () => {
const state = {
contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }],
articleRecords: [{ id: 1 }],
};
mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]);
expect(state.articleRecords).toEqual([]);
});
});
});
@@ -317,8 +317,10 @@ export default {
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
ARTICLE_SEARCH_SET_UI_FLAG: 'ARTICLE_SEARCH_SET_UI_FLAG',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
+55 -17
View File
@@ -25,33 +25,71 @@ export const getHeadingsfromTheArticle = () => {
return rows;
};
/**
* Converts various input formats to URL objects.
* Handles URL objects, domain strings, relative paths, and full URLs.
* @param {string|URL} input - Input to convert to URL object
* @returns {URL|null} URL object or null if input is invalid
*/
const toURL = input => {
if (!input) return null;
if (input instanceof URL) return input;
if (
typeof input === 'string' &&
!input.includes('://') &&
!input.startsWith('/')
) {
return new URL(`https://${input}`);
}
if (typeof input === 'string' && input.startsWith('/')) {
return new URL(input, window.location.origin);
}
return new URL(input);
};
/**
* Determines if two URLs belong to the same host by comparing their normalized URL objects.
* Handles various input formats including URL objects, domain strings, relative paths, and full URLs.
* Returns false if either URL cannot be parsed or normalized.
* @param {string|URL} url1 - First URL to compare
* @param {string|URL} url2 - Second URL to compare
* @returns {boolean} True if both URLs have the same host, false otherwise
*/
const isSameHost = (url1, url2) => {
try {
const urlObj1 = toURL(url1);
const urlObj2 = toURL(url2);
if (!urlObj1 || !urlObj2) return false;
return urlObj1.hostname === urlObj2.hostname;
} catch (error) {
return false;
}
};
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isSameHost =
window.location.href.includes(customDomain) ||
window.location.href.includes(hostURL);
// Modify external links only on articles page
const isOnArticlePage =
isSameHost && document.querySelector('#cw-article-content') !== null;
document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => {
if (!isOnArticlePage) return;
// Some of the links come wrapped in strong tag through prosemirror
const link = event.target.closest('a');
const isTagAnchor = event.target.tagName === 'A';
const isParentTagAnchor =
event.target.tagName === 'STRONG' &&
event.target.parentNode.tagName === 'A';
if (isTagAnchor || isParentTagAnchor) {
const link = isTagAnchor ? event.target : event.target.parentNode;
if (link) {
const currentLocation = window.location.href;
const linkHref = link.href;
// Check against current location and custom domains
const isInternalLink =
link.hostname === window.location.hostname ||
link.href.includes(customDomain) ||
link.href.includes(hostURL);
isSameHost(linkHref, currentLocation) ||
(customDomain && isSameHost(linkHref, customDomain)) ||
(hostURL && isSameHost(linkHref, hostURL));
if (!isInternalLink) {
link.target = '_blank';
+140 -1
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { InitializationHelpers } from '../portalHelpers';
import {
InitializationHelpers,
openExternalLinksInNewTab,
} from '../portalHelpers';
describe('InitializationHelpers.navigateToLocalePage', () => {
let dom;
@@ -44,3 +47,139 @@ describe('InitializationHelpers.navigateToLocalePage', () => {
);
});
});
describe('openExternalLinksInNewTab', () => {
let dom;
let document;
let window;
beforeEach(() => {
dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="cw-article-content">
<a href="https://external.com" id="external">External</a>
<a href="https://app.chatwoot.com/page" id="internal">Internal</a>
<a href="https://custom.domain.com/page" id="custom">Custom</a>
<a href="https://example.com" id="nested"><code>Code</code><strong>Bold</strong></a>
<ul>
<li>Visit the preferences centre here &gt; <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
</ul>
</div>
</body>
</html>`,
{ url: 'https://app.chatwoot.com/hc/article' }
);
document = dom.window.document;
window = dom.window;
window.portalConfig = {
customDomain: 'custom.domain.com',
hostURL: 'app.chatwoot.com',
};
global.document = document;
global.window = window;
});
afterEach(() => {
dom = null;
document = null;
window = null;
delete global.document;
delete global.window;
});
const simulateClick = selector => {
const element = document.querySelector(selector);
const event = new window.MouseEvent('click', { bubbles: true });
element.dispatchEvent(event);
return element.closest('a') || element;
};
it('opens external links in new tab', () => {
openExternalLinksInNewTab();
const link = simulateClick('#external');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('preserves internal links', () => {
openExternalLinksInNewTab();
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(internal.target).not.toBe('_blank');
expect(custom.target).not.toBe('_blank');
});
it('handles clicks on nested elements', () => {
openExternalLinksInNewTab();
simulateClick('#nested code');
simulateClick('#nested strong');
const link = document.getElementById('nested');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('handles links inside list items with strong tags', () => {
openExternalLinksInNewTab();
// Click on the strong element inside the link in the list
simulateClick('#list-link strong');
const link = document.getElementById('list-link');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('opens external links in a new tab even if customDomain is empty', () => {
window = dom.window;
window.portalConfig = {
hostURL: 'app.chatwoot.com',
};
global.window = window;
openExternalLinksInNewTab();
const link = simulateClick('#external');
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(internal.target).not.toBe('_blank');
// this will be blank since the configs customDomain is empty
// which is a fair expectation
expect(custom.target).toBe('_blank');
});
it('opens external links in a new tab even if hostURL is empty', () => {
window = dom.window;
window.portalConfig = {
customDomain: 'custom.domain.com',
};
global.window = window;
openExternalLinksInNewTab();
const link = simulateClick('#external');
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(internal.target).not.toBe('_blank');
expect(custom.target).not.toBe('_blank');
});
});