-
+
+ >
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue
index b64b1ef58..be4deb337 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
+import { copyTextToClipboard } from 'shared/helpers/clipboard';
+import { useToggle } from '@vueuse/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
+import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
const props = defineProps({
type: {
@@ -42,6 +45,9 @@ const formState = reactive({
botAvatarUrl: '',
});
+const [showAccessToken, toggleAccessToken] = useToggle();
+const accessToken = ref('');
+
const v$ = useVuelidate(
{
botName: {
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
: uiFlags.value.isUpdating
);
-const dialogTitle = computed(() =>
- props.type === MODAL_TYPES.CREATE
+const dialogTitle = computed(() => {
+ if (showAccessToken.value) {
+ return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
+ }
+
+ return props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE')
- : t('AGENT_BOTS.EDIT.TITLE')
-);
+ : t('AGENT_BOTS.EDIT.TITLE');
+});
+
+const dialogDescription = computed(() => {
+ if (showAccessToken.value) {
+ return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
+ }
+ return '';
+});
const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
);
+const showAccessTokenInput = computed(
+ () =>
+ showAccessToken.value ||
+ props.type === MODAL_TYPES.EDIT ||
+ accessToken.value
+);
+
const resetForm = () => {
Object.assign(formState, {
botName: '',
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
+ if (showAccessToken.value) return;
const botData = {
name: formState.botName,
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
? botData
: { id: props.selectedBot.id, data: botData };
- await store.dispatch(
+ const response = await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload
);
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
- dialogRef.value.close();
+ // Show access token after creation
+ if (isCreate) {
+ const { access_token: responseAccessToken, id } = response || {};
+
+ if (id && responseAccessToken) {
+ accessToken.value = responseAccessToken;
+ toggleAccessToken(true);
+ } else {
+ accessToken.value = '';
+ dialogRef.value.close();
+ }
+ } else {
+ dialogRef.value.close();
+ }
+
resetForm();
} catch (error) {
const errorKey = isCreate
@@ -166,17 +205,56 @@ const handleSubmit = async () => {
const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) {
- const { name, description, outgoing_url, thumbnail, bot_config } =
- props.selectedBot;
+ const {
+ name,
+ description,
+ outgoing_url: botUrl,
+ thumbnail,
+ bot_config: botConfig,
+ access_token: botAccessToken,
+ } = props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
- formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
+ formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
+
+ if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
+ accessToken.value = botAccessToken;
+ }
} else {
resetForm();
}
};
+const onCopyToken = async value => {
+ await copyTextToClipboard(value);
+ useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
+};
+
+const onResetToken = async () => {
+ const response = await store.dispatch(
+ 'agentBots/resetAccessToken',
+ props.selectedBot.id
+ );
+ if (response) {
+ accessToken.value = response.access_token;
+ useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
+ } else {
+ useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
+ }
+};
+
+const closeModal = () => {
+ if (!showAccessToken.value) v$.value?.$reset();
+ accessToken.value = '';
+ toggleAccessToken(false);
+};
+
+const onClickClose = () => {
+ closeModal();
+ dialogRef.value.close();
+};
+
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef });
@@ -187,48 +265,79 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="edit"
:title="dialogTitle"
+ :description="dialogDescription"
:show-cancel-button="false"
:show-confirm-button="false"
- @close="v$.$reset()"
+ @close="closeModal"
>
diff --git a/app/javascript/dashboard/store/captain/copilotMessages.js b/app/javascript/dashboard/store/captain/copilotMessages.js
new file mode 100644
index 000000000..83b7fddce
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/copilotMessages.js
@@ -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);
+ },
+ }),
+});
diff --git a/app/javascript/dashboard/store/captain/copilotThreads.js b/app/javascript/dashboard/store/captain/copilotThreads.js
new file mode 100644
index 000000000..8d820f305
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/copilotThreads.js
@@ -0,0 +1,7 @@
+import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
+import { createStore } from './storeFactory';
+
+export default createStore({
+ name: 'CopilotThreads',
+ API: CopilotThreadsAPI,
+});
diff --git a/app/javascript/dashboard/store/captain/storeFactory.js b/app/javascript/dashboard/store/captain/storeFactory.js
index a55522062..ad669f62b 100644
--- a/app/javascript/dashboard/store/captain/storeFactory.js
+++ b/app/javascript/dashboard/store/captain/storeFactory.js
@@ -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),
diff --git a/app/javascript/dashboard/store/captain/storeFactory.spec.js b/app/javascript/dashboard/store/captain/storeFactory.spec.js
new file mode 100644
index 000000000..0aec26e40
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/storeFactory.spec.js
@@ -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',
+ ]);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/captain/storeFactoryHelper.js b/app/javascript/dashboard/store/captain/storeFactoryHelper.js
new file mode 100644
index 000000000..7a04d2e81
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/storeFactoryHelper.js
@@ -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 });
+ }
+ };
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 5daf73ae1..960285ebf 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -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,
});
diff --git a/app/javascript/dashboard/store/modules/agentBots.js b/app/javascript/dashboard/store/modules/agentBots.js
index 3e9931057..bd7bff5f0 100644
--- a/app/javascript/dashboard/store/modules/agentBots.js
+++ b/app/javascript/dashboard/store/modules/agentBots.js
@@ -172,6 +172,17 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
}
},
+
+ resetAccessToken: async ({ commit }, botId) => {
+ try {
+ const response = await AgentBotsAPI.resetAccessToken(botId);
+ commit(types.EDIT_AGENT_BOT, response.data);
+ return response.data;
+ } catch (error) {
+ throwErrorMessage(error);
+ return null;
+ }
+ },
};
export const mutations = {
diff --git a/app/javascript/dashboard/store/modules/auth.js b/app/javascript/dashboard/store/modules/auth.js
index fa49af339..f329fb009 100644
--- a/app/javascript/dashboard/store/modules/auth.js
+++ b/app/javascript/dashboard/store/modules/auth.js
@@ -2,6 +2,8 @@ import types from '../mutation-types';
import authAPI from '../../api/auth';
import { setUser, clearCookiesOnLogout } from '../utils/api';
+import SessionStorage from 'shared/helpers/sessionStorage';
+import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
const initialState = {
currentUser: {
@@ -145,8 +147,15 @@ export const actions = {
updateUISettings: async ({ commit }, params) => {
try {
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
- const response = await authAPI.updateUISettings(params);
- commit(types.SET_CURRENT_USER, response.data);
+
+ const isImpersonating = SessionStorage.get(
+ SESSION_STORAGE_KEYS.IMPERSONATION_USER
+ );
+
+ if (!isImpersonating) {
+ const response = await authAPI.updateUISettings(params);
+ commit(types.SET_CURRENT_USER, response.data);
+ }
} catch (error) {
// Ignore error
}
@@ -204,6 +213,16 @@ export const actions = {
}
},
+ resetAccessToken: async ({ commit }) => {
+ try {
+ const response = await authAPI.resetAccessToken();
+ commit(types.SET_CURRENT_USER, response.data);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ },
+
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
diff --git a/app/javascript/dashboard/store/modules/conversationSearch.js b/app/javascript/dashboard/store/modules/conversationSearch.js
index b4d540fbe..d1d739076 100644
--- a/app/javascript/dashboard/store/modules/conversationSearch.js
+++ b/app/javascript/dashboard/store/modules/conversationSearch.js
@@ -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 = [];
},
};
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index f5b83e546..9f5744fbb 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -18,13 +18,34 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
- getFilteredConversations: ({
- allConversations,
- chatSortFilter,
- appliedFilters,
- }) => {
+ getFilteredConversations: (
+ { allConversations, chatSortFilter, appliedFilters },
+ _,
+ __,
+ rootGetters
+ ) => {
+ const currentUser = rootGetters.getCurrentUser;
+ const currentUserId = rootGetters.getCurrentUser.id;
+ const currentAccountId = rootGetters.getCurrentAccountId;
+
+ const permissions = getUserPermissions(currentUser, currentAccountId);
+ const userRole = getUserRole(currentUser, currentAccountId);
+
return allConversations
- .filter(conversation => matchesFilters(conversation, appliedFilters))
+ .filter(conversation => {
+ const matchesFilterResult = matchesFilters(
+ conversation,
+ appliedFilters
+ );
+ const allowedForRole = applyRoleFilter(
+ conversation,
+ userRole,
+ permissions,
+ currentUserId
+ );
+
+ return matchesFilterResult && allowedForRole;
+ })
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
diff --git a/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js b/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js
index b2fa47313..168c8f78c 100644
--- a/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js
@@ -170,4 +170,21 @@ describe('#actions', () => {
]);
});
});
+ describe('#resetAccessToken', () => {
+ it('sends correct actions if API is success', async () => {
+ const mockResponse = {
+ data: { ...agentBotRecords[0], access_token: 'new_token_123' },
+ };
+ axios.post.mockResolvedValue(mockResponse);
+ const result = await actions.resetAccessToken(
+ { commit },
+ agentBotRecords[0].id
+ );
+
+ expect(commit.mock.calls).toEqual([
+ [types.EDIT_AGENT_BOT, mockResponse.data],
+ ]);
+ expect(result).toBe(mockResponse.data);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js
index 3de56b1fe..b5dfebe26 100644
--- a/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js
@@ -228,4 +228,20 @@ describe('#actions', () => {
);
});
});
+
+ describe('#resetAccessToken', () => {
+ it('sends correct actions if API is success', async () => {
+ const mockResponse = {
+ data: { id: 1, name: 'John', access_token: 'new_token_123' },
+ headers: { expiry: 581842904 },
+ };
+ axios.post.mockResolvedValue(mockResponse);
+ const result = await actions.resetAccessToken({ commit });
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CURRENT_USER, mockResponse.data],
+ ]);
+ expect(result).toBe(true);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js
index ebf6c0557..6ac21f84a 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js
@@ -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 });
diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js
index ea3ca7048..efce6084a 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js
@@ -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 },
});
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js
index 7bef2e527..bf7e833d0 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js
@@ -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([]);
});
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
index 8ac89f49a..7b6c38456 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
@@ -325,4 +325,308 @@ describe('#getters', () => {
});
});
});
+
+ describe('#getFilteredConversations', () => {
+ const mockConversations = [
+ {
+ id: 1,
+ status: 'open',
+ meta: { assignee: { id: 1 } },
+ last_activity_at: 1000,
+ },
+ {
+ id: 2,
+ status: 'open',
+ meta: {},
+ last_activity_at: 2000,
+ },
+ {
+ id: 3,
+ status: 'resolved',
+ meta: { assignee: { id: 2 } },
+ last_activity_at: 3000,
+ },
+ ];
+
+ const mockRootGetters = {
+ getCurrentUser: {
+ id: 1,
+ accounts: [{ id: 1, role: 'agent', permissions: [] }],
+ },
+ getCurrentAccountId: 1,
+ };
+
+ it('filters conversations based on role permissions for administrator', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [{ id: 1, role: 'administrator', permissions: [] }],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[2],
+ mockConversations[1],
+ mockConversations[0],
+ ]);
+ });
+
+ it('filters conversations based on role permissions for agent', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [{ id: 1, role: 'agent', permissions: [] }],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[2],
+ mockConversations[1],
+ mockConversations[0],
+ ]);
+ });
+
+ it('filters conversations for custom role with conversation_manage permission', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[2],
+ mockConversations[1],
+ mockConversations[0],
+ ]);
+ });
+
+ it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_unassigned_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should include conversation assigned to user (id: 1) and unassigned conversation
+ expect(result).toEqual([mockConversations[1], mockConversations[0]]);
+ });
+
+ it('filters conversations for custom role with conversation_participating_manage permission', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_participating_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should only include conversation assigned to user (id: 1)
+ expect(result).toEqual([mockConversations[0]]);
+ });
+
+ it('filters conversations for custom role with no permissions', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: [],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should return empty array as user has no permissions
+ expect(result).toEqual([]);
+ });
+
+ it('applies filters and role permissions together', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: ['open'],
+ query_operator: 'and',
+ },
+ ],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_participating_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should only include open conversation assigned to user (id: 1)
+ expect(result).toEqual([mockConversations[0]]);
+ });
+
+ it('returns empty array when no conversations match filters', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: ['pending'],
+ query_operator: 'and',
+ },
+ ],
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ mockRootGetters
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('sorts filtered conversations according to chatSortFilter', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_asc',
+ appliedFilters: [],
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ mockRootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[0],
+ mockConversations[1],
+ mockConversations[2],
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index a74207e92..f3817c45a 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -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',
diff --git a/app/javascript/dashboard/store/utils/api.js b/app/javascript/dashboard/store/utils/api.js
index 281b911b5..470d98df7 100644
--- a/app/javascript/dashboard/store/utils/api.js
+++ b/app/javascript/dashboard/store/utils/api.js
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import differenceInDays from 'date-fns/differenceInDays';
import Cookies from 'js-cookie';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
+import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
+import SessionStorage from 'shared/helpers/sessionStorage';
import { emitter } from 'shared/helpers/mitt';
import {
ANALYTICS_IDENTITY,
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
};
+export const clearSessionStorageOnLogout = () => {
+ SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
+};
+
export const deleteIndexedDBOnLogout = async () => {
let dbs = [];
try {
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
emitter.emit(ANALYTICS_RESET);
clearBrowserSessionCookies();
clearLocalStorageOnLogout();
+ clearSessionStorageOnLogout();
const globalConfig = window.globalConfig || {};
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
window.location = logoutRedirectLink;
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 5cced0fa4..bed50387e 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -2,6 +2,7 @@ import { createApp } from 'vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { directive as onClickaway } from 'vue3-click-away';
+import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
@@ -27,31 +28,23 @@ export const getHeadingsfromTheArticle = () => {
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
- const isSameHost =
- window.location.href.includes(customDomain) ||
- window.location.href.includes(hostURL);
-
- // Modify external links only on articles page
const isOnArticlePage =
- isSameHost && document.querySelector('#cw-article-content') !== null;
+ document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => {
if (!isOnArticlePage) return;
- // Some of the links come wrapped in strong tag through prosemirror
+ const link = event.target.closest('a');
- const isTagAnchor = event.target.tagName === 'A';
- const isParentTagAnchor =
- event.target.tagName === 'STRONG' &&
- event.target.parentNode.tagName === 'A';
-
- if (isTagAnchor || isParentTagAnchor) {
- const link = isTagAnchor ? event.target : event.target.parentNode;
+ if (link) {
+ const currentLocation = window.location.href;
+ const linkHref = link.href;
+ // Check against current location and custom domains
const isInternalLink =
- link.hostname === window.location.hostname ||
- link.href.includes(customDomain) ||
- link.href.includes(hostURL);
+ isSameHost(linkHref, currentLocation) ||
+ (customDomain && isSameHost(linkHref, customDomain)) ||
+ (hostURL && isSameHost(linkHref, hostURL));
if (!isInternalLink) {
link.target = '_blank';
diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js
index 13edd3718..5205c5d45 100644
--- a/app/javascript/portal/specs/portal.spec.js
+++ b/app/javascript/portal/specs/portal.spec.js
@@ -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(
+ `
+
+
+
+
+ `,
+ { 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');
+ });
+});
diff --git a/app/javascript/shared/helpers/sessionStorage.js b/app/javascript/shared/helpers/sessionStorage.js
new file mode 100644
index 000000000..db1eca2b8
--- /dev/null
+++ b/app/javascript/shared/helpers/sessionStorage.js
@@ -0,0 +1,26 @@
+export default {
+ clearAll() {
+ window.sessionStorage.clear();
+ },
+
+ get(key) {
+ try {
+ const value = window.sessionStorage.getItem(key);
+ return value ? JSON.parse(value) : null;
+ } catch (error) {
+ return window.sessionStorage.getItem(key);
+ }
+ },
+
+ set(key, value) {
+ if (typeof value === 'object') {
+ window.sessionStorage.setItem(key, JSON.stringify(value));
+ } else {
+ window.sessionStorage.setItem(key, value);
+ }
+ },
+
+ remove(key) {
+ window.sessionStorage.removeItem(key);
+ },
+};
diff --git a/app/javascript/shared/helpers/specs/sessionStorage.spec.js b/app/javascript/shared/helpers/specs/sessionStorage.spec.js
new file mode 100644
index 000000000..899c6435a
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/sessionStorage.spec.js
@@ -0,0 +1,137 @@
+import SessionStorage from '../sessionStorage';
+
+// Mocking sessionStorage
+const sessionStorageMock = (() => {
+ let store = {};
+ return {
+ getItem: key => store[key] || null,
+ setItem: (key, value) => {
+ store[key] = String(value);
+ },
+ removeItem: key => delete store[key],
+ clear: () => {
+ store = {};
+ },
+ };
+})();
+
+Object.defineProperty(window, 'sessionStorage', {
+ value: sessionStorageMock,
+});
+
+describe('SessionStorage utility', () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
+
+ describe('clearAll method', () => {
+ it('should clear all items from sessionStorage', () => {
+ sessionStorage.setItem('testKey1', 'testValue1');
+ sessionStorage.setItem('testKey2', 'testValue2');
+
+ SessionStorage.clearAll();
+
+ expect(sessionStorage.getItem('testKey1')).toBeNull();
+ expect(sessionStorage.getItem('testKey2')).toBeNull();
+ });
+ });
+
+ describe('get method', () => {
+ it('should retrieve and parse JSON values correctly', () => {
+ const testObject = { a: 1, b: 'test' };
+ sessionStorage.setItem('testKey', JSON.stringify(testObject));
+
+ expect(SessionStorage.get('testKey')).toEqual(testObject);
+ });
+
+ it('should return null for non-existent keys', () => {
+ expect(SessionStorage.get('nonExistentKey')).toBeNull();
+ });
+
+ it('should handle non-JSON values by returning the raw value', () => {
+ sessionStorage.setItem('testKey', 'plain string value');
+
+ expect(SessionStorage.get('testKey')).toBe('plain string value');
+ });
+
+ it('should handle malformed JSON gracefully', () => {
+ sessionStorage.setItem('testKey', '{malformed:json}');
+
+ expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
+ });
+ });
+
+ describe('set method', () => {
+ it('should store object values as JSON strings', () => {
+ const testObject = { a: 1, b: 'test' };
+ SessionStorage.set('testKey', testObject);
+
+ expect(sessionStorage.getItem('testKey')).toBe(
+ JSON.stringify(testObject)
+ );
+ });
+
+ it('should store primitive values directly', () => {
+ SessionStorage.set('stringKey', 'test string');
+ expect(sessionStorage.getItem('stringKey')).toBe('test string');
+
+ SessionStorage.set('numberKey', 42);
+ expect(sessionStorage.getItem('numberKey')).toBe('42');
+
+ SessionStorage.set('booleanKey', true);
+ expect(sessionStorage.getItem('booleanKey')).toBe('true');
+ });
+
+ it('should handle null values', () => {
+ SessionStorage.set('nullKey', null);
+
+ expect(sessionStorage.getItem('nullKey')).toBe('null');
+ expect(SessionStorage.get('nullKey')).toBeNull();
+ });
+
+ it('should handle undefined values', () => {
+ SessionStorage.set('undefinedKey', undefined);
+
+ expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
+ });
+ });
+
+ describe('remove method', () => {
+ it('should remove an item from sessionStorage', () => {
+ SessionStorage.set('testKey', 'testValue');
+ expect(SessionStorage.get('testKey')).toBe('testValue');
+
+ SessionStorage.remove('testKey');
+
+ expect(SessionStorage.get('testKey')).toBeNull();
+ });
+
+ it('should do nothing when removing a non-existent key', () => {
+ expect(() => {
+ SessionStorage.remove('nonExistentKey');
+ }).not.toThrow();
+ });
+ });
+
+ describe('Integration of methods', () => {
+ it('should set, get, and remove values correctly', () => {
+ SessionStorage.set('testKey', { value: 'test' });
+
+ expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
+
+ SessionStorage.remove('testKey');
+ expect(SessionStorage.get('testKey')).toBeNull();
+ });
+
+ it('should correctly handle impersonation flag (common use case)', () => {
+ SessionStorage.set('impersonationUser', true);
+
+ expect(SessionStorage.get('impersonationUser')).toBe(true);
+
+ expect(sessionStorage.getItem('impersonationUser')).toBe('true');
+
+ SessionStorage.remove('impersonationUser');
+ expect(SessionStorage.get('impersonationUser')).toBeNull();
+ });
+ });
+});
diff --git a/app/javascript/v3/components/Form/Select.vue b/app/javascript/v3/components/Form/Select.vue
index 0fd9ad002..f91c1ebe2 100644
--- a/app/javascript/v3/components/Form/Select.vue
+++ b/app/javascript/v3/components/Form/Select.vue
@@ -64,11 +64,11 @@ export default {
:selected="modelValue"
:name="name"
:class="{
- 'text-ash-400': !modelValue,
- 'text-ash-900': modelValue,
+ 'text-n-slate-9': !modelValue,
+ 'text-n-slate-12': modelValue,
'pl-9': icon,
}"
- class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm outline-none appearance-none rounded-xl select-caret ring-ash-200 ring-1 ring-inset placeholder:text-ash-900 focus:ring-2 focus:ring-inset focus:ring-primary-500 text-sm leading-6"
+ class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6"
@input="onInput"
>
+ <% if @article.present? %>
+
+ <% end %>